mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-24 21:23:51 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98b864249c | |||
| aebc2f0efb | |||
| 0c488d9470 | |||
| 9a3ab0468d | |||
| 520478dc37 | |||
| f707df92e7 | |||
| f1ebb74acc | |||
| bde54ffd2f | |||
| d01e9b400d | |||
| 77b46481f1 | |||
| a16b667cbb | |||
| 7f634496b6 | |||
| 3a849c4c18 | |||
| 2f609efa14 | |||
| eccbf48273 | |||
| 84df82c26f | |||
| 29770dc798 | |||
| 09392d18a1 | |||
| 142ca6eec3 | |||
| 36949804e8 | |||
| a43febc187 | |||
| e7cb02dedf | |||
| be84b73abe | |||
| 3dbe352d1b | |||
| fcfcc1dcff | |||
| cd4363ba53 | |||
| 90739c889b | |||
| a4e5494e66 | |||
| efdd255435 | |||
| ed8a6f2fb2 | |||
| 0fb5c83a26 | |||
| 07bda82f1e | |||
| 49b94444ba | |||
| 1c21123c68 | |||
| 15c9308e41 | |||
| 3da9d9e60b |
@@ -73,7 +73,7 @@ workflows:
|
||||
'.github/workflows/ui.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/setup-node@v7.1.0'
|
||||
- 'pnpm/action-setup@v6.0.10'
|
||||
- 'pnpm/action-setup@v6.1.0'
|
||||
dependencies:
|
||||
'actions/cache@v6.1.0':
|
||||
ref: 'v6.1.0'
|
||||
@@ -125,9 +125,9 @@ dependencies:
|
||||
commit: 'sha1-dfe3c3f87815947d99a8997f908cb6525fc44e9e'
|
||||
owner_id: 761456
|
||||
repo_id: 258271226
|
||||
'pnpm/action-setup@v6.0.10':
|
||||
ref: 'v6.0.10'
|
||||
commit: 'sha1-0977fd99725f1db4007ccb2928dbb4e90d06cc86'
|
||||
'pnpm/action-setup@v6.1.0':
|
||||
ref: 'v6.1.0'
|
||||
commit: 'sha1-ea17c68df8912ef543352723c149a84f56e3d413'
|
||||
owner_id: 21320719
|
||||
repo_id: 262289747
|
||||
'softprops/action-gh-release@v3.0.3':
|
||||
|
||||
@@ -2,7 +2,8 @@ name: Apt Packages
|
||||
|
||||
# Path-filtered like image-scripts.yml: this resolves setup.sh's runtime package
|
||||
# list against each supported suite's real indices, so a renamed, dropped, or
|
||||
# ambiguously virtual package fails here rather than on a user's machine.
|
||||
# ambiguously virtual package fails here rather than on a user's machine. The
|
||||
# dnf job does the same for Oracle Linux, through setup.sh's own repo setup.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -11,6 +12,7 @@ on:
|
||||
paths:
|
||||
- scripts/setup.sh
|
||||
- scripts/lint-apt-packages.sh
|
||||
- scripts/lint-dnf-packages.sh
|
||||
- .github/workflows/apt-packages.yml
|
||||
pull_request:
|
||||
branches:
|
||||
@@ -19,6 +21,7 @@ on:
|
||||
paths:
|
||||
- scripts/setup.sh
|
||||
- scripts/lint-apt-packages.sh
|
||||
- scripts/lint-dnf-packages.sh
|
||||
- .github/workflows/apt-packages.yml
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -44,3 +47,14 @@ jobs:
|
||||
|
||||
- name: Resolve runtime package list
|
||||
run: ./scripts/lint-apt-packages.sh ${{ matrix.image }}
|
||||
|
||||
dnf_packages:
|
||||
name: Resolve dnf list (oraclelinux:10)
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: Resolve runtime package list
|
||||
run: ./scripts/lint-dnf-packages.sh oraclelinux:10
|
||||
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot)
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
run: cd spinifex && make build-microvm-image
|
||||
|
||||
@@ -144,6 +144,7 @@ jobs:
|
||||
--notes-file "$RUNNER_TEMP/notes.md" \
|
||||
"dist/spinifex-$TAG-linux-amd64.tar.gz" \
|
||||
"dist/spinifex-$TAG-linux-amd64.tar.gz.sha256" \
|
||||
dist/qboot-*.tar.gz \
|
||||
"scripts/setup.sh"
|
||||
|
||||
# The assertion that matters. A prerelease must never become the latest
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot)
|
||||
run: cd spinifex && make build-microvm-image
|
||||
|
||||
- name: Build amd64 Tarball
|
||||
@@ -72,6 +72,7 @@ jobs:
|
||||
path: |
|
||||
spinifex/dist/spinifex-${{ github.ref_name }}-linux-amd64.tar.gz
|
||||
spinifex/dist/spinifex-${{ github.ref_name }}-linux-amd64.tar.gz.sha256
|
||||
spinifex/dist/qboot-*.tar.gz
|
||||
spinifex/scripts/setup.sh
|
||||
|
||||
distro-arm64:
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
uses: pnpm/action-setup@v6.1.0
|
||||
with:
|
||||
package_json_file: spinifex/runtime/roles/spinifexui/frontend/package.json
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ bin/
|
||||
dist/
|
||||
build/microvm/vmlinuz
|
||||
build/microvm/initramfs.cpio.gz
|
||||
build/microvm/qboot.rom
|
||||
build/microvm/qboot.LICENSE
|
||||
build/qboot/
|
||||
!spinifex/runtime/roles/spinifexui/frontend/dist/
|
||||
tmp/
|
||||
*.test
|
||||
|
||||
@@ -140,12 +140,23 @@ $(MICROVM_ARTIFACTS) &: $(MICROVM_INPUTS)
|
||||
bin/lb-agent:
|
||||
$(MAKE) build-lb-agent
|
||||
|
||||
build-microvm-image: $(MICROVM_ARTIFACTS) ## Build microVM kernel + initramfs (incremental — skips when up to date)
|
||||
# qboot is the microvm firmware. The source tarball ships with each release
|
||||
# because qboot is GPL-2.0.
|
||||
QBOOT_COMMIT := $(shell sed -n 's/^QBOOT_COMMIT="\(.*\)"$$/\1/p' scripts/build-qboot.sh)
|
||||
QBOOT_SOURCE := build/qboot/qboot-$(QBOOT_COMMIT).tar.gz
|
||||
QBOOT_ARTIFACTS := $(MICROVM_OUT_DIR)/qboot.rom $(MICROVM_OUT_DIR)/qboot.LICENSE $(QBOOT_SOURCE)
|
||||
|
||||
install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share/spinifex/microvm/
|
||||
$(QBOOT_ARTIFACTS) &: scripts/build-qboot.sh
|
||||
./scripts/build-qboot.sh
|
||||
|
||||
build-microvm-image: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Build microVM kernel, initramfs and qboot firmware (incremental — skips when up to date)
|
||||
|
||||
install-microvm: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Install microVM artifacts to /usr/share/spinifex/microvm/
|
||||
sudo install -d /usr/share/spinifex/microvm
|
||||
sudo install -m 0644 $(MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/vmlinuz
|
||||
sudo install -m 0644 $(MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/initramfs.cpio.gz
|
||||
sudo install -m 0644 $(MICROVM_OUT_DIR)/qboot.rom /usr/share/spinifex/microvm/qboot.rom
|
||||
sudo install -m 0644 $(MICROVM_OUT_DIR)/qboot.LICENSE /usr/share/spinifex/microvm/qboot.LICENSE
|
||||
|
||||
# the pre-commit gate: manifest checks, lint, vuln, and the unit and e2e-harness tiers.
|
||||
# integration and race tests skipped to keep quick, they run in CI.
|
||||
@@ -167,7 +178,7 @@ preflight:
|
||||
# reason: between them they install the software, wire OVN, erase the state and
|
||||
# remove the install, so a shellcheck warning in one is an outage or a wrong
|
||||
# deletion, not a style note.
|
||||
NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh
|
||||
NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh scripts/lib/distro.sh
|
||||
|
||||
test-build-scripts:
|
||||
@echo -e "\n....Running build/scripts/**/*_test.sh...."
|
||||
@@ -175,8 +186,8 @@ test-build-scripts:
|
||||
echo "-- $$t"; \
|
||||
sh "$$t" || exit 1; \
|
||||
done
|
||||
@echo -e "\n....Running scripts/*_test.sh...."
|
||||
@for t in $$(find scripts -maxdepth 1 -name '*_test.sh' | sort); do \
|
||||
@echo -e "\n....Running scripts/*_test.sh and scripts/lib/*_test.sh...."
|
||||
@for t in $$( (find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib -name '*_test.sh') | sort); do \
|
||||
echo "-- $$t"; \
|
||||
sh "$$t" || exit 1; \
|
||||
done
|
||||
@@ -333,14 +344,20 @@ fuzz:
|
||||
# Microvm artifacts are reinstalled when they already exist on disk — the rule's
|
||||
# input timestamps drive a rebuild only if anything actually changed. On a fresh
|
||||
# checkout (no build/microvm/vmlinuz yet) the install step is skipped; run
|
||||
# `make install-microvm` explicitly the first time.
|
||||
# `make install-microvm` explicitly the first time. They go in before the
|
||||
# preflight, which checks the microvm firmware.
|
||||
deploy: build
|
||||
sudo install -m 755 bin/spx /usr/local/bin/spx
|
||||
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ]; then \
|
||||
$(MAKE) install-microvm; \
|
||||
else \
|
||||
echo "[deploy] microvm artifacts absent — run 'make install-microvm' for first-time setup"; \
|
||||
fi
|
||||
@if [ "$${SKIP_ASSET_PREFLIGHT:-}" != "1" ]; then \
|
||||
if ! sudo /usr/local/bin/spx admin preflight; then \
|
||||
echo ""; \
|
||||
echo "[deploy] host-asset preflight failed: this node's helper scripts or sudoers"; \
|
||||
echo " grants don't match the binary just installed. Remediate with:"; \
|
||||
echo "[deploy] host-asset preflight failed: this node's helper scripts, sudoers"; \
|
||||
echo " grants, QEMU or microvm firmware don't match the binary just installed. Remediate with:"; \
|
||||
echo " scripts/update-nodes.sh (from the mulga root)"; \
|
||||
echo " or:"; \
|
||||
echo " make reinstall"; \
|
||||
@@ -350,11 +367,6 @@ deploy: build
|
||||
else \
|
||||
echo "[deploy] SKIP_ASSET_PREFLIGHT=1 — skipping host-asset preflight"; \
|
||||
fi
|
||||
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ]; then \
|
||||
$(MAKE) install-microvm; \
|
||||
else \
|
||||
echo "[deploy] microvm artifacts absent — run 'make install-microvm' for first-time setup"; \
|
||||
fi
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart spinifex.target
|
||||
|
||||
@@ -367,21 +379,30 @@ clean:
|
||||
rm -f ./bin/$(GO_PROJECT_NAME)
|
||||
rm -rf spinifex/runtime/roles/spinifexui/frontend/dist
|
||||
|
||||
# On dnf hosts the runtime packages and repositories come from setup.sh, so the
|
||||
# two cannot drift; nbdkit-devel, for building the plugin, is only in CodeReady.
|
||||
install-system:
|
||||
@echo -e "\n....Installing system dependencies for $(ARCH)...."
|
||||
sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
-o Dpkg::Options::="--force-confdef" \
|
||||
-o Dpkg::Options::="--force-confold" \
|
||||
nbdkit nbdkit-plugin-dev pkg-config qemu-system-x86 qemu-system-arm qemu-utils \
|
||||
ovmf qemu-efi-aarch64 \
|
||||
libvirt-daemon-system libvirt-clients libvirt-dev make gcc jq curl \
|
||||
iproute2 netcat-openbsd openssh-client wget git unzip sudo xz-utils file \
|
||||
ovn-central ovn-host openvswitch-switch dhcpcd-base
|
||||
@pm=$$(INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '. scripts/setup.sh && distro_pkg_manager') || exit 1; \
|
||||
if [ "$$pm" = dnf ]; then \
|
||||
INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '. scripts/setup.sh && setup_sudo && detect_os && install_dnf_deps' && \
|
||||
sudo dnf config-manager --set-enabled ol10_codeready_builder && \
|
||||
sudo dnf install -y nbdkit-devel pkgconf-pkg-config make gcc openssh-clients git sudo; \
|
||||
else \
|
||||
sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
-o Dpkg::Options::="--force-confdef" \
|
||||
-o Dpkg::Options::="--force-confold" \
|
||||
nbdkit nbdkit-plugin-dev pkg-config qemu-system-x86 qemu-system-arm qemu-utils \
|
||||
ovmf qemu-efi-aarch64 \
|
||||
libvirt-daemon-system libvirt-clients libvirt-dev make gcc jq curl \
|
||||
iproute2 netcat-openbsd openssh-client wget git unzip sudo xz-utils file \
|
||||
ovn-central ovn-host openvswitch-switch dhcpcd-base; \
|
||||
fi
|
||||
|
||||
install-go:
|
||||
@echo -e "\n....Installing Go 1.27.2 for $(ARCH) ($(GO_ARCH))...."
|
||||
@echo -e "\n....Installing Go 1.27.3 for $(ARCH) ($(GO_ARCH))...."
|
||||
@if [ ! -d "/usr/local/go" ]; then \
|
||||
curl -L https://go.dev/dl/go1.27.2.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
|
||||
curl -L https://go.dev/dl/go1.27.3.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
|
||||
else \
|
||||
echo "Go already installed in /usr/local/go"; \
|
||||
fi
|
||||
@@ -412,7 +433,7 @@ fix:
|
||||
|
||||
govulncheck:
|
||||
@echo "Running govulncheck..."
|
||||
$(_Q)scripts/run-gate.sh govulncheck scripts/govulncheck-gate.sh
|
||||
$(_Q)scripts/run-gate.sh govulncheck go tool govulncheck ./...
|
||||
@echo " govulncheck ok"
|
||||
|
||||
# NilAway — advisory nil-panic analysis. Not in preflight due to false positives
|
||||
@@ -438,10 +459,11 @@ distro-amd64:
|
||||
-f build/Dockerfile.distro \
|
||||
--output type=local,dest=dist/amd64/ \
|
||||
../
|
||||
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ] && [ -f $(MICROVM_OUT_DIR)/initramfs.cpio.gz ]; then \
|
||||
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ] && [ -f $(MICROVM_OUT_DIR)/initramfs.cpio.gz ] && [ -f $(MICROVM_OUT_DIR)/qboot.rom ] && [ -f $(QBOOT_SOURCE) ]; then \
|
||||
echo "[distro-amd64] staging microvm artifacts into tarball"; \
|
||||
mkdir -p dist/amd64/microvm; \
|
||||
cp $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz dist/amd64/microvm/; \
|
||||
cp $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz $(MICROVM_OUT_DIR)/qboot.rom $(MICROVM_OUT_DIR)/qboot.LICENSE dist/amd64/microvm/; \
|
||||
cp $(QBOOT_SOURCE) dist/; \
|
||||
else \
|
||||
echo "[distro-amd64] WARNING: microvm artifacts missing — tarball will not include them"; \
|
||||
echo "[distro-amd64] run 'make build-microvm-image' before 'make distro-amd64'"; \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
ARG VERSION=dev
|
||||
|
||||
FROM golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61 AS builder
|
||||
FROM golang:1.27.3-bookworm@sha256:5c3e9b43cfd3bb95c7937462e29d87ada57b481a96fa23ad0eb61778d7a00110 AS builder
|
||||
|
||||
ARG VERSION
|
||||
|
||||
@@ -45,6 +45,7 @@ RUN cd viperblock && GOFIPS140=v1.0.0 go build -o /out/nbdkit-viperblock-plugin.
|
||||
# Copy setup scripts and systemd units
|
||||
RUN cp spinifex/scripts/setup.sh /out/setup.sh 2>/dev/null || true
|
||||
RUN cp spinifex/scripts/setup-ovn.sh /out/setup-ovn.sh
|
||||
RUN mkdir -p /out/lib && cp spinifex/scripts/lib/distro.sh /out/lib/distro.sh
|
||||
RUN mkdir -p /out/systemd && cp spinifex/build/systemd/* /out/systemd/
|
||||
RUN mkdir -p /out/scripts && cp spinifex/build/scripts/* /out/scripts/
|
||||
RUN cp spinifex/build/logrotate/spinifex /out/logrotate-spinifex
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# spinifex-unit-version: 3
|
||||
# spinifex-unit-version: 5
|
||||
[Unit]
|
||||
Description=Spinifex Daemon (VM Orchestration)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service spinifex-viperblock.service openvswitch-switch.service ovn-controller.service openvswitch-ipsec.service
|
||||
# Wants (not Requires): stay bootable when openvswitch-ipsec is absent
|
||||
Wants=openvswitch-ipsec.service
|
||||
After=spinifex-nats.service spinifex-viperblock.service ovsdb-server.service ovs-vswitchd.service ovn-controller.service openvswitch-ipsec.service spinifex-hostnet.service
|
||||
# Wants (not Requires): boot without openvswitch-ipsec, survive OVS restarts via hostnet
|
||||
Wants=openvswitch-ipsec.service spinifex-hostnet.service
|
||||
|
||||
[Service]
|
||||
EnvironmentFile=-/etc/spinifex/telemetry.env
|
||||
@@ -14,6 +14,7 @@ Type=simple
|
||||
Slice=spinifex-system.slice
|
||||
User=spinifex-daemon
|
||||
Group=spinifex
|
||||
ExecStartPre=/usr/local/bin/spx admin hostnet check
|
||||
ExecStart=/usr/local/bin/spx service spinifex start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# spinifex-unit-version: 1
|
||||
[Unit]
|
||||
Description=Spinifex host network wiring (veth pairs, mgmt address, encap route)
|
||||
# PartOf/WantedBy ovs-vswitchd: re-applied whenever OVS starts; daemon and vpcd only Want it
|
||||
Requires=ovs-vswitchd.service
|
||||
After=ovs-vswitchd.service NetworkManager-wait-online.service
|
||||
PartOf=ovs-vswitchd.service
|
||||
Before=spinifex.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/local/bin/spx admin hostnet apply
|
||||
# Covers the 120s wait behind setup-ovn.sh plus the 60s wait for links
|
||||
TimeoutStartSec=240
|
||||
TimeoutStopSec=10
|
||||
ProtectSystem=full
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target ovs-vswitchd.service
|
||||
@@ -1,9 +1,9 @@
|
||||
# spinifex-unit-version: 1
|
||||
# spinifex-unit-version: 3
|
||||
[Unit]
|
||||
Description=Spinifex VPC Daemon (OVN Networking)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service spinifex-daemon.service systemd-networkd.service openvswitch-switch.service ovn-controller.service
|
||||
Wants=spinifex-nats.service ovn-controller.service
|
||||
After=spinifex-nats.service spinifex-daemon.service systemd-networkd.service ovsdb-server.service ovs-vswitchd.service ovn-controller.service spinifex-hostnet.service
|
||||
Wants=spinifex-nats.service ovn-controller.service spinifex-hostnet.service
|
||||
|
||||
[Service]
|
||||
EnvironmentFile=-/etc/spinifex/telemetry.env
|
||||
@@ -11,6 +11,7 @@ Type=simple
|
||||
Slice=spinifex-system.slice
|
||||
User=spinifex-vpcd
|
||||
Group=spinifex
|
||||
ExecStartPre=/usr/local/bin/spx admin hostnet check
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service vpcd start
|
||||
Restart=on-failure
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# spinifex-unit-version: 1
|
||||
# spinifex-unit-version: 2
|
||||
[Unit]
|
||||
Description=Spinifex Infrastructure Platform
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Wants=spinifex-nats.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer openvswitch-switch.service ovn-controller.service
|
||||
Wants=spinifex-nats.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer ovsdb-server.service ovs-vswitchd.service ovn-controller.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -40,7 +40,7 @@ The request (`account_id`) and reply (`vpc_id`, `error`) bodies are still declar
|
||||
## Platform inventory
|
||||
|
||||
`PlatformInventorySubject` (`ec2.v1.PlatformInventory`) is a request/reply route on which a daemon in the `spinifex-workers` queue group reports one account's key pairs, placement groups, launch templates and their `ec2-tags` entries from the EC2 owner stores.
|
||||
EC2 owns it: `domains/ec2/accountinventory` produces the reply and the daemon serves it; the only consumer today is `accountteardown`, which binds an adapter but does not call it.
|
||||
EC2 owns it: `domains/ec2/accountinventory` produces the reply and the daemon serves it; the only consumer today is `accountteardown`, which calls it only on an explicit operator dry run (`spx admin account delete --dry-run` or the admin API `DryRun`) and only logs a comparison with its legacy listings; no teardown calls it.
|
||||
|
||||
The request names the account in its body (`account_id`, a 12-digit ID) and may carry an opaque `correlation_ref` that is echoed for logs.
|
||||
The daemon ignores `X-Account-ID` for this subject.
|
||||
|
||||
+2
-2
@@ -109,7 +109,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin kv digest` | `--store-dir` (default `<spinifex-dir>/nats/jetstream`), `--work-dir` (holds the temporary copy, default the system temp dir), `--stream` (repeatable filter), `--seqs` (also print a hash per sequence), `--json` (print one JSON document, the input `spx admin kv compare` reads) | Read-only per-replica consistency check. Copies this node's `$G` streams tree (without consumer state) to a temporary directory, opens the copy in a private in-process NATS server with no listener, and prints one line per stream with first/last sequence, message count and a SHA-256 over every stored message (sequence, subject, timestamp, headers, data). Run it on every node with `sudo` and diff the output with the `#` lines removed: replicas of one stream must match. A direct get over NATS is not used because any replica may answer it. Writes landing during the copy can change a replica's tail, so gate API writes or re-run before calling a tail difference divergence. Prefer `--json --seqs` on every node followed by `spx admin kv compare`, which tells a diverged replica from one that is merely behind. |
|
||||
| `spx admin kv compare <digest.json>...` | — (`-` reads any number of digests from stdin) | Reads the `spx admin kv digest --json --seqs` output of every node and checks that each stream's replicas hold the same content. A replica that is merely behind is not divergence; these are: the same sequence holding different content on two replicas, a message missing from one replica that nothing on it explains (no later write to the same subject, no expiry under `max_age` or a message TTL, no stream limit dropping its head), a stream held by fewer or more nodes than its replica count, replicas disagreeing on that count, or no sequence range common to every replica. Sequences past the slowest replica's last are not compared. Exits 0 when every stream is consistent, 1 on divergence, 2 on bad input. Digests are taken from live stores seconds apart, so re-run before trusting a failure that a write landing between two copies could explain. `install-node.sh` gates cluster formation on it. |
|
||||
| `spx admin kv compare <digest.json>...` | — (`-` reads any number of digests from stdin) | Reads the `spx admin kv digest --json --seqs` output of every node and checks that each stream's replicas hold the same content. A replica that is merely behind is not divergence; these are: the same sequence holding different content on two replicas, a message missing from one replica that nothing on it explains (no later write to the same subject, no expiry under `max_age` or a message TTL, no stream limit dropping its head), a stream held by fewer or more nodes than its replica count, replicas disagreeing on that count, or no sequence range common to every replica. A stream with a `max_age` whose replicas were copied at different times and share no sequence range is reported as `skipped` (volatile) rather than diverged, since a short-lived bucket such as a leader lease turns over between copies; any sequence two of its replicas share must still match, and each message a replica lacks must be superseded, expired, or written after that replica was copied. Sequences past the slowest replica's last are not compared. Exits 0 when every stream is consistent, 1 on divergence, 2 on bad input. Digests are taken from live stores seconds apart, so re-run before trusting a failure that a write landing between two copies could explain. `install-node.sh` gates cluster formation on it. |
|
||||
| `spx admin cluster shutdown` | `--force` (shutdown even if nodes don't respond), `--timeout` (max wait per phase, default 120s), `--dry-run` (print phase plan without executing) | Performs coordinated, phased shutdown of entire cluster. Phases execute in order: GATE (stop API/UI) → DRAIN (stop VMs) → STORAGE (stop viperblock) → PERSIST (stop predastore) → INFRA (stop NATS/daemon). Each phase waits for all nodes to ACK before proceeding. Uses JetStream state tracking. |
|
||||
| `spx admin cluster drain-dhcp` | `--timeout` (reply-collection window, default 30s) | Asks each vpcd to DHCPRELEASE every external-pool DHCP lease it currently holds, returning them to the upstream DHCP server. Run on teardown before stopping services — an env reset otherwise strands held leases upstream until their TTL expires, eventually exhausting the upstream scope. Best-effort: warns and exits 0 if the cluster is already down. |
|
||||
| `spx admin node drain --local` | `--local` (drain the local node only, required), `--timeout` (max wait per phase, default 120s), `--unless-restarting` (drain on a real host shutdown or a plain target stop, and skip only when a restart of the stack is already queued; unset, it always drains) | Runs the GATE and DRAIN phases against the local node only: powers down its guests via QMP and unmounts their volumes (flushing the viperblock WAL) while every service is still up. STORAGE/PERSIST/INFRA are left to systemd's ordered teardown. Wired as the `ExecStop` of `spinifex-shutdown.service`, so `systemctl stop spinifex.target` and host reboot/poweroff drain guests before any storage service stops. |
|
||||
@@ -126,7 +126,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin preflight` | `--quiet` (print only non-OK rows) | Reports whether this host has the privileged helper scripts and sudoers grants the installed build depends on. `spx version` knows the build but not the host: `setup.sh` and `setup-ovn.sh` install those assets, and a binary-swap deploy (`make deploy`, or a manual copy) skips them. It also reports any running service still executing a replaced binary — overwriting `/usr/local/bin/spx` while `spinifex.target` is up does not move services onto it, so each keeps executing the replaced inode until it restarts and a node can serve a mixture of two builds indefinitely. Checks compare content, not existence, so a present-but-stale asset is caught. Exits non-zero if any asset is Missing, Stale or Ungranted. |
|
||||
| `spx admin preflight` | `--quiet` (print only non-OK rows) | Reports whether this host has the privileged helper scripts and sudoers grants the installed build depends on. `spx version` knows the build but not the host: `setup.sh` and `setup-ovn.sh` install those assets, and a binary-swap deploy (`make deploy`, or a manual copy) skips them. It also reports any running service still executing a replaced binary — overwriting `/usr/local/bin/spx` while `spinifex.target` is up does not move services onto it, so each keeps executing the replaced inode until it restarts and a node can serve a mixture of two builds indefinitely. Checks compare content, not existence, so a present-but-stale asset is caught. On x86_64 it also checks that the QEMU binary the daemon will run lists the `microvm` machine type and that the microvm firmware `/usr/share/spinifex/microvm/qboot.rom` is installed, since load balancers boot on both. Exits non-zero if any asset is Missing, Stale, Ungranted or Unsupported. |
|
||||
| `spx admin node js-probe` | `--timeout` (default: 10s) | Round-trips a canary key through the cluster-state KV bucket and exits non-zero if the write or the read-back fails. JetStream can latch a write error per message block and keep serving reads — nats-server's filestore records the failure on the block and returns it on every later write, and nothing clears it short of a restart. The HTTP health endpoint reports the server-wide JetStream state, which that latch never touches, so only an actual write observes it. |
|
||||
| `spx admin banner` | `--boot-check` | Writes the node information banner to `/etc/issue` (shown before login on the physical/serial console) and `/etc/motd` (shown after SSH login). With `--boot-check` it also detects a management IP that has changed since last boot and updates `/etc/spinifex/node.conf` accordingly. |
|
||||
|
||||
|
||||
@@ -446,7 +446,7 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
- `providers/ebs/viperblock` → `runtime/compute/nbd` and `runtime/host/process` (dependency, moved unchanged from `services/viperblockd`): the adapter formats NBD endpoints and manages nbdkit processes through runtime packages. ADR-0001 does not list providers → runtime as forbidden and the layering test does not enforce it (same category as `providers/ebs/conformance` → `runtime/compute/nbd`), but a provider should not depend on node runtime. Clears when NBD endpoint naming has a provider-facing home (a candidate for `contracts/ebs/v1`) and process supervision is injected by the role. The adapter also imports `providers/objectstore`, a provider-to-provider edge that is allowed.
|
||||
- `gateway/ec2.go` (registration): the EC2 dispatch map stays a `GatewayConfig` method in `gateway` and imports every `domains/ec2/awsapi/*` package; `gateway/ec2_idempotency.go` also imports `domains/ec2/awsapi/idem` (`KVBucket`, `TTL`, `TokenAndParams`) for the dispatch-time idempotency wrapper. ADR-0001 makes the domain own its action registration; moving the map waits on the generic ingress registration seam and must not be done as a package move.
|
||||
- `gateway/requestaudit.go`: the outermost request-audit middleware records AWS fields and is updated from four places in the gateway's authentication flow through the gateway's own context key. Moving it would export about seven names used only by that flow, so it stays in `gateway` until the generic ingress registration seam defines how ingress middleware receives authentication results.
|
||||
- `accountteardown` (dependency): its EC2 reapers call `domains/ec2/awsapi/*` action adapters directly, as they did under `gateway/ec2`. The ADR's `accountteardown` current-to-target map row replaces this with domain-owned cleanup capabilities before the package moves. It also holds a read-only consumer of the EC2 platform-inventory contract (`contracts/ec2/v1`, `ec2.v1.PlatformInventory`), served by `domains/ec2/accountinventory`; no engine path calls it yet, and the key-pair, placement-group and launch-template reapers still use the `awsapi` adapters.
|
||||
- `accountteardown` (dependency): its EC2 reapers call `domains/ec2/awsapi/*` action adapters directly, as they did under `gateway/ec2`. The ADR's `accountteardown` current-to-target map row replaces this with domain-owned cleanup capabilities before the package moves. It also holds a read-only consumer of the EC2 platform-inventory contract (`contracts/ec2/v1`, `ec2.v1.PlatformInventory`), served by `domains/ec2/accountinventory`; only the explicit dry run (`Engine.DryRun`) calls it, to log a shadow comparison, and the key-pair, placement-group and launch-template reapers still use the `awsapi` adapters. A domain-owned cleanup capability for the proven-safe subset of those three kinds (key pairs and empty placement groups no instance, spot request or live launch-template version names, and launch-template residue; never live launch templates) exists as `domains/ec2/accountcleanup` (a Go interface with injected owners and reference readers, no NATS subject); nothing calls it yet.
|
||||
- `domains/admission/quota` (dependency, moved from `handlers/quota` unchanged): `live.go` imports `gateway/elbv2` and `handlers/rds` for its live ELBv2 and RDS counters; `records.go` imports `runtime/compute/vm` for the instance-record vCPU sweep; `live.go`, `vcpu.go` and `storage.go` call `domains/ec2/awsapi/{vpc,eip,volume}` describe adapters to count VPCs, subnets, addresses and volumes, as they did under `gateway/ec2`. The ADR's `handlers/quota` current-to-target map row replaces these with reader capabilities once their final locations exist.
|
||||
- `domains/ec2/awsapi/instance` (dependency): imports `handlers/iam` (`IAMService` in `DescribeInstances.go`, `RunInstances.go` and `IamInstanceProfileAssociation.go`; `InstanceProfile` in the latter two), `runtime/compute/cache` (`NodeState`, `NodeUnknown`, `NodeLive`) and `runtime/compute/vm` (`VM`), both in `status_synthesis.go`. The IAM edge needs an IAM capability; the runtime edges are domain-to-runtime and need the instance record and node liveness exposed through an EC2-owned projection.
|
||||
- `domains/ec2/awsapi/spotinstance` (dependency): imports `handlers/iam.IAMService` and `domains/admission/quota.Service` in `RequestSpotInstances.go`.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# distroless base (no shell, no package manager) for a small, production-shaped
|
||||
# image suitable for pushing to ECR and pulling onto EKS workers.
|
||||
|
||||
FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
|
||||
FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY main.go ./
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module github.com/mulgadc/spinifex-demo
|
||||
|
||||
go 1.27.2
|
||||
go 1.27.3
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/mulgadc/spinifex
|
||||
|
||||
go 1.27.2
|
||||
go 1.27.3
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go v1.55.8
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Reachable vulnerabilities accepted for a bounded time, read by scripts/govulncheck-gate.sh.
|
||||
# Format: <OSV-ID> <expiry YYYY-MM-DD> <reason>
|
||||
# The gate fails once the expiry passes: fix the dependency or renew deliberately.
|
||||
+39
-1
@@ -7,11 +7,45 @@ Node installation, dev-environment lifecycle, guest image builds, and verificati
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `setup.sh` | The production binary installer, served from `install.mulgadc.com`. Installs deps, users, sudoers, files, systemd units, logrotate and udev rules. `SETUP_STAGES` runs a subset; `ISO_BUILD=1` runs it inside the ISO builder's chroot. |
|
||||
| `setup-ovn.sh` | Bootstraps a compute node for OVN VPC networking: installs OVN/OVS, creates `br-int`, configures the WAN bridge (bridged, DHCP, or routed-NAT uplink), sets the chassis identity, and applies overlay sysctls. `--management` also starts the OVN central services. |
|
||||
| `setup-ovn.sh` | Bootstraps a compute node for OVN VPC networking: installs OVN/OVS, creates `br-int`, configures the WAN bridge (bridged, DHCP, or routed-NAT uplink), sets the chassis identity, and applies overlay sysctls. It runs OVS and OVN as root, setting `OVS_USER_ID` and `OVN_USER_ID` where `/etc/sysconfig` holds them, and hooks the socket-permission helper onto `ovs-vswitchd`, `ovn-controller`, `openvswitch-ipsec` and, on EL, `ovn-northd`. `--management` also starts the OVN central services. The links it creates persist through systemd-networkd where that is enabled, and otherwise through `/etc/spinifex/hostnet.json`, which `spinifex-hostnet.service` (`spx admin hostnet apply`) replays at boot and on every OVS start; the first run records the choice in `/etc/spinifex/hostnet-backend`. Where NetworkManager runs, it leaves the links Spinifex owns unmanaged. |
|
||||
| `install-node.sh` | Forms a multi-node cluster across hosts that are already running Spinifex: rebuilds the OVN database in clustered form, runs `spx admin init` on the first host and `spx admin join` on the rest, then verifies. The scripted form of [install-multi-node](../docs/install/install-multi-node/README.md). Destroys the joining nodes' logical network state and crypto — see its `--help`. |
|
||||
| `dev-install.sh` | Full local dev setup through the production installer: builds from source, packs a tarball, runs `setup.sh`, initialises the cluster, and starts services under systemd. |
|
||||
| `clone-deps.sh` | Clones or updates the viperblock and predastore checkouts alongside spinifex for cross-repo development. |
|
||||
|
||||
### `lib/distro.sh`: host-OS capability probes
|
||||
|
||||
The node scripts select host-OS behaviour by probing for the capability, not by reading `/etc/os-release`; only `setup.sh`'s supported-OS gate keys on the OS ID.
|
||||
Each probe prints its answer, or names every candidate it tried on stderr and returns 1, which it also does when it finds both candidates of a pair.
|
||||
|
||||
| Probe | Answers |
|
||||
|---|---|
|
||||
| `distro_pkg_manager` | `apt` or `dnf` |
|
||||
| `distro_ovs_unit` | `openvswitch-switch.service` (Debian, Ubuntu) or `openvswitch.service` (EL) |
|
||||
| `distro_ovn_central_layout` | `split` (Debian's `ovn-central` plus separate NB and SB units) or `lone-northd` (EL's single `ovn-northd.service`) |
|
||||
| `distro_ca_trust` | the trust-store update command and its anchor directory |
|
||||
| `distro_ovn_logrotate` | `/etc/logrotate.d/ovn-common` or `/etc/logrotate.d/ovn` |
|
||||
|
||||
`distro_missing_ovs_deps <unit-file>...` is a check rather than a probe: it prints each OVS or OVN unit the given unit files depend on or order against that is not installed, and fails only when a file cannot be read.
|
||||
`setup-ovn.sh` runs it against the installed Spinifex units and stops when one is missing, because systemd silently drops ordering against a unit that does not exist; a missing `openvswitch-ipsec.service` is only a warning.
|
||||
|
||||
`setup-ovn.sh`, `uninstall-spx.sh` and `dev-install.sh` source it from `lib/` beside themselves, then from `/usr/local/share/spinifex/lib/distro.sh`, where `setup.sh` installs it, and stop naming both paths when neither exists.
|
||||
`setup.sh` is piped from the install endpoint before the tarball is downloaded, and `install-node.sh --wipe` copies `node-reset.sh` alone to each host, so those two carry inline copies of the probes they need between `# --- BEGIN distro.sh probes ---` markers.
|
||||
`lib/distro_test.sh` fails when an inline copy differs from the library.
|
||||
Go code uses `spinifex/runtime/host/hostos` for the same purpose.
|
||||
|
||||
### System packages
|
||||
|
||||
`setup.sh` installs `APT_RUNTIME_PACKAGES` with apt, or on Oracle Linux `OL_RUNTIME_PACKAGES` with dnf, choosing by `distro_pkg_manager`.
|
||||
Before the dnf install it enables `ol10_UEKR8` and `ol10_kvm_utils`, excludes `qemu-kvm*` and `qemu-img*` from `ol10_appstream` so QEMU always comes from KVM Utilities, and writes `/etc/yum.repos.d/spinifex-nfv-sig.repo` for OVS 3.5 and OVN 25.03 from the CentOS NFV SIG.
|
||||
That repo carries an `includepkgs=` allowlist, and its key is refused unless its fingerprint matches `NFV_SIG_KEY_FPR`.
|
||||
It then installs `kernel-uek-modules` for the running kernel, stopping with a reboot instruction if that version has left the repositories, and loads `openvswitch`, `geneve`, `vhost_net` and `tun`.
|
||||
`make install-system` takes the same dnf path, adding the build packages from CodeReady Builder.
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `lint-apt-packages.sh <image>` | Resolves the apt list against a Debian or Ubuntu image's real indices with `apt-get install -s`. |
|
||||
| `lint-dnf-packages.sh [image]` | Runs `setup.sh`'s Oracle Linux repository setup in an `oraclelinux:10` container, resolves the dnf list, and fails unless QEMU comes from KVM Utilities and OVS and OVN from the NFV SIG. |
|
||||
|
||||
## Teardown
|
||||
|
||||
Both ship to `/usr/local/share/spinifex/` on every node, so they are available on a host installed with `curl … | bash` and not only from a checkout. They differ in what they take away:
|
||||
@@ -21,6 +55,9 @@ Both ship to `/usr/local/share/spinifex/` on every node, so they are available o
|
||||
| `node-reset.sh` | Clears **state** and leaves the node installed: stops services, destroys guests, wipes `/etc/spinifex`, `/var/lib/spinifex`, `/var/log/spinifex` and the OVN databases, and removes the OVS bridges. The node is then ready for a fresh `spx admin init`. `--keep-data` preserves `/var/lib/spinifex`; `SPX_RESET_CONFIRM=reset` runs it unattended. |
|
||||
| `uninstall-spx.sh` | Removes the **software** — the inverse of `setup.sh`. Delegates the state teardown above to `node-reset.sh` rather than reimplementing it, then removes the binary, the nbdkit plugin, the systemd units and slices, the service users, the sudoers grant, the sysctl/logrotate/chrony/udev drop-ins, the `systemd-networkd` units, the host firewall policy and its loaded nft table, and the Spinifex CA from the host trust store. Verifies afterwards and exits non-zero naming anything that survived. |
|
||||
|
||||
`node-reset.sh` finds guests by process name, `qemu-system-*` or `qemu-kvm`, owned by the daemon's service user, and waits for them to exit before it deletes any state.
|
||||
`node-reset_test.sh` checks that match against real processes, and that `reset-dev-env.sh` uses the same pattern.
|
||||
|
||||
`uninstall-spx.sh` keeps `/var/lib/spinifex` and every apt package by default — uninstalling software should not destroy data, and a `curl … | bash` install lands on hosts that were already using openvswitch, libvirt and chrony. `--purge-data` and `--purge-deps` opt into each, and both confirm separately even under `--yes` (`SPX_PURGE_CONFIRM=destroy` for unattended data removal). `--dry-run` prints the whole plan and touches nothing.
|
||||
|
||||
**It refuses on a multi-node cluster, and `--yes` does not override that.** Uninstalling one node of a running cluster strands its guests and leaves the survivors reconciling against a chassis that will never answer. Run `spx admin cluster shutdown` first, then the uninstaller on each node.
|
||||
@@ -52,6 +89,7 @@ Both ship to `/usr/local/share/spinifex/` on every node, so they are available o
|
||||
| `build-system-image.sh` | Builds a minimal system AMI (Alpine or Ubuntu) from a manifest. All customisation happens inside the libguestfs appliance, so the build touches no host block device, no host mount, and needs no sudo. `--import` also registers it as an AMI. |
|
||||
| `publish-system-image.sh` | Converts a built raw image to compressed qcow2, writes the `.sha256` sidecar the catalog verifier expects, and uploads both to the R2 bucket behind `iso.mulgadc.com/system-ami/`. |
|
||||
| `build-microvm-image.sh` | Builds the minimal Alpine kernel + initramfs used by the QEMU microvm machine type and the lb-agent, carrying haproxy (ALB) and nginx-stream (NLB) data planes. |
|
||||
| `build-qboot.sh` | Builds qboot, the firmware the daemon passes to every microvm with `-bios`, from a pinned upstream commit in a digest-pinned Debian container. Fails unless the output matches the recorded sha256, which is also Debian 13's `qboot.rom`. Writes `qboot.rom` and its GPL-2.0 licence beside the microvm kernel, and the matching source tarball, which releases publish, under `build/qboot/`. `make build-microvm-image` runs it. |
|
||||
| `mkosi-build.sh` | Runs an mkosi system-image build inside the pinned builder container, so a runner needs only Docker and the toolchain cannot drift. `--image` names an output and expands to the right profile list. |
|
||||
| `mkosi-publish.sh` | Publishes an mkosi-built image to R2 from inside the same pinned container — qcow2 conversion, `.sha256` sidecar, and upload. |
|
||||
| `mkosi-common.sh` | Shared plumbing sourced by both mkosi scripts. Builds the toolchain image once, with the builder account matched to the invoking user so bind-mounted output is not written back as root. |
|
||||
|
||||
@@ -17,7 +17,7 @@ BUILD_DIR="$REPO_ROOT/build/microvm"
|
||||
INIT_SH="$BUILD_DIR/init.sh"
|
||||
INITTAB="$BUILD_DIR/inittab"
|
||||
|
||||
ALPINE_VERSION="${ALPINE_VERSION:-3.20}"
|
||||
ALPINE_VERSION="${ALPINE_VERSION:-3.24}"
|
||||
ALPINE_MIRROR="${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}"
|
||||
# haproxy: ALB (L7) data plane. nginx + nginx-mod-stream: NLB (L4) data plane —
|
||||
# the `stream` module (a separate Alpine package) load-balances TCP/UDP/TLS,
|
||||
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
# build-qboot.sh — build qboot, the firmware the QEMU microvm machine boots
|
||||
# system instances (ALB/NLB) with, from a pinned upstream commit.
|
||||
#
|
||||
# Outputs:
|
||||
# $REPO_ROOT/build/microvm/qboot.rom firmware, shipped beside the microvm kernel
|
||||
# $REPO_ROOT/build/microvm/qboot.LICENSE qboot's GPL-2.0 licence text
|
||||
# $REPO_ROOT/build/qboot/qboot-<commit>.tar.gz the matching source, published with releases
|
||||
#
|
||||
# The build runs in a digest-pinned Debian container, so its output is
|
||||
# reproducible and is checked against a recorded hash. That hash is also the
|
||||
# sha256 of Debian 13's /usr/share/qemu/qboot.rom, which is built from the same commit.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUT_DIR="$REPO_ROOT/build/microvm"
|
||||
SRC_DIR="$REPO_ROOT/build/qboot"
|
||||
|
||||
QBOOT_REPO="${QBOOT_REPO:-https://gitlab.com/qemu-project/qboot.git}"
|
||||
QBOOT_COMMIT="8ca302e86d685fa05b16e2b208888243da319941"
|
||||
QBOOT_SHA256="5c9c4b12c4438e3cf11d087263afe6519c8c05dd5896727b8b169b8f5dbf9877"
|
||||
BUILD_IMAGE="debian:trixie@sha256:4c25275446ca371766b8b093c696782eabce06263fccacb8d5d813b3dc44b308"
|
||||
|
||||
log() { echo "[build-qboot] $*"; }
|
||||
fail() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
CONTAINER_TOOL=""
|
||||
for tool in docker podman; do
|
||||
if command -v "$tool" >/dev/null 2>&1 && "$tool" info >/dev/null 2>&1; then
|
||||
CONTAINER_TOOL=$tool
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "$CONTAINER_TOOL" ] || fail "qboot builds in a container; install docker or podman"
|
||||
command -v git >/dev/null 2>&1 || fail "required tool not found: git"
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
log "fetching qboot $QBOOT_COMMIT from $QBOOT_REPO"
|
||||
git init -q "$WORK/src"
|
||||
git -C "$WORK/src" fetch -q --depth 1 "$QBOOT_REPO" "$QBOOT_COMMIT"
|
||||
git -C "$WORK/src" checkout -q FETCH_HEAD
|
||||
got_commit=$(git -C "$WORK/src" rev-parse HEAD)
|
||||
[ "$got_commit" = "$QBOOT_COMMIT" ] || fail "fetched qboot $got_commit, not the pinned $QBOOT_COMMIT"
|
||||
|
||||
mkdir -p "$SRC_DIR"
|
||||
src_tarball="$SRC_DIR/qboot-${QBOOT_COMMIT}.tar.gz"
|
||||
git -C "$WORK/src" archive --format=tar.gz --prefix="qboot-${QBOOT_COMMIT}/" -o "$src_tarball" "$QBOOT_COMMIT"
|
||||
|
||||
log "building in $BUILD_IMAGE with $CONTAINER_TOOL"
|
||||
# The firmware comes back on stdout, so nothing the container writes is left
|
||||
# root-owned on the host.
|
||||
"$CONTAINER_TOOL" run --rm --platform linux/amd64 -v "$WORK/src:/src:ro" \
|
||||
-e DEBIAN_FRONTEND=noninteractive "$BUILD_IMAGE" sh -euc '
|
||||
{
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends gcc libc6-dev meson ninja-build binutils
|
||||
meson setup /tmp/build /src
|
||||
ninja -C /tmp/build
|
||||
} >/dev/null
|
||||
cat /tmp/build/bios.bin
|
||||
' > "$WORK/qboot.rom"
|
||||
|
||||
got_sha=$(sha256sum "$WORK/qboot.rom" | cut -d' ' -f1)
|
||||
if [ "$got_sha" != "$QBOOT_SHA256" ]; then
|
||||
fail "qboot.rom has sha256 $got_sha, not the recorded $QBOOT_SHA256.
|
||||
The pinned commit or build image changed; rebuild, boot an ALB on it, then update QBOOT_SHA256."
|
||||
fi
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
install -m 0644 "$WORK/qboot.rom" "$OUT_DIR/qboot.rom"
|
||||
install -m 0644 "$WORK/src/LICENSE" "$OUT_DIR/qboot.LICENSE"
|
||||
log "qboot.rom sha256 $got_sha"
|
||||
log "source: $src_tarball"
|
||||
@@ -284,6 +284,12 @@ if [[ "$DISTRO" == "alpine" ]] && [[ -n "${APK_PACKAGES:-}" ]]; then
|
||||
CUST+=(--run-command 'sed -i "s|^#\(.*community\)|\1|" /etc/apk/repositories 2>/dev/null || true')
|
||||
CUST+=(--run-command 'grep -q community /etc/apk/repositories || grep main /etc/apk/repositories | head -1 | sed "s|/main|/community|" >> /etc/apk/repositories')
|
||||
CUST+=(--run-command 'apk update')
|
||||
# The base cloud image's kernel is only as fresh as its release; upgrade so
|
||||
# the AMI ships the branch's current linux-virt and security fixes.
|
||||
CUST+=(--run-command 'apk upgrade')
|
||||
# apk's mkinitfs trigger does not always fire in the appliance; a new kernel
|
||||
# with the old initramfs cannot load virtio_blk and never mounts root.
|
||||
CUST+=(--run-command 'k=$(ls /lib/modules | sort -V | tail -1) && mkinitfs "$k" && zcat /boot/initramfs-virt | cpio -t | grep -qE "^(usr/)?lib/modules/$k/.*/virtio_blk\.ko"')
|
||||
CUST+=(--run-command "apk add ${APK_PACKAGES}")
|
||||
elif [[ "$DISTRO" == "ubuntu" ]] && [[ -n "${APT_PACKAGES:-}" ]]; then
|
||||
echo "Will install packages: ${APT_PACKAGES}"
|
||||
|
||||
@@ -19,6 +19,19 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
MULGA_ROOT="$(cd "$PROJECT_ROOT/.." && pwd)"
|
||||
|
||||
# Host-OS capability probes: beside this script in a checkout and in the
|
||||
# installed share dir, else where setup.sh installs them.
|
||||
DISTRO_LIB=""
|
||||
for candidate in "$(dirname "${BASH_SOURCE[0]}")/lib/distro.sh" /usr/local/share/spinifex/lib/distro.sh; do
|
||||
if [ -f "$candidate" ]; then DISTRO_LIB="$candidate"; break; fi
|
||||
done
|
||||
if [ -z "$DISTRO_LIB" ]; then
|
||||
echo "ERROR: distro.sh not found at $(dirname "${BASH_SOURCE[0]}")/lib/distro.sh or /usr/local/share/spinifex/lib/distro.sh; re-run setup.sh to install it" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck source=lib/distro.sh
|
||||
. "$DISTRO_LIB"
|
||||
|
||||
echo "=== Building ==="
|
||||
cd "$PROJECT_ROOT" && make build
|
||||
cd "$MULGA_ROOT/viperblock" && make go_build_nbd
|
||||
@@ -31,6 +44,8 @@ cp "$MULGA_ROOT/viperblock/lib/nbdkit-viperblock-plugin.so" "$STAGING/"
|
||||
cp "$PROJECT_ROOT/scripts/setup-ovn.sh" "$STAGING/"
|
||||
cp "$PROJECT_ROOT/scripts/node-reset.sh" "$STAGING/"
|
||||
cp "$PROJECT_ROOT/scripts/uninstall-spx.sh" "$STAGING/"
|
||||
mkdir -p "$STAGING/lib"
|
||||
cp "$PROJECT_ROOT/scripts/lib/distro.sh" "$STAGING/lib/"
|
||||
mkdir -p "$STAGING/systemd"
|
||||
cp "$PROJECT_ROOT/build/systemd/"* "$STAGING/systemd/"
|
||||
mkdir -p "$STAGING/scripts"
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fails on any reachable vulnerability that is not listed in
|
||||
# govulncheck-accepted.txt, and on any acceptance whose expiry has passed.
|
||||
# Each line is "<OSV-ID> <YYYY-MM-DD expiry> <reason>"; '#' starts a comment.
|
||||
# GOVULNCHECK_JSON substitutes a saved -format json report, for tests.
|
||||
set -euo pipefail
|
||||
|
||||
accepted_file=${GOVULNCHECK_ACCEPTED:-govulncheck-accepted.txt}
|
||||
today=${GOVULNCHECK_TODAY:-$(date -u +%F)}
|
||||
|
||||
if [ -n "${GOVULNCHECK_JSON:-}" ]; then
|
||||
report=$(cat "$GOVULNCHECK_JSON")
|
||||
else
|
||||
report=$(go tool govulncheck -format json ./...)
|
||||
fi
|
||||
|
||||
# A finding whose first trace frame names a function is reachable from our
|
||||
# code; text mode reports only these, so module- and package-level ones pass.
|
||||
mapfile -t called < <(printf '%s\n' "$report" | jq -rs \
|
||||
'[.[] | select(.finding) | .finding | select(.trace[0].function) | .osv] | unique | .[]')
|
||||
|
||||
declare -A expiry reason
|
||||
if [ -f "$accepted_file" ]; then
|
||||
while read -r id until why; do
|
||||
[[ -z "$id" || "$id" == \#* ]] && continue
|
||||
expiry[$id]=$until
|
||||
reason[$id]=$why
|
||||
done <"$accepted_file"
|
||||
fi
|
||||
|
||||
fail=0
|
||||
for id in "${called[@]}"; do
|
||||
if [ -z "${expiry[$id]:-}" ]; then
|
||||
echo " $id is reachable and not accepted; run 'go tool govulncheck ./...' for the trace"
|
||||
fail=1
|
||||
elif [[ "$today" > "${expiry[$id]}" ]]; then
|
||||
echo " $id acceptance expired on ${expiry[$id]}: ${reason[$id]}"
|
||||
fail=1
|
||||
else
|
||||
echo " $id accepted until ${expiry[$id]}: ${reason[$id]}"
|
||||
fi
|
||||
done
|
||||
|
||||
for id in "${!expiry[@]}"; do
|
||||
if ! printf '%s\n' "${called[@]}" | grep -qx "$id"; then
|
||||
echo " $id is accepted but no longer reported; remove it from $accepted_file"
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$fail"
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Tests the accepted-vulnerability gate against saved reports, so the logic is
|
||||
# checked without running the scanner.
|
||||
set -u
|
||||
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
gate="$here/govulncheck-gate.sh"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
cat >"$tmp/accepted.txt" <<'EOF'
|
||||
# comment
|
||||
GO-2026-6597 2026-11-07 accepted for the test
|
||||
EOF
|
||||
cat >"$tmp/reachable.json" <<'EOF'
|
||||
{"config":{}}
|
||||
{"finding":{"osv":"GO-2026-6597","trace":[{"module":"m","package":"p","function":"Pull"}]}}
|
||||
{"finding":{"osv":"GO-2026-0001","trace":[{"module":"m"}]}}
|
||||
EOF
|
||||
cat >"$tmp/new.json" <<'EOF'
|
||||
{"finding":{"osv":"GO-2026-6597","trace":[{"module":"m","package":"p","function":"Pull"}]}}
|
||||
{"finding":{"osv":"GO-2026-7777","trace":[{"module":"m","package":"p","function":"F"}]}}
|
||||
EOF
|
||||
echo '{"config":{}}' >"$tmp/clean.json"
|
||||
|
||||
fails=0
|
||||
check() {
|
||||
want=$1 name=$2 report=$3 today=$4 accepted=$5 expect=$6
|
||||
out=$(GOVULNCHECK_JSON="$tmp/$report" GOVULNCHECK_TODAY=$today GOVULNCHECK_ACCEPTED=$accepted bash "$gate" 2>&1)
|
||||
rc=$?
|
||||
if [ "$rc" != "$want" ] || ! printf '%s' "$out" | grep -q "$expect"; then
|
||||
echo "FAIL [$name] rc=$rc want=$want output: $out"
|
||||
fails=$((fails + 1))
|
||||
else
|
||||
echo "ok [$name]"
|
||||
fi
|
||||
}
|
||||
|
||||
check 0 "accepted reachable finding passes" reachable.json 2026-10-07 "$tmp/accepted.txt" "GO-2026-6597 accepted until 2026-11-07"
|
||||
check 0 "expiry day itself still passes" reachable.json 2026-11-07 "$tmp/accepted.txt" "accepted until"
|
||||
check 1 "expired acceptance fails" reachable.json 2026-11-08 "$tmp/accepted.txt" "acceptance expired on 2026-11-07"
|
||||
check 1 "new reachable finding fails" new.json 2026-10-07 "$tmp/accepted.txt" "GO-2026-7777 is reachable and not accepted"
|
||||
out=$(GOVULNCHECK_JSON="$tmp/reachable.json" GOVULNCHECK_TODAY=2026-10-07 GOVULNCHECK_ACCEPTED="$tmp/accepted.txt" bash "$gate" 2>&1)
|
||||
if printf '%s' "$out" | grep -q "GO-2026-0001"; then
|
||||
echo "FAIL [module-level finding is not reachable] output: $out"
|
||||
fails=$((fails + 1))
|
||||
else
|
||||
echo "ok [module-level finding is not reachable]"
|
||||
fi
|
||||
check 0 "fixed finding warns that acceptance is stale" clean.json 2026-10-07 "$tmp/accepted.txt" "no longer reported"
|
||||
check 1 "missing acceptance file fails reachable finding" reachable.json 2026-10-07 "$tmp/none" "not accepted"
|
||||
|
||||
[ "$fails" -eq 0 ] || exit 1
|
||||
@@ -0,0 +1,135 @@
|
||||
# shellcheck shell=bash
|
||||
# distro.sh — host-OS capability probes. Each prints its answer, or names every
|
||||
# candidate it tried on stderr and returns 1, also when it finds both of a pair.
|
||||
# setup.sh and node-reset.sh carry inline copies; distro_test.sh keeps them equal.
|
||||
|
||||
# Reads unit files off disk, not through systemctl, so it answers in a chroot.
|
||||
# DISTRO_ROOT prefixes every host path the probes read; tests set it.
|
||||
distro_unit_exists() {
|
||||
local dir
|
||||
for dir in /etc/systemd/system /run/systemd/system /usr/lib/systemd/system /lib/systemd/system; do
|
||||
if [ -e "${DISTRO_ROOT:-}$dir/$1" ] || [ -L "${DISTRO_ROOT:-}$dir/$1" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
distro_pkg_manager() {
|
||||
local apt=false dnf=false
|
||||
command -v apt-get >/dev/null 2>&1 && apt=true
|
||||
command -v dnf >/dev/null 2>&1 && dnf=true
|
||||
if $apt && $dnf; then
|
||||
echo "distro.sh: package manager: found both apt-get and dnf; cannot tell which one manages this host" >&2
|
||||
return 1
|
||||
fi
|
||||
if $apt; then echo apt; return 0; fi
|
||||
if $dnf; then echo dnf; return 0; fi
|
||||
echo "distro.sh: package manager: found neither apt-get nor dnf on PATH" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# The OVS umbrella unit: openvswitch-switch.service on Debian, openvswitch.service on EL.
|
||||
distro_ovs_unit() {
|
||||
local deb=false el=false
|
||||
distro_unit_exists openvswitch-switch.service && deb=true
|
||||
distro_unit_exists openvswitch.service && el=true
|
||||
if $deb && $el; then
|
||||
echo "distro.sh: OVS unit: found both openvswitch-switch.service and openvswitch.service; cannot tell which one owns OVS" >&2
|
||||
return 1
|
||||
fi
|
||||
if $deb; then echo openvswitch-switch.service; return 0; fi
|
||||
if $el; then echo openvswitch.service; return 0; fi
|
||||
echo "distro.sh: OVS unit: found neither openvswitch-switch.service nor openvswitch.service; is Open vSwitch installed?" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# "split": Debian's ovn-central plus separate NB and SB DB units.
|
||||
# "lone-northd": EL's single ovn-northd.service, which starts both DB servers.
|
||||
distro_ovn_central_layout() {
|
||||
local split=false lone=false unit missing=""
|
||||
for unit in ovn-central.service ovn-ovsdb-server-nb.service ovn-ovsdb-server-sb.service; do
|
||||
if distro_unit_exists "$unit"; then
|
||||
split=true
|
||||
else
|
||||
missing="${missing:+$missing, }$unit"
|
||||
fi
|
||||
done
|
||||
if distro_unit_exists ovn-northd.service && [ -f "${DISTRO_ROOT:-}/etc/sysconfig/ovn" ]; then
|
||||
lone=true
|
||||
fi
|
||||
if $split && $lone; then
|
||||
echo "distro.sh: OVN central layout: found both the split units (ovn-central.service) and a lone ovn-northd.service with /etc/sysconfig/ovn; cannot tell which one owns the OVN databases" >&2
|
||||
return 1
|
||||
fi
|
||||
if $split; then
|
||||
if [ -n "$missing" ]; then
|
||||
echo "distro.sh: OVN central layout: ovn-central is installed but $missing is missing" >&2
|
||||
return 1
|
||||
fi
|
||||
echo split
|
||||
return 0
|
||||
fi
|
||||
if $lone; then echo lone-northd; return 0; fi
|
||||
echo "distro.sh: OVN central layout: found neither ovn-central.service nor ovn-northd.service with /etc/sysconfig/ovn; is OVN central installed?" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# The trust-store update command and its local anchor directory, space-separated.
|
||||
distro_ca_trust() {
|
||||
local deb=false el=false
|
||||
command -v update-ca-certificates >/dev/null 2>&1 && deb=true
|
||||
command -v update-ca-trust >/dev/null 2>&1 && el=true
|
||||
if $deb && $el; then
|
||||
echo "distro.sh: CA trust: found both update-ca-certificates and update-ca-trust; cannot tell which trust store TLS clients read" >&2
|
||||
return 1
|
||||
fi
|
||||
if $deb; then echo "update-ca-certificates /usr/local/share/ca-certificates"; return 0; fi
|
||||
if $el; then echo "update-ca-trust /etc/pki/ca-trust/source/anchors"; return 0; fi
|
||||
echo "distro.sh: CA trust: found neither update-ca-certificates nor update-ca-trust on PATH" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
distro_ovn_logrotate() {
|
||||
local deb=false el=false
|
||||
[ -f "${DISTRO_ROOT:-}/etc/logrotate.d/ovn-common" ] && deb=true
|
||||
[ -f "${DISTRO_ROOT:-}/etc/logrotate.d/ovn" ] && el=true
|
||||
if $deb && $el; then
|
||||
echo "distro.sh: OVN logrotate: found both /etc/logrotate.d/ovn-common and /etc/logrotate.d/ovn; cannot tell which one the OVN packages own" >&2
|
||||
return 1
|
||||
fi
|
||||
if $deb; then echo /etc/logrotate.d/ovn-common; return 0; fi
|
||||
if $el; then echo /etc/logrotate.d/ovn; return 0; fi
|
||||
echo "distro.sh: OVN logrotate: found neither /etc/logrotate.d/ovn-common nor /etc/logrotate.d/ovn; is OVN installed?" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# The OVS and OVN units the given unit files depend on or order against that
|
||||
# are not installed, one per line; prints nothing when all are. Fails only
|
||||
# when a unit file cannot be read.
|
||||
distro_missing_ovs_deps() {
|
||||
local file line dep missing=""
|
||||
for file in "$@"; do
|
||||
if [ ! -r "${DISTRO_ROOT:-}$file" ]; then
|
||||
echo "distro.sh: OVS dependencies: cannot read $file" >&2
|
||||
return 1
|
||||
fi
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
After=* | Before=* | Wants=* | Requires=* | Requisite=* | BindsTo=* | PartOf=* | Upholds=*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
for dep in ${line#*=}; do
|
||||
case "$dep" in
|
||||
ovs* | ovn* | openvswitch*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
case " $missing " in
|
||||
*" $dep "*) continue ;;
|
||||
esac
|
||||
distro_unit_exists "$dep" || missing="${missing:+$missing }$dep"
|
||||
done
|
||||
done <"${DISTRO_ROOT:-}$file"
|
||||
done
|
||||
for dep in $missing; do echo "$dep"; done
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/bin/sh
|
||||
# distro_test.sh — tests for the host-OS probes and the inline copies of them.
|
||||
#
|
||||
# Every probe runs against a fixture tree (DISTRO_ROOT) and a PATH holding only
|
||||
# stub commands, so the result never depends on the distro running the test.
|
||||
set -eu
|
||||
|
||||
LIB_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
|
||||
SCRIPTS_DIR=$(dirname -- "$LIB_DIR")
|
||||
DISTRO="$LIB_DIR/distro.sh"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
|
||||
echo "-- lib/distro.sh"
|
||||
|
||||
# fixture <name> — a fresh empty host root and stub bin dir.
|
||||
fixture() {
|
||||
ROOT="$TMP/$1/root"
|
||||
BIN="$TMP/$1/bin"
|
||||
mkdir -p "$ROOT" "$BIN"
|
||||
}
|
||||
unit() { mkdir -p "$ROOT/usr/lib/systemd/system" && : > "$ROOT/usr/lib/systemd/system/$1"; }
|
||||
file() { mkdir -p "$ROOT$(dirname "$1")" && : > "$ROOT$1"; }
|
||||
cmd() { printf '#!/bin/sh\nexit 0\n' > "$BIN/$1" && chmod +x "$BIN/$1"; }
|
||||
|
||||
# probe <fn> [arg...] — runs one probe in bash against the current fixture,
|
||||
# with only the stub dir on PATH. Sets OUT, ERR and RC.
|
||||
probe() {
|
||||
RC=0
|
||||
OUT=$(DISTRO_ROOT="$ROOT" PATH="$BIN" /bin/bash -c '. "$1"; shift; "$@"' _ "$DISTRO" "$@" 2>"$TMP/err") || RC=$?
|
||||
ERR=$(cat "$TMP/err")
|
||||
}
|
||||
|
||||
# expect_ok <desc> <want>; expect_fail <desc> <word>... — every word must appear
|
||||
# in the error, which is how "names every candidate" is checked.
|
||||
expect_ok() {
|
||||
if [ "$RC" = 0 ] && [ "$OUT" = "$2" ]; then pass "$1"; else fail "$1 (rc=$RC out='$OUT' err='$ERR')"; fi
|
||||
}
|
||||
expect_fail() {
|
||||
desc=$1; shift
|
||||
if [ "$RC" = 0 ]; then fail "$desc (succeeded with '$OUT')"; return; fi
|
||||
for word in "$@"; do
|
||||
case "$ERR" in
|
||||
*"$word"*) ;;
|
||||
*) fail "$desc (error does not name $word: '$ERR')"; return ;;
|
||||
esac
|
||||
done
|
||||
pass "$desc"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Package manager.
|
||||
# --------------------------------------------------------------------------
|
||||
fixture pm-apt; cmd apt-get; probe distro_pkg_manager
|
||||
expect_ok "package manager: apt-get alone is apt" apt
|
||||
fixture pm-dnf; cmd dnf; probe distro_pkg_manager
|
||||
expect_ok "package manager: dnf alone is dnf" dnf
|
||||
fixture pm-both; cmd apt-get; cmd dnf; probe distro_pkg_manager
|
||||
expect_fail "package manager: both fails naming both" apt-get dnf
|
||||
fixture pm-none; probe distro_pkg_manager
|
||||
expect_fail "package manager: neither fails naming both" apt-get dnf
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# OVS umbrella unit. Unit files are found under any systemd unit directory, and
|
||||
# a masked unit (a symlink to /dev/null) still counts as installed.
|
||||
# --------------------------------------------------------------------------
|
||||
fixture ovs-deb; unit openvswitch-switch.service; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: Debian name" openvswitch-switch.service
|
||||
fixture ovs-el; unit openvswitch.service; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: EL name" openvswitch.service
|
||||
fixture ovs-masked; mkdir -p "$ROOT/etc/systemd/system"
|
||||
ln -s /dev/null "$ROOT/etc/systemd/system/openvswitch.service"; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: a masked unit in /etc counts" openvswitch.service
|
||||
fixture ovs-lib; mkdir -p "$ROOT/lib/systemd/system"
|
||||
: > "$ROOT/lib/systemd/system/openvswitch-switch.service"; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: found under /lib on an unmerged-/usr host" openvswitch-switch.service
|
||||
fixture ovs-both; unit openvswitch-switch.service; unit openvswitch.service; probe distro_ovs_unit
|
||||
expect_fail "OVS unit: both fails naming both" openvswitch-switch.service openvswitch.service
|
||||
fixture ovs-none; probe distro_ovs_unit
|
||||
expect_fail "OVS unit: neither fails naming both" openvswitch-switch.service openvswitch.service
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# OVN central layout. Both distros ship ovn-northd.service, so the unit alone
|
||||
# must not decide it: Debian's split layout carries one too.
|
||||
# --------------------------------------------------------------------------
|
||||
split_units() { unit ovn-central.service; unit ovn-ovsdb-server-nb.service; unit ovn-ovsdb-server-sb.service; unit ovn-northd.service; }
|
||||
|
||||
fixture ovn-split; split_units; probe distro_ovn_central_layout
|
||||
expect_ok "OVN layout: Debian split units" split
|
||||
fixture ovn-lone; unit ovn-northd.service; file /etc/sysconfig/ovn; probe distro_ovn_central_layout
|
||||
expect_ok "OVN layout: EL lone ovn-northd" lone-northd
|
||||
fixture ovn-both; split_units; file /etc/sysconfig/ovn; probe distro_ovn_central_layout
|
||||
expect_fail "OVN layout: both fails naming both" ovn-central.service ovn-northd.service /etc/sysconfig/ovn
|
||||
fixture ovn-partial; unit ovn-central.service; unit ovn-ovsdb-server-nb.service; probe distro_ovn_central_layout
|
||||
expect_fail "OVN layout: a partial split install names the missing unit" ovn-ovsdb-server-sb.service
|
||||
fixture ovn-northd-only; unit ovn-northd.service; probe distro_ovn_central_layout
|
||||
expect_fail "OVN layout: ovn-northd without /etc/sysconfig/ovn is not EL" ovn-central.service ovn-northd.service /etc/sysconfig/ovn
|
||||
fixture ovn-none; probe distro_ovn_central_layout
|
||||
expect_fail "OVN layout: neither fails naming both" ovn-central.service ovn-northd.service
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CA trust.
|
||||
# --------------------------------------------------------------------------
|
||||
fixture ca-deb; cmd update-ca-certificates; probe distro_ca_trust
|
||||
expect_ok "CA trust: Debian command and anchor dir" "update-ca-certificates /usr/local/share/ca-certificates"
|
||||
fixture ca-el; cmd update-ca-trust; probe distro_ca_trust
|
||||
expect_ok "CA trust: EL command and anchor dir" "update-ca-trust /etc/pki/ca-trust/source/anchors"
|
||||
fixture ca-both; cmd update-ca-certificates; cmd update-ca-trust; probe distro_ca_trust
|
||||
expect_fail "CA trust: both fails naming both" update-ca-certificates update-ca-trust
|
||||
fixture ca-none; probe distro_ca_trust
|
||||
expect_fail "CA trust: neither fails naming both" update-ca-certificates update-ca-trust
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# OVN logrotate file.
|
||||
# --------------------------------------------------------------------------
|
||||
fixture lr-deb; file /etc/logrotate.d/ovn-common; probe distro_ovn_logrotate
|
||||
expect_ok "OVN logrotate: Debian file" /etc/logrotate.d/ovn-common
|
||||
fixture lr-el; file /etc/logrotate.d/ovn; probe distro_ovn_logrotate
|
||||
expect_ok "OVN logrotate: EL file" /etc/logrotate.d/ovn
|
||||
fixture lr-both; file /etc/logrotate.d/ovn-common; file /etc/logrotate.d/ovn; probe distro_ovn_logrotate
|
||||
expect_fail "OVN logrotate: both fails naming both" /etc/logrotate.d/ovn-common /etc/logrotate.d/ovn
|
||||
fixture lr-none; probe distro_ovn_logrotate
|
||||
expect_fail "OVN logrotate: neither fails naming both" /etc/logrotate.d/ovn-common /etc/logrotate.d/ovn
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# OVS and OVN units the shipped Spinifex units name. Run against the real unit
|
||||
# files, so a unit that names a Debian-only OVS unit fails on the EL fixture.
|
||||
# --------------------------------------------------------------------------
|
||||
UNITS_SRC="$(dirname -- "$SCRIPTS_DIR")/build/systemd"
|
||||
spinifex_units() {
|
||||
mkdir -p "$ROOT/etc/systemd/system"
|
||||
cp "$UNITS_SRC"/spinifex.target "$UNITS_SRC"/spinifex-*.service "$ROOT/etc/systemd/system/"
|
||||
}
|
||||
deps_probe() {
|
||||
set --
|
||||
for f in "$ROOT"/etc/systemd/system/spinifex*; do set -- "$@" "${f#"$ROOT"}"; done
|
||||
probe distro_missing_ovs_deps "$@"
|
||||
}
|
||||
el_ovs_units() {
|
||||
for u in openvswitch.service ovsdb-server.service ovs-vswitchd.service ovn-controller.service ovn-northd.service openvswitch-ipsec.service; do unit "$u"; done
|
||||
}
|
||||
|
||||
fixture deps-el; spinifex_units; el_ovs_units; deps_probe
|
||||
expect_ok "OVS dependencies: every one the shipped units name is installed on EL" ""
|
||||
fixture deps-no-ipsec; spinifex_units; el_ovs_units; rm "$ROOT/usr/lib/systemd/system/openvswitch-ipsec.service"; deps_probe
|
||||
expect_ok "OVS dependencies: a missing unit is printed once" openvswitch-ipsec.service
|
||||
fixture deps-debian-only; el_ovs_units
|
||||
mkdir -p "$ROOT/etc/systemd/system"
|
||||
printf '[Unit]\n# After=ovs-old.service\nAfter=openvswitch-switch.service ovn-controller.service\nWants=openvswitch-switch.service\n' >"$ROOT/etc/systemd/system/spinifex-x.service"
|
||||
printf '[Unit]\nRequires=ovn-central.service' >"$ROOT/etc/systemd/system/spinifex-y.service"
|
||||
deps_probe
|
||||
expect_ok "OVS dependencies: Debian-only names are missing on EL, comments are not read" "openvswitch-switch.service
|
||||
ovn-central.service"
|
||||
fixture deps-unreadable; probe distro_missing_ovs_deps /etc/systemd/system/spinifex.target
|
||||
expect_fail "OVS dependencies: an unreadable unit file fails naming it" "cannot read /etc/systemd/system/spinifex.target"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Inline copies. setup.sh and node-reset.sh cannot source the library, so they
|
||||
# carry copies between markers; each copied function must match the library's
|
||||
# byte for byte, or a fix to one silently misses the other.
|
||||
# --------------------------------------------------------------------------
|
||||
# fn_body <file> <name> — the function from "name() {" to the first "}" line.
|
||||
fn_body() {
|
||||
awk -v name="$2" '$0 == name "() {" { on = 1 } on { print } on && $0 == "}" { exit }' "$1"
|
||||
}
|
||||
|
||||
for copy in setup.sh node-reset.sh; do
|
||||
path="$SCRIPTS_DIR/$copy"
|
||||
block=$(sed -n '/^# --- BEGIN distro.sh probes ---$/,/^# --- END distro.sh probes ---$/p' "$path")
|
||||
if [ -z "$block" ]; then
|
||||
fail "$copy: no distro.sh probe block"
|
||||
continue
|
||||
fi
|
||||
names=$(printf '%s\n' "$block" | sed -n 's/^\([a-z_][a-z0-9_]*\)() {$/\1/p')
|
||||
if [ -z "$names" ]; then
|
||||
fail "$copy: the distro.sh probe block holds no functions"
|
||||
continue
|
||||
fi
|
||||
for name in $names; do
|
||||
want=$(fn_body "$DISTRO" "$name")
|
||||
got=$(fn_body "$path" "$name")
|
||||
if [ -z "$want" ]; then
|
||||
fail "$copy: $name is not in lib/distro.sh"
|
||||
elif [ "$got" = "$want" ]; then
|
||||
pass "$copy: $name matches lib/distro.sh"
|
||||
else
|
||||
fail "$copy: $name differs from lib/distro.sh"
|
||||
fi
|
||||
done
|
||||
|
||||
# A copied probe calling a probe helper the block lacks fails only when run.
|
||||
for called in $(printf '%s\n' "$block" | grep -v '^ *#' | grep -o 'distro_[a-z_]*' | sort -u); do
|
||||
case " $(echo $names) " in
|
||||
*" $called "*) ;;
|
||||
*) fail "$copy: copies a probe that calls $called without copying it" ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# A consumer with no library beside it and none installed must stop naming
|
||||
# both paths, not run on with the probes undefined. Skipped on a host where
|
||||
# setup.sh has installed the library, since the fallback then finds it.
|
||||
# --------------------------------------------------------------------------
|
||||
if [ -e /usr/local/share/spinifex/lib/distro.sh ]; then
|
||||
echo " skip - consumers without distro.sh: /usr/local/share/spinifex/lib/distro.sh is installed here"
|
||||
else
|
||||
# uninstall-spx.sh sources the library after its --help, so it is run into
|
||||
# its multi-node refusal, which exits 1 without touching the host either way.
|
||||
mkdir -p "$TMP/alone/bin" "$TMP/alone/etc"
|
||||
printf '#!/bin/sh\nexec "$@"\n' > "$TMP/alone/bin/sudo"
|
||||
chmod +x "$TMP/alone/bin/sudo"
|
||||
printf '[nodes.node1]\n[nodes.node2]\n' > "$TMP/alone/etc/spinifex.toml"
|
||||
for consumer in "setup-ovn.sh --help" "uninstall-spx.sh --dry-run"; do
|
||||
script=${consumer%% *}
|
||||
cp "$SCRIPTS_DIR/$script" "$TMP/alone/"
|
||||
PATH="$TMP/alone/bin:$PATH" SPX_ETC_DIR="$TMP/alone/etc" "$TMP/alone/$script" "${consumer#* }" >"$TMP/out" 2>&1 && rc=0 || rc=$?
|
||||
if [ "$rc" != 0 ] && grep -q "distro.sh not found at $TMP/alone/lib/distro.sh or /usr/local/share/spinifex/lib/distro.sh" "$TMP/out"; then
|
||||
pass "$script: stops naming both paths when distro.sh is missing"
|
||||
else
|
||||
fail "$script: without distro.sh exited $rc: $(head -3 "$TMP/out")"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$fails" -gt 0 ]; then
|
||||
echo "$fails failure(s)"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs setup.sh's Oracle Linux repository setup in a container, then resolves
|
||||
# its runtime dnf list against the real repositories with dnf install --assumeno.
|
||||
# Catches a renamed or dropped package, a SIG dependency the includepkgs
|
||||
# allowlist misses, a changed SIG key, and QEMU or OVS resolving from the wrong
|
||||
# repository.
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:-oraclelinux:10}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "Resolving ${IMAGE}..."
|
||||
# The repository setup and the list both come from setup.sh itself, so the lint
|
||||
# cannot drift from what the installer does.
|
||||
docker run --rm -v "${SCRIPT_DIR}/setup.sh:/setup.sh:ro" "${IMAGE}" bash -c '
|
||||
set -euo pipefail
|
||||
INSTALL_SPINIFEX_LIB_ONLY=1 . /setup.sh
|
||||
SUDO=""
|
||||
ensure_ol_repos
|
||||
|
||||
# --assumeno always exits 1, so the transaction is judged from its output.
|
||||
# shellcheck disable=SC2086
|
||||
out=$(dnf install --assumeno $OL_RUNTIME_PACKAGES 2>&1) || true
|
||||
if ! grep -q "^Operation aborted" <<< "$out" || grep -qE "^(Error|Problem)|No match for argument" <<< "$out"; then
|
||||
echo "$out"
|
||||
echo "FAIL: the Oracle Linux package list does not resolve" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# repo_of <package> — the repository dnf would install the package from.
|
||||
repo_of() { awk -v p="$1" "\$1 == p { print \$4; exit }" <<< "$out"; }
|
||||
for want in "qemu-kvm $OL_KVM_UTILS_REPO" "qemu-img $OL_KVM_UTILS_REPO" \
|
||||
"openvswitch3.5 $NFV_SIG_REPO_ID" "ovn25.03-central $NFV_SIG_REPO_ID"; do
|
||||
pkg=${want% *} repo=${want#* }
|
||||
got=$(repo_of "$pkg")
|
||||
if [ "$got" != "$repo" ]; then
|
||||
echo "$out"
|
||||
echo "FAIL: $pkg resolves from ${got:-nowhere}, not $repo" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
'
|
||||
|
||||
echo "OK: every package resolves on ${IMAGE}, QEMU from KVM Utilities and OVS/OVN from the NFV SIG"
|
||||
+78
-3
@@ -57,6 +57,77 @@ run() {
|
||||
"$@"
|
||||
}
|
||||
|
||||
# --- BEGIN distro.sh probes ---
|
||||
# Copied from scripts/lib/distro.sh: install-node.sh --wipe copies this script
|
||||
# alone to each host, so it cannot source the library. distro_test.sh keeps them equal.
|
||||
distro_unit_exists() {
|
||||
local dir
|
||||
for dir in /etc/systemd/system /run/systemd/system /usr/lib/systemd/system /lib/systemd/system; do
|
||||
if [ -e "${DISTRO_ROOT:-}$dir/$1" ] || [ -L "${DISTRO_ROOT:-}$dir/$1" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
distro_ovs_unit() {
|
||||
local deb=false el=false
|
||||
distro_unit_exists openvswitch-switch.service && deb=true
|
||||
distro_unit_exists openvswitch.service && el=true
|
||||
if $deb && $el; then
|
||||
echo "distro.sh: OVS unit: found both openvswitch-switch.service and openvswitch.service; cannot tell which one owns OVS" >&2
|
||||
return 1
|
||||
fi
|
||||
if $deb; then echo openvswitch-switch.service; return 0; fi
|
||||
if $el; then echo openvswitch.service; return 0; fi
|
||||
echo "distro.sh: OVS unit: found neither openvswitch-switch.service nor openvswitch.service; is Open vSwitch installed?" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
distro_ovn_central_layout() {
|
||||
local split=false lone=false unit missing=""
|
||||
for unit in ovn-central.service ovn-ovsdb-server-nb.service ovn-ovsdb-server-sb.service; do
|
||||
if distro_unit_exists "$unit"; then
|
||||
split=true
|
||||
else
|
||||
missing="${missing:+$missing, }$unit"
|
||||
fi
|
||||
done
|
||||
if distro_unit_exists ovn-northd.service && [ -f "${DISTRO_ROOT:-}/etc/sysconfig/ovn" ]; then
|
||||
lone=true
|
||||
fi
|
||||
if $split && $lone; then
|
||||
echo "distro.sh: OVN central layout: found both the split units (ovn-central.service) and a lone ovn-northd.service with /etc/sysconfig/ovn; cannot tell which one owns the OVN databases" >&2
|
||||
return 1
|
||||
fi
|
||||
if $split; then
|
||||
if [ -n "$missing" ]; then
|
||||
echo "distro.sh: OVN central layout: ovn-central is installed but $missing is missing" >&2
|
||||
return 1
|
||||
fi
|
||||
echo split
|
||||
return 0
|
||||
fi
|
||||
if $lone; then echo lone-northd; return 0; fi
|
||||
echo "distro.sh: OVN central layout: found neither ovn-central.service nor ovn-northd.service with /etc/sysconfig/ovn; is OVN central installed?" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
distro_ca_trust() {
|
||||
local deb=false el=false
|
||||
command -v update-ca-certificates >/dev/null 2>&1 && deb=true
|
||||
command -v update-ca-trust >/dev/null 2>&1 && el=true
|
||||
if $deb && $el; then
|
||||
echo "distro.sh: CA trust: found both update-ca-certificates and update-ca-trust; cannot tell which trust store TLS clients read" >&2
|
||||
return 1
|
||||
fi
|
||||
if $deb; then echo "update-ca-certificates /usr/local/share/ca-certificates"; return 0; fi
|
||||
if $el; then echo "update-ca-trust /etc/pki/ca-trust/source/anchors"; return 0; fi
|
||||
echo "distro.sh: CA trust: found neither update-ca-certificates nor update-ca-trust on PATH" >&2
|
||||
return 1
|
||||
}
|
||||
# --- END distro.sh probes ---
|
||||
|
||||
WIPE_DIRS=("$ETC_DIR" "$LOG_DIR" "$RUN_DIR")
|
||||
$KEEP_DATA || WIPE_DIRS+=("$DATA_DIR")
|
||||
|
||||
@@ -102,6 +173,9 @@ fi
|
||||
|
||||
log "stopping services"
|
||||
run sudo systemctl stop spinifex.target 2>/dev/null || true
|
||||
# spinifex-hostnet starts with OVS, which is started below to delete bridges; it
|
||||
# would rebuild the old veth pairs. setup-ovn.sh enables it again.
|
||||
run sudo systemctl disable --now spinifex-hostnet.service 2>/dev/null || true
|
||||
run sudo systemctl reset-failed 'spinifex-*' 2>/dev/null || true
|
||||
|
||||
# Stopping the target is not the same as its services being stopped.
|
||||
@@ -134,8 +208,9 @@ fi
|
||||
# `pgrep -x` and `pkill -x` match against comm, which the kernel truncates to 15
|
||||
# characters, so `qemu-system-x86_64` is stored as `qemu-system-x86` and an -x
|
||||
# match on the full name can never fire. Match the prefix instead: it is 11
|
||||
# characters, well under the limit, and covers both x86_64 and aarch64. This is
|
||||
# the same rule qemuProcessPrefix applies in spinifex/runtime/compute/vm/orphan_scan.go.
|
||||
# characters, well under the limit, and covers both x86_64 and aarch64. EL-family
|
||||
# hosts run /usr/libexec/qemu-kvm, whose comm is exactly `qemu-kvm`. This is the
|
||||
# rule hostos.IsQEMUProcess applies in spinifex/runtime/host/hostos.
|
||||
#
|
||||
# Deliberately not -f. That matches the whole command line, where argv[0] may
|
||||
# carry a path, so an anchored pattern misses the process it was written for.
|
||||
@@ -144,7 +219,7 @@ fi
|
||||
# ones has both under the same comm and an unscoped kill would take the wrong
|
||||
# set down. The daemon runs guests as its own service user on an installed node;
|
||||
# a dev install runs them as whoever invoked spx.
|
||||
QEMU_PGREP=('^qemu-system')
|
||||
QEMU_PGREP=('^(qemu-system|qemu-kvm$)')
|
||||
for qemu_user in spinifex-daemon spinifex "${SUDO_USER:-$(id -un)}"; do
|
||||
if id -u "$qemu_user" >/dev/null 2>&1; then
|
||||
QEMU_PGREP=(-u "$qemu_user" "${QEMU_PGREP[@]}")
|
||||
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/bin/sh
|
||||
# node-reset_test.sh — tests the QEMU process match node-reset.sh uses to stop
|
||||
# guests before it deletes Viperblock state, against real processes.
|
||||
#
|
||||
# Each fake guest is a copy of sleep named like a QEMU binary, so the kernel
|
||||
# gives it the same comm, including the 15-character truncation.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
|
||||
TMP=$(mktemp -d)
|
||||
pids=""
|
||||
cleanup() {
|
||||
for pid in $pids; do kill "$pid" 2>/dev/null || true; done
|
||||
rm -rf "$TMP"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
|
||||
echo "-- node-reset.sh QEMU process match"
|
||||
|
||||
# pattern_in <script> — the pgrep pattern the script assigns to QEMU_PGREP.
|
||||
pattern_in() {
|
||||
sed -n "s/^QEMU_PGREP=('\(.*\)')$/\1/p" "$1"
|
||||
}
|
||||
|
||||
PATTERN=$(pattern_in "$SCRIPT_DIR/node-reset.sh")
|
||||
if [ -z "$PATTERN" ]; then
|
||||
echo " FAIL - no QEMU_PGREP=('...') line in node-reset.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# reset-dev-env.sh stops guests the same way and must not drift from it.
|
||||
dev_pattern=$(pattern_in "$SCRIPT_DIR/reset-dev-env.sh")
|
||||
if [ "$dev_pattern" = "$PATTERN" ]; then
|
||||
pass "reset-dev-env.sh uses node-reset.sh's pattern"
|
||||
else
|
||||
fail "reset-dev-env.sh pattern '$dev_pattern' differs from node-reset.sh's '$PATTERN'"
|
||||
fi
|
||||
|
||||
# start <name> — runs a process whose comm is <name>, truncated to 15
|
||||
# characters. Its PID is left in $!.
|
||||
start() {
|
||||
cp "$(command -v sleep)" "$TMP/$1"
|
||||
"$TMP/$1" 300 &
|
||||
pids="$pids $!"
|
||||
}
|
||||
start qemu-system-x86_64; pid_x86=$!
|
||||
start qemu-system-aarch64; pid_arm=$!
|
||||
start qemu-kvm; pid_kvm=$!
|
||||
start qemu-img; pid_img=$!
|
||||
start qemu-kvm-helper; pid_helper=$!
|
||||
|
||||
me=$(id -un)
|
||||
found=" $(pgrep -u "$me" "$PATTERN" | tr '\n' ' ' || true) "
|
||||
|
||||
check_found() {
|
||||
case "$found" in
|
||||
*" $2 "*) pass "finds a $1 guest owned by the service user" ;;
|
||||
*) fail "misses a $1 guest (pid $2) owned by the service user" ;;
|
||||
esac
|
||||
}
|
||||
check_found qemu-system-x86_64 "$pid_x86"
|
||||
check_found qemu-system-aarch64 "$pid_arm"
|
||||
check_found qemu-kvm "$pid_kvm"
|
||||
|
||||
check_ignored() {
|
||||
case "$found" in
|
||||
*" $2 "*) fail "matches $1 (pid $2), which is not a guest" ;;
|
||||
*) pass "ignores $1" ;;
|
||||
esac
|
||||
}
|
||||
check_ignored qemu-img "$pid_img"
|
||||
check_ignored qemu-kvm-helper "$pid_helper"
|
||||
|
||||
# The owner scope: the same guests, asked for as another user, are not found.
|
||||
other=root
|
||||
[ "$me" = root ] && other=nobody
|
||||
other_found=" $(pgrep -u "$other" "$PATTERN" | tr '\n' ' ' || true) "
|
||||
case "$other_found" in
|
||||
*" $pid_kvm "* | *" $pid_x86 "*) fail "a scope of -u $other still matches guests owned by $me" ;;
|
||||
*) pass "ignores guests owned by another user" ;;
|
||||
esac
|
||||
|
||||
if [ "$fails" -gt 0 ]; then
|
||||
echo "$fails failure(s)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -359,17 +359,29 @@ fi
|
||||
# --- Shutdown services ---
|
||||
echo "==> Stopping services"
|
||||
sudo systemctl stop spinifex.target 2>/dev/null || true
|
||||
# spinifex-hostnet starts with OVS, which is started below to delete bridges; it
|
||||
# would rebuild the old veth pairs. setup-ovn.sh enables it again.
|
||||
sudo systemctl disable --now spinifex-hostnet.service 2>/dev/null || true
|
||||
sudo systemctl reset-failed 'spinifex-*' 2>/dev/null || true
|
||||
sudo pkill -x qemu-system-x86_64 2>/dev/null || true
|
||||
sudo pkill -x qemu-system-aarch64 2>/dev/null || true
|
||||
# comm truncates to 15 characters, so match qemu-system-* by prefix; EL-family
|
||||
# hosts run qemu-kvm. Scoped to the user the daemon runs guests as, as in
|
||||
# node-reset.sh, so libvirt guests on the same host are left alone.
|
||||
QEMU_PGREP=('^(qemu-system|qemu-kvm$)')
|
||||
for qemu_user in spinifex-daemon spinifex "$INVOKING_USER"; do
|
||||
if id -u "$qemu_user" >/dev/null 2>&1; then
|
||||
QEMU_PGREP=(-u "$qemu_user" "${QEMU_PGREP[@]}")
|
||||
break
|
||||
fi
|
||||
done
|
||||
sudo pkill "${QEMU_PGREP[@]}" 2>/dev/null || true
|
||||
|
||||
# Wait for QEMU to fully exit before tearing down viperblock state.
|
||||
timeout=30
|
||||
elapsed=0
|
||||
while pgrep -x 'qemu-system-x86_64|qemu-system-aarch64' > /dev/null 2>&1; do
|
||||
while pgrep "${QEMU_PGREP[@]}" > /dev/null 2>&1; do
|
||||
if [ "$elapsed" -ge "$timeout" ]; then
|
||||
echo "❌ QEMU still running after ${timeout}s:"
|
||||
pgrep -af 'qemu-system-' || true
|
||||
pgrep -a "${QEMU_PGREP[@]}" || true
|
||||
echo " Kill them manually and re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -66,7 +66,7 @@ MAX_QEMU_ATTEMPTS=60
|
||||
QEMU_CMD=""
|
||||
|
||||
while [ $QEMU_ATTEMPTS -lt $MAX_QEMU_ATTEMPTS ]; do
|
||||
QEMU_CMD=$(ps auxw | grep "$INSTANCE_ID" | grep qemu-system | grep -v grep)
|
||||
QEMU_CMD=$(ps auxw | grep "$INSTANCE_ID" | grep -E "qemu-system|qemu-kvm" | grep -v grep)
|
||||
|
||||
if [ -n "$QEMU_CMD" ]; then
|
||||
break
|
||||
|
||||
+376
-74
@@ -4,12 +4,20 @@
|
||||
#
|
||||
# This script bootstraps a compute node for OVN-based VPC networking:
|
||||
# 1. Installs OVN/OVS packages (if not present)
|
||||
# 2. Enables required services (openvswitch-switch, ovn-controller)
|
||||
# 2. Enables required services (Open vSwitch, ovn-controller)
|
||||
# 3. Creates br-int with secure fail-mode
|
||||
# 4. Configures WAN bridge for public subnet uplink (auto-detected or manual)
|
||||
# 5. Configures OVS external_ids for OVN chassis identity
|
||||
# 6. Applies sysctl tuning for overlay networking
|
||||
#
|
||||
# Host network persistence: the links this script creates are kernel-only. On
|
||||
# a host where systemd-networkd is enabled, or that already has Spinifex
|
||||
# networkd files, they persist in /etc/systemd/network. Otherwise (for example
|
||||
# a NetworkManager host) the topology goes to /etc/spinifex/hostnet.json and
|
||||
# spinifex-hostnet.service re-creates it at boot and on every OVS start. The
|
||||
# first run records which in /etc/spinifex/hostnet-backend; it never changes.
|
||||
# Where NetworkManager runs, the links Spinifex owns are made unmanaged.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/setup-ovn.sh [options]
|
||||
#
|
||||
@@ -121,6 +129,19 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Host-OS capability probes: beside this script in a checkout and in the
|
||||
# installed share dir, else where setup.sh installs them.
|
||||
DISTRO_LIB=""
|
||||
for candidate in "$(dirname "${BASH_SOURCE[0]}")/lib/distro.sh" /usr/local/share/spinifex/lib/distro.sh; do
|
||||
if [ -f "$candidate" ]; then DISTRO_LIB="$candidate"; break; fi
|
||||
done
|
||||
if [ -z "$DISTRO_LIB" ]; then
|
||||
echo "ERROR: distro.sh not found at $(dirname "${BASH_SOURCE[0]}")/lib/distro.sh or /usr/local/share/spinifex/lib/distro.sh; re-run setup.sh to install it" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck source=lib/distro.sh
|
||||
. "$DISTRO_LIB"
|
||||
|
||||
# Defaults
|
||||
MANAGEMENT=false
|
||||
WAN_BRIDGE=""
|
||||
@@ -222,6 +243,152 @@ iface_master() {
|
||||
printf '%s' "$m"
|
||||
}
|
||||
|
||||
# --- Host network persistence ---
|
||||
#
|
||||
# Links this script creates are kernel-only and vanish on reboot. The first run
|
||||
# picks how they persist and records it: networkd where systemd-networkd is
|
||||
# enabled, else hostnet (spinifex-hostnet.service replays HOSTNET_STATE at boot
|
||||
# and on every ovs-vswitchd start). There is no switching; reset the node.
|
||||
HOSTNET_BACKEND_FILE=/etc/spinifex/hostnet-backend
|
||||
HOSTNET_STATE=/etc/spinifex/hostnet.json
|
||||
HOSTNET_LOCK=/run/spinifex/hostnet.lock
|
||||
NETWORKD_DIR=/etc/systemd/network
|
||||
NM_UNMANAGED_CONF=/etc/NetworkManager/conf.d/00-spinifex-unmanaged.conf
|
||||
NM_UNMANAGED_RULE=/etc/udev/rules.d/90-spinifex-nm-unmanaged.rules
|
||||
# System-instance management TAPs: "mg" plus 13 hex digits of the instance ID.
|
||||
# NetworkManager globs have no character classes, so this match goes to udev.
|
||||
MGMT_TAP_GLOB='mg[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'
|
||||
|
||||
# Prints the recorded backend, or picks one and records it. A node with
|
||||
# Spinifex networkd files and no record was set up before the record existed.
|
||||
select_hostnet_backend() {
|
||||
local backend
|
||||
if sudo test -e "$HOSTNET_BACKEND_FILE"; then
|
||||
backend=$(sudo cat "$HOSTNET_BACKEND_FILE" | tr -d '[:space:]')
|
||||
case "$backend" in
|
||||
networkd|hostnet) printf '%s\n' "$backend"; return 0 ;;
|
||||
esac
|
||||
echo "ERROR: $HOSTNET_BACKEND_FILE says '$backend'; expected networkd or hostnet" >&2
|
||||
return 1
|
||||
fi
|
||||
if compgen -G "$NETWORKD_DIR/*-spinifex-*.network" >/dev/null || compgen -G "$NETWORKD_DIR/*-spinifex-*.netdev" >/dev/null; then
|
||||
backend=networkd
|
||||
elif systemctl is-enabled --quiet systemd-networkd.service 2>/dev/null; then
|
||||
backend=networkd
|
||||
else
|
||||
backend=hostnet
|
||||
fi
|
||||
sudo mkdir -p "$(dirname "$HOSTNET_BACKEND_FILE")"
|
||||
printf '%s\n' "$backend" | sudo tee "$HOSTNET_BACKEND_FILE" >/dev/null
|
||||
# Read by `spx admin hostnet check`, which the daemon and vpcd run unprivileged.
|
||||
sudo chmod 0644 "$HOSTNET_BACKEND_FILE"
|
||||
printf '%s\n' "$backend"
|
||||
}
|
||||
|
||||
# write_hostnet_state <true|false> — records the topology for
|
||||
# `spx admin hostnet apply`, atomically. true marks it pending: apply refuses
|
||||
# it until the run that wrote it finishes.
|
||||
write_hostnet_state() {
|
||||
local pending="$1" body
|
||||
body=$(printf '{\n "version": 1,\n "pending": %s,\n "int_bridge": "br-int"' "$pending")
|
||||
case "$WAN_BRIDGE_MODE" in
|
||||
existing)
|
||||
body="$body"$(printf ',\n "wan": {"mode": "existing", "ovs_bridge": "%s"}' "$WAN_BRIDGE") ;;
|
||||
veth)
|
||||
body="$body"$(printf ',\n "wan": {"mode": "veth", "ovs_bridge": "%s", "linux_bridge": "%s", "host_end": "veth-wan-br", "ovs_end": "veth-wan-ovs"}' "$WAN_BRIDGE" "$LINUX_BRIDGE") ;;
|
||||
direct)
|
||||
body="$body"$(printf ',\n "wan": {"mode": "direct", "ovs_bridge": "%s", "nic": "%s"}' "$WAN_BRIDGE" "$WAN_IFACE") ;;
|
||||
nat)
|
||||
body="$body"$(printf ',\n "wan": {"mode": "nat", "ovs_bridge": "%s", "host_end": "spx-nat-host", "ovs_end": "spx-nat-ovs", "host_mac": "%s", "host_cidr": "%s"}' "$WAN_BRIDGE" "$NAT_TRANSIT_HOST_MAC" "$NAT_TRANSIT_GW_CIDR") ;;
|
||||
esac
|
||||
if [ "$MGMT_BRIDGE_ENABLED" = true ]; then
|
||||
body="$body"$(printf ',\n "mgmt": {"bridge": "%s", "cidr": "%s", "nic": "%s"}' "$MGMT_BRIDGE" "$MGMT_CIDR" "$MGMT_IFACE")
|
||||
fi
|
||||
if [ -n "${ENCAP_ROUTE_DST:-}" ]; then
|
||||
body="$body"$(printf ',\n "encap_route": {"dst": "%s", "dev": "%s", "src": "%s", "metric": %s}' \
|
||||
"$ENCAP_ROUTE_DST" "$ENCAP_ROUTE_DEV" "$ENCAP_IP" "$ENCAP_ROUTE_METRIC")
|
||||
fi
|
||||
sudo mkdir -p "$(dirname "$HOSTNET_STATE")"
|
||||
printf '%s\n}\n' "$body" | sudo tee "$HOSTNET_STATE.tmp" >/dev/null
|
||||
sudo chmod 0644 "$HOSTNET_STATE.tmp"
|
||||
sudo mv -f "$HOSTNET_STATE.tmp" "$HOSTNET_STATE"
|
||||
}
|
||||
|
||||
# Holds the lock `spx admin hostnet apply` takes, from a root flock process fed
|
||||
# by a pipe, so an apply started by an OVS restart waits for this run's links.
|
||||
# Never run systemctl against OVS or spinifex-hostnet while holding it.
|
||||
hostnet_lock() {
|
||||
local reply=""
|
||||
sudo mkdir -p "$(dirname "$HOSTNET_LOCK")"
|
||||
coproc HOSTNET_LOCK_HOLDER { exec sudo flock -w 120 "$HOSTNET_LOCK" sh -c 'echo locked; read -r _'; }
|
||||
read -r reply <&"${HOSTNET_LOCK_HOLDER[0]}" || true
|
||||
if [ "$reply" != locked ]; then
|
||||
echo "ERROR: $HOSTNET_LOCK still held after 120 s; is spx admin hostnet apply stuck? (systemctl status spinifex-hostnet)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
hostnet_unlock() {
|
||||
local fd="${HOSTNET_LOCK_HOLDER[1]}" pid="$HOSTNET_LOCK_HOLDER_PID"
|
||||
exec {fd}>&-
|
||||
wait "$pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
nm_running() {
|
||||
systemctl is-active --quiet NetworkManager.service 2>/dev/null
|
||||
}
|
||||
|
||||
# nm_release_nic <nic> — before a NIC moves into OVS, refuse the one carrying
|
||||
# the default route or this SSH session, then take it from NetworkManager. Its
|
||||
# profiles are left alone: once unmanaged, none can activate on it.
|
||||
nm_release_nic() {
|
||||
local nic="$1" ssh_ip state
|
||||
if sudo ovs-vsctl port-to-br "$nic" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if ip -4 route show default 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="dev") print $(i+1)}' | grep -qx "$nic"; then
|
||||
echo "ERROR: $nic carries the default route; moving it into OVS would cut this host off" >&2
|
||||
exit 1
|
||||
fi
|
||||
ssh_ip=$(printf '%s\n' "${SSH_CONNECTION:-}" | awk '{print $3}')
|
||||
if [ -n "$ssh_ip" ] && ip -o -4 addr show dev "$nic" 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | grep -qxF "$ssh_ip"; then
|
||||
echo "ERROR: this SSH session arrived on $nic ($ssh_ip); moving it into OVS would drop it" >&2
|
||||
exit 1
|
||||
fi
|
||||
state=$(nmcli -g GENERAL.STATE device show "$nic" 2>/dev/null || true)
|
||||
case "$state" in
|
||||
*"(connect"*)
|
||||
sudo nmcli device disconnect "$nic" >/dev/null
|
||||
echo " $nic: disconnected from NetworkManager"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Writes the NetworkManager config and udev rule that leave every link Spinifex
|
||||
# owns unmanaged, then has NetworkManager and udev reread them. The host uplink
|
||||
# (a Linux bridge such as br-wan, and its NIC) stays NetworkManager's.
|
||||
write_nm_unmanaged() {
|
||||
local names=(br-int "$WAN_BRIDGE" 'veth-wan-*' 'spx-nat-*' 'tap*' 'ime-*' 'imi-*' 'vhp-*' br-imds 'genev_sys_*')
|
||||
local list="" name
|
||||
if [ "$MGMT_BRIDGE_ENABLED" = true ]; then
|
||||
names+=("$MGMT_BRIDGE" "$MGMT_IFACE")
|
||||
fi
|
||||
if [ "$WAN_BRIDGE_MODE" = direct ]; then
|
||||
names+=("$WAN_IFACE")
|
||||
fi
|
||||
for name in "${names[@]}"; do
|
||||
[ -n "$name" ] && list="${list:+$list;}interface-name:$name"
|
||||
done
|
||||
sudo mkdir -p "$(dirname "$NM_UNMANAGED_CONF")" "$(dirname "$NM_UNMANAGED_RULE")"
|
||||
printf '# Written by setup-ovn.sh on every run: links Spinifex owns.\n[keyfile]\nunmanaged-devices+=%s\n' "$list" \
|
||||
| sudo tee "$NM_UNMANAGED_CONF" >/dev/null
|
||||
printf '# Written by setup-ovn.sh: system-instance management TAPs.\nSUBSYSTEM=="net", KERNEL=="%s", ENV{NM_UNMANAGED}="1"\n' "$MGMT_TAP_GLOB" \
|
||||
| sudo tee "$NM_UNMANAGED_RULE" >/dev/null
|
||||
sudo udevadm control --reload
|
||||
sudo udevadm trigger --action=change --subsystem-match=net --sysname-match='mg*'
|
||||
sudo nmcli general reload conf
|
||||
}
|
||||
|
||||
teardown() {
|
||||
echo "Tearing down the OVN host wiring built by this script"
|
||||
[ "$DRY_RUN" = true ] && echo " (dry run — nothing is changed)"
|
||||
@@ -469,6 +636,15 @@ else
|
||||
detect_wan_bridge
|
||||
fi
|
||||
|
||||
# Routed NAT transit, used by nat mode and recorded for spinifex-hostnet.
|
||||
NAT_TRANSIT_CIDR="100.127.0.0/24"
|
||||
NAT_TRANSIT_GW_CIDR="100.127.0.1/24"
|
||||
# Same on every node. OVN keeps one MAC binding for the transit nexthop
|
||||
# cluster-wide, so a random per-node MAC sends every chassis's egress to
|
||||
# whichever node seeded it; the pair is node-local, so one address everywhere
|
||||
# is unambiguous.
|
||||
NAT_TRANSIT_HOST_MAC="02:00:64:7f:00:01"
|
||||
|
||||
# Auto-detect encap IP if not specified
|
||||
if [ -z "$ENCAP_IP" ]; then
|
||||
# Prefer br-vpc IP if it exists (dedicated VPC data plane)
|
||||
@@ -575,19 +751,70 @@ ensure_clustered_db_storage() {
|
||||
# fresh SB and re-registers under whatever system-id it started with — which
|
||||
# on a renamed node is the old one, and that row then holds this node's encap
|
||||
# IP so the correctly-named chassis can never commit. Step 5 restarts it.
|
||||
sudo systemctl stop ovn-northd ovn-ovsdb-server-nb ovn-ovsdb-server-sb ovn-central ovn-controller 2>/dev/null || true
|
||||
sudo systemctl stop "${OVN_CENTRAL_UNITS[@]}" ovn-controller 2>/dev/null || true
|
||||
sudo rm -f "${standalone[@]}"
|
||||
}
|
||||
|
||||
# set_root_service_user <file> <var> — sets <var>="root:root" in <file> when
|
||||
# the file exists. Returns 0 only when it changed the file.
|
||||
set_root_service_user() {
|
||||
local file=$1 var=$2
|
||||
[ -f "$file" ] || return 1
|
||||
grep -qx "$var=\"root:root\"" "$file" && return 1
|
||||
if grep -q "^$var=" "$file"; then
|
||||
sudo sed -i "s|^$var=.*|$var=\"root:root\"|" "$file"
|
||||
else
|
||||
echo "$var=\"root:root\"" | sudo tee -a "$file" >/dev/null
|
||||
fi
|
||||
echo " $file: $var=\"root:root\""
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "Step 2: Enabling services..."
|
||||
|
||||
sudo systemctl enable openvswitch-switch
|
||||
sudo systemctl start openvswitch-switch
|
||||
echo " openvswitch-switch: started"
|
||||
# Unit names differ by distro: the OVS umbrella, and whether the OVN databases
|
||||
# have units of their own (Debian) or are started by ovn-northd.service (EL).
|
||||
OVS_UNIT=$(distro_ovs_unit) || exit 1
|
||||
OVN_CENTRAL_LAYOUT=""
|
||||
if [ "$MANAGEMENT" = true ] || distro_unit_exists ovn-central.service || distro_unit_exists ovn-northd.service; then
|
||||
OVN_CENTRAL_LAYOUT=$(distro_ovn_central_layout) || exit 1
|
||||
fi
|
||||
case "$OVN_CENTRAL_LAYOUT" in
|
||||
split)
|
||||
OVN_CENTRAL_UNIT=ovn-central.service
|
||||
OVN_DB_UNITS=(ovn-ovsdb-server-nb.service ovn-ovsdb-server-sb.service)
|
||||
OVN_SERVER_UNITS=("${OVN_DB_UNITS[@]}" ovn-northd.service)
|
||||
OVN_CENTRAL_UNITS=(ovn-central.service "${OVN_SERVER_UNITS[@]}")
|
||||
;;
|
||||
lone-northd)
|
||||
OVN_CENTRAL_UNIT=ovn-northd.service
|
||||
OVN_DB_UNITS=(ovn-northd.service)
|
||||
OVN_SERVER_UNITS=(ovn-northd.service)
|
||||
OVN_CENTRAL_UNITS=(ovn-northd.service)
|
||||
;;
|
||||
esac
|
||||
|
||||
# OVS and OVN run as root, as on Debian: the service users reach the sockets
|
||||
# through the spinifex group, a model tested only against root-owned daemons.
|
||||
# EL ships non-root users and re-chowns the run dirs to them on every start.
|
||||
OVS_USER_CHANGED=false
|
||||
if set_root_service_user /etc/sysconfig/openvswitch OVS_USER_ID; then
|
||||
OVS_USER_CHANGED=true
|
||||
fi
|
||||
# Takes effect below: ovn-northd and ovn-controller are restarted either way.
|
||||
set_root_service_user /etc/sysconfig/ovn OVN_USER_ID || true
|
||||
|
||||
sudo systemctl enable "$OVS_UNIT"
|
||||
if [ "$OVS_USER_CHANGED" = true ]; then
|
||||
# start is a no-op on a running OVS, which would keep its old user.
|
||||
sudo systemctl restart "$OVS_UNIT"
|
||||
else
|
||||
sudo systemctl start "$OVS_UNIT"
|
||||
fi
|
||||
echo " ${OVS_UNIT%.service}: started"
|
||||
|
||||
if [ "$MANAGEMENT" = true ]; then
|
||||
sudo systemctl enable ovn-central
|
||||
sudo systemctl enable "$OVN_CENTRAL_UNIT"
|
||||
|
||||
# LAN-plane NB/SB client listener, added to the loopback one set below.
|
||||
# Guard is load-bearing: --db-nb-addr defaults to 0.0.0.0, so
|
||||
@@ -651,10 +878,10 @@ ExecStop=/usr/share/ovn/scripts/ovn-ctl stop_northd --no-monitor --ovn-manage-ov
|
||||
EOF
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# The ovn-central aggregator is ExecStart=/bin/true, so restarting it
|
||||
# won't restart the children — restart the per-DB units directly to
|
||||
# pick up the new OVN_CTL_OPTS.
|
||||
sudo systemctl restart ovn-ovsdb-server-nb ovn-ovsdb-server-sb ovn-northd
|
||||
# Debian's ovn-central aggregator is ExecStart=/bin/true, so restarting
|
||||
# it won't restart the children — restart the DB and northd units
|
||||
# directly to pick up the new OVN_CTL_OPTS.
|
||||
sudo systemctl restart "${OVN_SERVER_UNITS[@]}"
|
||||
echo " ovn-central: started clustered (NB DB + SB DB + ovn-northd)"
|
||||
else
|
||||
# Always write the file, even when there is nothing to put in it. This
|
||||
@@ -664,12 +891,12 @@ EOF
|
||||
# peer that no longer exists, which looks like a hung leader election.
|
||||
echo "OVN_CTL_OPTS=\"$OVN_DB_LISTEN_OPTS\"" | sudo tee /etc/default/ovn-central >/dev/null
|
||||
echo " wrote /etc/default/ovn-central"
|
||||
sudo systemctl start ovn-central
|
||||
sudo systemctl start "$OVN_CENTRAL_UNIT"
|
||||
|
||||
# ovn-central is ExecStart=/bin/true; restarting it does not restart
|
||||
# its children, so a re-run with changed options needs the per-DB units
|
||||
# restarted directly to pick them up.
|
||||
sudo systemctl restart ovn-ovsdb-server-nb ovn-ovsdb-server-sb
|
||||
# Debian's ovn-central is ExecStart=/bin/true; restarting it does not
|
||||
# restart its children, so a re-run with changed options needs the DB
|
||||
# units restarted directly to pick them up.
|
||||
sudo systemctl restart "${OVN_DB_UNITS[@]}"
|
||||
echo " ovn-central: started (NB DB + SB DB + ovn-northd)"
|
||||
if [ -n "$LAN_ADDR" ]; then
|
||||
echo " NB/SB client listen: 127.0.0.1, $LAN_ADDR"
|
||||
@@ -812,10 +1039,10 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if systemctl is-enabled ovn-central >/dev/null 2>&1 ||
|
||||
systemctl is-active ovn-central >/dev/null 2>&1; then
|
||||
sudo systemctl stop ovn-central ovn-northd ovn-ovsdb-server-nb ovn-ovsdb-server-sb 2>/dev/null || true
|
||||
sudo systemctl disable ovn-central ovn-northd ovn-ovsdb-server-nb ovn-ovsdb-server-sb 2>/dev/null || true
|
||||
if [ -n "$OVN_CENTRAL_LAYOUT" ] && { systemctl is-enabled "$OVN_CENTRAL_UNIT" >/dev/null 2>&1 ||
|
||||
systemctl is-active "$OVN_CENTRAL_UNIT" >/dev/null 2>&1; }; then
|
||||
sudo systemctl stop "${OVN_CENTRAL_UNITS[@]}" 2>/dev/null || true
|
||||
sudo systemctl disable "${OVN_CENTRAL_UNITS[@]}" 2>/dev/null || true
|
||||
echo " ovn-central: stopped and disabled (compute node runs no database)"
|
||||
fi
|
||||
for db in "$OVN_DBDIR/ovnnb_db.db" "$OVN_DBDIR/ovnsb_db.db"; do
|
||||
@@ -825,6 +1052,34 @@ else
|
||||
done
|
||||
fi
|
||||
|
||||
# --- Step 2b: Host network persistence and NetworkManager ---
|
||||
echo ""
|
||||
echo "Step 2b: Host network persistence..."
|
||||
HOSTNET_BACKEND=$(select_hostnet_backend)
|
||||
echo " backend: $HOSTNET_BACKEND (recorded in $HOSTNET_BACKEND_FILE)"
|
||||
|
||||
if nm_running; then
|
||||
if [ "$MGMT_BRIDGE_ENABLED" = true ] && [ -n "$MGMT_IFACE" ]; then
|
||||
nm_release_nic "$MGMT_IFACE"
|
||||
fi
|
||||
if [ "$WAN_BRIDGE_MODE" = direct ]; then
|
||||
nm_release_nic "$WAN_IFACE"
|
||||
fi
|
||||
write_nm_unmanaged
|
||||
echo " NetworkManager: Spinifex links unmanaged ($NM_UNMANAGED_CONF, $NM_UNMANAGED_RULE)"
|
||||
fi
|
||||
|
||||
# Held until the wiring below is complete; a run that dies before then leaves
|
||||
# the state pending, and spinifex-hostnet refuses it until this script is rerun.
|
||||
ENCAP_ROUTE_DST=""
|
||||
ENCAP_ROUTE_DEV=""
|
||||
ENCAP_ROUTE_METRIC=50
|
||||
if [ "$HOSTNET_BACKEND" = hostnet ]; then
|
||||
hostnet_lock
|
||||
write_hostnet_state true
|
||||
echo " $HOSTNET_STATE: pending until this run finishes wiring"
|
||||
fi
|
||||
|
||||
# --- Step 3: Create and configure br-int ---
|
||||
echo ""
|
||||
echo "Step 3: Configuring br-int..."
|
||||
@@ -842,7 +1097,7 @@ echo " br-int: created, fail-mode=secure, up"
|
||||
# still forwards, but ovn-controller flow programming and any tooling that
|
||||
# probes link state misbehave. Unmanaged=yes keeps OVS in sole control.
|
||||
OVS_INTERNAL_NET=/etc/systemd/network/05-spinifex-ovs-internal.network
|
||||
if [ ! -f "$OVS_INTERNAL_NET" ]; then
|
||||
if [ "$HOSTNET_BACKEND" = networkd ] && [ ! -f "$OVS_INTERNAL_NET" ]; then
|
||||
sudo tee "$OVS_INTERNAL_NET" >/dev/null <<'NETWORK'
|
||||
[Match]
|
||||
Name=br-int br-ext
|
||||
@@ -956,23 +1211,24 @@ if [ -n "$WAN_BRIDGE" ]; then
|
||||
# existing networkd config. networkd searches /etc, /run, /usr/lib;
|
||||
# check all three. Our own file is excluded so idempotent re-runs
|
||||
# still rewrite when needed.
|
||||
BR_WAN_NETDEV="/etc/systemd/network/14-spinifex-br-wan.netdev"
|
||||
VETH_NETDEV="/etc/systemd/network/15-spinifex-veth-wan.netdev"
|
||||
VETH_NETWORK="/etc/systemd/network/15-spinifex-veth-wan.network"
|
||||
VETH_OVS_NETWORK="/etc/systemd/network/16-spinifex-veth-wan-ovs.network"
|
||||
EXISTING_BR_NETDEV=$(grep -rls --include="*.netdev" -E "^\s*Name=$LINUX_BRIDGE\s*$" \
|
||||
/etc/systemd/network /run/systemd/network /usr/lib/systemd/network 2>/dev/null \
|
||||
| grep -v "^$BR_WAN_NETDEV$" || true)
|
||||
if [ -n "$EXISTING_BR_NETDEV" ]; then
|
||||
echo " skipping $BR_WAN_NETDEV — operator-managed NetDev already declares $LINUX_BRIDGE: $EXISTING_BR_NETDEV"
|
||||
else
|
||||
sudo tee "$BR_WAN_NETDEV" >/dev/null <<NETDEV
|
||||
if [ "$HOSTNET_BACKEND" = networkd ]; then
|
||||
BR_WAN_NETDEV="/etc/systemd/network/14-spinifex-br-wan.netdev"
|
||||
VETH_NETDEV="/etc/systemd/network/15-spinifex-veth-wan.netdev"
|
||||
VETH_NETWORK="/etc/systemd/network/15-spinifex-veth-wan.network"
|
||||
VETH_OVS_NETWORK="/etc/systemd/network/16-spinifex-veth-wan-ovs.network"
|
||||
EXISTING_BR_NETDEV=$(grep -rls --include="*.netdev" -E "^\s*Name=$LINUX_BRIDGE\s*$" \
|
||||
/etc/systemd/network /run/systemd/network /usr/lib/systemd/network 2>/dev/null \
|
||||
| grep -v "^$BR_WAN_NETDEV$" || true)
|
||||
if [ -n "$EXISTING_BR_NETDEV" ]; then
|
||||
echo " skipping $BR_WAN_NETDEV — operator-managed NetDev already declares $LINUX_BRIDGE: $EXISTING_BR_NETDEV"
|
||||
else
|
||||
sudo tee "$BR_WAN_NETDEV" >/dev/null <<NETDEV
|
||||
[NetDev]
|
||||
Name=$LINUX_BRIDGE
|
||||
Kind=bridge
|
||||
NETDEV
|
||||
fi
|
||||
sudo tee "$VETH_NETDEV" >/dev/null <<NETDEV
|
||||
fi
|
||||
sudo tee "$VETH_NETDEV" >/dev/null <<NETDEV
|
||||
[NetDev]
|
||||
Name=veth-wan-br
|
||||
Kind=veth
|
||||
@@ -980,7 +1236,7 @@ Kind=veth
|
||||
[Peer]
|
||||
Name=veth-wan-ovs
|
||||
NETDEV
|
||||
sudo tee "$VETH_NETWORK" >/dev/null <<NETWORK
|
||||
sudo tee "$VETH_NETWORK" >/dev/null <<NETWORK
|
||||
[Match]
|
||||
Name=veth-wan-br
|
||||
|
||||
@@ -988,12 +1244,12 @@ Name=veth-wan-br
|
||||
Bridge=$LINUX_BRIDGE
|
||||
ConfigureWithoutCarrier=yes
|
||||
NETWORK
|
||||
# Second unit admin-ups the OVS end of the pair. OVS owns the port
|
||||
# (enslaved via ovs-vsctl add-port above) but does not flip admin
|
||||
# state on external ports — that's networkd's job. Without this,
|
||||
# veth-wan-ovs stays DOWN after reboot, peer goes LOWERLAYERDOWN,
|
||||
# br-wan loses carrier.
|
||||
sudo tee "$VETH_OVS_NETWORK" >/dev/null <<NETWORK
|
||||
# Second unit admin-ups the OVS end of the pair. OVS owns the port
|
||||
# (enslaved via ovs-vsctl add-port above) but does not flip admin
|
||||
# state on external ports — that's networkd's job. Without this,
|
||||
# veth-wan-ovs stays DOWN after reboot, peer goes LOWERLAYERDOWN,
|
||||
# br-wan loses carrier.
|
||||
sudo tee "$VETH_OVS_NETWORK" >/dev/null <<NETWORK
|
||||
[Match]
|
||||
Name=veth-wan-ovs
|
||||
|
||||
@@ -1003,8 +1259,9 @@ RequiredForOnline=no
|
||||
[Network]
|
||||
ConfigureWithoutCarrier=yes
|
||||
NETWORK
|
||||
sudo networkctl reload 2>/dev/null || true
|
||||
echo " wrote $VETH_NETDEV + $VETH_NETWORK + $VETH_OVS_NETWORK (veth persists on reboot)"
|
||||
sudo networkctl reload 2>/dev/null || true
|
||||
echo " wrote $VETH_NETDEV + $VETH_NETWORK + $VETH_OVS_NETWORK (veth persists on reboot)"
|
||||
fi
|
||||
;;
|
||||
|
||||
direct)
|
||||
@@ -1038,14 +1295,6 @@ NETWORK
|
||||
# forwards and masquerades the transit /24 out whatever uplink it
|
||||
# has. OVN SNATs each VPC CIDR to its gateway LRP transit IP, so
|
||||
# the masquerade rule below is the only host-side NAT state.
|
||||
NAT_TRANSIT_CIDR="100.127.0.0/24"
|
||||
NAT_TRANSIT_GW_CIDR="100.127.0.1/24"
|
||||
# Same on every node. OVN keeps one MAC binding for the transit
|
||||
# nexthop cluster-wide, so a random per-node MAC sends every
|
||||
# chassis's egress to whichever node seeded it; the pair is
|
||||
# node-local, so one address everywhere is unambiguous.
|
||||
NAT_TRANSIT_HOST_MAC="02:00:64:7f:00:01"
|
||||
|
||||
if ! sudo ovs-vsctl br-exists "$WAN_BRIDGE" 2>/dev/null; then
|
||||
sudo ovs-vsctl --may-exist add-br "$WAN_BRIDGE"
|
||||
echo " created OVS bridge: $WAN_BRIDGE"
|
||||
@@ -1073,10 +1322,11 @@ NETWORK
|
||||
|
||||
# Persist the veth pair + transit IP across reboot (veths are
|
||||
# kernel-only; same rationale as veth mode above).
|
||||
NAT_NETDEV="/etc/systemd/network/17-spinifex-nat.netdev"
|
||||
NAT_NETWORK="/etc/systemd/network/17-spinifex-nat.network"
|
||||
NAT_OVS_NETWORK="/etc/systemd/network/18-spinifex-nat-ovs.network"
|
||||
sudo tee "$NAT_NETDEV" >/dev/null <<NETDEV
|
||||
if [ "$HOSTNET_BACKEND" = networkd ]; then
|
||||
NAT_NETDEV="/etc/systemd/network/17-spinifex-nat.netdev"
|
||||
NAT_NETWORK="/etc/systemd/network/17-spinifex-nat.network"
|
||||
NAT_OVS_NETWORK="/etc/systemd/network/18-spinifex-nat-ovs.network"
|
||||
sudo tee "$NAT_NETDEV" >/dev/null <<NETDEV
|
||||
[NetDev]
|
||||
Name=spx-nat-host
|
||||
Kind=veth
|
||||
@@ -1085,7 +1335,7 @@ MACAddress=$NAT_TRANSIT_HOST_MAC
|
||||
[Peer]
|
||||
Name=spx-nat-ovs
|
||||
NETDEV
|
||||
sudo tee "$NAT_NETWORK" >/dev/null <<NETWORK
|
||||
sudo tee "$NAT_NETWORK" >/dev/null <<NETWORK
|
||||
[Match]
|
||||
Name=spx-nat-host
|
||||
|
||||
@@ -1093,9 +1343,9 @@ Name=spx-nat-host
|
||||
Address=$NAT_TRANSIT_GW_CIDR
|
||||
ConfigureWithoutCarrier=yes
|
||||
NETWORK
|
||||
# Admin-up the OVS end after reboot (OVS owns the port but does
|
||||
# not flip admin state on external ports — same as veth mode).
|
||||
sudo tee "$NAT_OVS_NETWORK" >/dev/null <<NETWORK
|
||||
# Admin-up the OVS end after reboot (OVS owns the port but does
|
||||
# not flip admin state on external ports — same as veth mode).
|
||||
sudo tee "$NAT_OVS_NETWORK" >/dev/null <<NETWORK
|
||||
[Match]
|
||||
Name=spx-nat-ovs
|
||||
|
||||
@@ -1105,8 +1355,9 @@ RequiredForOnline=no
|
||||
[Network]
|
||||
ConfigureWithoutCarrier=yes
|
||||
NETWORK
|
||||
sudo networkctl reload 2>/dev/null || true
|
||||
echo " wrote $NAT_NETDEV + $NAT_NETWORK + $NAT_OVS_NETWORK (transit veth persists on reboot)"
|
||||
sudo networkctl reload 2>/dev/null || true
|
||||
echo " wrote $NAT_NETDEV + $NAT_NETWORK + $NAT_OVS_NETWORK (transit veth persists on reboot)"
|
||||
fi
|
||||
|
||||
# Kernel egress: masquerade the transit /24 out any uplink and
|
||||
# accept forwarded transit traffic. vpcd re-ensures these on every
|
||||
@@ -1261,8 +1512,9 @@ if [ "$MGMT_BRIDGE_ENABLED" = true ]; then
|
||||
# Persist the IP across reboots via a systemd-networkd drop-in. The
|
||||
# OVS bridge definition itself survives in ovsdb; only the L3 IP
|
||||
# needs re-applying on boot.
|
||||
MGMT_NETD_UNIT="/etc/systemd/network/10-spinifex-mgmt.network"
|
||||
sudo tee "$MGMT_NETD_UNIT" >/dev/null <<NETD
|
||||
if [ "$HOSTNET_BACKEND" = networkd ]; then
|
||||
MGMT_NETD_UNIT="/etc/systemd/network/10-spinifex-mgmt.network"
|
||||
sudo tee "$MGMT_NETD_UNIT" >/dev/null <<NETD
|
||||
[Match]
|
||||
Name=$MGMT_BRIDGE
|
||||
|
||||
@@ -1270,7 +1522,8 @@ Name=$MGMT_BRIDGE
|
||||
Address=$MGMT_CIDR
|
||||
ConfigureWithoutCarrier=yes
|
||||
NETD
|
||||
echo " wrote $MGMT_NETD_UNIT (IP persists on reboot via systemd-networkd)"
|
||||
echo " wrote $MGMT_NETD_UNIT (IP persists on reboot via systemd-networkd)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1401,8 +1654,10 @@ DATA_IFACE=$(ip -o -4 addr show | awk -v ip="$ENCAP_IP" '$0 ~ ip"/" {print $2}')
|
||||
if [ -n "$DATA_IFACE" ]; then
|
||||
SUBNET=$(ip -o -4 route show dev "$DATA_IFACE" proto kernel scope link | awk '{print $1}' | head -1)
|
||||
if [ -n "$SUBNET" ]; then
|
||||
sudo ip route replace "$SUBNET" dev "$DATA_IFACE" src "$ENCAP_IP" metric 50
|
||||
echo " data route: $SUBNET via $DATA_IFACE src $ENCAP_IP (metric 50)"
|
||||
sudo ip route replace "$SUBNET" dev "$DATA_IFACE" src "$ENCAP_IP" metric "$ENCAP_ROUTE_METRIC"
|
||||
echo " data route: $SUBNET via $DATA_IFACE src $ENCAP_IP (metric $ENCAP_ROUTE_METRIC)"
|
||||
ENCAP_ROUTE_DST="$SUBNET"
|
||||
ENCAP_ROUTE_DEV="$DATA_IFACE"
|
||||
else
|
||||
echo " skipped: no kernel route found for $DATA_IFACE"
|
||||
fi
|
||||
@@ -1410,6 +1665,12 @@ else
|
||||
echo " skipped: could not find interface for $ENCAP_IP"
|
||||
fi
|
||||
|
||||
if [ "$HOSTNET_BACKEND" = hostnet ]; then
|
||||
write_hostnet_state false
|
||||
hostnet_unlock
|
||||
echo " $HOSTNET_STATE: recorded for spinifex-hostnet"
|
||||
fi
|
||||
|
||||
# --- Step 7: Verify Geneve kernel support ---
|
||||
echo ""
|
||||
echo "Step 7: Verifying Geneve kernel module..."
|
||||
@@ -1509,17 +1770,24 @@ echo " OVS/OVN sockets: 0660 root:spinifex (group-scoped, no world access)"
|
||||
# file is the old 0666 override, and skipping would leave that exposure in place.
|
||||
# openvswitch-ipsec is included because Step 10 restarts it after this sweep,
|
||||
# so its ctl socket would otherwise be the one file left at the shipped mode.
|
||||
for unit in openvswitch-switch:/var/run/openvswitch/db.sock \
|
||||
"ovn-controller:/var/run/ovn/*.ctl" \
|
||||
"openvswitch-ipsec:/var/run/openvswitch/ovs-monitor-ipsec.*.ctl"; do
|
||||
# ovs-vswitchd starts after ovsdb-server and restarts with it (Requires=), so
|
||||
# its hook sees both daemons' sockets and fires on a restart of either.
|
||||
PERMS_UNITS=("ovs-vswitchd:/var/run/openvswitch/ovs-vswitchd.*.ctl"
|
||||
"ovn-controller:/var/run/ovn/*.ctl"
|
||||
"openvswitch-ipsec:/var/run/openvswitch/ovs-monitor-ipsec.*.ctl")
|
||||
# EL's ovn-northd.service starts the NB and SB databases as well as northd.
|
||||
if [ "$OVN_CENTRAL_LAYOUT" = lone-northd ]; then
|
||||
PERMS_UNITS+=("ovn-northd:/var/run/ovn/ovn-northd.*.ctl")
|
||||
fi
|
||||
for unit in "${PERMS_UNITS[@]}"; do
|
||||
UNIT="${unit%%:*}"
|
||||
WAIT_GLOB="${unit#*:}"
|
||||
OVERRIDE_DIR="/etc/systemd/system/${UNIT}.service.d"
|
||||
sudo mkdir -p "$OVERRIDE_DIR"
|
||||
# `ExecStartPost=-` so a failure here cannot stop the unit. This drop-in goes
|
||||
# into another package's service, and tightening a socket mode is hardening,
|
||||
# not a liveness requirement — it must never be the reason openvswitch-switch
|
||||
# or ovn-controller will not start. Without the dash, removing the helper
|
||||
# not a liveness requirement — it must never be the reason OVS or
|
||||
# ovn-controller will not start. Without the dash, removing the helper
|
||||
# (an uninstall, a partial re-run that has not reached this step yet) takes
|
||||
# OVS down across the whole host with `Failed at step EXEC`, which reads as
|
||||
# an OVS fault and is nothing of the sort.
|
||||
@@ -1570,7 +1838,7 @@ fi
|
||||
# to prevent log spam during those retries.
|
||||
echo ""
|
||||
echo "Step 10: Enabling OVN auto-start on boot..."
|
||||
sudo systemctl enable openvswitch-switch 2>/dev/null || true
|
||||
sudo systemctl enable "$OVS_UNIT" 2>/dev/null || true
|
||||
sudo systemctl enable ovn-controller 2>/dev/null || true
|
||||
# ovs-monitor-ipsec drives strongSwan from OVS DB cert pointers. The daemon's
|
||||
# enableOVNIPSec() flips ipsec_encapsulation=true at runtime and silently drops
|
||||
@@ -1583,9 +1851,43 @@ sudo systemctl enable openvswitch-ipsec.service 2>/dev/null || true
|
||||
# now that strongswan-starter is present.
|
||||
sudo systemctl reset-failed openvswitch-ipsec.service 2>/dev/null || true
|
||||
sudo systemctl restart openvswitch-ipsec.service 2>/dev/null || true
|
||||
echo " openvswitch-switch: enabled on boot"
|
||||
echo " ovn-controller: enabled on boot"
|
||||
echo " openvswitch-ipsec: enabled on boot"
|
||||
printf ' %-21s enabled on boot\n' "${OVS_UNIT%.service}:" ovn-controller: openvswitch-ipsec:
|
||||
|
||||
# Applied now, outside the hostnet lock, so the daemon and vpcd find its
|
||||
# readiness marker; on the networkd backend the apply does nothing.
|
||||
if [ -f /etc/systemd/system/spinifex-hostnet.service ]; then
|
||||
sudo systemctl enable spinifex-hostnet.service >/dev/null 2>&1
|
||||
if ! sudo systemctl restart spinifex-hostnet.service; then
|
||||
echo "ERROR: spinifex-hostnet could not apply the host network: journalctl -u spinifex-hostnet" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf ' %-21s enabled on boot, applied (%s backend)\n' spinifex-hostnet: "$HOSTNET_BACKEND"
|
||||
else
|
||||
echo " spinifex-hostnet: not installed yet — run setup.sh, then re-run this script"
|
||||
fi
|
||||
|
||||
# systemd silently drops ordering against a unit that is not installed, so a
|
||||
# missing OVS or OVN unit would leave Spinifex starting unordered against OVS.
|
||||
SPINIFEX_UNITS=()
|
||||
for f in /etc/systemd/system/spinifex.target /etc/systemd/system/spinifex-*.service; do
|
||||
[ -f "$f" ] && SPINIFEX_UNITS+=("$f")
|
||||
done
|
||||
if [ ${#SPINIFEX_UNITS[@]} -eq 0 ]; then
|
||||
echo " Spinifex units: not installed yet — run setup.sh, then re-run this script"
|
||||
else
|
||||
MISSING_DEPS=$(distro_missing_ovs_deps "${SPINIFEX_UNITS[@]}") || exit 1
|
||||
DEPS_OK=true
|
||||
for dep in $MISSING_DEPS; do
|
||||
if [ "$dep" = openvswitch-ipsec.service ]; then
|
||||
echo " WARNING: $dep is not installed; IPsec cannot be turned on"
|
||||
else
|
||||
echo "ERROR: the Spinifex units order against $dep, which is not installed" >&2
|
||||
DEPS_OK=false
|
||||
fi
|
||||
done
|
||||
[ "$DEPS_OK" = true ] || exit 1
|
||||
echo " OVS/OVN units the Spinifex units name: installed"
|
||||
fi
|
||||
|
||||
# --- Step 11: Health check ---
|
||||
echo ""
|
||||
|
||||
+128
-2
@@ -43,7 +43,13 @@ esac
|
||||
# Teardown, dry run. Every mutation must go through run(), so a dry run can
|
||||
# report the whole plan without performing any of it.
|
||||
# --------------------------------------------------------------------------
|
||||
out=$("$SETUP" --teardown --dry-run 2>&1) && rc=0 || rc=$?
|
||||
# The teardown plans the veth steps only when ovs-vsctl exists and the link is
|
||||
# present, so stub both or the order check depends on the machine running it.
|
||||
mkdir "$TMP/bin"
|
||||
printf '#!/bin/sh\nexit 0\n' > "$TMP/bin/ip"
|
||||
cp "$TMP/bin/ip" "$TMP/bin/ovs-vsctl"
|
||||
chmod +x "$TMP/bin/ip" "$TMP/bin/ovs-vsctl"
|
||||
out=$(PATH="$TMP/bin:$PATH" "$SETUP" --teardown --dry-run 2>&1) && rc=0 || rc=$?
|
||||
[ "$rc" = 0 ] && pass "--teardown --dry-run exits 0" || fail "--teardown --dry-run exited $rc, want 0"
|
||||
|
||||
case "$out" in
|
||||
@@ -125,7 +131,7 @@ esac
|
||||
|
||||
# The script and the Go constant must agree: vpcd repairs a veth made before
|
||||
# this existed, and a disagreement would have the two fighting every pass.
|
||||
script_mac=$(printf '%s\n' "$nat_mode_body" | sed -n 's/.*NAT_TRANSIT_HOST_MAC="\([^"]*\)".*/\1/p' | head -1)
|
||||
script_mac=$(sed -n 's/^NAT_TRANSIT_HOST_MAC="\([^"]*\)".*/\1/p' "$SETUP" | head -1)
|
||||
go_mac=$(sed -n 's/.*NATTransitHostMAC = "\([^"]*\)".*/\1/p' "$SCRIPT_DIR/../spinifex/domains/network/host/routed.go" | head -1)
|
||||
if [ -n "$script_mac" ] && [ "$script_mac" = "$go_mac" ]; then
|
||||
pass "the transit MAC matches host.NATTransitHostMAC"
|
||||
@@ -133,6 +139,126 @@ else
|
||||
fail "transit MAC disagrees: script=$script_mac go=$go_mac"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# OVS and OVN service users.
|
||||
# --------------------------------------------------------------------------
|
||||
# EL runs OVS and OVN as non-root users and re-chowns their sockets on every
|
||||
# start. The function is run on its own against scratch files, through a sudo
|
||||
# that only runs its arguments.
|
||||
awk '$0 == "set_root_service_user() {" { on = 1 } on { print } on && $0 == "}" { exit }' "$SETUP" >"$TMP/user_fn.sh"
|
||||
mkdir -p "$TMP/bin"
|
||||
printf '#!/bin/sh\nexec "$@"\n' >"$TMP/bin/sudo"
|
||||
chmod +x "$TMP/bin/sudo"
|
||||
|
||||
# set_user <file> — runs the function on <file> for OVS_USER_ID; sets RC.
|
||||
set_user() {
|
||||
RC=0
|
||||
PATH="$TMP/bin:$PATH" bash -c '. "$1"; set_root_service_user "$2" OVS_USER_ID' _ "$TMP/user_fn.sh" "$1" >/dev/null || RC=$?
|
||||
}
|
||||
|
||||
set_user "$TMP/absent"
|
||||
if [ "$RC" != 0 ] && [ ! -e "$TMP/absent" ]; then pass "service user: no file, no change"; else fail "service user: absent file gave rc=$RC or was created"; fi
|
||||
|
||||
printf '# OVS_USER_ID="openvswitch:openvswitch"\nOPTIONS=""\nOVS_USER_ID="openvswitch:hugetlbfs"\n' >"$TMP/el"
|
||||
set_user "$TMP/el"
|
||||
want='# OVS_USER_ID="openvswitch:openvswitch"
|
||||
OPTIONS=""
|
||||
OVS_USER_ID="root:root"'
|
||||
if [ "$RC" = 0 ] && [ "$(cat "$TMP/el")" = "$want" ]; then pass "service user: EL default replaced in place, comments and other lines kept"; else fail "service user: EL file rc=$RC: $(cat "$TMP/el")"; fi
|
||||
|
||||
set_user "$TMP/el"
|
||||
if [ "$RC" != 0 ] && [ "$(cat "$TMP/el")" = "$want" ]; then pass "service user: already root reports no change"; else fail "service user: second run rc=$RC: $(cat "$TMP/el")"; fi
|
||||
|
||||
printf 'OPTIONS=""\n' >"$TMP/unset"
|
||||
set_user "$TMP/unset"
|
||||
if [ "$RC" = 0 ] && [ "$(cat "$TMP/unset")" = 'OPTIONS=""
|
||||
OVS_USER_ID="root:root"' ]; then pass "service user: appended when the file does not set it"; else fail "service user: unset file rc=$RC: $(cat "$TMP/unset")"; fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Host network persistence backend.
|
||||
# --------------------------------------------------------------------------
|
||||
# The backend is picked once and recorded; every later run reads the record.
|
||||
# The functions run on their own against scratch paths, with systemctl stubbed.
|
||||
for fn in select_hostnet_backend write_nm_unmanaged; do
|
||||
awk -v f="$fn() {" '$0 == f { on = 1 } on { print } on && $0 == "}" { exit }' "$SETUP" >>"$TMP/hostnet_fn.sh"
|
||||
done
|
||||
|
||||
# backend <networkd-enabled:yes|no> — runs select_hostnet_backend; sets RC, OUT.
|
||||
backend() {
|
||||
printf '#!/bin/sh
|
||||
[ "$1 $2" = "is-enabled --quiet" ] && [ "%s" = yes ]
|
||||
' "$1" >"$TMP/bin/systemctl"
|
||||
chmod +x "$TMP/bin/systemctl"
|
||||
RC=0
|
||||
OUT=$(PATH="$TMP/bin:$PATH" HOSTNET_BACKEND_FILE="$TMP/hn/etc/hostnet-backend" NETWORKD_DIR="$TMP/hn/network" \
|
||||
bash -c '. "$1"; select_hostnet_backend' _ "$TMP/hostnet_fn.sh" 2>&1) || RC=$?
|
||||
}
|
||||
|
||||
reset_hn() { rm -rf "$TMP/hn"; mkdir -p "$TMP/hn/network"; }
|
||||
|
||||
reset_hn
|
||||
backend no
|
||||
if [ "$RC" = 0 ] && [ "$OUT" = hostnet ] && [ "$(cat "$TMP/hn/etc/hostnet-backend")" = hostnet ]; then pass "backend: no networkd picks and records hostnet"; else fail "backend: no networkd gave rc=$RC out=$OUT"; fi
|
||||
|
||||
reset_hn
|
||||
backend yes
|
||||
if [ "$RC" = 0 ] && [ "$OUT" = networkd ] && [ "$(cat "$TMP/hn/etc/hostnet-backend")" = networkd ]; then pass "backend: networkd enabled picks networkd"; else fail "backend: networkd enabled gave rc=$RC out=$OUT"; fi
|
||||
|
||||
reset_hn
|
||||
: >"$TMP/hn/network/10-spinifex-mgmt.network"
|
||||
backend no
|
||||
if [ "$RC" = 0 ] && [ "$OUT" = networkd ]; then pass "backend: legacy Spinifex networkd files keep networkd"; else fail "backend: legacy files gave rc=$RC out=$OUT"; fi
|
||||
|
||||
reset_hn
|
||||
mkdir -p "$TMP/hn/etc"
|
||||
echo hostnet >"$TMP/hn/etc/hostnet-backend"
|
||||
backend yes
|
||||
if [ "$RC" = 0 ] && [ "$OUT" = hostnet ]; then pass "backend: the record wins over networkd being enabled"; else fail "backend: recorded hostnet gave rc=$RC out=$OUT"; fi
|
||||
|
||||
echo ifupdown >"$TMP/hn/etc/hostnet-backend"
|
||||
backend yes
|
||||
case "$RC:$OUT" in
|
||||
0:*) fail "backend: an unknown record was accepted: $OUT" ;;
|
||||
*"expected networkd or hostnet"*) pass "backend: an unknown record is refused" ;;
|
||||
*) fail "backend: an unknown record gave rc=$RC out=$OUT" ;;
|
||||
esac
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# NetworkManager unmanaged config.
|
||||
# --------------------------------------------------------------------------
|
||||
# NetworkManager globs have no character classes, so the management TAPs go to
|
||||
# a udev rule; the keyfile lists the rest. The host uplink bridge stays managed.
|
||||
printf '#!/bin/sh
|
||||
exit 0
|
||||
' >"$TMP/bin/udevadm"
|
||||
cp "$TMP/bin/udevadm" "$TMP/bin/nmcli"
|
||||
chmod +x "$TMP/bin/udevadm" "$TMP/bin/nmcli"
|
||||
glob=$(sed -n "s/^MGMT_TAP_GLOB='\([^']*\)'$/\1/p" "$SETUP")
|
||||
RC=0
|
||||
PATH="$TMP/bin:$PATH" NM_UNMANAGED_CONF="$TMP/nm/conf.d/00-spinifex-unmanaged.conf" NM_UNMANAGED_RULE="$TMP/nm/rules.d/90.rules" \
|
||||
MGMT_TAP_GLOB="$glob" WAN_BRIDGE=br-ext WAN_BRIDGE_MODE=direct WAN_IFACE=eth3 LINUX_BRIDGE=br-wan \
|
||||
MGMT_BRIDGE_ENABLED=true MGMT_BRIDGE=br-mgmt MGMT_IFACE=eth2 \
|
||||
bash -c '. "$1"; write_nm_unmanaged' _ "$TMP/hostnet_fn.sh" >/dev/null 2>&1 || RC=$?
|
||||
conf=$(cat "$TMP/nm/conf.d/00-spinifex-unmanaged.conf" 2>/dev/null || true)
|
||||
line=$(printf '%s\n' "$conf" | grep '^unmanaged-devices+=' || true)
|
||||
if [ "$RC" = 0 ] && printf '%s\n' "$conf" | grep -qx '\[keyfile\]' && [ -n "$line" ]; then pass "nm: keyfile section appends to unmanaged-devices"; else fail "nm: rc=$RC conf=$conf"; fi
|
||||
for want in br-int br-ext 'veth-wan-*' 'spx-nat-*' 'tap*' br-mgmt eth2 eth3; do
|
||||
case ";${line#unmanaged-devices+=};" in
|
||||
*";interface-name:$want;"*) pass "nm: $want unmanaged" ;;
|
||||
*) fail "nm: $want missing from $line" ;;
|
||||
esac
|
||||
done
|
||||
case "$line" in
|
||||
*br-wan*) fail "nm: the host uplink bridge br-wan was made unmanaged" ;;
|
||||
*'['*) fail "nm: keyfile uses a character class NetworkManager cannot match: $line" ;;
|
||||
*) pass "nm: host uplink left managed and no character classes" ;;
|
||||
esac
|
||||
rule=$(cat "$TMP/nm/rules.d/90.rules" 2>/dev/null || true)
|
||||
case "$rule" in
|
||||
*"KERNEL==\"$glob\", ENV{NM_UNMANAGED}=\"1\""*) pass "nm: udev rule unmanages management TAPs" ;;
|
||||
*) fail "nm: udev rule is $rule" ;;
|
||||
esac
|
||||
|
||||
if [ "$fails" -ne 0 ]; then
|
||||
echo " setup-ovn.sh: $fails failure(s)"
|
||||
exit 1
|
||||
|
||||
+288
-30
@@ -7,7 +7,7 @@
|
||||
# INSTALL_SPINIFEX_CHANNEL Release channel: latest (default), dev
|
||||
# INSTALL_SPINIFEX_VERSION Pin to specific version (overrides channel)
|
||||
# INSTALL_SPINIFEX_TARBALL Path to local tarball (skips download, for testing/air-gapped)
|
||||
# INSTALL_SPINIFEX_SKIP_APT Set to 1 to skip apt dependency install
|
||||
# INSTALL_SPINIFEX_SKIP_APT Set to 1 to skip the system package install (apt or dnf)
|
||||
# INSTALL_SPINIFEX_SKIP_AWS Set to 1 to skip AWS CLI install
|
||||
# INSTALL_SPINIFEX_SKIP_NEWGRP Set to 1 to skip newgrp exec at end (for callers like dev-install.sh)
|
||||
# ISO_BUILD Set to 1 when running inside a debootstrap chroot from the ISO
|
||||
@@ -58,6 +58,24 @@ info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
fatal() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
||||
|
||||
# --- BEGIN distro.sh probes ---
|
||||
# Copied from scripts/lib/distro.sh, which this script installs but cannot source
|
||||
# when piped from the install endpoint. distro_test.sh keeps the copies equal.
|
||||
distro_pkg_manager() {
|
||||
local apt=false dnf=false
|
||||
command -v apt-get >/dev/null 2>&1 && apt=true
|
||||
command -v dnf >/dev/null 2>&1 && dnf=true
|
||||
if $apt && $dnf; then
|
||||
echo "distro.sh: package manager: found both apt-get and dnf; cannot tell which one manages this host" >&2
|
||||
return 1
|
||||
fi
|
||||
if $apt; then echo apt; return 0; fi
|
||||
if $dnf; then echo dnf; return 0; fi
|
||||
echo "distro.sh: package manager: found neither apt-get nor dnf on PATH" >&2
|
||||
return 1
|
||||
}
|
||||
# --- END distro.sh probes ---
|
||||
|
||||
stage() {
|
||||
[ "${VERBOSE:-0}" = "1" ] && echo "[setup] $*"
|
||||
return 0
|
||||
@@ -92,14 +110,25 @@ setup_sudo() {
|
||||
}
|
||||
|
||||
# --- OS detection ---
|
||||
# os_release_field <key> <file> — the value of KEY=value, with one level of quotes removed.
|
||||
os_release_field() {
|
||||
sed -n "s/^$1=//p" "$2" | head -n 1 | sed -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'$/\1/"
|
||||
}
|
||||
|
||||
# The only place that keys on the OS ID: the gate and, later, the package table.
|
||||
# Everything else probes for the capability (scripts/lib/distro.sh).
|
||||
detect_os() {
|
||||
if [ ! -f /etc/os-release ]; then
|
||||
fatal "Cannot detect OS: /etc/os-release not found"
|
||||
local os_release="${SPINIFEX_OS_RELEASE:-/etc/os-release}"
|
||||
if [ ! -f "$os_release" ]; then
|
||||
fatal "Cannot detect OS: $os_release not found"
|
||||
fi
|
||||
|
||||
. /etc/os-release
|
||||
# Parsed, not sourced, so a malformed file is refused instead of executed.
|
||||
ID=$(os_release_field ID "$os_release")
|
||||
VERSION_ID=$(os_release_field VERSION_ID "$os_release")
|
||||
PRETTY_NAME=$(os_release_field PRETTY_NAME "$os_release")
|
||||
|
||||
case "$ID" in
|
||||
case "${ID:-}" in
|
||||
debian)
|
||||
if [ "${VERSION_ID%%.*}" -lt 13 ] 2>/dev/null; then
|
||||
fatal "Debian $VERSION_ID is not supported. Minimum: Debian 13"
|
||||
@@ -111,12 +140,77 @@ detect_os() {
|
||||
fatal "Ubuntu $VERSION_ID is not supported. Minimum: Ubuntu 24.04"
|
||||
fi
|
||||
;;
|
||||
ol)
|
||||
check_oracle_linux
|
||||
;;
|
||||
*)
|
||||
fatal "Unsupported OS: $ID $VERSION_ID. Spinifex requires Debian 13+ or Ubuntu 24.04+"
|
||||
fatal "Unsupported OS: ${ID:-unknown} ${VERSION_ID:-}. Spinifex requires Debian 13+, Ubuntu 24.04+ or Oracle Linux 10.2+"
|
||||
;;
|
||||
esac
|
||||
|
||||
info "Detected OS: $PRETTY_NAME"
|
||||
info "Detected OS: ${PRETTY_NAME:-$ID $VERSION_ID}"
|
||||
}
|
||||
|
||||
# Oracle Linux 10.2+ on x86_64 only, running UEK R8: the KVM Utilities QEMU
|
||||
# needs UEK, and Oracle ships kernel modules per kernel, so installing a newer
|
||||
# kernel-uek alone leaves the running one without openvswitch.ko.
|
||||
check_oracle_linux() {
|
||||
local unsupported="Oracle Linux ${VERSION_ID:-<no VERSION_ID>} is not supported. Spinifex requires Oracle Linux 10.2 or a later 10.x"
|
||||
local major="${VERSION_ID%%.*}" minor="${VERSION_ID#*.}" kernel
|
||||
minor="${minor%%.*}"
|
||||
case "${VERSION_ID:-}" in *.*) ;; *) fatal "$unsupported" ;; esac
|
||||
case "$major" in "" | *[!0-9]*) fatal "$unsupported" ;; esac
|
||||
case "$minor" in "" | *[!0-9]*) fatal "$unsupported" ;; esac
|
||||
if [ "$major" -ne 10 ] || [ "$minor" -lt 2 ]; then
|
||||
fatal "$unsupported"
|
||||
fi
|
||||
|
||||
if [ "$(uname -m)" != "x86_64" ]; then
|
||||
fatal "Oracle Linux on $(uname -m) is not supported. Spinifex supports Oracle Linux on x86_64 only"
|
||||
fi
|
||||
|
||||
kernel=$(uname -r)
|
||||
case "$kernel" in
|
||||
6.12.0-*uek*) ;;
|
||||
*) fatal "The running kernel $kernel is not UEK R8 (6.12.0-*uek). Install kernel-uek, reboot into it, then re-run this installer" ;;
|
||||
esac
|
||||
|
||||
check_x86_64_v3
|
||||
check_selinux_not_enforcing
|
||||
}
|
||||
|
||||
# Spinifex ships no SELinux policy yet. Oracle's templates boot enforcing, and
|
||||
# setenforce alone reverts on reboot, so the config file is checked as well.
|
||||
check_selinux_not_enforcing() {
|
||||
local config="${SPINIFEX_SELINUX_CONFIG:-/etc/selinux/config}" now="" boot=""
|
||||
if command -v getenforce >/dev/null 2>&1; then
|
||||
now=$(getenforce 2>/dev/null | tr '[:upper:]' '[:lower:]')
|
||||
fi
|
||||
if [ -f "$config" ]; then
|
||||
boot=$(os_release_field SELINUX "$config" | tr '[:upper:]' '[:lower:]')
|
||||
fi
|
||||
if [ "$now" = "enforcing" ]; then
|
||||
fatal "SELinux is enforcing, and Spinifex has no SELinux policy. Run 'sudo setenforce 0', set SELINUX=permissive in $config, then re-run this installer"
|
||||
fi
|
||||
if [ "$boot" = "enforcing" ]; then
|
||||
fatal "SELinux is set to enforcing at next boot (SELINUX=enforcing in $config). Set SELINUX=permissive there, then re-run this installer"
|
||||
fi
|
||||
}
|
||||
|
||||
# The NFV SIG OVS/OVN and the KVM Utilities QEMU are built for x86-64-v3, so an
|
||||
# older CPU installs cleanly and then dies on an illegal instruction.
|
||||
check_x86_64_v3() {
|
||||
local cpuinfo="${SPINIFEX_CPUINFO:-/proc/cpuinfo}" flags flag missing=""
|
||||
flags=" $(grep -m1 '^flags' "$cpuinfo" 2>/dev/null | cut -d: -f2) "
|
||||
for flag in cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3 avx avx2 bmi1 bmi2 f16c fma abm movbe xsave; do
|
||||
case "$flags" in
|
||||
*" $flag "*) ;;
|
||||
*) missing="${missing:+$missing }$flag" ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
fatal "This CPU is below x86-64-v3 (missing: $missing). Oracle Linux's OVS, OVN and QEMU builds require x86-64-v3"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Architecture detection ---
|
||||
@@ -733,7 +827,7 @@ SUDOERS
|
||||
info "Scoped sudoers rules installed for spinifex-daemon (spinifex-vpcd needs none)"
|
||||
}
|
||||
|
||||
# --- Install apt dependencies ---
|
||||
# --- Install system dependencies ---
|
||||
# NOTE: Runtime deps must stay in sync with the ISO package list at
|
||||
# scripts/iso-builder/build/packages.list in the mulga repo. When you add,
|
||||
# remove, or change a runtime package here, review that file too — drift means
|
||||
@@ -750,29 +844,43 @@ jq curl iproute2 ethtool netcat-openbsd wget unzip xz-utils file
|
||||
ovn-central ovn-host openvswitch-switch openvswitch-ipsec strongswan-charon dhcpcd-base
|
||||
chrony nftables"
|
||||
|
||||
install_apt_deps() {
|
||||
stage "installing apt dependencies"
|
||||
# The Oracle Linux equivalents, plus what a minimal OL install lacks. OVS 3.5
|
||||
# and OVN 25.03 come from the CentOS NFV SIG; 25.03 is Debian 13's OVN, which
|
||||
# the hand-written OVN NB models are written against.
|
||||
OL_RUNTIME_PACKAGES="nbdkit nbdkit-basic-filters
|
||||
qemu-kvm qemu-img edk2-ovmf seabios-bin less
|
||||
pciutils
|
||||
jq curl iproute ethtool nmap-ncat wget unzip xz file
|
||||
ovn25.03-central ovn25.03-host openvswitch3.5 openvswitch3.5-ipsec libreswan dhcpcd
|
||||
chrony nftables iptables-nft systemd-resolved"
|
||||
|
||||
# Oracle ships no OVS or OVN for OL10. The allowlist stops the SIG supplying
|
||||
# anything but OVS, OVN and the two SIG-only packages they depend on.
|
||||
NFV_SIG_REPO_ID="spinifex-nfv-sig"
|
||||
NFV_SIG_KEY_URL="https://www.centos.org/keys/RPM-GPG-KEY-CentOS-SIG-NFV"
|
||||
NFV_SIG_KEY_FILE="/etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-SIG-NFV"
|
||||
NFV_SIG_KEY_FPR="35154228174901BEFA8E69A621465E289D2A76A7"
|
||||
NFV_SIG_INCLUDEPKGS="openvswitch3.5* ovn25.03* python3-openvswitch3.5 openvswitch-selinux-extra-policy"
|
||||
|
||||
# Only the KVM Utilities QEMU has the microvm machine. Excluding QEMU from
|
||||
# AppStream stops a later AppStream build ever replacing part of that stack.
|
||||
OL_KVM_UTILS_REPO="ol10_kvm_utils"
|
||||
OL_APPSTREAM_REPO="ol10_appstream"
|
||||
OL_APPSTREAM_EXCLUDES="qemu-kvm* qemu-img*"
|
||||
# The running kernel's modules come from here, and KVM Utilities requires kernel-uek.
|
||||
OL_UEK_REPO="ol10_UEKR8"
|
||||
|
||||
install_deps() {
|
||||
stage "installing system dependencies"
|
||||
if [ "${INSTALL_SPINIFEX_SKIP_APT}" = "1" ]; then
|
||||
info "Skipping apt dependencies (INSTALL_SPINIFEX_SKIP_APT=1)"
|
||||
info "Skipping system dependencies (INSTALL_SPINIFEX_SKIP_APT=1)"
|
||||
else
|
||||
info "Installing system dependencies..."
|
||||
$SUDO apt-get update -qq
|
||||
|
||||
# Unquoted on purpose: both variables are whitespace-separated package lists.
|
||||
# shellcheck disable=SC2086
|
||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq \
|
||||
$QEMU_PACKAGES $APT_RUNTIME_PACKAGES \
|
||||
> /dev/null
|
||||
|
||||
info "System dependencies installed"
|
||||
|
||||
# Only on a machine that already boots from ZFS. Pulling zfsutils-linux
|
||||
# in unconditionally would drag zfs-dkms onto every dev box and rebuild
|
||||
# the module on each kernel upgrade for no benefit.
|
||||
if [ "$(findmnt -no FSTYPE /)" = "zfs" ] && ! command -v zpool >/dev/null 2>&1; then
|
||||
info "ZFS root detected, installing zfsutils-linux..."
|
||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq zfsutils-linux > /dev/null
|
||||
fi
|
||||
local pm
|
||||
pm=$(distro_pkg_manager) || fatal "Cannot tell which package manager this host uses"
|
||||
case "$pm" in
|
||||
apt) install_apt_deps ;;
|
||||
dnf) install_dnf_deps ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Mask the standalone dhcpcd.service auto-enabled on Debian Trixie when
|
||||
@@ -786,6 +894,149 @@ install_apt_deps() {
|
||||
$SUDO systemctl mask dhcpcd.service 2>/dev/null || true
|
||||
}
|
||||
|
||||
install_apt_deps() {
|
||||
info "Installing system dependencies..."
|
||||
$SUDO apt-get update -qq
|
||||
|
||||
# Unquoted on purpose: both variables are whitespace-separated package lists.
|
||||
# shellcheck disable=SC2086
|
||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq \
|
||||
$QEMU_PACKAGES $APT_RUNTIME_PACKAGES \
|
||||
> /dev/null
|
||||
|
||||
info "System dependencies installed"
|
||||
|
||||
# Only on a machine that already boots from ZFS. Pulling zfsutils-linux
|
||||
# in unconditionally would drag zfs-dkms onto every dev box and rebuild
|
||||
# the module on each kernel upgrade for no benefit.
|
||||
if [ "$(findmnt -no FSTYPE /)" = "zfs" ] && ! command -v zpool >/dev/null 2>&1; then
|
||||
info "ZFS root detected, installing zfsutils-linux..."
|
||||
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq zfsutils-linux > /dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# The package table is the other place keyed on the OS ID: dnf alone does not
|
||||
# say which repositories or package names a host has.
|
||||
install_dnf_deps() {
|
||||
case "${ID:-}" in
|
||||
ol) ;;
|
||||
*) fatal "No dnf package list for ${ID:-an unknown OS}. Spinifex installs with dnf on Oracle Linux only" ;;
|
||||
esac
|
||||
if [ "$(findmnt -no FSTYPE /)" = "zfs" ]; then
|
||||
fatal "A ZFS root is not supported on Oracle Linux"
|
||||
fi
|
||||
|
||||
ensure_ol_repos
|
||||
install_running_kernel_modules
|
||||
|
||||
info "Installing system dependencies..."
|
||||
# shellcheck disable=SC2086 # a whitespace-separated package list
|
||||
$SUDO dnf install -y -q $OL_RUNTIME_PACKAGES > /dev/null
|
||||
info "System dependencies installed"
|
||||
|
||||
load_kernel_modules openvswitch geneve vhost_net tun
|
||||
}
|
||||
|
||||
# Enables KVM Utilities, pins QEMU to it, and adds the NFV SIG repository with
|
||||
# a key whose fingerprint must match the one recorded above.
|
||||
ensure_ol_repos() {
|
||||
info "Configuring Oracle Linux repositories..."
|
||||
local repo
|
||||
$SUDO dnf install -y -q dnf-plugins-core gnupg2 curl > /dev/null
|
||||
|
||||
for repo in "$OL_UEK_REPO" "$OL_KVM_UTILS_REPO" "$OL_APPSTREAM_REPO"; do
|
||||
dnf config-manager --dump "$repo" > /dev/null 2>&1 \
|
||||
|| fatal "Repository $repo is not defined on this host. Oracle Linux's release packages define it; reinstall oraclelinux-release-el10"
|
||||
done
|
||||
$SUDO dnf config-manager --set-enabled "$OL_UEK_REPO" "$OL_KVM_UTILS_REPO" > /dev/null
|
||||
exclude_from_appstream
|
||||
|
||||
install_nfv_sig_key
|
||||
$SUDO tee "/etc/yum.repos.d/${NFV_SIG_REPO_ID}.repo" > /dev/null <<EOF
|
||||
# Written by the Spinifex installer. Re-running it rewrites this file.
|
||||
[${NFV_SIG_REPO_ID}]
|
||||
name=CentOS NFV SIG Open vSwitch and OVN, for Spinifex
|
||||
baseurl=https://mirror.stream.centos.org/SIGs/10-stream/nfv/\$basearch/openvswitch-2/
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
gpgkey=file://${NFV_SIG_KEY_FILE}
|
||||
includepkgs=${NFV_SIG_INCLUDEPKGS}
|
||||
EOF
|
||||
|
||||
for repo in "$OL_UEK_REPO" "$OL_APPSTREAM_REPO" "$OL_KVM_UTILS_REPO" "$NFV_SIG_REPO_ID"; do
|
||||
$SUDO dnf -q makecache --repo "$repo" > /dev/null \
|
||||
|| fatal "Repository $repo is unreachable. Check this host's network access and proxy settings, then re-run this installer"
|
||||
done
|
||||
}
|
||||
|
||||
# Adds the QEMU excludes to AppStream, keeping any exclude already set there.
|
||||
exclude_from_appstream() {
|
||||
local current pattern merged="" patterns
|
||||
current=$(dnf config-manager --dump "$OL_APPSTREAM_REPO" | sed -n 's/^excludepkgs = //p' | tr ',' ' ')
|
||||
# read, not an unquoted expansion, so the patterns are never globbed.
|
||||
read -ra patterns <<< "$current $OL_APPSTREAM_EXCLUDES"
|
||||
for pattern in "${patterns[@]}"; do
|
||||
case " $merged " in
|
||||
*" $pattern "*) ;;
|
||||
*) merged="${merged:+$merged }$pattern" ;;
|
||||
esac
|
||||
done
|
||||
$SUDO dnf config-manager --save --setopt="${OL_APPSTREAM_REPO}.excludepkgs=${merged// /,}" > /dev/null
|
||||
}
|
||||
|
||||
# gpg_fingerprints <file> — the primary-key fingerprint of each key in file.
|
||||
gpg_fingerprints() {
|
||||
local home
|
||||
home=$(mktemp -d)
|
||||
GNUPGHOME="$home" gpg --batch --with-colons --show-keys "$1" 2>/dev/null \
|
||||
| awk -F: '$1 == "pub" { want = 1; next } want && $1 == "fpr" { print $10; want = 0 }'
|
||||
rm -rf "$home"
|
||||
}
|
||||
|
||||
install_nfv_sig_key() {
|
||||
if [ -f "$NFV_SIG_KEY_FILE" ] && [ "$(gpg_fingerprints "$NFV_SIG_KEY_FILE")" = "$NFV_SIG_KEY_FPR" ]; then
|
||||
$SUDO rpm --import "$NFV_SIG_KEY_FILE"
|
||||
return 0
|
||||
fi
|
||||
local tmp got
|
||||
tmp=$(mktemp)
|
||||
curl -fsSL "$NFV_SIG_KEY_URL" -o "$tmp" \
|
||||
|| { rm -f "$tmp"; fatal "Cannot download the CentOS NFV SIG key from $NFV_SIG_KEY_URL"; }
|
||||
got=$(gpg_fingerprints "$tmp")
|
||||
if [ "$got" != "$NFV_SIG_KEY_FPR" ]; then
|
||||
rm -f "$tmp"
|
||||
fatal "The key at $NFV_SIG_KEY_URL has fingerprint ${got:-<unreadable>}, not the CentOS NFV SIG's $NFV_SIG_KEY_FPR. Refusing to trust it"
|
||||
fi
|
||||
$SUDO install -D -m 0644 "$tmp" "$NFV_SIG_KEY_FILE"
|
||||
rm -f "$tmp"
|
||||
$SUDO rpm --import "$NFV_SIG_KEY_FILE"
|
||||
}
|
||||
|
||||
# Oracle ships kernel modules per kernel version. The unversioned package
|
||||
# follows the newest kernel and leaves the running one without openvswitch.ko.
|
||||
install_running_kernel_modules() {
|
||||
local pkg
|
||||
pkg="kernel-uek-modules-$(uname -r)"
|
||||
if rpm -q "$pkg" > /dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if ! $SUDO dnf -q list --available "$pkg" > /dev/null 2>&1; then
|
||||
fatal "$pkg, the modules for the running kernel, is no longer in the repositories. Run 'sudo dnf update kernel-uek', reboot into the new kernel, then re-run this installer"
|
||||
fi
|
||||
info "Installing $pkg (it pulls in linux-firmware, about 800 MB)..."
|
||||
$SUDO dnf install -y -q "$pkg" > /dev/null
|
||||
}
|
||||
|
||||
load_kernel_modules() {
|
||||
local mod failed=""
|
||||
for mod in "$@"; do
|
||||
$SUDO modprobe "$mod" || failed="${failed:+$failed }$mod"
|
||||
done
|
||||
if [ -n "$failed" ]; then
|
||||
fatal "Cannot load kernel modules for the running kernel $(uname -r): $failed"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Install AWS CLI ---
|
||||
install_aws_cli() {
|
||||
stage "installing AWS CLI v2"
|
||||
@@ -891,6 +1142,13 @@ install_files() {
|
||||
info " /usr/local/share/spinifex/setup-ovn.sh"
|
||||
fi
|
||||
|
||||
# Sourced, not run, by setup-ovn.sh and uninstall-spx.sh wherever they are
|
||||
# installed, including the ISO's /usr/local/bin/setup-ovn.sh.
|
||||
if [ -f "$EXTRACT_DIR/lib/distro.sh" ]; then
|
||||
$SUDO install -D -m 0644 "$EXTRACT_DIR/lib/distro.sh" /usr/local/share/spinifex/lib/distro.sh
|
||||
info " /usr/local/share/spinifex/lib/distro.sh"
|
||||
fi
|
||||
|
||||
# Install setup.sh itself so firstboot and future re-runs can find it at a
|
||||
# stable path on both ISO and curl|bash installs.
|
||||
if [ -f "$EXTRACT_DIR/setup.sh" ]; then
|
||||
@@ -1451,7 +1709,7 @@ main() {
|
||||
download_spinifex
|
||||
fi
|
||||
|
||||
stage_enabled deps && install_apt_deps
|
||||
stage_enabled deps && install_deps
|
||||
stage_enabled aws && install_aws_cli
|
||||
stage_enabled users && create_service_users
|
||||
stage_enabled sudoers && install_sudoers
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/bin/sh
|
||||
# setup_test.sh — tests for setup.sh's supported-OS gate.
|
||||
#
|
||||
# setup.sh is sourced with INSTALL_SPINIFEX_LIB_ONLY=1 so main never runs, and
|
||||
# detect_os reads fixture os-release, cpuinfo and SELinux config files, and stub
|
||||
# uname and getenforce commands.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
|
||||
SETUP="$SCRIPT_DIR/setup.sh"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
|
||||
echo "-- setup.sh OS gate"
|
||||
|
||||
V3_FLAGS="fpu cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3 avx avx2 bmi1 bmi2 f16c fma abm movbe xsave"
|
||||
V2_FLAGS="fpu cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3"
|
||||
UEK_R8="6.12.0-207.111.5.4.el10uek.x86_64"
|
||||
|
||||
# gate <os-release body> [machine] [kernel] [cpu flags] — runs detect_os and
|
||||
# sets RC and OUT (stdout and stderr together). SELINUX_NOW is what getenforce
|
||||
# prints; SELINUX_BOOT is the config file's SELINUX=, or "none" for no file.
|
||||
gate() {
|
||||
printf '%s\n' "$1" > "$TMP/os-release"
|
||||
printf 'processor\t: 0\nflags\t\t: %s\n' "${4:-$V3_FLAGS}" > "$TMP/cpuinfo"
|
||||
rm -f "$TMP/selinux-config"
|
||||
if [ "${SELINUX_BOOT:-permissive}" != none ]; then
|
||||
printf '# fixture\nSELINUX=%s\nSELINUXTYPE=targeted\n' "${SELINUX_BOOT:-permissive}" > "$TMP/selinux-config"
|
||||
fi
|
||||
mkdir -p "$TMP/bin"
|
||||
printf '#!/bin/sh\necho %s\n' "${SELINUX_NOW:-Permissive}" > "$TMP/bin/getenforce"
|
||||
chmod +x "$TMP/bin/getenforce"
|
||||
cat > "$TMP/bin/uname" <<EOF
|
||||
#!/bin/sh
|
||||
case "\$1" in
|
||||
-m) echo "${2:-x86_64}" ;;
|
||||
-r) echo "${3:-$UEK_R8}" ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$TMP/bin/uname"
|
||||
RC=0
|
||||
OUT=$(PATH="$TMP/bin:$PATH" SPINIFEX_OS_RELEASE="$TMP/os-release" SPINIFEX_CPUINFO="$TMP/cpuinfo" \
|
||||
SPINIFEX_SELINUX_CONFIG="$TMP/selinux-config" INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '. "$1"; detect_os' _ "$SETUP" 2>&1) || RC=$?
|
||||
# Some shells keep a prefix assignment to a function call after it returns.
|
||||
unset SELINUX_NOW SELINUX_BOOT
|
||||
}
|
||||
accepts() {
|
||||
if [ "$RC" = 0 ]; then pass "accepts $1"; else fail "rejects $1: $OUT"; fi
|
||||
}
|
||||
# rejects <desc> <word> — the refusal must say why, not just exit.
|
||||
rejects() {
|
||||
if [ "$RC" = 0 ]; then
|
||||
fail "accepts $1"
|
||||
else
|
||||
case "$OUT" in
|
||||
*"$2"*) pass "rejects $1" ;;
|
||||
*) fail "rejects $1 without naming '$2': $OUT" ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
OL='ID="ol"
|
||||
NAME="Oracle Linux Server"
|
||||
PRETTY_NAME="Oracle Linux Server 10.2"'
|
||||
|
||||
gate 'ID=debian
|
||||
VERSION_ID="13"
|
||||
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"'
|
||||
accepts "Debian 13"
|
||||
|
||||
# Debian testing and sid carry no VERSION_ID. The gate has always let them
|
||||
# through, and Debian behaviour is not this gate's to change.
|
||||
gate 'ID=debian
|
||||
PRETTY_NAME="Debian GNU/Linux forky/sid"'
|
||||
accepts "Debian testing with no VERSION_ID"
|
||||
|
||||
gate 'ID=debian
|
||||
VERSION_ID="12"'
|
||||
rejects "Debian 12" "Minimum: Debian 13"
|
||||
|
||||
gate 'ID=ubuntu
|
||||
VERSION_ID="26.04"
|
||||
PRETTY_NAME="Ubuntu 26.04 LTS"'
|
||||
accepts "Ubuntu 26.04"
|
||||
|
||||
gate 'ID=ubuntu
|
||||
VERSION_ID="22.04"'
|
||||
rejects "Ubuntu 22.04" "Minimum: Ubuntu 24.04"
|
||||
|
||||
gate "$OL
|
||||
VERSION_ID=\"10.2\""
|
||||
accepts "Oracle Linux 10.2 on UEK R8, x86_64, x86-64-v3"
|
||||
|
||||
gate "$OL
|
||||
VERSION_ID=\"10.4\""
|
||||
accepts "Oracle Linux 10.4"
|
||||
|
||||
gate "$OL
|
||||
VERSION_ID=\"10.1\""
|
||||
rejects "Oracle Linux 10.1" "Oracle Linux 10.2 or a later 10.x"
|
||||
|
||||
gate "$OL
|
||||
VERSION_ID=\"10\""
|
||||
rejects "Oracle Linux with no minor version" "Oracle Linux 10.2 or a later 10.x"
|
||||
|
||||
gate "$OL
|
||||
VERSION_ID=\"11.0\""
|
||||
rejects "Oracle Linux 11" "Oracle Linux 10.2 or a later 10.x"
|
||||
|
||||
gate "$OL
|
||||
VERSION_ID=\"9.6\"" x86_64 "5.15.0-310.184.5.2.el9uek.x86_64"
|
||||
rejects "Oracle Linux 9" "Oracle Linux 10.2 or a later 10.x"
|
||||
|
||||
gate "$OL" x86_64
|
||||
rejects "Oracle Linux with no VERSION_ID" "Oracle Linux 10.2 or a later 10.x"
|
||||
|
||||
gate "$OL
|
||||
VERSION_ID=\"10.2\"" aarch64 "6.12.0-207.111.5.4.el10uek.aarch64"
|
||||
rejects "Oracle Linux on aarch64" "x86_64 only"
|
||||
|
||||
gate "$OL
|
||||
VERSION_ID=\"10.2\"" x86_64 "6.12.0-55.9.1.el10_0.x86_64"
|
||||
rejects "Oracle Linux on the Red Hat compatible kernel" "is not UEK R8"
|
||||
|
||||
gate "$OL
|
||||
VERSION_ID=\"10.2\"" x86_64 "5.15.0-310.184.5.2.el9uek.x86_64"
|
||||
rejects "Oracle Linux on a UEK older than R8" "is not UEK R8"
|
||||
|
||||
gate "$OL
|
||||
VERSION_ID=\"10.2\"" x86_64 "$UEK_R8" "$V2_FLAGS"
|
||||
rejects "Oracle Linux on an x86-64-v2 CPU" "missing: avx avx2 bmi1 bmi2 f16c fma abm movbe xsave"
|
||||
|
||||
OL102="$OL
|
||||
VERSION_ID=\"10.2\""
|
||||
|
||||
SELINUX_NOW=Enforcing gate "$OL102"
|
||||
rejects "Oracle Linux with SELinux enforcing" "SELinux is enforcing"
|
||||
|
||||
SELINUX_BOOT=enforcing gate "$OL102"
|
||||
rejects "Oracle Linux with SELinux permissive now but enforcing at next boot" "enforcing at next boot"
|
||||
|
||||
SELINUX_BOOT='"enforcing"' gate "$OL102"
|
||||
rejects "Oracle Linux with a quoted SELINUX=\"enforcing\"" "enforcing at next boot"
|
||||
|
||||
SELINUX_NOW=Disabled SELINUX_BOOT=disabled gate "$OL102"
|
||||
accepts "Oracle Linux with SELinux disabled"
|
||||
|
||||
SELINUX_NOW=Disabled SELINUX_BOOT=none gate "$OL102"
|
||||
accepts "Oracle Linux with no SELinux config file"
|
||||
|
||||
# Spinifex has no SELinux policy on any OS, but refusing enforcing on Debian
|
||||
# would change Debian behaviour, so only the Oracle Linux gate checks it.
|
||||
SELINUX_NOW=Enforcing SELINUX_BOOT=enforcing gate 'ID=debian
|
||||
VERSION_ID="13"'
|
||||
accepts "Debian 13 regardless of SELinux"
|
||||
|
||||
gate 'ID="rocky"
|
||||
VERSION_ID="10.0"'
|
||||
rejects "Rocky Linux 10" "Unsupported OS: rocky"
|
||||
|
||||
gate 'this is not an os-release file'
|
||||
rejects "a malformed os-release" "Unsupported OS"
|
||||
|
||||
if [ "$fails" -gt 0 ]; then
|
||||
echo "$fails failure(s)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -73,6 +73,19 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
done
|
||||
|
||||
# Host-OS capability probes: beside this script in a checkout and in the
|
||||
# installed share dir, else where setup.sh installs them.
|
||||
DISTRO_LIB=""
|
||||
for candidate in "$(dirname "${BASH_SOURCE[0]}")/lib/distro.sh" /usr/local/share/spinifex/lib/distro.sh; do
|
||||
if [ -f "$candidate" ]; then DISTRO_LIB="$candidate"; break; fi
|
||||
done
|
||||
if [ -z "$DISTRO_LIB" ]; then
|
||||
echo "ERROR: distro.sh not found at $(dirname "${BASH_SOURCE[0]}")/lib/distro.sh or /usr/local/share/spinifex/lib/distro.sh; re-run setup.sh to install it" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck source=lib/distro.sh
|
||||
. "$DISTRO_LIB"
|
||||
|
||||
log() { echo "[uninstall-spx] $*"; }
|
||||
run() {
|
||||
if $DRY_RUN; then
|
||||
@@ -208,7 +221,7 @@ fi
|
||||
#
|
||||
# It handles the guests, the OVS bridges, the OVN databases and the JetStream
|
||||
# store, and it carries correctness that is invisible in its code: QEMU matched
|
||||
# as ^qemu-system because comm truncates at 15 characters, a wait for QEMU to
|
||||
# by a comm prefix because comm truncates at 15 characters, a wait for QEMU to
|
||||
# exit rather than for the signal, and the firewall-mode stash that stops an
|
||||
# interrupted run leaving the node unarmed. A second copy of that would drift,
|
||||
# and the failure mode of the drift is data loss.
|
||||
@@ -294,12 +307,14 @@ rm_path /etc/systemd/system/spinifex-*.service \
|
||||
# exactly that way on the first dev-oci round trip.
|
||||
log "removing drop-ins written into other units"
|
||||
rm_path /etc/systemd/system/openvswitch-switch.service.d/spinifex-perms.conf \
|
||||
/etc/systemd/system/ovs-vswitchd.service.d/spinifex-perms.conf \
|
||||
/etc/systemd/system/ovn-controller.service.d/spinifex-perms.conf \
|
||||
/etc/systemd/system/ovn-controller.service.d/log-level.conf \
|
||||
/etc/systemd/system/openvswitch-ipsec.service.d/spinifex-perms.conf \
|
||||
/etc/systemd/system/ovn-northd.service.d/spinifex-perms.conf \
|
||||
/etc/systemd/system/ovn-northd.service.d/no-manage-ovsdb.conf
|
||||
# Only if we emptied them — another package may own the same override dir.
|
||||
for d in openvswitch-switch ovn-controller openvswitch-ipsec ovn-northd; do
|
||||
for d in openvswitch-switch ovs-vswitchd ovn-controller openvswitch-ipsec ovn-northd; do
|
||||
dir="/etc/systemd/system/${d}.service.d"
|
||||
if sudo test -d "$dir" && [ -z "$(sudo ls -A "$dir" 2>/dev/null)" ]; then
|
||||
rm_path "$dir"
|
||||
@@ -379,8 +394,12 @@ rm_path /etc/sysctl.d/*spinifex* \
|
||||
/etc/sudoers.d/spinifex-network \
|
||||
/etc/chrony/conf.d/spinifex.conf \
|
||||
/etc/logrotate.d/spinifex \
|
||||
/etc/udev/rules.d/*spinifex*
|
||||
/etc/udev/rules.d/*spinifex* \
|
||||
/etc/NetworkManager/conf.d/*spinifex*
|
||||
run sudo sysctl --system >/dev/null 2>&1 || true
|
||||
if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then
|
||||
run sudo nmcli general reload conf 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Spinifex directories.
|
||||
@@ -503,6 +522,7 @@ if ! $DRY_RUN; then
|
||||
for p in /etc/systemd/system/spinifex-*.service /etc/systemd/system/spinifex*.slice \
|
||||
/etc/systemd/system/spinifex.target /etc/sysctl.d/*spinifex* \
|
||||
/etc/systemd/network/1[5-8]-spinifex-* \
|
||||
/etc/NetworkManager/conf.d/*spinifex* \
|
||||
/etc/systemd/system/*.service.d/spinifex-perms.conf \
|
||||
/etc/systemd/system/ovn-northd.service.d/no-manage-ovsdb.conf \
|
||||
/etc/systemd/system/ovn-controller.service.d/log-level.conf; do
|
||||
|
||||
@@ -81,9 +81,15 @@ mkcfg() { # mkcfg <dir> <node>...
|
||||
mkcfg "$TMP/three" node1 node2 node3
|
||||
mkcfg "$TMP/one" node1
|
||||
|
||||
# The node count is read through sudo, so a pass-through stub keeps the refusal
|
||||
# from depending on whether the machine running this has sudo.
|
||||
mkdir "$TMP/bin"
|
||||
printf '#!/bin/sh\nexec "$@"\n' > "$TMP/bin/sudo"
|
||||
chmod +x "$TMP/bin/sudo"
|
||||
|
||||
for args in "" "--yes" "--yes --dry-run"; do
|
||||
# shellcheck disable=SC2086
|
||||
SPX_ETC_DIR="$TMP/three" "$UNINSTALL" $args >"$TMP/out" 2>"$TMP/err" && rc=0 || rc=$?
|
||||
PATH="$TMP/bin:$PATH" SPX_ETC_DIR="$TMP/three" "$UNINSTALL" $args >"$TMP/out" 2>"$TMP/err" && rc=0 || rc=$?
|
||||
label=${args:-"(no flags)"}
|
||||
[ "$rc" = 1 ] \
|
||||
&& pass "three-node config refuses with $label" \
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ec2PlatformInventoryTimeout bounds one inventory request. The scan reads
|
||||
// four stores for one account; a reply later than this is scan-incomplete.
|
||||
// ec2PlatformInventoryTimeout bounds one inventory request; an earlier caller
|
||||
// deadline wins. A reply later than either is scan-incomplete.
|
||||
const ec2PlatformInventoryTimeout = 30 * time.Second
|
||||
|
||||
// ec2PlatformInventory is EC2's read-only report of an account's key pairs,
|
||||
@@ -39,8 +39,17 @@ func newEC2PlatformInventoryClient(nc *nats.Conn) *ec2PlatformInventoryClient {
|
||||
// responder, which is also how a daemon predating the subject answers, a
|
||||
// timeout, an error reply or a malformed reply each make every kind incomplete.
|
||||
func (c *ec2PlatformInventoryClient) PlatformInventory(ctx context.Context, accountID, correlationRef string) ec2v1.PlatformInventoryReply {
|
||||
// The NATS request takes a timeout, not a context, so the caller's deadline
|
||||
// shortens it here; a context already done is a timeout without a request.
|
||||
timeout := c.timeout
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
timeout = min(timeout, time.Until(deadline))
|
||||
}
|
||||
if ctx.Err() != nil || timeout <= 0 {
|
||||
return ec2v1.IncompletePlatformInventory(accountID, correlationRef, ec2v1.PlatformReasonTimeout)
|
||||
}
|
||||
reply, err := natsmsg.NATSRequest[ec2v1.PlatformInventoryReply](ctx, c.nc, ec2v1.PlatformInventorySubject,
|
||||
&ec2v1.PlatformInventoryRequest{AccountID: accountID, CorrelationRef: correlationRef}, c.timeout, accountID)
|
||||
&ec2v1.PlatformInventoryRequest{AccountID: accountID, CorrelationRef: correlationRef}, timeout, accountID)
|
||||
if err != nil {
|
||||
return ec2v1.IncompletePlatformInventory(accountID, correlationRef, inventoryFailureReason(err))
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ package accountteardown
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountinventory"
|
||||
@@ -67,9 +70,13 @@ func TestEC2PlatformInventoryClientReturnsEC2sReport(t *testing.T) {
|
||||
KeyName: aws.String("deploy"), PublicKeyMaterial: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
o.store.failDelete = func(key string) bool { return key == "keys/"+testAccountID+"/deploy" }
|
||||
_, err = o.keys.DeleteKeyPair(ctx, &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
// A public key without metadata, as an older release's failed delete left.
|
||||
_, err = o.store.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(platformBucket), Key: aws.String("keys/" + testAccountID + "/deploy"), Body: strings.NewReader("ssh-ed25519 AAAA"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
reply := newEC2PlatformInventoryClient(o.nc).PlatformInventory(ctx, testAccountID, "req-1")
|
||||
|
||||
@@ -153,19 +160,10 @@ func TestEC2PlatformInventoryClientDistrustsAMalformedReply(t *testing.T) {
|
||||
}, reply.Kinds)
|
||||
}
|
||||
|
||||
// failingInventory fails the test if anything consults it.
|
||||
type failingInventory struct {
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (f failingInventory) PlatformInventory(context.Context, string, string) ec2v1.PlatformInventoryReply {
|
||||
f.t.Error("an engine path called the EC2 platform inventory")
|
||||
return ec2v1.PlatformInventoryReply{}
|
||||
}
|
||||
|
||||
// The cluster wiring binds the NATS adapter, and no engine path consults it:
|
||||
// precheck, inventory, dry run and a full teardown all leave it uncalled.
|
||||
func TestNoEnginePathCallsTheEC2PlatformInventory(t *testing.T) {
|
||||
// The cluster wiring binds the NATS adapter, and only the explicit dry run
|
||||
// consults it: precheck, the inventory the CLI prints before a real teardown,
|
||||
// a Teardown request marked dry run and a full teardown all leave it uncalled.
|
||||
func TestOnlyTheExplicitDryRunCallsTheEC2PlatformInventory(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
@@ -175,9 +173,10 @@ func TestNoEnginePathCallsTheEC2PlatformInventory(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.IsType(t, &ec2PlatformInventoryClient{}, cluster.ec2Inventory)
|
||||
|
||||
inventory := &fakeInventory{reply: ec2v1.IncompletePlatformInventory(testAccountID, "req-1", ec2v1.PlatformReasonNoResponder)}
|
||||
reaper := &fakeReaper{kind: "key-pair", stage: StagePlatform, resources: []Resource{{Kind: "key-pair", ID: "deploy"}}}
|
||||
engine := quietEngine(newFakeAccounts(), reaper)
|
||||
engine.ec2Inventory = failingInventory{t: t}
|
||||
engine.ec2Inventory = inventory
|
||||
|
||||
require.NoError(t, engine.Precheck(testAccountID, testAccountName))
|
||||
_, err = engine.Inventory(testCtx(t), testAccountID)
|
||||
@@ -186,7 +185,49 @@ func TestNoEnginePathCallsTheEC2PlatformInventory(t *testing.T) {
|
||||
dryRun.DryRun = true
|
||||
_, err = engine.Teardown(testCtx(t), dryRun)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, inventory.calls())
|
||||
|
||||
_, err = engine.DryRun(testCtx(t), testAccountID, "req-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"req-1"}, inventory.calls())
|
||||
|
||||
result, err := engine.Teardown(testCtx(t), request())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.AccountDeleted)
|
||||
assert.Equal(t, []string{"req-1"}, inventory.calls(), "a real teardown never consults the inventory")
|
||||
}
|
||||
|
||||
// The shadow matches legacy listings to inventory kinds by the reapers' kind
|
||||
// names, so the registered platform reapers must use the contract's names.
|
||||
func TestPlatformReaperKindsAreTheInventoryKinds(t *testing.T) {
|
||||
var kinds []string
|
||||
for _, reaper := range EC2Reapers(nil, 1, nil) {
|
||||
if reaper.Stage() == StagePlatform {
|
||||
kinds = append(kinds, reaper.Kind())
|
||||
}
|
||||
}
|
||||
for _, kind := range ec2ShadowKinds {
|
||||
assert.Contains(t, kinds, kind.kind)
|
||||
}
|
||||
}
|
||||
|
||||
// A caller deadline shorter than the adapter's own timeout ends the request
|
||||
// at that deadline, and a context already done sends nothing.
|
||||
func TestEC2PlatformInventoryClientHonoursTheCallersDeadline(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
var requests atomic.Int32
|
||||
serveInventory(t, nc, func() []byte { requests.Add(1); return nil })
|
||||
client := newEC2PlatformInventoryClient(nc)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
assertAllIncomplete(t, client.PlatformInventory(ctx, testAccountID, ""), ec2v1.PlatformReasonTimeout)
|
||||
assert.Less(t, time.Since(started), 5*time.Second)
|
||||
assert.Equal(t, int32(1), requests.Load())
|
||||
|
||||
done, cancelDone := context.WithCancel(t.Context())
|
||||
cancelDone()
|
||||
assertAllIncomplete(t, client.PlatformInventory(done, testAccountID, ""), ec2v1.PlatformReasonTimeout)
|
||||
assert.Equal(t, int32(1), requests.Load())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package accountteardown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
otelsetup "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
|
||||
)
|
||||
|
||||
// ec2PlatformShadowBudget caps how long a dry run waits for EC2's platform
|
||||
// inventory after its own listings, never past the caller's deadline. A later
|
||||
// answer is scan-incomplete and the dry run returns exactly as it would without.
|
||||
const ec2PlatformShadowBudget = 10 * time.Second
|
||||
|
||||
// ec2PlatformShadowMessage is the one operator audit record a dry run writes.
|
||||
const ec2PlatformShadowMessage = "Account teardown dry-run EC2 platform shadow comparison"
|
||||
|
||||
// Shadow outcomes, per kind and for the whole record. Only agree means the
|
||||
// legacy listing and the inventory matched and nothing hidden was reported.
|
||||
const (
|
||||
shadowAgree = "agree"
|
||||
shadowDiffer = "differ"
|
||||
shadowScanIncomplete = "scan-incomplete"
|
||||
)
|
||||
|
||||
// Bases a kind's visible resources are compared on.
|
||||
const (
|
||||
shadowBasisResourceID = "resource-id"
|
||||
shadowBasisCount = "count"
|
||||
)
|
||||
|
||||
// shadowKind is one compared kind. The key-pair and placement-group reapers
|
||||
// list by name, which the inventory never carries, so those compare by count.
|
||||
type shadowKind struct {
|
||||
kind string
|
||||
field string
|
||||
byName bool
|
||||
}
|
||||
|
||||
var ec2ShadowKinds = []shadowKind{
|
||||
{kind: ec2v1.PlatformKindKeyPair, field: "keyPair", byName: true},
|
||||
{kind: ec2v1.PlatformKindPlacementGroup, field: "placementGroup", byName: true},
|
||||
{kind: ec2v1.PlatformKindLaunchTemplate, field: "launchTemplate"},
|
||||
}
|
||||
|
||||
// loggableResourceID admits only the AWS ID shape of the three kinds, so a
|
||||
// reply that put stored content where an ID belongs never reaches the log.
|
||||
var loggableResourceID = regexp.MustCompile(`^(key|pg|lt)-[0-9a-f]{1,32}$`)
|
||||
|
||||
var knownShadowReasons = []string{
|
||||
ec2v1.PlatformReasonStoreListFailed, ec2v1.PlatformReasonRecordReadFailed,
|
||||
ec2v1.PlatformReasonOwnerRecordsUnresolved, ec2v1.PlatformReasonNoResponder,
|
||||
ec2v1.PlatformReasonTimeout, ec2v1.PlatformReasonTransportFailed,
|
||||
ec2v1.PlatformReasonErrorReply, ec2v1.PlatformReasonInvalidReply,
|
||||
ec2v1.PlatformReasonKindNotReported,
|
||||
}
|
||||
|
||||
var knownShadowClasses = []string{
|
||||
ec2v1.PlatformClassPublicKeyWithoutMetadata, ec2v1.PlatformClassPublicKeyUnresolved,
|
||||
ec2v1.PlatformClassUnreadableRecord, ec2v1.PlatformClassVersionBodyWithoutHeader,
|
||||
ec2v1.PlatformClassNameIndexWithoutHeader, ec2v1.PlatformClassUnrecognisedTemplateEntry,
|
||||
}
|
||||
|
||||
// shadowUnknownClass stands in for a count class this consumer does not know.
|
||||
const shadowUnknownClass = "unrecognised-class"
|
||||
|
||||
func (e *Engine) ec2ShadowBudget() time.Duration {
|
||||
if e.ec2PlatformShadowBudget > 0 {
|
||||
return e.ec2PlatformShadowBudget
|
||||
}
|
||||
return ec2PlatformShadowBudget
|
||||
}
|
||||
|
||||
// shadowEC2Platform compares EC2's platform inventory with the legacy listings
|
||||
// in result and logs the comparison. It reads result and never changes it.
|
||||
func (e *Engine) shadowEC2Platform(ctx context.Context, result *Result, correlationRef string) {
|
||||
start := time.Now()
|
||||
reply := e.awaitEC2PlatformInventory(ctx, result.AccountID, correlationRef)
|
||||
|
||||
reports := make(map[string]ec2v1.PlatformKindReport, len(reply.Kinds))
|
||||
for _, report := range reply.Kinds {
|
||||
reports[report.Kind] = report
|
||||
}
|
||||
|
||||
outcome := shadowAgree
|
||||
attrs := []slog.Attr{
|
||||
slog.String("accountID", result.AccountID),
|
||||
slog.String("correlationRef", correlationRef),
|
||||
}
|
||||
for _, kind := range ec2ShadowKinds {
|
||||
attr, kindOutcome := compareShadowKind(kind, legacyIDs(result, kind.kind), reports[kind.kind], reply)
|
||||
attrs = append(attrs, attr)
|
||||
outcome = worseShadowOutcome(outcome, kindOutcome)
|
||||
}
|
||||
tagAttr, tagOutcome := shadowTagIndex(reports[ec2v1.PlatformKindTagIndex], reply)
|
||||
attrs = append(attrs, tagAttr)
|
||||
outcome = worseShadowOutcome(outcome, tagOutcome)
|
||||
|
||||
attrs = append(attrs, slog.String("outcome", outcome), slog.Int64("duration_ms", otelsetup.Millis(time.Since(start))))
|
||||
e.logger().LogAttrs(ctx, slog.LevelInfo, ec2PlatformShadowMessage, attrs...)
|
||||
}
|
||||
|
||||
// awaitEC2PlatformInventory bounds the inventory call by the shadow budget even
|
||||
// if the implementation ignores its context, and distrusts the reply's shape.
|
||||
func (e *Engine) awaitEC2PlatformInventory(ctx context.Context, accountID, correlationRef string) ec2v1.PlatformInventoryReply {
|
||||
ctx, cancel := context.WithTimeout(ctx, e.ec2ShadowBudget())
|
||||
defer cancel()
|
||||
|
||||
replies := make(chan ec2v1.PlatformInventoryReply, 1)
|
||||
go func() { replies <- e.ec2Inventory.PlatformInventory(ctx, accountID, correlationRef) }()
|
||||
select {
|
||||
case reply := <-replies:
|
||||
return normalisePlatformInventory(reply, accountID, correlationRef)
|
||||
case <-ctx.Done():
|
||||
return ec2v1.IncompletePlatformInventory(accountID, correlationRef, ec2v1.PlatformReasonTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func legacyIDs(result *Result, kind string) []string {
|
||||
var ids []string
|
||||
for _, stage := range result.Stages {
|
||||
for _, resource := range stage.Deleted {
|
||||
if resource.Kind == kind {
|
||||
ids = append(ids, resource.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// compareShadowKind compares one kind's legacy listing with the inventory's
|
||||
// live and dependency-held items, and reports its hidden records as counts.
|
||||
func compareShadowKind(kind shadowKind, legacy []string, report ec2v1.PlatformKindReport, reply ec2v1.PlatformInventoryReply) (slog.Attr, string) {
|
||||
var visible, held []string
|
||||
hidden := map[string]int{}
|
||||
hiddenTotal := 0
|
||||
for _, item := range reply.Items {
|
||||
if item.Kind != kind.kind {
|
||||
continue
|
||||
}
|
||||
switch item.Disposition {
|
||||
case ec2v1.PlatformDispositionLive:
|
||||
visible = append(visible, item.ResourceID)
|
||||
case ec2v1.PlatformDispositionDependencyHeld:
|
||||
visible = append(visible, item.ResourceID)
|
||||
held = append(held, item.ResourceID)
|
||||
default:
|
||||
hidden[shadowUnknownClass]++
|
||||
hiddenTotal++
|
||||
}
|
||||
}
|
||||
for _, count := range reply.Counts {
|
||||
if count.Kind != kind.kind || count.Count <= 0 {
|
||||
continue
|
||||
}
|
||||
class := count.Class
|
||||
if !slices.Contains(knownShadowClasses, class) {
|
||||
class = shadowUnknownClass
|
||||
}
|
||||
hidden[class] += count.Count
|
||||
hiddenTotal += count.Count
|
||||
}
|
||||
|
||||
basis := shadowBasisResourceID
|
||||
var onlyLegacy, onlyInventory []string
|
||||
agree := len(legacy) == len(visible)
|
||||
if kind.byName {
|
||||
basis = shadowBasisCount
|
||||
} else {
|
||||
onlyLegacy, onlyInventory = setDifference(legacy, visible), setDifference(visible, legacy)
|
||||
agree = len(onlyLegacy) == 0 && len(onlyInventory) == 0
|
||||
}
|
||||
|
||||
comparison := shadowDiffer
|
||||
if agree {
|
||||
comparison = shadowAgree
|
||||
}
|
||||
outcome := comparison
|
||||
if hiddenTotal > 0 {
|
||||
outcome = shadowDiffer
|
||||
}
|
||||
if report.Status != ec2v1.PlatformStatusObservedClean && report.Status != ec2v1.PlatformStatusRecordsFound {
|
||||
comparison, outcome = shadowScanIncomplete, shadowScanIncomplete
|
||||
}
|
||||
|
||||
loggableOnlyLegacy, droppedLegacy := loggableIDs(onlyLegacy)
|
||||
loggableOnlyInventory, droppedInventory := loggableIDs(onlyInventory)
|
||||
loggableHeld, droppedHeld := loggableIDs(held)
|
||||
return slog.Group(kind.field,
|
||||
slog.String("status", report.Status),
|
||||
slog.Any("incompleteReasons", fixedReasons(report.IncompleteReasons)),
|
||||
slog.Time("scanStartedAt", report.ScanStartedAt),
|
||||
slog.Time("scanEndedAt", report.ScanEndedAt),
|
||||
slog.String("comparison", comparison),
|
||||
slog.String("matchBasis", basis),
|
||||
slog.Int("legacyCount", len(legacy)),
|
||||
slog.Int("inventoryVisibleCount", len(visible)),
|
||||
slog.Any("onlyInLegacy", loggableOnlyLegacy),
|
||||
slog.Any("onlyInInventory", loggableOnlyInventory),
|
||||
slog.Any("dependencyHeld", loggableHeld),
|
||||
slog.Int("hiddenRecordCount", hiddenTotal),
|
||||
slog.Any("hiddenRecords", hidden),
|
||||
slog.Int("unloggableIDCount", droppedLegacy+droppedInventory+droppedHeld),
|
||||
), outcome
|
||||
}
|
||||
|
||||
// shadowTagIndex reports tag-index entries whose resource has no owner record,
|
||||
// which no legacy reaper lists.
|
||||
func shadowTagIndex(report ec2v1.PlatformKindReport, reply ec2v1.PlatformInventoryReply) (slog.Attr, string) {
|
||||
var residue []string
|
||||
for _, item := range reply.Items {
|
||||
if item.Kind == ec2v1.PlatformKindTagIndex {
|
||||
residue = append(residue, item.ResourceID)
|
||||
}
|
||||
}
|
||||
outcome := shadowAgree
|
||||
if len(residue) > 0 {
|
||||
outcome = shadowDiffer
|
||||
}
|
||||
if report.Status != ec2v1.PlatformStatusObservedClean && report.Status != ec2v1.PlatformStatusRecordsFound {
|
||||
outcome = shadowScanIncomplete
|
||||
}
|
||||
loggable, dropped := loggableIDs(residue)
|
||||
return slog.Group("tagIndex",
|
||||
slog.String("status", report.Status),
|
||||
slog.Any("incompleteReasons", fixedReasons(report.IncompleteReasons)),
|
||||
slog.Time("scanStartedAt", report.ScanStartedAt),
|
||||
slog.Time("scanEndedAt", report.ScanEndedAt),
|
||||
slog.Int("residueCount", len(residue)),
|
||||
slog.Any("residue", loggable),
|
||||
slog.Int("unloggableIDCount", dropped),
|
||||
), outcome
|
||||
}
|
||||
|
||||
func worseShadowOutcome(a, b string) string {
|
||||
rank := map[string]int{shadowAgree: 0, shadowDiffer: 1, shadowScanIncomplete: 2}
|
||||
if rank[b] > rank[a] {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// setDifference returns the sorted members of a absent from b.
|
||||
func setDifference(a, b []string) []string {
|
||||
out := []string{}
|
||||
for _, id := range a {
|
||||
if !slices.Contains(b, id) && !slices.Contains(out, id) {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func loggableIDs(ids []string) ([]string, int) {
|
||||
out := []string{}
|
||||
dropped := 0
|
||||
for _, id := range ids {
|
||||
if loggableResourceID.MatchString(id) {
|
||||
out = append(out, id)
|
||||
continue
|
||||
}
|
||||
dropped++
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out, dropped
|
||||
}
|
||||
|
||||
// fixedReasons keeps only the contract's reason categories; anything else a
|
||||
// reply carried is reported as an invalid reply rather than logged.
|
||||
func fixedReasons(reasons []string) []string {
|
||||
out := []string{}
|
||||
for _, reason := range reasons {
|
||||
if !slices.Contains(knownShadowReasons, reason) {
|
||||
reason = ec2v1.PlatformReasonInvalidReply
|
||||
}
|
||||
if !slices.Contains(out, reason) {
|
||||
out = append(out, reason)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
package accountteardown
|
||||
|
||||
//test:in-package — the shadow comparison, the inventory binding it reads and
|
||||
// its budget are unexported.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountinventory"
|
||||
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var shadowScanTime = time.Date(2026, 10, 16, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
// fakeInventory answers with reply, or never when blocked, and records the
|
||||
// correlation reference of every call.
|
||||
type fakeInventory struct {
|
||||
reply ec2v1.PlatformInventoryReply
|
||||
blocked chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
refs []string
|
||||
}
|
||||
|
||||
func (f *fakeInventory) PlatformInventory(_ context.Context, _, correlationRef string) ec2v1.PlatformInventoryReply {
|
||||
f.mu.Lock()
|
||||
f.refs = append(f.refs, correlationRef)
|
||||
f.mu.Unlock()
|
||||
if f.blocked != nil {
|
||||
<-f.blocked
|
||||
}
|
||||
return f.reply
|
||||
}
|
||||
|
||||
func (f *fakeInventory) calls() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.refs...)
|
||||
}
|
||||
|
||||
// countingReaper counts listings, so a dry run can be shown to list once.
|
||||
type countingReaper struct {
|
||||
Reaper
|
||||
|
||||
lists int
|
||||
}
|
||||
|
||||
func (r *countingReaper) List(ctx context.Context, accountID string) ([]Resource, error) {
|
||||
r.lists++
|
||||
return r.Reaper.List(ctx, accountID)
|
||||
}
|
||||
|
||||
func platformKinds(status string) []ec2v1.PlatformKindReport {
|
||||
var kinds []ec2v1.PlatformKindReport
|
||||
for _, kind := range ec2v1.PlatformKinds() {
|
||||
kinds = append(kinds, ec2v1.PlatformKindReport{
|
||||
Kind: kind, Status: status, ScanStartedAt: shadowScanTime, ScanEndedAt: shadowScanTime.Add(time.Second),
|
||||
})
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
func setKind(kinds []ec2v1.PlatformKindReport, kind, status string, reasons ...string) {
|
||||
for i := range kinds {
|
||||
if kinds[i].Kind == kind {
|
||||
kinds[i].Status, kinds[i].IncompleteReasons = status, reasons
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func item(kind, id, disposition string) ec2v1.PlatformItem {
|
||||
return ec2v1.PlatformItem{ResourceID: id, Kind: kind, Disposition: disposition}
|
||||
}
|
||||
|
||||
// legacyPlatform is a key pair, a placement group and a launch template as the
|
||||
// legacy reapers list them: the first two by name, the third by ID.
|
||||
func legacyPlatform() []Reaper {
|
||||
return []Reaper{
|
||||
&fakeReaper{kind: "instance", stage: StageCompute, resources: []Resource{{Kind: "instance", ID: "i-0123456789abcdef0"}}},
|
||||
&fakeReaper{kind: "key-pair", stage: StagePlatform, resources: []Resource{{Kind: "key-pair", ID: "deploy"}}},
|
||||
&fakeReaper{kind: "placement-group", stage: StagePlatform, resources: []Resource{{Kind: "placement-group", ID: "spread"}}},
|
||||
&fakeReaper{kind: "launch-template", stage: StagePlatform, resources: []Resource{{Kind: "launch-template", ID: "lt-0aaaaaaaaaaaaaaaa"}}},
|
||||
}
|
||||
}
|
||||
|
||||
// matchingInventory reports exactly what legacyPlatform lists.
|
||||
func matchingInventory() ec2v1.PlatformInventoryReply {
|
||||
return ec2v1.PlatformInventoryReply{
|
||||
AccountID: testAccountID,
|
||||
Kinds: platformKinds(ec2v1.PlatformStatusRecordsFound),
|
||||
Items: []ec2v1.PlatformItem{
|
||||
item(ec2v1.PlatformKindKeyPair, "key-0aaaaaaaaaaaaaaaa", ec2v1.PlatformDispositionLive),
|
||||
item(ec2v1.PlatformKindPlacementGroup, "pg-0aaaaaaaaaaaaaaaa", ec2v1.PlatformDispositionLive),
|
||||
item(ec2v1.PlatformKindLaunchTemplate, "lt-0aaaaaaaaaaaaaaaa", ec2v1.PlatformDispositionLive),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// shadowEngine is a quiet engine whose log is captured as JSON in logs.
|
||||
func shadowEngine(inventory ec2PlatformInventory, reapers ...Reaper) (*Engine, *bytes.Buffer) {
|
||||
engine := quietEngine(newFakeAccounts(), reapers...)
|
||||
logs := &bytes.Buffer{}
|
||||
engine.Logger = slog.New(slog.NewJSONHandler(logs, nil))
|
||||
engine.ec2Inventory = inventory
|
||||
return engine, logs
|
||||
}
|
||||
|
||||
// shadowRecords returns every shadow comparison record in logs.
|
||||
func shadowRecords(t *testing.T, logs *bytes.Buffer) []map[string]any {
|
||||
t.Helper()
|
||||
var records []map[string]any
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(logs.String()), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var record map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(line), &record))
|
||||
if record["msg"] == ec2PlatformShadowMessage {
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func shadowRecord(t *testing.T, logs *bytes.Buffer) map[string]any {
|
||||
t.Helper()
|
||||
records := shadowRecords(t, logs)
|
||||
require.Len(t, records, 1, "one shadow record per dry run")
|
||||
return records[0]
|
||||
}
|
||||
|
||||
func group(t *testing.T, record map[string]any, name string) map[string]any {
|
||||
t.Helper()
|
||||
g, ok := record[name].(map[string]any)
|
||||
require.True(t, ok, "record has a %s group", name)
|
||||
return g
|
||||
}
|
||||
|
||||
func strs(values ...string) []any {
|
||||
out := []any{}
|
||||
for _, v := range values {
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resultJSON(t *testing.T, result *Result) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(result)
|
||||
require.NoError(t, err)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// noShadowResult is what the same dry run returns with no inventory bound.
|
||||
func noShadowResult(t *testing.T, reapers ...Reaper) string {
|
||||
t.Helper()
|
||||
result, err := quietEngine(newFakeAccounts(), reapers...).Inventory(testCtx(t), testAccountID)
|
||||
require.NoError(t, err)
|
||||
return resultJSON(t, result)
|
||||
}
|
||||
|
||||
// Visible resources that match on both sides agree. The legacy key-pair and
|
||||
// placement-group reapers list names, so those kinds compare by count.
|
||||
func TestDryRunShadowAgreesWhenBothSidesListTheSameResources(t *testing.T) {
|
||||
reapers := legacyPlatform()
|
||||
counted := make([]Reaper, 0, len(reapers))
|
||||
for _, reaper := range reapers {
|
||||
counted = append(counted, &countingReaper{Reaper: reaper})
|
||||
}
|
||||
inventory := &fakeInventory{reply: matchingInventory()}
|
||||
engine, logs := shadowEngine(inventory, counted...)
|
||||
|
||||
result, err := engine.DryRun(testCtx(t), testAccountID, "req-agree")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, noShadowResult(t, legacyPlatform()...), resultJSON(t, result))
|
||||
assert.Equal(t, []string{"req-agree"}, inventory.calls())
|
||||
for _, reaper := range counted {
|
||||
assert.Equal(t, 1, reaper.(*countingReaper).lists, "%s listed once: the shadow reuses the dry run's listing", reaper.Kind())
|
||||
}
|
||||
|
||||
record := shadowRecord(t, logs)
|
||||
assert.Equal(t, "INFO", record["level"])
|
||||
assert.Equal(t, testAccountID, record["accountID"])
|
||||
assert.Equal(t, "req-agree", record["correlationRef"])
|
||||
assert.Equal(t, shadowAgree, record["outcome"])
|
||||
assert.Contains(t, record, "duration_ms")
|
||||
|
||||
keyPair := group(t, record, "keyPair")
|
||||
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, keyPair["status"])
|
||||
assert.Equal(t, shadowAgree, keyPair["comparison"])
|
||||
assert.Equal(t, shadowBasisCount, keyPair["matchBasis"])
|
||||
assert.InDelta(t, 1, keyPair["legacyCount"], 0)
|
||||
assert.InDelta(t, 1, keyPair["inventoryVisibleCount"], 0)
|
||||
assert.Equal(t, shadowScanTime.Format(time.RFC3339), keyPair["scanStartedAt"])
|
||||
|
||||
launchTemplate := group(t, record, "launchTemplate")
|
||||
assert.Equal(t, shadowAgree, launchTemplate["comparison"])
|
||||
assert.Equal(t, shadowBasisResourceID, launchTemplate["matchBasis"])
|
||||
assert.Equal(t, strs(), launchTemplate["onlyInLegacy"])
|
||||
assert.Equal(t, strs(), launchTemplate["onlyInInventory"])
|
||||
|
||||
tagIndex := group(t, record, "tagIndex")
|
||||
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, tagIndex["status"])
|
||||
assert.InDelta(t, 0, tagIndex["residueCount"], 0)
|
||||
}
|
||||
|
||||
// Visible resources one side has and the other does not are named by ID where
|
||||
// the legacy reaper lists IDs, and counted where it lists names.
|
||||
func TestDryRunShadowReportsVisibleResourcesOnlyOneSideSees(t *testing.T) {
|
||||
reapers := legacyPlatform()
|
||||
reapers[1].(*fakeReaper).resources = append(reapers[1].(*fakeReaper).resources, Resource{Kind: "key-pair", ID: "ci"})
|
||||
reapers[3].(*fakeReaper).resources = []Resource{
|
||||
{Kind: "launch-template", ID: "lt-0aaaaaaaaaaaaaaaa"}, {Kind: "launch-template", ID: "lt-0bbbbbbbbbbbbbbbb"},
|
||||
}
|
||||
reply := matchingInventory()
|
||||
reply.Items = append(reply.Items, item(ec2v1.PlatformKindLaunchTemplate, "lt-0cccccccccccccccc", ec2v1.PlatformDispositionLive))
|
||||
engine, logs := shadowEngine(&fakeInventory{reply: reply}, reapers...)
|
||||
|
||||
_, err := engine.DryRun(testCtx(t), testAccountID, "req-differ")
|
||||
require.NoError(t, err)
|
||||
|
||||
record := shadowRecord(t, logs)
|
||||
assert.Equal(t, shadowDiffer, record["outcome"])
|
||||
keyPair := group(t, record, "keyPair")
|
||||
assert.Equal(t, shadowDiffer, keyPair["comparison"])
|
||||
assert.InDelta(t, 2, keyPair["legacyCount"], 0)
|
||||
assert.InDelta(t, 1, keyPair["inventoryVisibleCount"], 0)
|
||||
assert.Equal(t, strs(), keyPair["onlyInLegacy"], "legacy key-pair names are never logged")
|
||||
|
||||
launchTemplate := group(t, record, "launchTemplate")
|
||||
assert.Equal(t, shadowDiffer, launchTemplate["comparison"])
|
||||
assert.Equal(t, strs("lt-0bbbbbbbbbbbbbbbb"), launchTemplate["onlyInLegacy"])
|
||||
assert.Equal(t, strs("lt-0cccccccccccccccc"), launchTemplate["onlyInInventory"])
|
||||
assert.Equal(t, shadowAgree, group(t, record, "placementGroup")["comparison"])
|
||||
}
|
||||
|
||||
// Residue the legacy reapers cannot see makes the record differ even when the
|
||||
// visible resources agree: as counts per class, and tag-index entries by ID.
|
||||
func TestDryRunShadowReportsHiddenResidueTheLegacyReapersMiss(t *testing.T) {
|
||||
reply := matchingInventory()
|
||||
reply.Counts = []ec2v1.PlatformRecordCount{
|
||||
{Kind: ec2v1.PlatformKindKeyPair, Disposition: ec2v1.PlatformDispositionResidue, Class: ec2v1.PlatformClassPublicKeyWithoutMetadata, Count: 1},
|
||||
{Kind: ec2v1.PlatformKindLaunchTemplate, Disposition: ec2v1.PlatformDispositionResidue, Class: ec2v1.PlatformClassVersionBodyWithoutHeader, Count: 2},
|
||||
{Kind: ec2v1.PlatformKindLaunchTemplate, Disposition: ec2v1.PlatformDispositionUnreadable, Class: ec2v1.PlatformClassUnreadableRecord, Count: 1},
|
||||
}
|
||||
reply.Items = append(reply.Items, item(ec2v1.PlatformKindTagIndex, "pg-0dddddddddddddddd", ec2v1.PlatformDispositionResidue))
|
||||
engine, logs := shadowEngine(&fakeInventory{reply: reply}, legacyPlatform()...)
|
||||
|
||||
_, err := engine.DryRun(testCtx(t), testAccountID, "req-residue")
|
||||
require.NoError(t, err)
|
||||
|
||||
record := shadowRecord(t, logs)
|
||||
assert.Equal(t, shadowDiffer, record["outcome"])
|
||||
keyPair := group(t, record, "keyPair")
|
||||
assert.Equal(t, shadowAgree, keyPair["comparison"], "the visible key pairs still agree")
|
||||
assert.InDelta(t, 1, keyPair["hiddenRecordCount"], 0)
|
||||
assert.Equal(t, map[string]any{ec2v1.PlatformClassPublicKeyWithoutMetadata: float64(1)}, keyPair["hiddenRecords"])
|
||||
launchTemplate := group(t, record, "launchTemplate")
|
||||
assert.InDelta(t, 3, launchTemplate["hiddenRecordCount"], 0)
|
||||
assert.Equal(t, map[string]any{
|
||||
ec2v1.PlatformClassVersionBodyWithoutHeader: float64(2),
|
||||
ec2v1.PlatformClassUnreadableRecord: float64(1),
|
||||
}, launchTemplate["hiddenRecords"])
|
||||
tagIndex := group(t, record, "tagIndex")
|
||||
assert.InDelta(t, 1, tagIndex["residueCount"], 0)
|
||||
assert.Equal(t, strs("pg-0dddddddddddddddd"), tagIndex["residue"])
|
||||
}
|
||||
|
||||
// A placement group still holding instances is a plain listed resource to the
|
||||
// legacy dry run, which learns otherwise only when its delete fails.
|
||||
func TestDryRunShadowReportsADependencyHeldPlacementGroup(t *testing.T) {
|
||||
reply := matchingInventory()
|
||||
reply.Items[1].Disposition = ec2v1.PlatformDispositionDependencyHeld
|
||||
engine, logs := shadowEngine(&fakeInventory{reply: reply}, legacyPlatform()...)
|
||||
|
||||
_, err := engine.DryRun(testCtx(t), testAccountID, "req-held")
|
||||
require.NoError(t, err)
|
||||
|
||||
placementGroup := group(t, shadowRecord(t, logs), "placementGroup")
|
||||
assert.Equal(t, shadowAgree, placementGroup["comparison"])
|
||||
assert.InDelta(t, 1, placementGroup["inventoryVisibleCount"], 0)
|
||||
assert.Equal(t, strs("pg-0aaaaaaaaaaaaaaaa"), placementGroup["dependencyHeld"])
|
||||
}
|
||||
|
||||
// An owner store that could not be read is scan-incomplete, never agreement,
|
||||
// even where the IDs it did report match.
|
||||
func TestDryRunShadowNeverReportsAnIncompleteScanAsAgreement(t *testing.T) {
|
||||
reply := matchingInventory()
|
||||
setKind(reply.Kinds, ec2v1.PlatformKindLaunchTemplate, ec2v1.PlatformStatusScanIncomplete, ec2v1.PlatformReasonRecordReadFailed)
|
||||
engine, logs := shadowEngine(&fakeInventory{reply: reply}, legacyPlatform()...)
|
||||
|
||||
result, err := engine.DryRun(testCtx(t), testAccountID, "req-store")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, noShadowResult(t, legacyPlatform()...), resultJSON(t, result))
|
||||
|
||||
record := shadowRecord(t, logs)
|
||||
assert.Equal(t, shadowScanIncomplete, record["outcome"])
|
||||
launchTemplate := group(t, record, "launchTemplate")
|
||||
assert.Equal(t, shadowScanIncomplete, launchTemplate["comparison"])
|
||||
assert.Equal(t, strs(ec2v1.PlatformReasonRecordReadFailed), launchTemplate["incompleteReasons"])
|
||||
assert.Equal(t, shadowAgree, group(t, record, "keyPair")["comparison"])
|
||||
}
|
||||
|
||||
// However the inventory fails, the dry run returns what it returns without it
|
||||
// and the record says scan-incomplete with the fixed reason.
|
||||
func TestDryRunShadowFailureLeavesTheDryRunUnchanged(t *testing.T) {
|
||||
want := noShadowResult(t, legacyPlatform()...)
|
||||
for _, reason := range []string{
|
||||
ec2v1.PlatformReasonNoResponder, ec2v1.PlatformReasonTimeout,
|
||||
ec2v1.PlatformReasonTransportFailed, ec2v1.PlatformReasonErrorReply,
|
||||
} {
|
||||
t.Run(reason, func(t *testing.T) {
|
||||
engine, logs := shadowEngine(&fakeInventory{reply: ec2v1.IncompletePlatformInventory(testAccountID, "req-fail", reason)}, legacyPlatform()...)
|
||||
result, err := engine.DryRun(testCtx(t), testAccountID, "req-fail")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, resultJSON(t, result))
|
||||
|
||||
record := shadowRecord(t, logs)
|
||||
assert.Equal(t, shadowScanIncomplete, record["outcome"])
|
||||
for _, name := range []string{"keyPair", "placementGroup", "launchTemplate", "tagIndex"} {
|
||||
g := group(t, record, name)
|
||||
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, g["status"], name)
|
||||
assert.Equal(t, strs(reason), g["incompleteReasons"], name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("reply for another account", func(t *testing.T) {
|
||||
reply := matchingInventory()
|
||||
reply.AccountID = "000000000099"
|
||||
engine, logs := shadowEngine(&fakeInventory{reply: reply}, legacyPlatform()...)
|
||||
result, err := engine.DryRun(testCtx(t), testAccountID, "req-fail")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, resultJSON(t, result))
|
||||
record := shadowRecord(t, logs)
|
||||
assert.Equal(t, shadowScanIncomplete, record["outcome"])
|
||||
assert.Equal(t, strs(ec2v1.PlatformReasonInvalidReply), group(t, record, "keyPair")["incompleteReasons"])
|
||||
})
|
||||
|
||||
t.Run("no daemon serves the subject", func(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
engine, logs := shadowEngine(newEC2PlatformInventoryClient(nc), legacyPlatform()...)
|
||||
result, err := engine.DryRun(testCtx(t), testAccountID, "req-fail")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, resultJSON(t, result))
|
||||
assert.Equal(t, strs(ec2v1.PlatformReasonNoResponder), group(t, shadowRecord(t, logs), "tagIndex")["incompleteReasons"])
|
||||
})
|
||||
}
|
||||
|
||||
// An inventory that never answers, even one ignoring its context, holds the
|
||||
// dry run for the shadow budget at most and is logged as a timeout.
|
||||
func TestDryRunShadowIsBoundedByItsBudget(t *testing.T) {
|
||||
inventory := &fakeInventory{reply: matchingInventory(), blocked: make(chan struct{})}
|
||||
t.Cleanup(func() { close(inventory.blocked) })
|
||||
engine, logs := shadowEngine(inventory, legacyPlatform()...)
|
||||
engine.ec2PlatformShadowBudget = 20 * time.Millisecond
|
||||
|
||||
started := time.Now()
|
||||
result, err := engine.DryRun(testCtx(t), testAccountID, "req-slow")
|
||||
require.NoError(t, err)
|
||||
assert.Less(t, time.Since(started), 5*time.Second)
|
||||
assert.Equal(t, noShadowResult(t, legacyPlatform()...), resultJSON(t, result))
|
||||
assert.Equal(t, strs(ec2v1.PlatformReasonTimeout), group(t, shadowRecord(t, logs), "launchTemplate")["incompleteReasons"])
|
||||
}
|
||||
|
||||
// The caller's deadline wins over the budget, so a dry run never outlasts the
|
||||
// bound its caller already set.
|
||||
func TestDryRunShadowStopsAtTheCallersDeadline(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
serveInventory(t, nc, func() []byte { return nil })
|
||||
engine, logs := shadowEngine(newEC2PlatformInventoryClient(nc), legacyPlatform()...)
|
||||
require.Greater(t, engine.ec2ShadowBudget(), 5*time.Second)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
result, err := engine.DryRun(ctx, testAccountID, "req-deadline")
|
||||
require.NoError(t, err)
|
||||
assert.Less(t, time.Since(started), 5*time.Second)
|
||||
assert.Equal(t, noShadowResult(t, legacyPlatform()...), resultJSON(t, result))
|
||||
assert.Equal(t, strs(ec2v1.PlatformReasonTimeout), group(t, shadowRecord(t, logs), "keyPair")["incompleteReasons"])
|
||||
}
|
||||
|
||||
// A dry run whose own listing fails returns that error as before, and nothing
|
||||
// is compared against a listing that does not exist.
|
||||
func TestDryRunWithAFailedListingDoesNotCallTheInventory(t *testing.T) {
|
||||
reapers := legacyPlatform()
|
||||
reapers[2].(*fakeReaper).listErr = errors.New("describe failed")
|
||||
inventory := &fakeInventory{reply: matchingInventory()}
|
||||
engine, logs := shadowEngine(inventory, reapers...)
|
||||
|
||||
_, err := engine.DryRun(testCtx(t), testAccountID, "req-list")
|
||||
require.ErrorContains(t, err, "inventory placement-group")
|
||||
assert.Empty(t, inventory.calls())
|
||||
assert.Empty(t, shadowRecords(t, logs))
|
||||
}
|
||||
|
||||
// A reply that puts anything but the contract's fixed vocabulary where a
|
||||
// reason, class or ID belongs never reaches the log; the IDs are counted.
|
||||
func TestDryRunShadowLogsOnlyTheFixedVocabulary(t *testing.T) {
|
||||
reply := matchingInventory()
|
||||
setKind(reply.Kinds, ec2v1.PlatformKindKeyPair, ec2v1.PlatformStatusScanIncomplete, "read keys/000000000042/secret-key-name: denied")
|
||||
reply.Counts = []ec2v1.PlatformRecordCount{{Kind: ec2v1.PlatformKindLaunchTemplate, Disposition: ec2v1.PlatformDispositionResidue, Class: "secret-template-name", Count: 1}}
|
||||
reply.Items = append(reply.Items,
|
||||
item(ec2v1.PlatformKindLaunchTemplate, "lt-secret-template-name", ec2v1.PlatformDispositionLive),
|
||||
item(ec2v1.PlatformKindTagIndex, "pg-team=secret-tag-value", ec2v1.PlatformDispositionResidue),
|
||||
)
|
||||
reapers := legacyPlatform()
|
||||
reapers[1].(*fakeReaper).resources = []Resource{{Kind: "key-pair", ID: "secret-key-name"}}
|
||||
engine, logs := shadowEngine(&fakeInventory{reply: reply}, reapers...)
|
||||
|
||||
_, err := engine.DryRun(testCtx(t), testAccountID, "req-disclosure")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotContains(t, logs.String(), "secret")
|
||||
record := shadowRecord(t, logs)
|
||||
assert.Equal(t, strs(ec2v1.PlatformReasonInvalidReply), group(t, record, "keyPair")["incompleteReasons"])
|
||||
launchTemplate := group(t, record, "launchTemplate")
|
||||
assert.Equal(t, map[string]any{shadowUnknownClass: float64(1)}, launchTemplate["hiddenRecords"])
|
||||
assert.InDelta(t, 1, launchTemplate["unloggableIDCount"], 0)
|
||||
assert.InDelta(t, 1, group(t, record, "tagIndex")["unloggableIDCount"], 0)
|
||||
}
|
||||
|
||||
// End to end over the real owner stores and the NATS adapter: the record names
|
||||
// the residue each reaper misses and nothing a customer named or stored.
|
||||
func TestDryRunShadowOverTheOwnerStores(t *testing.T) {
|
||||
o := newPlatformOwners(t)
|
||||
ctx := testCtx(t)
|
||||
inv := accountinventory.New(o.keys, o.groups, o.templates, o.tags)
|
||||
sub, err := o.nc.QueueSubscribe(ec2v1.PlatformInventorySubject, "spinifex-workers", func(msg *nats.Msg) {
|
||||
natsmsg.ServeNATSRequestCtx(msg, inv.PlatformInventory)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
|
||||
material := []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl")
|
||||
for _, name := range []string{"secret-key-name", "secret-orphan-key"} {
|
||||
_, err = o.keys.ImportKeyPair(ctx, &ec2.ImportKeyPairInput{KeyName: aws.String(name), PublicKeyMaterial: material}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err = o.keys.DeleteKeyPair(ctx, &ec2.DeleteKeyPairInput{KeyName: aws.String("secret-orphan-key")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
// Residue an older release's failed deletes left behind.
|
||||
_, err = o.store.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(platformBucket), Key: aws.String("keys/" + testAccountID + "/secret-orphan-key"), Body: bytes.NewReader(material),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = o.groups.CreatePlacementGroup(ctx, &ec2.CreatePlacementGroupInput{
|
||||
GroupName: aws.String("secret-group-name"), Strategy: aws.String(ec2.PlacementStrategySpread),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
doomed, err := o.groups.CreatePlacementGroup(ctx, &ec2.CreatePlacementGroupInput{
|
||||
GroupName: aws.String("secret-doomed-group"), Strategy: aws.String(ec2.PlacementStrategySpread),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
lt, err := o.templates.CreateLaunchTemplate(ctx, &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("secret-template-name"),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{UserData: aws.String(base64.StdEncoding.EncodeToString([]byte("secret-user-data")))},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
doomedID, ltID := aws.StringValue(doomed.PlacementGroup.GroupId), aws.StringValue(lt.LaunchTemplate.LaunchTemplateId)
|
||||
_, err = o.tags.CreateTags(ctx, &ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(doomedID), aws.String(ltID)},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("secret-tag-key"), Value: aws.String("secret-tag-value")}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
_, err = o.groups.DeletePlacementGroup(ctx, &ec2.DeletePlacementGroupInput{GroupName: aws.String("secret-doomed-group")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
_, err = o.tags.CreateTags(ctx, &ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(doomedID)},
|
||||
Tags: []*ec2.Tag{{Key: aws.String("secret-tag-key"), Value: aws.String("secret-tag-value")}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
engine, logs := shadowEngine(newEC2PlatformInventoryClient(o.nc), platformReapers(o.nc)...)
|
||||
result, err := engine.DryRun(ctx, testAccountID, "req-owners")
|
||||
require.NoError(t, err)
|
||||
plain, err := quietEngine(newFakeAccounts(), platformReapers(o.nc)...).Inventory(ctx, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, resultJSON(t, plain), resultJSON(t, result))
|
||||
|
||||
assert.NotContains(t, logs.String(), "secret")
|
||||
assert.NotContains(t, logs.String(), base64.StdEncoding.EncodeToString([]byte("secret-user-data")))
|
||||
record := shadowRecord(t, logs)
|
||||
assert.Equal(t, shadowDiffer, record["outcome"])
|
||||
keyPair := group(t, record, "keyPair")
|
||||
assert.Equal(t, shadowAgree, keyPair["comparison"])
|
||||
assert.Equal(t, map[string]any{ec2v1.PlatformClassPublicKeyWithoutMetadata: float64(1)}, keyPair["hiddenRecords"])
|
||||
assert.Equal(t, shadowAgree, group(t, record, "placementGroup")["comparison"])
|
||||
launchTemplate := group(t, record, "launchTemplate")
|
||||
assert.Equal(t, shadowAgree, launchTemplate["comparison"])
|
||||
assert.Equal(t, shadowBasisResourceID, launchTemplate["matchBasis"])
|
||||
assert.Equal(t, strs(doomedID), group(t, record, "tagIndex")["residue"])
|
||||
}
|
||||
@@ -29,9 +29,12 @@ type Engine struct {
|
||||
// and this is the only view into it while it runs.
|
||||
OnStage func(ctx context.Context, stage StageResult)
|
||||
|
||||
// ec2Inventory is EC2's read-only platform inventory. Bound by the cluster
|
||||
// wiring but consulted by no teardown or inventory path yet.
|
||||
// ec2Inventory is EC2's read-only platform inventory. Only DryRun consults
|
||||
// it, and only to log a comparison; no teardown or Inventory call does.
|
||||
ec2Inventory ec2PlatformInventory
|
||||
|
||||
// ec2PlatformShadowBudget overrides ec2PlatformShadowBudget when positive.
|
||||
ec2PlatformShadowBudget time.Duration
|
||||
}
|
||||
|
||||
// NewEngine builds an engine with the default timeouts.
|
||||
@@ -87,6 +90,20 @@ func (e *Engine) Inventory(ctx context.Context, accountID string) (*Result, erro
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DryRun is the explicit operator dry run. It returns exactly what Inventory
|
||||
// returns; beside it, EC2's platform inventory is compared with the legacy
|
||||
// listings and only logged, under correlationRef, which authorises nothing.
|
||||
func (e *Engine) DryRun(ctx context.Context, accountID, correlationRef string) (*Result, error) {
|
||||
result, err := e.Inventory(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.ec2Inventory != nil {
|
||||
e.shadowEC2Platform(ctx, result, correlationRef)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Teardown empties the account and then removes it.
|
||||
//
|
||||
// The account is marked TERMINATING before anything is deleted. Without that
|
||||
|
||||
@@ -34,27 +34,11 @@ import (
|
||||
|
||||
const platformBucket = "test-bucket"
|
||||
|
||||
// deleteFailingStore fails object deletes its predicate selects.
|
||||
type deleteFailingStore struct {
|
||||
*objectstore.MemoryObjectStore
|
||||
|
||||
failDelete func(key string) bool
|
||||
}
|
||||
|
||||
var _ objectstore.ObjectStore = (*deleteFailingStore)(nil)
|
||||
|
||||
func (s *deleteFailingStore) DeleteObject(ctx context.Context, in *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) {
|
||||
if s.failDelete != nil && s.failDelete(aws.StringValue(in.Key)) {
|
||||
return nil, errors.New("injected store failure")
|
||||
}
|
||||
return s.MemoryObjectStore.DeleteObject(ctx, in)
|
||||
}
|
||||
|
||||
// platformOwners runs the real key, placement-group, launch-template and tag
|
||||
// services behind the subjects the platform reapers call.
|
||||
type platformOwners struct {
|
||||
nc *nats.Conn
|
||||
store *deleteFailingStore
|
||||
store *objectstore.MemoryObjectStore
|
||||
keys *ec2key.KeyServiceImpl
|
||||
groups *ec2placementgroup.PlacementGroupServiceImpl
|
||||
templates *ec2launchtemplate.LaunchTemplateServiceImpl
|
||||
@@ -81,7 +65,7 @@ func newPlatformOwners(t *testing.T) *platformOwners {
|
||||
t.Helper()
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
ctx := t.Context()
|
||||
o := &platformOwners{nc: nc, store: &deleteFailingStore{MemoryObjectStore: objectstore.NewMemoryObjectStore()}}
|
||||
o := &platformOwners{nc: nc, store: objectstore.NewMemoryObjectStore()}
|
||||
|
||||
var err error
|
||||
o.tagsKV, err = ec2tags.GetOrCreateTagsBucket(ctx, js)
|
||||
@@ -92,8 +76,10 @@ func newPlatformOwners(t *testing.T) *platformOwners {
|
||||
o.keys.SetCentralTagStore(o.tags)
|
||||
o.groups, err = ec2placementgroup.NewPlacementGroupServiceImplWithNATS(ctx, nil, nc)
|
||||
require.NoError(t, err)
|
||||
o.groups.SetCentralTagStore(o.tags)
|
||||
o.templates, err = ec2launchtemplate.NewLaunchTemplateServiceImplWithNATS(ctx, nil, nc)
|
||||
require.NoError(t, err)
|
||||
o.templates.SetCentralTagStore(o.tags)
|
||||
o.pgKV, err = js.KeyValue(ctx, ec2placementgroup.KVBucketPlacementGroups)
|
||||
require.NoError(t, err)
|
||||
o.ltKV, err = js.KeyValue(ctx, ec2launchtemplate.KVBucketLaunchTemplates)
|
||||
@@ -172,9 +158,18 @@ func TestCurrentBehaviour_AccountTermination_PlatformReapersReportDrainedOverHid
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Residue each owner's Describe skips.
|
||||
publicKey := "keys/" + testAccountID + "/deploy"
|
||||
o.store.failDelete = func(key string) bool { return key == publicKey }
|
||||
// Residue each owner's Describe skips, as left by older releases or a
|
||||
// crash between writes.
|
||||
publicKey := "keys/" + testAccountID + "/old-deploy"
|
||||
_, err = o.store.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(platformBucket), Key: aws.String(publicKey), Body: strings.NewReader("ssh-ed25519 AAAA"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
staleTagged := "pg-0stale000000000"
|
||||
_, err = o.tags.CreateTags(ctx, &ec2.CreateTagsInput{
|
||||
Resources: []*string{aws.String(staleTagged)}, Tags: []*ec2.Tag{{Key: aws.String("team"), Value: aws.String("blue")}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
_, err = o.store.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(platformBucket), Key: aws.String("keys/" + testAccountID + "/key-0badbadbadbadbad0.json"),
|
||||
Body: strings.NewReader("{not json"),
|
||||
@@ -210,10 +205,6 @@ func TestCurrentBehaviour_AccountTermination_PlatformReapersReportDrainedOverHid
|
||||
assert.ElementsMatch(t, []string{publicKey, "keys/" + testAccountID + "/key-0badbadbadbadbad0.json"}, o.objectKeys(t))
|
||||
assert.Equal(t, []string{testAccountID + ".broken"}, accountKVKeys(t, o.pgKV))
|
||||
assert.Equal(t, []string{orphanBody}, accountKVKeys(t, o.ltKV))
|
||||
assert.Equal(t, sortedStrings(testAccountID+"."+pgID, testAccountID+"."+ltID), accountKVKeys(t, o.tagsKV))
|
||||
}
|
||||
|
||||
func sortedStrings(s ...string) []string {
|
||||
slices.Sort(s)
|
||||
return s
|
||||
// The owner deletes clear their own tags; only the stale entry remains.
|
||||
assert.Equal(t, []string{testAccountID + "." + staleTagged}, accountKVKeys(t, o.tagsKV))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostnet"
|
||||
)
|
||||
|
||||
// hostnetUnit is the unit whose failure this check surfaces.
|
||||
const hostnetUnit = "spinifex-hostnet.service"
|
||||
|
||||
// hostnetOVNRemote is a variable so tests never query a real OVS.
|
||||
var hostnetOVNRemote = hostnet.DefaultEnv().OVNRemote
|
||||
|
||||
// checkHostnet runs the check the daemon and vpcd run before they start, so a
|
||||
// failed host network apply shows here, not only as a refused service start.
|
||||
func checkHostnet(root string) Result {
|
||||
env := hostnet.DefaultEnv()
|
||||
env.Paths = hostnet.PathsAt(root)
|
||||
env.OVNRemote = hostnetOVNRemote
|
||||
if err := hostnet.Check(context.Background(), env); err != nil {
|
||||
return Result{Path: hostnetUnit, Kind: "hostnet", Status: Failed, Detail: err.Error()}
|
||||
}
|
||||
return Result{Path: hostnetUnit, Kind: "hostnet", Status: OK}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckHostAt_Hostnet(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
backend string
|
||||
state bool
|
||||
want Status
|
||||
detail string
|
||||
}{
|
||||
"no record, nothing set up": {want: OK},
|
||||
"networkd backend": {backend: "networkd", want: OK},
|
||||
"hostnet backend, no state": {backend: "hostnet", want: Failed, detail: "hostnet.json"},
|
||||
"hostnet backend, no marker": {backend: "hostnet", state: true, want: Failed, detail: "host network not applied"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
etc := filepath.Join(root, "etc/spinifex")
|
||||
if err := os.MkdirAll(etc, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.backend != "" {
|
||||
if err := os.WriteFile(filepath.Join(etc, "hostnet-backend"), []byte(tc.backend+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if tc.state {
|
||||
state := `{"version": 1, "pending": false, "int_bridge": "br-int"}`
|
||||
if err := os.WriteFile(filepath.Join(etc, "hostnet.json"), []byte(state), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
r := findResult(t, CheckHostAt(root), hostnetUnit)
|
||||
if r.Status != tc.want || !strings.Contains(r.Detail, tc.detail) {
|
||||
t.Errorf("hostnet result = %s %q, want %s containing %q", r.Status, r.Detail, tc.want, tc.detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
|
||||
)
|
||||
|
||||
// microvmMachine is the QEMU machine type load balancer system instances boot on.
|
||||
const microvmMachine = "microvm"
|
||||
|
||||
// microvmChecked reports whether this host runs system instances. The microvm
|
||||
// machine type exists only in x86 QEMU.
|
||||
var microvmChecked = runtime.GOARCH == "amd64"
|
||||
|
||||
// resolveQEMU and machineHelp are variables so tests never run a real QEMU.
|
||||
var (
|
||||
resolveQEMU = func() (string, error) { return hostos.QEMUBinary("x86_64") }
|
||||
machineHelp = func(binary string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, binary, "-machine", "help").Output()
|
||||
}
|
||||
)
|
||||
|
||||
// checkMicroVM checks the QEMU binary the daemon will run, and the firmware it
|
||||
// passes, can boot a microvm. A QEMU build without microvm, such as Oracle
|
||||
// Linux's AppStream qemu-kvm, otherwise surfaces only at the first ALB launch.
|
||||
func checkMicroVM(root string) []Result {
|
||||
if !microvmChecked {
|
||||
return nil
|
||||
}
|
||||
return []Result{checkQEMUMicroVM(), checkMicroVMFirmware(root)}
|
||||
}
|
||||
|
||||
func checkQEMUMicroVM() Result {
|
||||
binary, err := resolveQEMU()
|
||||
if err != nil {
|
||||
return Result{Path: "qemu", Kind: "qemu", Status: Missing, Detail: err.Error()}
|
||||
}
|
||||
out, err := machineHelp(binary)
|
||||
if err != nil {
|
||||
return Result{Path: binary, Kind: "qemu", Status: Missing, Detail: fmt.Sprintf("%s -machine help: %v", binary, err)}
|
||||
}
|
||||
if !listsMachine(out, microvmMachine) {
|
||||
return Result{
|
||||
Path: binary,
|
||||
Kind: "qemu",
|
||||
Status: Unsupported,
|
||||
Detail: fmt.Sprintf("no %s machine type, so load balancers cannot boot; install a QEMU build that has it (on Oracle Linux, qemu-kvm from ol10_kvm_utils)", microvmMachine),
|
||||
}
|
||||
}
|
||||
return Result{Path: binary, Kind: "qemu", Status: OK}
|
||||
}
|
||||
|
||||
// listsMachine reports whether `-machine help` output has a row for name. Rows
|
||||
// are "<name> <description>", after a "Supported machines are:" header.
|
||||
func listsMachine(help []byte, name string) bool {
|
||||
sc := bufio.NewScanner(bytes.NewReader(help))
|
||||
for sc.Scan() {
|
||||
if fields := strings.Fields(sc.Text()); len(fields) > 0 && fields[0] == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkMicroVMFirmware(root string) Result {
|
||||
path := filepath.Join(vm.MicroVMDir, vm.MicroVMFirmware)
|
||||
if _, err := os.Stat(filepath.Join(root, path)); err != nil {
|
||||
return Result{Path: path, Kind: "firmware", Status: Missing, Detail: err.Error()}
|
||||
}
|
||||
return Result{Path: path, Kind: "firmware", Status: OK}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
|
||||
)
|
||||
|
||||
// Debian's qemu-system-x86_64 -machine help, trimmed.
|
||||
const helpWithMicroVM = `Supported machines are:
|
||||
microvm microvm (i386)
|
||||
pc Standard PC (i440FX + PIIX, 1996) (alias of pc-i440fx-10.0)
|
||||
q35 Standard PC (Q35 + ICH9, 2009) (alias of pc-q35-10.0)
|
||||
`
|
||||
|
||||
// Oracle Linux 10's AppStream qemu-kvm 10.1.0 -machine help, trimmed. It has
|
||||
// no microvm row.
|
||||
const helpWithoutMicroVM = `Supported machines are:
|
||||
pc RHEL 10.0.0 PC (i440FX + PIIX, 1996) (alias of pc-i440fx-rhel10.0.0)
|
||||
pc-i440fx-rhel10.0.0 RHEL 10.0.0 PC (i440FX + PIIX, 1996) (default) (deprecated)
|
||||
pc-q35-rhel9.8.0 RHEL-9.8.0 PC (Q35 + ICH9, 2009) (deprecated)
|
||||
`
|
||||
|
||||
var microvmFirmwarePath = filepath.Join(vm.MicroVMDir, vm.MicroVMFirmware)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
microvmChecked = true
|
||||
resolveQEMU = func() (string, error) { return hostos.QEMUKVMPath, nil }
|
||||
machineHelp = func(string) ([]byte, error) { return []byte(helpWithMicroVM), nil }
|
||||
hostnetOVNRemote = func(context.Context) (string, error) { return "", nil }
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func stubQEMU(t *testing.T, binary string, resolveErr error, help string, helpErr error) {
|
||||
t.Helper()
|
||||
origResolve, origHelp := resolveQEMU, machineHelp
|
||||
t.Cleanup(func() { resolveQEMU, machineHelp = origResolve, origHelp })
|
||||
var asked string
|
||||
resolveQEMU = func() (string, error) { return binary, resolveErr }
|
||||
machineHelp = func(b string) ([]byte, error) {
|
||||
asked = b
|
||||
return []byte(help), helpErr
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if resolveErr == nil && asked != binary {
|
||||
t.Errorf("machine help ran %q, want the resolved binary %q", asked, binary)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func qemuResult(t *testing.T, results []Result) Result {
|
||||
t.Helper()
|
||||
for _, r := range results {
|
||||
if r.Kind == "qemu" {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no qemu result in %+v", results)
|
||||
return Result{}
|
||||
}
|
||||
|
||||
func TestCheckHostAt_QEMUMicroVM(t *testing.T) {
|
||||
t.Run("resolved binary lists microvm", func(t *testing.T) {
|
||||
stubQEMU(t, hostos.QEMUKVMPath, nil, helpWithMicroVM, nil)
|
||||
r := qemuResult(t, CheckHostAt(t.TempDir()))
|
||||
if r.Status != OK || r.Path != hostos.QEMUKVMPath {
|
||||
t.Errorf("got %+v, want OK for %s", r, hostos.QEMUKVMPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resolved binary has no microvm", func(t *testing.T) {
|
||||
stubQEMU(t, hostos.QEMUKVMPath, nil, helpWithoutMicroVM, nil)
|
||||
r := qemuResult(t, CheckHostAt(t.TempDir()))
|
||||
if r.Status != Unsupported || !strings.Contains(r.Detail, "ol10_kvm_utils") {
|
||||
t.Errorf("got %+v, want Unsupported naming ol10_kvm_utils", r)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no QEMU resolves", func(t *testing.T) {
|
||||
noQEMU := errors.New("no QEMU for x86_64 guests: found neither /usr/libexec/qemu-kvm (package qemu-kvm) nor qemu-system-x86_64 (package qemu-system-x86) on PATH")
|
||||
stubQEMU(t, "", noQEMU, "", nil)
|
||||
r := qemuResult(t, CheckHostAt(t.TempDir()))
|
||||
if r.Status != Missing || r.Detail != noQEMU.Error() {
|
||||
t.Errorf("got %+v, want Missing carrying the resolver's error", r)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("machine help fails", func(t *testing.T) {
|
||||
stubQEMU(t, "/usr/bin/qemu-system-x86_64", nil, "", errors.New("exit status 1"))
|
||||
r := qemuResult(t, CheckHostAt(t.TempDir()))
|
||||
if r.Status != Missing || !strings.Contains(r.Detail, "exit status 1") {
|
||||
t.Errorf("got %+v, want Missing with the exec error", r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckHostAt_MicroVMFirmware(t *testing.T) {
|
||||
t.Run("absent", func(t *testing.T) {
|
||||
r := findResult(t, CheckHostAt(t.TempDir()), microvmFirmwarePath)
|
||||
if r.Status != Missing {
|
||||
t.Errorf("status = %v, want Missing", r.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("present", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeHelper(t, root, microvmFirmwarePath, []byte("qboot"))
|
||||
r := findResult(t, CheckHostAt(root), microvmFirmwarePath)
|
||||
if r.Status != OK {
|
||||
t.Errorf("status = %v, want OK (detail: %s)", r.Status, r.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckHostAt_NoMicroVMChecksOffX86(t *testing.T) {
|
||||
orig := microvmChecked
|
||||
microvmChecked = false
|
||||
t.Cleanup(func() { microvmChecked = orig })
|
||||
|
||||
for _, r := range CheckHostAt(t.TempDir()) {
|
||||
if r.Kind == "qemu" || r.Kind == "firmware" {
|
||||
t.Errorf("unexpected %s result on a host without system instances: %+v", r.Kind, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ const (
|
||||
Stale
|
||||
// Ungranted means the sudoers grant for a helper is absent or missing its NOPASSWD line.
|
||||
Ungranted
|
||||
// Unsupported means the asset is present but lacks a capability this build needs.
|
||||
Unsupported
|
||||
// Failed means a host check that gates the services did not pass.
|
||||
Failed
|
||||
)
|
||||
|
||||
// String returns the human-readable name of the status, used in table output and log lines.
|
||||
@@ -42,6 +46,10 @@ func (s Status) String() string {
|
||||
return "Stale"
|
||||
case Ungranted:
|
||||
return "Ungranted"
|
||||
case Unsupported:
|
||||
return "Unsupported"
|
||||
case Failed:
|
||||
return "Failed"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
@@ -50,7 +58,7 @@ func (s Status) String() string {
|
||||
// Result is the outcome of checking one managed asset against this build's manifest.
|
||||
type Result struct {
|
||||
Path string
|
||||
Kind string // "helper" | "sudoers-grant" | "service"
|
||||
Kind string // "helper" | "sudoers-grant" | "service" | "qemu" | "firmware" | "hostnet"
|
||||
Status Status
|
||||
Detail string
|
||||
}
|
||||
@@ -82,6 +90,8 @@ func CheckHostAt(root string) []Result {
|
||||
}
|
||||
results = append(results, checkSudoersGrant(root))
|
||||
results = append(results, checkRunningBinaries(root)...)
|
||||
results = append(results, checkMicroVM(root)...)
|
||||
results = append(results, checkHostnet(root))
|
||||
return results
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestHasProblem(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStatusString(t *testing.T) {
|
||||
cases := map[Status]string{OK: "OK", Missing: "Missing", Stale: "Stale", Ungranted: "Ungranted"}
|
||||
cases := map[Status]string{OK: "OK", Missing: "Missing", Stale: "Stale", Ungranted: "Ungranted", Unsupported: "Unsupported", Failed: "Failed"}
|
||||
for status, want := range cases {
|
||||
if got := status.String(); got != want {
|
||||
t.Errorf("Status(%d).String() = %q, want %q", status, got, want)
|
||||
|
||||
@@ -1825,9 +1825,6 @@ func (d *Daemon) startCluster() error {
|
||||
return fmt.Errorf("failed to get tags KV bucket: %w", err)
|
||||
}
|
||||
d.tagsService = ec2tags.NewTagsServiceImpl(d.config, tagsKV)
|
||||
// Key pairs keep their creation tags in their own metadata; project them so
|
||||
// describe-tags sees them, and clear them when the key pair is deleted.
|
||||
d.keyService.SetCentralTagStore(d.tagsService)
|
||||
|
||||
d.eigwService, err = initServiceWithRetry("EIGW service", func() (*ec2eigw.EgressOnlyIGWServiceImpl, error) {
|
||||
return ec2eigw.NewEgressOnlyIGWServiceImplWithNATS(d.ctx, d.config, d.natsConn)
|
||||
@@ -1856,6 +1853,7 @@ func (d *Daemon) startCluster() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize launch template service: %w", err)
|
||||
}
|
||||
d.wirePlatformTagIndex()
|
||||
|
||||
d.spotInstanceService, err = initServiceWithRetry("spot instance service", func() (*ec2spotinstance.SpotInstanceServiceImpl, error) {
|
||||
return ec2spotinstance.NewSpotInstanceServiceImplWithNATS(d.ctx, d.config, d.natsConn)
|
||||
@@ -3628,3 +3626,12 @@ func probeGPU() gpuProbeResult {
|
||||
r.Capable = len(r.Devices) > 0 && ((r.IOMMUActive && r.VFIOPresent) || hasMIG)
|
||||
return r
|
||||
}
|
||||
|
||||
// wirePlatformTagIndex gives the key-pair, placement-group and launch-template
|
||||
// owners the tag index their deletes clear; key pairs also project their
|
||||
// metadata-held creation tags into it so describe-tags sees them.
|
||||
func (d *Daemon) wirePlatformTagIndex() {
|
||||
d.keyService.SetCentralTagStore(d.tagsService)
|
||||
d.placementGroupService.SetCentralTagStore(d.tagsService)
|
||||
d.launchTemplateService.SetCentralTagStore(d.tagsService)
|
||||
}
|
||||
|
||||
@@ -164,6 +164,9 @@ func newInventoryCluster(t *testing.T) *inventoryCluster {
|
||||
d.placementGroupService = groups
|
||||
d.launchTemplateService = templates
|
||||
require.NoError(t, d.subscribeAll())
|
||||
// The caller is a separate connection, so the server must hold the SUBs
|
||||
// before it publishes or the request can see no responders.
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
c.caller, err = nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -774,10 +774,14 @@ func (d *Daemon) WaitForSystemInstance(instanceID string, timeout time.Duration)
|
||||
return fmt.Errorf("instance %s did not reach running state within %s", instanceID, timeout)
|
||||
}
|
||||
|
||||
// microvmDir holds the microvm kernel, initramfs and firmware. A variable so
|
||||
// tests can supply their own.
|
||||
var microvmDir = vm.MicroVMDir
|
||||
|
||||
// buildDirectBootConfig constructs a vm.Config for a direct-boot microVM
|
||||
// launch. It does not call buildBaseVMConfig — the microvm machine type
|
||||
// requires no PCIe root ports, no UEFI/BIOS, and no drives. Runtime paths
|
||||
// (PID file, console log, serial socket) are filled in later by startQEMU.
|
||||
// requires no PCIe root ports, no UEFI, and no drives; it boots qboot. Runtime
|
||||
// paths (PID file, console log, serial socket) are filled in later by startQEMU.
|
||||
//
|
||||
// Network topology from input.NICs is serialised to three fw_cfg tmpfiles
|
||||
// (netcfg, lb-agent-env, ca-cert) under hostprocess.RuntimeDir().
|
||||
@@ -794,7 +798,10 @@ func (d *Daemon) buildDirectBootConfig(instanceID string, input *handlers_elbv2.
|
||||
memMiB = int(instanceTypeMemoryMiB(it))
|
||||
}
|
||||
|
||||
const imagePath = "/usr/share/spinifex/microvm"
|
||||
firmware := filepath.Join(microvmDir, vm.MicroVMFirmware)
|
||||
if _, err := os.Stat(firmware); err != nil {
|
||||
return vm.Config{}, fmt.Errorf("microvm firmware %s is missing, so load balancer instances cannot boot; reinstall Spinifex to restore it: %w", firmware, err)
|
||||
}
|
||||
|
||||
fwCfg, err := d.writeFwCfgBlobs(instanceID, input)
|
||||
if err != nil {
|
||||
@@ -846,8 +853,9 @@ func (d *Daemon) buildDirectBootConfig(instanceID string, input *handlers_elbv2.
|
||||
CPUCount: vcpus,
|
||||
Memory: memMiB,
|
||||
MachineType: machineType,
|
||||
KernelImage: filepath.Join(imagePath, "vmlinuz"),
|
||||
Initrd: filepath.Join(imagePath, "initramfs.cpio.gz"),
|
||||
BIOS: firmware,
|
||||
KernelImage: filepath.Join(microvmDir, "vmlinuz"),
|
||||
Initrd: filepath.Join(microvmDir, "initramfs.cpio.gz"),
|
||||
KernelCmdline: cmdline,
|
||||
FwCfg: fwCfg,
|
||||
}
|
||||
|
||||
@@ -804,6 +804,37 @@ func TestBuildDirectBootConfig_InstanceTypeShape(t *testing.T) {
|
||||
assert.Len(t, cfg.FwCfg, 3)
|
||||
}
|
||||
|
||||
func TestBuildDirectBootConfig_BootsTheShippedQboot(t *testing.T) {
|
||||
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||
d := &Daemon{resourceMgr: &ResourceManager{}}
|
||||
|
||||
cfg, err := d.buildDirectBootConfig("i-qboot", &handlers_elbv2.SystemInstanceInput{
|
||||
NICs: []handlers_elbv2.NICConfig{{MAC: "02:00:00:00:00:05", IsDefault: true}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(microvmDir, vm.MicroVMFirmware), cfg.BIOS)
|
||||
for _, e := range cfg.FwCfg {
|
||||
assert.NoError(t, os.Remove(e.File))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDirectBootConfig_MissingFirmwareFailsByName(t *testing.T) {
|
||||
runtimeDir := t.TempDir()
|
||||
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
|
||||
orig := microvmDir
|
||||
microvmDir = t.TempDir()
|
||||
t.Cleanup(func() { microvmDir = orig })
|
||||
|
||||
_, err := (&Daemon{resourceMgr: &ResourceManager{}}).buildDirectBootConfig("i-nofw", &handlers_elbv2.SystemInstanceInput{
|
||||
NICs: []handlers_elbv2.NICConfig{{MAC: "02:00:00:00:00:06", IsDefault: true}},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), filepath.Join(microvmDir, vm.MicroVMFirmware))
|
||||
entries, readErr := os.ReadDir(runtimeDir)
|
||||
require.NoError(t, readErr)
|
||||
assert.Empty(t, entries, "a launch refused for missing firmware must leave no fw_cfg blobs behind")
|
||||
}
|
||||
|
||||
func TestBuildDirectBootConfig_FwCfgWriteError(t *testing.T) {
|
||||
notADir := filepath.Join(t.TempDir(), "file")
|
||||
require.NoError(t, os.WriteFile(notADir, nil, 0o600))
|
||||
|
||||
@@ -3,10 +3,12 @@ package daemon
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/clustersize"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
)
|
||||
|
||||
@@ -31,6 +33,19 @@ func TestMain(m *testing.M) {
|
||||
// has its own tests that inject a reader.
|
||||
os.Setenv("SPINIFEX_ADMISSION_LIVE_MEM", "0")
|
||||
|
||||
// System-instance launches refuse to build a config without the microvm
|
||||
// firmware, which a test host does not have installed.
|
||||
fwDir, err := os.MkdirTemp("", "microvm-*")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create microvm fixture dir: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(fwDir, vm.MicroVMFirmware), []byte("qboot"), 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to write microvm firmware fixture: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
microvmDir = fwDir
|
||||
|
||||
// Start a plain NATS server (no JetStream)
|
||||
ns, err := server.NewServer(&server.Options{
|
||||
Host: "127.0.0.1",
|
||||
@@ -88,6 +103,7 @@ func TestMain(m *testing.M) {
|
||||
ns.Shutdown()
|
||||
jsNS.Shutdown()
|
||||
os.RemoveAll(jsTmpDir)
|
||||
os.RemoveAll(fwDir)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -56,12 +55,15 @@ func newDisclosureCluster(t *testing.T) *disclosureCluster {
|
||||
require.NoError(t, err)
|
||||
d.keyService = ec2key.NewKeyServiceImplWithStore(store, disclosureBucket)
|
||||
d.tagsService = ec2tags.NewTagsServiceImplWithStore(cfg, store, tagsKV)
|
||||
d.keyService.SetCentralTagStore(d.tagsService)
|
||||
d.placementGroupService, err = ec2placementgroup.NewPlacementGroupServiceImplWithNATS(t.Context(), nil, nc)
|
||||
require.NoError(t, err)
|
||||
d.launchTemplateService, err = ec2launchtemplate.NewLaunchTemplateServiceImplWithNATS(t.Context(), nil, nc)
|
||||
require.NoError(t, err)
|
||||
d.wirePlatformTagIndex()
|
||||
require.NoError(t, d.subscribeAll())
|
||||
// The caller is a separate connection, so the server must hold the SUBs
|
||||
// before it publishes or the request can see no responders.
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
caller, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
@@ -225,12 +227,13 @@ func TestCurrentBehaviour_AccountTermination_DirectNATSCallerCannotSeeHiddenResi
|
||||
assert.Equal(t, before.taggedResources, after.taggedResources)
|
||||
}
|
||||
|
||||
// Pins current behaviour that differs from AWS: deleting a placement group or
|
||||
// launch template leaves its tag-index entry, which DescribeTags keeps
|
||||
// returning with its values. A key-pair delete clears its entry.
|
||||
func TestCurrentBehaviour_AccountTermination_PlatformDeletesLeaveTagIndexEntries(t *testing.T) {
|
||||
// Deleting a key pair, placement group or launch template clears its
|
||||
// tag-index entry, so DescribeTags no longer returns its rows, as in AWS.
|
||||
func TestPlatformDeletesClearTagIndexEntries(t *testing.T) {
|
||||
c := newDisclosureCluster(t)
|
||||
s := c.seed(t, disclosureAccount, "a")
|
||||
before := c.describe(t, disclosureAccount)
|
||||
require.Equal(t, sortedIDs(s.keyID, s.groupID, s.templateID), before.taggedResources)
|
||||
|
||||
c.call(t, "ec2.DeleteKeyPair", disclosureAccount, &ec2.DeleteKeyPairInput{KeyName: aws.String(s.keyName)}, nil)
|
||||
c.call(t, "ec2.DeletePlacementGroup", disclosureAccount, &ec2.DeletePlacementGroupInput{GroupName: aws.String(s.groupName)}, nil)
|
||||
@@ -240,13 +243,8 @@ func TestCurrentBehaviour_AccountTermination_PlatformDeletesLeaveTagIndexEntries
|
||||
assert.Empty(t, got.keyPairs)
|
||||
assert.Empty(t, got.placementGroups)
|
||||
assert.Empty(t, got.launchTemplates)
|
||||
assert.Equal(t, sortedIDs(s.groupID, s.templateID), got.taggedResources)
|
||||
|
||||
assert.Empty(t, got.taggedResources)
|
||||
var tags ec2.DescribeTagsOutput
|
||||
c.call(t, "ec2.DescribeTags", disclosureAccount, &ec2.DescribeTagsInput{}, &tags)
|
||||
for _, row := range tags.Tags {
|
||||
assert.Equal(t, "owner", aws.StringValue(row.Key))
|
||||
assert.Equal(t, "a", aws.StringValue(row.Value))
|
||||
assert.True(t, strings.HasPrefix(aws.StringValue(row.ResourceId), "pg-") || strings.HasPrefix(aws.StringValue(row.ResourceId), "lt-"))
|
||||
}
|
||||
assert.Empty(t, tags.Tags)
|
||||
}
|
||||
|
||||
@@ -73,7 +73,9 @@ func TestEbsRequestWithTrace_ProducerConsumerLinked(t *testing.T) {
|
||||
sr := withRecordedSpans(t)
|
||||
daemon := createTestDaemon(t, sharedNATSURL)
|
||||
|
||||
consumerEnded := make(chan struct{})
|
||||
sub, err := daemon.natsConn.Subscribe("ebs.node-1.unmount", func(msg *nats.Msg) {
|
||||
defer close(consumerEnded)
|
||||
// Mirrors viperblockd's ebs.unmount handler: open a consumer span
|
||||
// joining whatever trace context the producer injected.
|
||||
_, span := natsmsg.StartConsumerSpan(msg)
|
||||
@@ -89,6 +91,9 @@ func TestEbsRequestWithTrace_ProducerConsumerLinked(t *testing.T) {
|
||||
|
||||
adapter := newVolumeMounterAdapter(daemon.natsConn, daemon.node, nil)
|
||||
require.NoError(t, adapter.UnmountOne(t.Context(), "", viperblocklegacyv1.EBSRequest{Name: "vol-linked"}))
|
||||
// The handler replies before its deferred span.End runs, so the consumer
|
||||
// span may not be recorded yet when UnmountOne returns.
|
||||
<-consumerEnded
|
||||
|
||||
spans := sr.Ended()
|
||||
require.Len(t, spans, 2, "expected one producer span and one consumer span")
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
// Package accountcleanup deletes the proven-safe subset of one account's EC2
|
||||
// platform resources through the owners' own delete paths: placement groups
|
||||
// with no members that no instance, spot request or live launch-template
|
||||
// version names; key pairs none of those name; and launch-template residue no
|
||||
// header owns. Live launch templates are never deleted: an EKS nodegroup may
|
||||
// name one and EC2 cannot see it. A reference check that cannot complete
|
||||
// deletes nothing of the kinds it guards. Every disposition is proved by an
|
||||
// owner-store re-scan, never by Describe.
|
||||
//
|
||||
// It adds no caller, checks no authority and holds no store or transport of
|
||||
// its own: the owners and reference readers are injected. Whoever binds it
|
||||
// decides who may invoke it. It is not exposed on any NATS subject.
|
||||
package accountcleanup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
ec2key "github.com/mulgadc/spinifex/spinifex/domains/ec2/key"
|
||||
ec2launchtemplate "github.com/mulgadc/spinifex/spinifex/domains/ec2/launchtemplate"
|
||||
ec2placementgroup "github.com/mulgadc/spinifex/spinifex/domains/ec2/placementgroup"
|
||||
ec2tags "github.com/mulgadc/spinifex/spinifex/domains/ec2/tags"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
otelsetup "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
|
||||
)
|
||||
|
||||
// PlatformCleaner deletes one account's EC2 platform resources and reports
|
||||
// what the owner stores hold afterwards. It is destructive.
|
||||
type PlatformCleaner interface {
|
||||
CleanupPlatform(ctx context.Context, req *Request) (*Result, error)
|
||||
}
|
||||
|
||||
var _ PlatformCleaner = (*Cleanup)(nil)
|
||||
|
||||
// KeyPairOwner is the key-pair owner's account scan and cleanup.
|
||||
type KeyPairOwner interface {
|
||||
ScanAccount(ctx context.Context, accountID string) (*ec2key.AccountScan, error)
|
||||
CleanupAccount(ctx context.Context, accountID string, settledBefore time.Time, referenced func(keyName string) string) (*ec2key.AccountCleanup, error)
|
||||
}
|
||||
|
||||
// PlacementGroupOwner is the placement-group owner's account scan and cleanup.
|
||||
type PlacementGroupOwner interface {
|
||||
ScanAccount(ctx context.Context, accountID string) (*ec2placementgroup.AccountScan, error)
|
||||
CleanupAccount(ctx context.Context, accountID string, referenced func(groupID, groupName string) string) (*ec2placementgroup.AccountCleanup, error)
|
||||
}
|
||||
|
||||
// LaunchTemplateOwner is the launch-template owner's account scan and residue
|
||||
// cleanup; live templates are never deleted by account cleanup.
|
||||
type LaunchTemplateOwner interface {
|
||||
ScanAccount(ctx context.Context, accountID string) (*ec2launchtemplate.AccountScan, error)
|
||||
CleanupAccountResidue(ctx context.Context, accountID string, settledBefore time.Time) (*ec2launchtemplate.AccountCleanup, error)
|
||||
}
|
||||
|
||||
// TagIndexOwner lists and clears the account's tag-index entries.
|
||||
type TagIndexOwner interface {
|
||||
ScanAccountResourceIDs(ctx context.Context, accountID string, prefixes ...string) ([]string, error)
|
||||
DeleteAllTags(ctx context.Context, accountID, resourceID string) error
|
||||
}
|
||||
|
||||
var (
|
||||
_ KeyPairOwner = (*ec2key.KeyServiceImpl)(nil)
|
||||
_ PlacementGroupOwner = (*ec2placementgroup.PlacementGroupServiceImpl)(nil)
|
||||
_ LaunchTemplateOwner = (*ec2launchtemplate.LaunchTemplateServiceImpl)(nil)
|
||||
_ TagIndexOwner = (*ec2tags.TagsServiceImpl)(nil)
|
||||
)
|
||||
|
||||
// DefaultSettleWindow is how old headerless residue must be before it is
|
||||
// removed; anything younger may be a create still in flight.
|
||||
const DefaultSettleWindow = 10 * time.Minute
|
||||
|
||||
// Cleanup composes the owner cleanups and re-scans.
|
||||
type Cleanup struct {
|
||||
keys KeyPairOwner
|
||||
groups PlacementGroupOwner
|
||||
templates LaunchTemplateOwner
|
||||
tags TagIndexOwner
|
||||
refs ReferenceReaders
|
||||
|
||||
// SettleWindow overrides DefaultSettleWindow when positive.
|
||||
SettleWindow time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New builds a cleanup over the four owners and the reference readers. A nil
|
||||
// reader counts as a failed check, so the kinds it guards delete nothing.
|
||||
func New(keys KeyPairOwner, groups PlacementGroupOwner, templates LaunchTemplateOwner, tags TagIndexOwner, refs ReferenceReaders) *Cleanup {
|
||||
return &Cleanup{keys: keys, groups: groups, templates: templates, tags: tags, refs: refs, now: time.Now}
|
||||
}
|
||||
|
||||
// Resource-ID prefixes of the three kinds in the tag index.
|
||||
const (
|
||||
keyPairIDPrefix = "key-"
|
||||
placementGroupIDPrefix = "pg-"
|
||||
launchTemplateIDPrefix = "lt-"
|
||||
)
|
||||
|
||||
// CleanupPlatform deletes the account's key pairs, empty placement groups and
|
||||
// launch templates, removes settled residue and orphaned tag-index entries,
|
||||
// then re-scans every owner store to build the result. It is safe to re-run.
|
||||
func (c *Cleanup) CleanupPlatform(ctx context.Context, req *Request) (*Result, error) {
|
||||
if req == nil || req.AccountID == "" {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorMissingParameter, "The request must specify an account ID.")
|
||||
}
|
||||
if !awsidentifiers.IsAccountID(req.AccountID) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "The account ID must be 12 digits.")
|
||||
}
|
||||
start := time.Now()
|
||||
window := c.SettleWindow
|
||||
if window <= 0 {
|
||||
window = DefaultSettleWindow
|
||||
}
|
||||
settledBefore := c.now().Add(-window)
|
||||
acct := req.AccountID
|
||||
res := &Result{AccountID: acct, CorrelationRef: req.CorrelationRef}
|
||||
|
||||
refs := c.readReferences(ctx, acct)
|
||||
keyRun := c.cleanKeyPairs(ctx, acct, settledBefore, refs)
|
||||
groupRun := c.cleanPlacementGroups(ctx, acct, refs)
|
||||
templateRun := c.cleanLaunchTemplates(ctx, acct, settledBefore)
|
||||
|
||||
// Tags are listed before the owner re-scans, so a resource created in
|
||||
// between is seen as owned and keeps its tags.
|
||||
tagKind := c.startKind(ec2v1.PlatformKindTagIndex)
|
||||
tagged, tagErr := c.tags.ScanAccountResourceIDs(ctx, acct, keyPairIDPrefix, placementGroupIDPrefix, launchTemplateIDPrefix)
|
||||
if tagErr != nil {
|
||||
slog.WarnContext(ctx, "Platform cleanup: tag index listing failed", "err", tagErr)
|
||||
tagKind.incomplete(ec2v1.PlatformReasonStoreListFailed)
|
||||
}
|
||||
|
||||
owners := map[string]ownedIDs{
|
||||
keyPairIDPrefix: c.finishKeyPairs(ctx, acct, keyRun, res),
|
||||
placementGroupIDPrefix: c.finishPlacementGroups(ctx, acct, groupRun, res),
|
||||
launchTemplateIDPrefix: c.finishLaunchTemplates(ctx, acct, templateRun, res),
|
||||
}
|
||||
res.Kinds = []KindReport{keyRun.kind.report, groupRun.kind.report, templateRun.kind.report}
|
||||
res.Kinds = append(res.Kinds, c.cleanTagIndex(ctx, acct, tagKind, tagged, tagErr == nil, owners, res))
|
||||
|
||||
slog.InfoContext(ctx, "Platform cleanup completed", "accountID", acct, "correlationRef", req.CorrelationRef,
|
||||
"items", len(res.Items), "duration_ms", otelsetup.Millis(time.Since(start)))
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ownedIDs is the set of IDs whose owner record a re-scan found, live or
|
||||
// unreadable; resolved is false unless the re-scan listed and read every
|
||||
// record, so an absent ID is not proof its tags are orphaned.
|
||||
type ownedIDs struct {
|
||||
ids map[string]bool
|
||||
resolved bool
|
||||
}
|
||||
|
||||
// kindRun accumulates one kind's report across its cleanup and re-scan.
|
||||
type kindRun struct {
|
||||
report KindReport
|
||||
found bool
|
||||
}
|
||||
|
||||
func (c *Cleanup) startKind(kind string) *kindRun {
|
||||
return &kindRun{report: KindReport{Kind: kind, StartedAt: c.now().UTC()}}
|
||||
}
|
||||
|
||||
func (k *kindRun) incomplete(reason string) {
|
||||
if !slices.Contains(k.report.IncompleteReasons, reason) {
|
||||
k.report.IncompleteReasons = append(k.report.IncompleteReasons, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cleanup) finishKind(k *kindRun) {
|
||||
k.report.EndedAt = c.now().UTC()
|
||||
switch {
|
||||
case len(k.report.IncompleteReasons) > 0:
|
||||
k.report.Status = ec2v1.PlatformStatusScanIncomplete
|
||||
case k.found:
|
||||
k.report.Status = ec2v1.PlatformStatusRecordsFound
|
||||
default:
|
||||
k.report.Status = ec2v1.PlatformStatusObservedClean
|
||||
}
|
||||
}
|
||||
|
||||
// attempts is what an owner said it did, by resource ID. kept holds items
|
||||
// left on purpose, with the fixed reason.
|
||||
type attempts struct {
|
||||
deleted, failed map[string]bool
|
||||
blocked map[string]int
|
||||
kept map[string]string
|
||||
}
|
||||
|
||||
func newAttempts() attempts {
|
||||
return attempts{deleted: map[string]bool{}, failed: map[string]bool{}, blocked: map[string]int{}, kept: map[string]string{}}
|
||||
}
|
||||
|
||||
// itemDisposition decides one ID from what the owner did and whether the
|
||||
// re-scan still found it. Only a complete re-scan that misses an ID is deleted;
|
||||
// an item kept on purpose stays blocked unless that re-scan shows it gone.
|
||||
func itemDisposition(id string, did attempts, present, rescanned bool) (string, string) {
|
||||
switch {
|
||||
case did.kept[id] != "" && (present || !rescanned):
|
||||
return DispositionBlocked, did.kept[id]
|
||||
case !rescanned:
|
||||
return DispositionFailed, ReasonAbsenceUnproven
|
||||
case !present:
|
||||
return DispositionDeleted, ""
|
||||
case did.blocked[id] > 0:
|
||||
return DispositionBlocked, ReasonInstanceMembers
|
||||
case did.failed[id]:
|
||||
return DispositionFailed, ReasonOwnerDeleteFailed
|
||||
case did.deleted[id]:
|
||||
return DispositionFailed, ReasonStillPresent
|
||||
default:
|
||||
return DispositionResidue, ReasonAppearedDuringCleanup
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Result) addItem(k *kindRun, id, disposition, reason string, members int) {
|
||||
if disposition != DispositionDeleted {
|
||||
k.found = true
|
||||
}
|
||||
r.Items = append(r.Items, Item{ResourceID: id, Kind: k.report.Kind, Disposition: disposition, Reason: reason, MemberCount: members})
|
||||
}
|
||||
|
||||
func (r *Result) addCount(k *kindRun, class, disposition, reason string, n int) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
if disposition != DispositionDeleted {
|
||||
k.found = true
|
||||
}
|
||||
r.Counts = append(r.Counts, Count{Kind: k.report.Kind, Class: class, Disposition: disposition, Reason: reason, Count: n})
|
||||
}
|
||||
|
||||
// residueCounts reports one headerless class: the proven decrease as deleted
|
||||
// (never more than the owner removed), and what remains split by cause, kept
|
||||
// records first, by reason in a fixed order.
|
||||
func (r *Result) residueCounts(k *kindRun, class string, before, after, removed, failed, unsettled int, kept map[string]int) {
|
||||
r.addCount(k, class, DispositionDeleted, "", min(removed, max(0, before-after)))
|
||||
left := after
|
||||
for _, reason := range keptReasons {
|
||||
n := min(left, kept[reason])
|
||||
r.addCount(k, class, DispositionBlocked, reason, n)
|
||||
left -= n
|
||||
}
|
||||
failedLeft := min(left, failed)
|
||||
unsettledLeft := min(left-failedLeft, unsettled)
|
||||
r.addCount(k, class, DispositionFailed, ReasonOwnerDeleteFailed, failedLeft)
|
||||
r.addCount(k, class, DispositionResidue, ReasonRecentlyWritten, unsettledLeft)
|
||||
r.addCount(k, class, DispositionFailed, ReasonStillPresent, left-failedLeft-unsettledLeft)
|
||||
}
|
||||
|
||||
// keptReasons are the reasons an owner may keep a record, in report order.
|
||||
var keptReasons = []string{ReasonReferenceCheckFailed, ReasonInstanceReference, ReasonSpotRequestReference, ReasonLaunchTemplateReference}
|
||||
|
||||
// sortedUnion returns the distinct IDs of all lists, sorted.
|
||||
func sortedUnion(lists ...[]string) []string {
|
||||
var out []string
|
||||
for _, l := range lists {
|
||||
for _, id := range l {
|
||||
if !slices.Contains(out, id) {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func idPrefix(id string) string {
|
||||
for _, prefix := range []string{keyPairIDPrefix, placementGroupIDPrefix, launchTemplateIDPrefix} {
|
||||
if strings.HasPrefix(id, prefix) {
|
||||
return prefix
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
package accountcleanup
|
||||
|
||||
//test:in-package — the clock is injected to place residue outside the settle
|
||||
// window without waiting for it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
ec2key "github.com/mulgadc/spinifex/spinifex/domains/ec2/key"
|
||||
ec2launchtemplate "github.com/mulgadc/spinifex/spinifex/domains/ec2/launchtemplate"
|
||||
ec2placementgroup "github.com/mulgadc/spinifex/spinifex/domains/ec2/placementgroup"
|
||||
ec2spotinstance "github.com/mulgadc/spinifex/spinifex/domains/ec2/spotinstance"
|
||||
ec2tags "github.com/mulgadc/spinifex/spinifex/domains/ec2/tags"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testAccount = "111122223333"
|
||||
otherAccount = "999988887777"
|
||||
testBucket = "test-bucket"
|
||||
testPubKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"
|
||||
memberID = "i-0123456789abcdef0"
|
||||
staleTagID = "pg-0stale000000000"
|
||||
)
|
||||
|
||||
var errInjected = errors.New("injected failure")
|
||||
|
||||
// faultyStore fails object deletes its predicate selects.
|
||||
type faultyStore struct {
|
||||
*objectstore.MemoryObjectStore
|
||||
|
||||
failDelete func(key string) bool
|
||||
}
|
||||
|
||||
var _ objectstore.ObjectStore = (*faultyStore)(nil)
|
||||
|
||||
func (f *faultyStore) DeleteObject(ctx context.Context, in *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) {
|
||||
if f.failDelete != nil && f.failDelete(aws.StringValue(in.Key)) {
|
||||
return nil, errInjected
|
||||
}
|
||||
return f.MemoryObjectStore.DeleteObject(ctx, in)
|
||||
}
|
||||
|
||||
// owners are the real EC2 owner services over an embedded JetStream, wired to
|
||||
// the tag index as the daemon wires them.
|
||||
type owners struct {
|
||||
store *faultyStore
|
||||
keys *ec2key.KeyServiceImpl
|
||||
groups *ec2placementgroup.PlacementGroupServiceImpl
|
||||
templates *ec2launchtemplate.LaunchTemplateServiceImpl
|
||||
tags *ec2tags.TagsServiceImpl
|
||||
spot *ec2spotinstance.SpotInstanceServiceImpl
|
||||
instances *fakeInstances
|
||||
js jetstream.JetStream
|
||||
}
|
||||
|
||||
// fakeInstances stands in for the cluster-wide instance listing, which needs
|
||||
// every daemon to answer; its own refusal is covered separately.
|
||||
type fakeInstances struct {
|
||||
refs *References
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeInstances) AccountInstanceReferences(context.Context, string) (*References, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.refs, nil
|
||||
}
|
||||
|
||||
func newOwners(t *testing.T) *owners {
|
||||
t.Helper()
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
o := &owners{store: &faultyStore{MemoryObjectStore: objectstore.NewMemoryObjectStore()}}
|
||||
tagsKV, err := ec2tags.GetOrCreateTagsBucket(t.Context(), js)
|
||||
require.NoError(t, err)
|
||||
o.tags = ec2tags.NewTagsServiceImplWithStore(&config.Config{Predastore: config.PredastoreConfig{Bucket: testBucket}}, o.store, tagsKV)
|
||||
o.keys = ec2key.NewKeyServiceImplWithStore(o.store, testBucket)
|
||||
o.keys.SetCentralTagStore(o.tags)
|
||||
o.groups, err = ec2placementgroup.NewPlacementGroupServiceImplWithNATS(t.Context(), nil, nc)
|
||||
require.NoError(t, err)
|
||||
o.groups.SetCentralTagStore(o.tags)
|
||||
o.templates, err = ec2launchtemplate.NewLaunchTemplateServiceImplWithNATS(t.Context(), nil, nc)
|
||||
require.NoError(t, err)
|
||||
o.templates.SetCentralTagStore(o.tags)
|
||||
o.spot, err = ec2spotinstance.NewSpotInstanceServiceImplWithNATS(t.Context(), nil, nc)
|
||||
require.NoError(t, err)
|
||||
o.instances = &fakeInstances{refs: newReferences()}
|
||||
o.js = js
|
||||
return o
|
||||
}
|
||||
|
||||
// readers are the real spot and launch-template checks and the instance fake.
|
||||
func (o *owners) readers() ReferenceReaders {
|
||||
return ReferenceReaders{Instances: o.instances, Spot: o.spot, Templates: o.templates}
|
||||
}
|
||||
|
||||
func (o *owners) newCleanup() *Cleanup {
|
||||
return New(o.keys, o.groups, o.templates, o.tags, o.readers())
|
||||
}
|
||||
|
||||
// cleanup runs over the owners with residue of any age treated as settled.
|
||||
func (o *owners) cleanup(t *testing.T) *Result {
|
||||
t.Helper()
|
||||
return run(t, o.newCleanup())
|
||||
}
|
||||
|
||||
func run(t *testing.T, c *Cleanup) *Result {
|
||||
t.Helper()
|
||||
c.now = func() time.Time { return time.Now().Add(time.Hour) }
|
||||
res, err := c.CleanupPlatform(t.Context(), &Request{AccountID: testAccount, CorrelationRef: "ref-1"})
|
||||
require.NoError(t, err)
|
||||
return res
|
||||
}
|
||||
|
||||
func (o *owners) importKey(t *testing.T, account, name string) string {
|
||||
t.Helper()
|
||||
out, err := o.keys.ImportKeyPair(t.Context(), &ec2.ImportKeyPairInput{
|
||||
KeyName: aws.String(name), PublicKeyMaterial: []byte(testPubKey),
|
||||
TagSpecifications: []*ec2.TagSpecification{{ResourceType: aws.String("key-pair"), Tags: secretTags()}},
|
||||
}, account)
|
||||
require.NoError(t, err)
|
||||
return aws.StringValue(out.KeyPairId)
|
||||
}
|
||||
|
||||
func (o *owners) createGroup(t *testing.T, account, name string) string {
|
||||
t.Helper()
|
||||
out, err := o.groups.CreatePlacementGroup(t.Context(), &ec2.CreatePlacementGroupInput{
|
||||
GroupName: aws.String(name), Strategy: aws.String(ec2.PlacementStrategyCluster),
|
||||
}, account)
|
||||
require.NoError(t, err)
|
||||
id := aws.StringValue(out.PlacementGroup.GroupId)
|
||||
o.tag(t, account, id)
|
||||
return id
|
||||
}
|
||||
|
||||
func (o *owners) addMember(t *testing.T, group string) {
|
||||
t.Helper()
|
||||
reserved, err := o.groups.ReserveClusterNode(t.Context(), &ec2placementgroup.ReserveClusterNodeInput{
|
||||
GroupName: group, EligibleNodes: []string{"node-1"},
|
||||
}, testAccount)
|
||||
require.NoError(t, err)
|
||||
_, err = o.groups.FinalizeClusterInstances(t.Context(), &ec2placementgroup.FinalizeClusterInstancesInput{
|
||||
GroupName: group, NodeInstances: map[string][]string{reserved.TargetNode: {memberID}},
|
||||
}, testAccount)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func (o *owners) removeMember(t *testing.T, group string) {
|
||||
t.Helper()
|
||||
_, err := o.groups.RemoveInstance(t.Context(), &ec2placementgroup.RemoveInstanceInput{
|
||||
GroupName: group, NodeName: "node-1", InstanceID: memberID,
|
||||
}, testAccount)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func (o *owners) createTemplate(t *testing.T, account, name string) string {
|
||||
t.Helper()
|
||||
out, err := o.templates.CreateLaunchTemplate(t.Context(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String(name),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
|
||||
ImageId: aws.String("ami-123"),
|
||||
UserData: aws.String(base64.StdEncoding.EncodeToString([]byte("secret-user-data"))),
|
||||
},
|
||||
}, account)
|
||||
require.NoError(t, err)
|
||||
id := aws.StringValue(out.LaunchTemplate.LaunchTemplateId)
|
||||
o.tag(t, account, id)
|
||||
return id
|
||||
}
|
||||
|
||||
func secretTags() []*ec2.Tag {
|
||||
return []*ec2.Tag{{Key: aws.String("secret-tag-key"), Value: aws.String("secret-tag-value")}}
|
||||
}
|
||||
|
||||
func (o *owners) tag(t *testing.T, account, id string) {
|
||||
t.Helper()
|
||||
_, err := o.tags.CreateTags(t.Context(), &ec2.CreateTagsInput{Resources: []*string{aws.String(id)}, Tags: secretTags()}, account)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func (o *owners) putObject(t *testing.T, key, body string) {
|
||||
t.Helper()
|
||||
_, err := o.store.PutObject(t.Context(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(testBucket), Key: aws.String("keys/" + testAccount + "/" + key), Body: strings.NewReader(body),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func statuses(res *Result) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, k := range res.Kinds {
|
||||
out[k.Kind] = k.Status
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func allStatus(status string) map[string]string {
|
||||
return map[string]string{
|
||||
ec2v1.PlatformKindKeyPair: status, ec2v1.PlatformKindPlacementGroup: status,
|
||||
ec2v1.PlatformKindLaunchTemplate: status, ec2v1.PlatformKindTagIndex: status,
|
||||
}
|
||||
}
|
||||
|
||||
func item(id, kind, disposition, reason string) Item {
|
||||
return Item{ResourceID: id, Kind: kind, Disposition: disposition, Reason: reason}
|
||||
}
|
||||
|
||||
// One run deletes every unreferenced key pair and empty placement group with
|
||||
// its tag-index entry, removes settled hidden residue and orphaned tags,
|
||||
// blocks on a group holding an instance, and leaves another account alone.
|
||||
// The next run, once the member leaves, converges to clean.
|
||||
func TestCleanupPlatformDeletesBlocksAndConverges(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
keyID := o.importKey(t, testAccount, "secret-key-name")
|
||||
emptyID := o.createGroup(t, testAccount, "secret-empty-group")
|
||||
heldID := o.createGroup(t, testAccount, "secret-held-group")
|
||||
o.addMember(t, "secret-held-group")
|
||||
o.putObject(t, "secret-orphan-key", testPubKey)
|
||||
o.tag(t, testAccount, staleTagID)
|
||||
theirKey := o.importKey(t, otherAccount, "theirs")
|
||||
o.createGroup(t, otherAccount, "theirs")
|
||||
o.createTemplate(t, otherAccount, "theirs")
|
||||
|
||||
res := o.cleanup(t)
|
||||
|
||||
assert.Equal(t, testAccount, res.AccountID)
|
||||
assert.Equal(t, "ref-1", res.CorrelationRef)
|
||||
assert.ElementsMatch(t, []Item{
|
||||
item(keyID, ec2v1.PlatformKindKeyPair, DispositionDeleted, ""),
|
||||
item(emptyID, ec2v1.PlatformKindPlacementGroup, DispositionDeleted, ""),
|
||||
{ResourceID: heldID, Kind: ec2v1.PlatformKindPlacementGroup, Disposition: DispositionBlocked, Reason: ReasonInstanceMembers, MemberCount: 1},
|
||||
item(staleTagID, ec2v1.PlatformKindTagIndex, DispositionDeleted, ""),
|
||||
}, res.Items)
|
||||
assert.Equal(t, []Count{{
|
||||
Kind: ec2v1.PlatformKindKeyPair, Class: ec2v1.PlatformClassPublicKeyWithoutMetadata, Disposition: DispositionDeleted, Count: 1,
|
||||
}}, res.Counts)
|
||||
// The held group's own tags are its, not residue.
|
||||
assert.Equal(t, map[string]string{
|
||||
ec2v1.PlatformKindKeyPair: ec2v1.PlatformStatusObservedClean,
|
||||
ec2v1.PlatformKindPlacementGroup: ec2v1.PlatformStatusRecordsFound,
|
||||
ec2v1.PlatformKindLaunchTemplate: ec2v1.PlatformStatusObservedClean,
|
||||
ec2v1.PlatformKindTagIndex: ec2v1.PlatformStatusRecordsFound,
|
||||
}, statuses(res))
|
||||
|
||||
theirs, err := o.keys.ScanAccount(t.Context(), otherAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{theirKey}, theirs.LiveKeyPairIDs)
|
||||
theirTags, err := o.tags.ScanAccountResourceIDs(t.Context(), otherAccount, "key-", "pg-", "lt-")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, theirTags, 3)
|
||||
|
||||
o.removeMember(t, "secret-held-group")
|
||||
res = o.cleanup(t)
|
||||
assert.Equal(t, []Item{item(heldID, ec2v1.PlatformKindPlacementGroup, DispositionDeleted, "")}, res.Items,
|
||||
"items finished on an earlier run are not reported again")
|
||||
assert.Empty(t, res.Counts)
|
||||
assert.Equal(t, allStatus(ec2v1.PlatformStatusObservedClean), statuses(res))
|
||||
|
||||
res = o.cleanup(t)
|
||||
assert.Empty(t, res.Items)
|
||||
assert.Equal(t, allStatus(ec2v1.PlatformStatusObservedClean), statuses(res))
|
||||
}
|
||||
|
||||
// The result carries IDs, kinds, dispositions, fixed reasons and counts only:
|
||||
// no names, tags, key material, user data, member instance IDs or error text.
|
||||
func TestCleanupPlatformResultDisclosesNoTenantData(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
o.importKey(t, testAccount, "secret-key-name")
|
||||
o.createGroup(t, testAccount, "secret-held-group")
|
||||
o.addMember(t, "secret-held-group")
|
||||
o.createTemplate(t, testAccount, "secret-template-name")
|
||||
o.putObject(t, "secret-orphan-key", testPubKey)
|
||||
o.putObject(t, "key-0badbadbadbadbad0.json", "{secret-broken")
|
||||
failing := o.importKey(t, testAccount, "secret-failing-key")
|
||||
o.store.failDelete = func(key string) bool { return strings.HasSuffix(key, failing+".json") }
|
||||
o.importKey(t, testAccount, "secret-instance-key")
|
||||
o.instances.refs.addKeys("secret-instance-key")
|
||||
o.importKey(t, testAccount, "secret-spot-key")
|
||||
o.openSpotRequest(t, "sir-0secret000000001", "secret-spot-key", "")
|
||||
o.importKey(t, testAccount, "secret-template-key")
|
||||
_, err := o.templates.CreateLaunchTemplate(t.Context(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("secret-referencing-template"),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{KeyName: aws.String("secret-template-key")},
|
||||
}, testAccount)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, err := json.Marshal(o.cleanup(t))
|
||||
require.NoError(t, err)
|
||||
for _, leak := range []string{
|
||||
"secret", "AAAAC3Nz", memberID, "node-1", "injected", "sir-",
|
||||
base64.StdEncoding.EncodeToString([]byte("secret-user-data")),
|
||||
} {
|
||||
assert.NotContains(t, string(body), leak)
|
||||
}
|
||||
assert.Contains(t, string(body), failing, "the IDs themselves are reported")
|
||||
}
|
||||
|
||||
// A delete that fails part way is failed, not deleted; the re-run completes
|
||||
// the partly deleted key pair and reports it deleted exactly once.
|
||||
func TestCleanupPlatformInterruptedRunConverges(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
keyID := o.importKey(t, testAccount, "deploy")
|
||||
o.store.failDelete = func(key string) bool { return strings.HasSuffix(key, ".json") }
|
||||
|
||||
res := o.cleanup(t)
|
||||
assert.Equal(t, []Item{item(keyID, ec2v1.PlatformKindKeyPair, DispositionFailed, ReasonOwnerDeleteFailed)}, res.Items)
|
||||
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindKeyPair])
|
||||
scan, err := o.keys.ScanAccount(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{keyID}, scan.LiveKeyPairIDs, "the metadata anchors the unfinished delete")
|
||||
|
||||
o.store.failDelete = nil
|
||||
res = o.cleanup(t)
|
||||
assert.Equal(t, []Item{item(keyID, ec2v1.PlatformKindKeyPair, DispositionDeleted, "")}, res.Items)
|
||||
assert.Equal(t, allStatus(ec2v1.PlatformStatusObservedClean), statuses(res))
|
||||
}
|
||||
|
||||
// Records the owner cannot read are never deleted and stay reported as residue,
|
||||
// and orphaned tags whose owner may be one of them are left.
|
||||
func TestCleanupPlatformLeavesUnreadableRecords(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
o.putObject(t, "key-0badbadbadbadbad0.json", "{not json")
|
||||
o.putObject(t, "orphan", testPubKey)
|
||||
|
||||
res := o.cleanup(t)
|
||||
assert.Empty(t, res.Items)
|
||||
assert.ElementsMatch(t, []Count{
|
||||
{Kind: ec2v1.PlatformKindKeyPair, Class: ec2v1.PlatformClassUnreadableRecord, Disposition: DispositionResidue, Reason: ReasonUnreadableRecord, Count: 1},
|
||||
{Kind: ec2v1.PlatformKindKeyPair, Class: ec2v1.PlatformClassPublicKeyUnresolved, Disposition: DispositionResidue, Reason: ReasonOwnerRecordsUnresolved, Count: 1},
|
||||
}, res.Counts)
|
||||
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindKeyPair])
|
||||
scan, err := o.keys.ScanAccount(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"key-0badbadbadbadbad0"}, scan.UnreadableKeyPairIDs)
|
||||
}
|
||||
|
||||
// Residue younger than the settle window may be a create in flight: it is
|
||||
// reported, not removed.
|
||||
func TestCleanupPlatformKeepsUnsettledResidue(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
o.putObject(t, "orphan", testPubKey)
|
||||
c := o.newCleanup()
|
||||
|
||||
res, err := c.CleanupPlatform(t.Context(), &Request{AccountID: testAccount})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []Count{{
|
||||
Kind: ec2v1.PlatformKindKeyPair, Class: ec2v1.PlatformClassPublicKeyWithoutMetadata,
|
||||
Disposition: DispositionResidue, Reason: ReasonRecentlyWritten, Count: 1,
|
||||
}}, res.Counts)
|
||||
}
|
||||
|
||||
// lyingKeys reports every key pair deleted without deleting anything.
|
||||
type lyingKeys struct{ *ec2key.KeyServiceImpl }
|
||||
|
||||
func (l lyingKeys) CleanupAccount(ctx context.Context, account string, _ time.Time, _ func(string) string) (*ec2key.AccountCleanup, error) {
|
||||
scan, err := l.ScanAccount(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ec2key.AccountCleanup{DeletedKeyPairIDs: scan.LiveKeyPairIDs}, nil
|
||||
}
|
||||
|
||||
// An owner that reports a delete is not believed: the re-scan still finds the
|
||||
// record, so it is failed and still present.
|
||||
func TestCleanupPlatformProvesDeletesByRescan(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
keyID := o.importKey(t, testAccount, "deploy")
|
||||
|
||||
res := run(t, New(lyingKeys{o.keys}, o.groups, o.templates, o.tags, o.readers()))
|
||||
assert.Contains(t, res.Items, item(keyID, ec2v1.PlatformKindKeyPair, DispositionFailed, ReasonStillPresent))
|
||||
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindKeyPair])
|
||||
}
|
||||
|
||||
// failingRescan fails every scan after the first, so the cleanup deletes but
|
||||
// cannot prove it.
|
||||
type failingRescan struct {
|
||||
*ec2launchtemplate.LaunchTemplateServiceImpl
|
||||
|
||||
scans int
|
||||
}
|
||||
|
||||
func (f *failingRescan) ScanAccount(ctx context.Context, account string) (*ec2launchtemplate.AccountScan, error) {
|
||||
if f.scans++; f.scans > 1 {
|
||||
return nil, errInjected
|
||||
}
|
||||
return f.LaunchTemplateServiceImpl.ScanAccount(ctx, account)
|
||||
}
|
||||
|
||||
// A failed re-scan is incomplete, never clean: the template stays blocked, and
|
||||
// a tag of that kind no reported resource owns stays, as residue.
|
||||
func TestCleanupPlatformFailedRescanIsIncomplete(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
ltID := o.createTemplate(t, testAccount, "web")
|
||||
o.tag(t, testAccount, "lt-0stale000000000")
|
||||
|
||||
res := run(t, New(o.keys, o.groups, &failingRescan{LaunchTemplateServiceImpl: o.templates}, o.tags, o.readers()))
|
||||
assert.Equal(t, []Item{
|
||||
item(ltID, ec2v1.PlatformKindLaunchTemplate, DispositionBlocked, ReasonReferenceUnknown),
|
||||
item("lt-0stale000000000", ec2v1.PlatformKindTagIndex, DispositionResidue, ReasonOwnerRecordsUnresolved),
|
||||
}, res.Items)
|
||||
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, statuses(res)[ec2v1.PlatformKindLaunchTemplate])
|
||||
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, statuses(res)[ec2v1.PlatformKindTagIndex])
|
||||
tagged, err := o.tags.ScanAccountResourceIDs(t.Context(), testAccount, "lt-")
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"lt-0stale000000000", ltID}, tagged)
|
||||
}
|
||||
|
||||
// failingTags fails every tag-index clear.
|
||||
type failingTags struct{ *ec2tags.TagsServiceImpl }
|
||||
|
||||
func (failingTags) DeleteAllTags(context.Context, string, string) error { return errInjected }
|
||||
|
||||
// A tag-index entry that cannot be cleared is failed and the kind keeps
|
||||
// records, never clean.
|
||||
func TestCleanupPlatformReportsUnclearedTags(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
o.tag(t, testAccount, staleTagID)
|
||||
|
||||
res := run(t, New(o.keys, o.groups, o.templates, failingTags{o.tags}, o.readers()))
|
||||
assert.Equal(t, []Item{item(staleTagID, ec2v1.PlatformKindTagIndex, DispositionFailed, ReasonOwnerDeleteFailed)}, res.Items)
|
||||
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindTagIndex])
|
||||
}
|
||||
|
||||
// failingCleanup fails the owner cleanup's own listing.
|
||||
type failingCleanup struct {
|
||||
*ec2placementgroup.PlacementGroupServiceImpl
|
||||
}
|
||||
|
||||
func (failingCleanup) CleanupAccount(context.Context, string, func(string, string) string) (*ec2placementgroup.AccountCleanup, error) {
|
||||
return nil, errInjected
|
||||
}
|
||||
|
||||
// An owner cleanup that cannot list deletes nothing and is incomplete; the
|
||||
// group it could not reach is still reported, from the re-scan.
|
||||
func TestCleanupPlatformOwnerListingFailureIsIncomplete(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
groupID := o.createGroup(t, testAccount, "web")
|
||||
|
||||
res := run(t, New(o.keys, failingCleanup{o.groups}, o.templates, o.tags, o.readers()))
|
||||
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, statuses(res)[ec2v1.PlatformKindPlacementGroup])
|
||||
assert.Contains(t, res.Items, item(groupID, ec2v1.PlatformKindPlacementGroup, DispositionResidue, ReasonAppearedDuringCleanup))
|
||||
}
|
||||
|
||||
func TestCleanupPlatformValidatesTheRequest(t *testing.T) {
|
||||
c := New(nil, nil, nil, nil, ReferenceReaders{})
|
||||
for name, tc := range map[string]struct {
|
||||
req *Request
|
||||
code string
|
||||
}{
|
||||
"nil": {nil, awserrors.ErrorMissingParameter},
|
||||
"empty": {&Request{}, awserrors.ErrorMissingParameter},
|
||||
"malformed": {&Request{AccountID: "acct-1"}, awserrors.ErrorInvalidParameterValue},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := c.CleanupPlatform(t.Context(), tc.req)
|
||||
require.Error(t, err)
|
||||
got, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, tc.code, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (o *owners) openSpotRequest(t *testing.T, id, keyName, groupName string) {
|
||||
t.Helper()
|
||||
spec := &ec2.LaunchSpecification{ImageId: aws.String("ami-123"), KeyName: aws.String(keyName)}
|
||||
if groupName != "" {
|
||||
spec.Placement = &ec2.SpotPlacement{GroupName: aws.String(groupName)}
|
||||
}
|
||||
_, err := o.spot.PutSpotInstanceRequests(t.Context(), &ec2spotinstance.PutSpotRequestsInput{Requests: []*ec2.SpotInstanceRequest{{
|
||||
SpotInstanceRequestId: aws.String(id), State: aws.String(ec2.SpotInstanceStateOpen), LaunchSpecification: spec,
|
||||
}}}, testAccount)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func (o *owners) putTemplateRecord(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
kv, err := o.js.KeyValue(t.Context(), ec2launchtemplate.KVBucketLaunchTemplates)
|
||||
require.NoError(t, err)
|
||||
_, err = kv.Put(t.Context(), key, []byte(value))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// A live launch template is never deleted: an EKS nodegroup may name it and
|
||||
// EC2 cannot read EKS records. It stays blocked, described and tagged on every
|
||||
// run; headerless version bodies and names, which nothing can resolve, are
|
||||
// removed once settled.
|
||||
func TestCleanupPlatformNeverDeletesLiveLaunchTemplates(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
ltID := o.createTemplate(t, testAccount, "web")
|
||||
o.putTemplateRecord(t, testAccount+".lt-0orphan000000000.v1", `{"version_number":1}`)
|
||||
|
||||
for range 2 {
|
||||
res := o.cleanup(t)
|
||||
assert.Equal(t, []Item{item(ltID, ec2v1.PlatformKindLaunchTemplate, DispositionBlocked, ReasonReferenceUnknown)}, res.Items)
|
||||
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindLaunchTemplate])
|
||||
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindTagIndex], "the template keeps its tags")
|
||||
}
|
||||
scan, err := o.templates.ScanAccount(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{ltID}, scan.LiveTemplateIDs)
|
||||
assert.Zero(t, scan.OrphanVersionBodies)
|
||||
out, err := o.templates.DescribeLaunchTemplateVersions(t.Context(), &ec2.DescribeLaunchTemplateVersionsInput{LaunchTemplateId: aws.String(ltID)}, testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.LaunchTemplateVersions, 1, "the version body is untouched")
|
||||
}
|
||||
|
||||
// A key pair a non-terminated instance names is blocked and kept, and so is an
|
||||
// orphan public key the instance names, because IMDS serves it by name. A
|
||||
// placement group a stopped instance names is blocked though it has no members.
|
||||
func TestCleanupPlatformBlocksOnInstanceReferences(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
usedKey := o.importKey(t, testAccount, "in-use")
|
||||
freeKey := o.importKey(t, testAccount, "free")
|
||||
o.putObject(t, "served-orphan", testPubKey)
|
||||
usedGroup := o.createGroup(t, testAccount, "stopped-member")
|
||||
o.instances.refs.addKeys("in-use", "served-orphan")
|
||||
o.instances.refs.addGroups("stopped-member")
|
||||
|
||||
res := o.cleanup(t)
|
||||
assert.ElementsMatch(t, []Item{
|
||||
item(usedKey, ec2v1.PlatformKindKeyPair, DispositionBlocked, ReasonInstanceReference),
|
||||
item(freeKey, ec2v1.PlatformKindKeyPair, DispositionDeleted, ""),
|
||||
item(usedGroup, ec2v1.PlatformKindPlacementGroup, DispositionBlocked, ReasonInstanceReference),
|
||||
}, res.Items)
|
||||
assert.Equal(t, []Count{{
|
||||
Kind: ec2v1.PlatformKindKeyPair, Class: ec2v1.PlatformClassPublicKeyWithoutMetadata,
|
||||
Disposition: DispositionBlocked, Reason: ReasonInstanceReference, Count: 1,
|
||||
}}, res.Counts)
|
||||
scan, err := o.keys.ScanAccount(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{usedKey}, scan.LiveKeyPairIDs)
|
||||
assert.Equal(t, 1, scan.OrphanPublicKeys)
|
||||
material, err := o.keys.GetPublicKeyMaterial(testAccount, "served-orphan")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testPubKey, material)
|
||||
}
|
||||
|
||||
// An open spot request's launch specification blocks the key pair and the
|
||||
// placement group it names until the request is cancelled.
|
||||
func TestCleanupPlatformBlocksOnSpotRequests(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
keyID := o.importKey(t, testAccount, "spot-key")
|
||||
groupID := o.createGroup(t, testAccount, "spot-group")
|
||||
o.openSpotRequest(t, "sir-0000000000000001", "spot-key", "spot-group")
|
||||
|
||||
res := o.cleanup(t)
|
||||
assert.ElementsMatch(t, []Item{
|
||||
item(keyID, ec2v1.PlatformKindKeyPair, DispositionBlocked, ReasonSpotRequestReference),
|
||||
item(groupID, ec2v1.PlatformKindPlacementGroup, DispositionBlocked, ReasonSpotRequestReference),
|
||||
}, res.Items)
|
||||
|
||||
_, err := o.spot.CancelSpotInstanceRequests(t.Context(), &ec2.CancelSpotInstanceRequestsInput{
|
||||
SpotInstanceRequestIds: []*string{aws.String("sir-0000000000000001")},
|
||||
}, testAccount)
|
||||
require.NoError(t, err)
|
||||
res = o.cleanup(t)
|
||||
assert.ElementsMatch(t, []Item{
|
||||
item(keyID, ec2v1.PlatformKindKeyPair, DispositionDeleted, ""),
|
||||
item(groupID, ec2v1.PlatformKindPlacementGroup, DispositionDeleted, ""),
|
||||
}, res.Items)
|
||||
}
|
||||
|
||||
// failingTemplates fails the launch-template reference check.
|
||||
type failingTemplates struct{}
|
||||
|
||||
func (failingTemplates) AccountTemplateReferences(context.Context, string) (*ec2launchtemplate.TemplateReferences, error) {
|
||||
return nil, errInjected
|
||||
}
|
||||
|
||||
// failingSpot fails the spot reference check.
|
||||
type failingSpot struct{}
|
||||
|
||||
func (failingSpot) AccountLaunchReferences(context.Context, string) (*ec2spotinstance.LaunchReferences, error) {
|
||||
return nil, errInjected
|
||||
}
|
||||
|
||||
// A reference check that cannot complete is never read as "no references":
|
||||
// key pairs, orphan public keys and placement groups are all kept, both kinds
|
||||
// are incomplete, and launch-template residue, which no check guards, is
|
||||
// still removed.
|
||||
func TestCleanupPlatformFailedReferenceCheckDeletesNothing(t *testing.T) {
|
||||
for name, build := range map[string]func(o *owners) *Cleanup{
|
||||
"instance listing fails": func(o *owners) *Cleanup {
|
||||
o.instances.err = errInjected
|
||||
return o.newCleanup()
|
||||
},
|
||||
"spot listing fails": func(o *owners) *Cleanup {
|
||||
r := o.readers()
|
||||
r.Spot = failingSpot{}
|
||||
return New(o.keys, o.groups, o.templates, o.tags, r)
|
||||
},
|
||||
"launch-template reading fails": func(o *owners) *Cleanup {
|
||||
r := o.readers()
|
||||
r.Templates = failingTemplates{}
|
||||
return New(o.keys, o.groups, o.templates, o.tags, r)
|
||||
},
|
||||
"instance reader absent": func(o *owners) *Cleanup {
|
||||
r := o.readers()
|
||||
r.Instances = nil
|
||||
return New(o.keys, o.groups, o.templates, o.tags, r)
|
||||
},
|
||||
"launch-template reader absent": func(o *owners) *Cleanup {
|
||||
r := o.readers()
|
||||
r.Templates = nil
|
||||
return New(o.keys, o.groups, o.templates, o.tags, r)
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
keyID := o.importKey(t, testAccount, "deploy")
|
||||
o.putObject(t, "orphan", testPubKey)
|
||||
groupID := o.createGroup(t, testAccount, "web")
|
||||
o.putTemplateRecord(t, testAccount+".lt-0orphan000000000.v1", `{"version_number":1}`)
|
||||
|
||||
res := run(t, build(o))
|
||||
assert.ElementsMatch(t, []Item{
|
||||
item(keyID, ec2v1.PlatformKindKeyPair, DispositionBlocked, ReasonReferenceCheckFailed),
|
||||
item(groupID, ec2v1.PlatformKindPlacementGroup, DispositionBlocked, ReasonReferenceCheckFailed),
|
||||
}, res.Items)
|
||||
assert.ElementsMatch(t, []Count{
|
||||
{Kind: ec2v1.PlatformKindKeyPair, Class: ec2v1.PlatformClassPublicKeyWithoutMetadata, Disposition: DispositionBlocked, Reason: ReasonReferenceCheckFailed, Count: 1},
|
||||
{Kind: ec2v1.PlatformKindLaunchTemplate, Class: ec2v1.PlatformClassVersionBodyWithoutHeader, Disposition: DispositionDeleted, Count: 1},
|
||||
}, res.Counts)
|
||||
for _, k := range res.Kinds {
|
||||
if k.Kind == ec2v1.PlatformKindKeyPair || k.Kind == ec2v1.PlatformKindPlacementGroup {
|
||||
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, k.Status, k.Kind)
|
||||
assert.Equal(t, []string{ReasonReferenceCheckFailed}, k.IncompleteReasons, k.Kind)
|
||||
}
|
||||
}
|
||||
keys, err := o.keys.ScanAccount(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{keyID}, keys.LiveKeyPairIDs)
|
||||
assert.Equal(t, 1, keys.OrphanPublicKeys)
|
||||
groups, err := o.groups.ScanAccount(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, groups.Groups, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A key pair or placement group named by any version of a live launch
|
||||
// template, by name or group ID, is blocked: RunInstances may resolve any
|
||||
// version by $Default, $Latest or number. The template itself stays blocked.
|
||||
func TestCleanupPlatformBlocksOnLaunchTemplateReferences(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
usedKey := o.importKey(t, testAccount, "template-key")
|
||||
freeKey := o.importKey(t, testAccount, "free")
|
||||
byName := o.createGroup(t, testAccount, "template-group")
|
||||
byID := o.createGroup(t, testAccount, "by-id")
|
||||
ltID := o.createTemplate(t, testAccount, "web")
|
||||
_, err := o.templates.CreateLaunchTemplateVersion(t.Context(), &ec2.CreateLaunchTemplateVersionInput{
|
||||
LaunchTemplateId: aws.String(ltID),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
|
||||
KeyName: aws.String("template-key"),
|
||||
Placement: &ec2.LaunchTemplatePlacementRequest{GroupName: aws.String("template-group")},
|
||||
},
|
||||
}, testAccount)
|
||||
require.NoError(t, err)
|
||||
_, err = o.templates.CreateLaunchTemplateVersion(t.Context(), &ec2.CreateLaunchTemplateVersionInput{
|
||||
LaunchTemplateId: aws.String(ltID),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{Placement: &ec2.LaunchTemplatePlacementRequest{GroupId: aws.String(byID)}},
|
||||
}, testAccount)
|
||||
require.NoError(t, err)
|
||||
|
||||
res := o.cleanup(t)
|
||||
assert.ElementsMatch(t, []Item{
|
||||
item(usedKey, ec2v1.PlatformKindKeyPair, DispositionBlocked, ReasonLaunchTemplateReference),
|
||||
item(freeKey, ec2v1.PlatformKindKeyPair, DispositionDeleted, ""),
|
||||
item(byName, ec2v1.PlatformKindPlacementGroup, DispositionBlocked, ReasonLaunchTemplateReference),
|
||||
item(byID, ec2v1.PlatformKindPlacementGroup, DispositionBlocked, ReasonLaunchTemplateReference),
|
||||
item(ltID, ec2v1.PlatformKindLaunchTemplate, DispositionBlocked, ReasonReferenceUnknown),
|
||||
}, res.Items)
|
||||
keys, err := o.keys.ScanAccount(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{usedKey}, keys.LiveKeyPairIDs)
|
||||
groups, err := o.groups.ScanAccount(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, groups.Groups, 2)
|
||||
}
|
||||
|
||||
// A live template version the owner cannot decode may name any key pair or
|
||||
// group, so both kinds are blocked and incomplete and nothing is deleted.
|
||||
func TestCleanupPlatformUndecodableTemplateVersionDeletesNothing(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
keyID := o.importKey(t, testAccount, "deploy")
|
||||
groupID := o.createGroup(t, testAccount, "web")
|
||||
ltID := o.createTemplate(t, testAccount, "web")
|
||||
o.putTemplateRecord(t, testAccount+"."+ltID+".v2", "{not json")
|
||||
|
||||
res := o.cleanup(t)
|
||||
assert.Contains(t, res.Items, item(keyID, ec2v1.PlatformKindKeyPair, DispositionBlocked, ReasonReferenceCheckFailed))
|
||||
assert.Contains(t, res.Items, item(groupID, ec2v1.PlatformKindPlacementGroup, DispositionBlocked, ReasonReferenceCheckFailed))
|
||||
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, statuses(res)[ec2v1.PlatformKindKeyPair])
|
||||
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, statuses(res)[ec2v1.PlatformKindPlacementGroup])
|
||||
keys, err := o.keys.ScanAccount(t.Context(), testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{keyID}, keys.LiveKeyPairIDs)
|
||||
}
|
||||
|
||||
// A tag-index entry whose owner re-scan holds an unreadable record is not
|
||||
// cleared, because that record may be its owner; it is residue, not deleted.
|
||||
func TestCleanupPlatformUnresolvedOwnerKeepsTags(t *testing.T) {
|
||||
for name, seed := range map[string]func(t *testing.T, o *owners) string{
|
||||
"key pair": func(t *testing.T, o *owners) string {
|
||||
o.putObject(t, "key-0badbadbadbadbad0.json", "{not json")
|
||||
return "key-0stale000000000"
|
||||
},
|
||||
"placement group": func(t *testing.T, o *owners) string {
|
||||
kv, err := o.js.KeyValue(t.Context(), ec2placementgroup.KVBucketPlacementGroups)
|
||||
require.NoError(t, err)
|
||||
_, err = kv.Put(t.Context(), testAccount+".broken", []byte("{not json"))
|
||||
require.NoError(t, err)
|
||||
return staleTagID
|
||||
},
|
||||
"launch template": func(t *testing.T, o *owners) string {
|
||||
o.putTemplateRecord(t, testAccount+".lt-0broken000000000", "{not json")
|
||||
return "lt-0stale000000000"
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
stale := seed(t, o)
|
||||
o.tag(t, testAccount, stale)
|
||||
|
||||
res := o.cleanup(t)
|
||||
assert.Contains(t, res.Items, item(stale, ec2v1.PlatformKindTagIndex, DispositionResidue, ReasonOwnerRecordsUnresolved))
|
||||
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, statuses(res)[ec2v1.PlatformKindTagIndex])
|
||||
tagged, err := o.tags.ScanAccountResourceIDs(t.Context(), testAccount, stale[:3])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{stale}, tagged)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Settled launch-template residue the owner fails to remove is reported
|
||||
// failed from the re-scan, never deleted.
|
||||
func TestCleanupPlatformResidueRemovalFailureIsFailed(t *testing.T) {
|
||||
o := newOwners(t)
|
||||
o.putTemplateRecord(t, testAccount+".lt-0orphan000000000.v1", `{"version_number":1}`)
|
||||
|
||||
res := run(t, New(o.keys, o.groups, failingResidue{o.templates}, o.tags, o.readers()))
|
||||
assert.Equal(t, []Count{{
|
||||
Kind: ec2v1.PlatformKindLaunchTemplate, Class: ec2v1.PlatformClassVersionBodyWithoutHeader,
|
||||
Disposition: DispositionFailed, Reason: ReasonOwnerDeleteFailed, Count: 1,
|
||||
}}, res.Counts)
|
||||
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindLaunchTemplate])
|
||||
}
|
||||
|
||||
// failingResidue reports every settled orphan as a failed removal and removes nothing.
|
||||
type failingResidue struct {
|
||||
*ec2launchtemplate.LaunchTemplateServiceImpl
|
||||
}
|
||||
|
||||
func (f failingResidue) CleanupAccountResidue(ctx context.Context, acct string, _ time.Time) (*ec2launchtemplate.AccountCleanup, error) {
|
||||
scan, err := f.ScanAccount(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ec2launchtemplate.AccountCleanup{KeptTemplateIDs: scan.LiveTemplateIDs, OrphanBodiesFailed: scan.OrphanVersionBodies}, nil
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package accountcleanup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
ec2key "github.com/mulgadc/spinifex/spinifex/domains/ec2/key"
|
||||
ec2launchtemplate "github.com/mulgadc/spinifex/spinifex/domains/ec2/launchtemplate"
|
||||
ec2placementgroup "github.com/mulgadc/spinifex/spinifex/domains/ec2/placementgroup"
|
||||
)
|
||||
|
||||
// Each kind runs scan, owner cleanup, then re-scan. The first scan only bounds
|
||||
// how much nameless residue a run can claim to have removed.
|
||||
|
||||
type keyPairRun struct {
|
||||
kind *kindRun
|
||||
// unchecked: the reference check failed, so nothing was attempted.
|
||||
unchecked bool
|
||||
before *ec2key.AccountScan
|
||||
cleaned *ec2key.AccountCleanup
|
||||
did attempts
|
||||
}
|
||||
|
||||
// cleanKeyPairs deletes only when the reference check completed; otherwise
|
||||
// every key pair and orphan public key is kept and the kind is incomplete.
|
||||
func (c *Cleanup) cleanKeyPairs(ctx context.Context, acct string, settledBefore time.Time, refs referenceCheck) *keyPairRun {
|
||||
run := &keyPairRun{kind: c.startKind(ec2v1.PlatformKindKeyPair), did: newAttempts()}
|
||||
if before, err := c.keys.ScanAccount(ctx, acct); err == nil {
|
||||
run.before = before
|
||||
}
|
||||
if !refs.complete {
|
||||
run.unchecked = true
|
||||
run.kind.incomplete(ReasonReferenceCheckFailed)
|
||||
return run
|
||||
}
|
||||
cleaned, err := c.keys.CleanupAccount(ctx, acct, settledBefore, refs.keyReason)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "Platform cleanup: key-pair cleanup failed", "err", err)
|
||||
run.kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
|
||||
return run
|
||||
}
|
||||
run.cleaned = cleaned
|
||||
markAll(run.did.deleted, cleaned.DeletedKeyPairIDs)
|
||||
markAll(run.did.failed, cleaned.FailedKeyPairIDs)
|
||||
maps.Copy(run.did.kept, cleaned.Referenced)
|
||||
return run
|
||||
}
|
||||
|
||||
func (c *Cleanup) finishKeyPairs(ctx context.Context, acct string, run *keyPairRun, res *Result) ownedIDs {
|
||||
defer c.finishKind(run.kind)
|
||||
after, err := c.keys.ScanAccount(ctx, acct)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "Platform cleanup: key-pair re-scan failed", "err", err)
|
||||
run.kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
|
||||
after = nil
|
||||
}
|
||||
present := map[string]bool{}
|
||||
if after != nil {
|
||||
markAll(present, after.LiveKeyPairIDs)
|
||||
markAll(present, after.UnreadableKeyPairIDs)
|
||||
if after.ReadFailures > 0 {
|
||||
run.kind.incomplete(ec2v1.PlatformReasonRecordReadFailed)
|
||||
}
|
||||
}
|
||||
var beforeLive, afterLive, deleted, failed []string
|
||||
if run.before != nil {
|
||||
beforeLive = run.before.LiveKeyPairIDs
|
||||
}
|
||||
if after != nil {
|
||||
afterLive = after.LiveKeyPairIDs
|
||||
}
|
||||
if run.cleaned != nil {
|
||||
deleted, failed = run.cleaned.DeletedKeyPairIDs, run.cleaned.FailedKeyPairIDs
|
||||
}
|
||||
if run.unchecked {
|
||||
keepAll(run.did.kept, beforeLive, ReasonReferenceCheckFailed)
|
||||
keepAll(run.did.kept, afterLive, ReasonReferenceCheckFailed)
|
||||
}
|
||||
for _, id := range sortedUnion(beforeLive, deleted, failed, afterLive) {
|
||||
disposition, reason := itemDisposition(id, run.did, present[id], after != nil)
|
||||
res.addItem(run.kind, id, disposition, reason, 0)
|
||||
}
|
||||
if after == nil {
|
||||
return ownedIDs{}
|
||||
}
|
||||
res.addCount(run.kind, ec2v1.PlatformClassUnreadableRecord, DispositionResidue, ReasonUnreadableRecord, len(after.UnreadableKeyPairIDs))
|
||||
res.addCount(run.kind, ec2v1.PlatformClassPublicKeyUnresolved, DispositionResidue, ReasonOwnerRecordsUnresolved, after.UnresolvedPublicKeys)
|
||||
var before, removed, failedN, unsettled int
|
||||
kept := map[string]int{}
|
||||
if run.before != nil {
|
||||
before = run.before.OrphanPublicKeys
|
||||
}
|
||||
if run.cleaned != nil {
|
||||
removed, failedN, unsettled = run.cleaned.OrphansRemoved, run.cleaned.OrphansFailed, run.cleaned.OrphansUnsettled
|
||||
kept = run.cleaned.OrphansReferenced
|
||||
} else if run.unchecked {
|
||||
kept[ReasonReferenceCheckFailed] = after.OrphanPublicKeys
|
||||
}
|
||||
res.residueCounts(run.kind, ec2v1.PlatformClassPublicKeyWithoutMetadata, before, after.OrphanPublicKeys, removed, failedN, unsettled, kept)
|
||||
return ownedIDs{ids: present, resolved: after.ReadFailures == 0 && len(after.UnreadableKeyPairIDs) == 0}
|
||||
}
|
||||
|
||||
type placementGroupRun struct {
|
||||
kind *kindRun
|
||||
unchecked bool
|
||||
before *ec2placementgroup.AccountScan
|
||||
cleaned *ec2placementgroup.AccountCleanup
|
||||
did attempts
|
||||
}
|
||||
|
||||
// cleanPlacementGroups deletes only when the reference check completed.
|
||||
func (c *Cleanup) cleanPlacementGroups(ctx context.Context, acct string, refs referenceCheck) *placementGroupRun {
|
||||
run := &placementGroupRun{kind: c.startKind(ec2v1.PlatformKindPlacementGroup), did: newAttempts()}
|
||||
if before, err := c.groups.ScanAccount(ctx, acct); err == nil {
|
||||
run.before = before
|
||||
}
|
||||
if !refs.complete {
|
||||
run.unchecked = true
|
||||
run.kind.incomplete(ReasonReferenceCheckFailed)
|
||||
return run
|
||||
}
|
||||
cleaned, err := c.groups.CleanupAccount(ctx, acct, refs.groupReason)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "Platform cleanup: placement-group cleanup failed", "err", err)
|
||||
run.kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
|
||||
return run
|
||||
}
|
||||
run.cleaned = cleaned
|
||||
markAll(run.did.deleted, cleaned.DeletedGroupIDs)
|
||||
markAll(run.did.failed, cleaned.FailedGroupIDs)
|
||||
for _, b := range cleaned.Blocked {
|
||||
run.did.blocked[b.GroupID] = b.MemberCount
|
||||
}
|
||||
maps.Copy(run.did.kept, cleaned.Referenced)
|
||||
return run
|
||||
}
|
||||
|
||||
// finishPlacementGroups reports a group that still names instances as
|
||||
// blocked with its member count, from the re-scan when it can.
|
||||
func (c *Cleanup) finishPlacementGroups(ctx context.Context, acct string, run *placementGroupRun, res *Result) ownedIDs {
|
||||
defer c.finishKind(run.kind)
|
||||
after, err := c.groups.ScanAccount(ctx, acct)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "Platform cleanup: placement-group re-scan failed", "err", err)
|
||||
run.kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
|
||||
after = nil
|
||||
}
|
||||
present := map[string]bool{}
|
||||
members := map[string]int{}
|
||||
var ids []string
|
||||
if run.before != nil {
|
||||
for _, g := range run.before.Groups {
|
||||
ids = append(ids, g.GroupID)
|
||||
}
|
||||
}
|
||||
if run.cleaned != nil {
|
||||
ids = append(ids, run.cleaned.DeletedGroupIDs...)
|
||||
ids = append(ids, run.cleaned.FailedGroupIDs...)
|
||||
for _, b := range run.cleaned.Blocked {
|
||||
ids = append(ids, b.GroupID)
|
||||
}
|
||||
for id := range run.cleaned.Referenced {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if after != nil {
|
||||
for _, g := range after.Groups {
|
||||
present[g.GroupID] = true
|
||||
members[g.GroupID] = g.MemberCount
|
||||
ids = append(ids, g.GroupID)
|
||||
}
|
||||
if after.ReadFailures > 0 {
|
||||
run.kind.incomplete(ec2v1.PlatformReasonRecordReadFailed)
|
||||
}
|
||||
}
|
||||
if run.unchecked {
|
||||
keepAll(run.did.kept, ids, ReasonReferenceCheckFailed)
|
||||
}
|
||||
for _, id := range sortedUnion(ids) {
|
||||
if present[id] && (members[id] > 0 || run.did.blocked[id] > 0) {
|
||||
count := members[id]
|
||||
if count == 0 {
|
||||
count = run.did.blocked[id]
|
||||
}
|
||||
res.addItem(run.kind, id, DispositionBlocked, ReasonInstanceMembers, count)
|
||||
continue
|
||||
}
|
||||
disposition, reason := itemDisposition(id, run.did, present[id], after != nil)
|
||||
res.addItem(run.kind, id, disposition, reason, 0)
|
||||
}
|
||||
if after == nil {
|
||||
return ownedIDs{}
|
||||
}
|
||||
res.addCount(run.kind, ec2v1.PlatformClassUnreadableRecord, DispositionResidue, ReasonUnreadableRecord, after.Unreadable)
|
||||
return ownedIDs{ids: present, resolved: after.ReadFailures == 0 && after.Unreadable == 0}
|
||||
}
|
||||
|
||||
type launchTemplateRun struct {
|
||||
kind *kindRun
|
||||
before *ec2launchtemplate.AccountScan
|
||||
cleaned *ec2launchtemplate.AccountCleanup
|
||||
did attempts
|
||||
}
|
||||
|
||||
func (c *Cleanup) cleanLaunchTemplates(ctx context.Context, acct string, settledBefore time.Time) *launchTemplateRun {
|
||||
run := &launchTemplateRun{kind: c.startKind(ec2v1.PlatformKindLaunchTemplate), did: newAttempts()}
|
||||
if before, err := c.templates.ScanAccount(ctx, acct); err == nil {
|
||||
run.before = before
|
||||
}
|
||||
cleaned, err := c.templates.CleanupAccountResidue(ctx, acct, settledBefore)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "Platform cleanup: launch-template residue cleanup failed", "err", err)
|
||||
run.kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
|
||||
return run
|
||||
}
|
||||
run.cleaned = cleaned
|
||||
keepAll(run.did.kept, cleaned.KeptTemplateIDs, ReasonReferenceUnknown)
|
||||
return run
|
||||
}
|
||||
|
||||
func (c *Cleanup) finishLaunchTemplates(ctx context.Context, acct string, run *launchTemplateRun, res *Result) ownedIDs {
|
||||
defer c.finishKind(run.kind)
|
||||
after, err := c.templates.ScanAccount(ctx, acct)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "Platform cleanup: launch-template re-scan failed", "err", err)
|
||||
run.kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
|
||||
after = nil
|
||||
}
|
||||
present := map[string]bool{}
|
||||
if after != nil {
|
||||
markAll(present, after.LiveTemplateIDs)
|
||||
markAll(present, after.UnreadableTemplateIDs)
|
||||
if after.ReadFailures > 0 {
|
||||
run.kind.incomplete(ec2v1.PlatformReasonRecordReadFailed)
|
||||
}
|
||||
}
|
||||
var beforeLive, afterLive, kept []string
|
||||
if run.before != nil {
|
||||
beforeLive = run.before.LiveTemplateIDs
|
||||
}
|
||||
if after != nil {
|
||||
afterLive = after.LiveTemplateIDs
|
||||
}
|
||||
if run.cleaned != nil {
|
||||
kept = run.cleaned.KeptTemplateIDs
|
||||
}
|
||||
// Live templates are never deleted here, so every one still present is
|
||||
// blocked on the unknown EKS nodegroup reference, whenever it appeared.
|
||||
for _, id := range afterLive {
|
||||
run.did.kept[id] = ReasonReferenceUnknown
|
||||
}
|
||||
for _, id := range sortedUnion(beforeLive, kept, afterLive) {
|
||||
disposition, reason := itemDisposition(id, run.did, present[id], after != nil)
|
||||
res.addItem(run.kind, id, disposition, reason, 0)
|
||||
}
|
||||
if after == nil {
|
||||
return ownedIDs{}
|
||||
}
|
||||
res.addCount(run.kind, ec2v1.PlatformClassUnreadableRecord, DispositionResidue, ReasonUnreadableRecord,
|
||||
len(after.UnreadableTemplateIDs)+after.UnreadableNameIndexEntries)
|
||||
res.addCount(run.kind, ec2v1.PlatformClassUnrecognisedTemplateEntry, DispositionResidue, ReasonUnrecognisedRecord, after.UnrecognisedEntries)
|
||||
var c0 ec2launchtemplate.AccountCleanup
|
||||
if run.cleaned != nil {
|
||||
c0 = *run.cleaned
|
||||
}
|
||||
var beforeBodies, beforeNames int
|
||||
if run.before != nil {
|
||||
beforeBodies, beforeNames = run.before.OrphanVersionBodies, run.before.OrphanNameIndexEntries
|
||||
}
|
||||
res.residueCounts(run.kind, ec2v1.PlatformClassVersionBodyWithoutHeader, beforeBodies, after.OrphanVersionBodies,
|
||||
c0.OrphanBodiesRemoved, c0.OrphanBodiesFailed, c0.OrphanBodiesUnsettled, nil)
|
||||
res.residueCounts(run.kind, ec2v1.PlatformClassNameIndexWithoutHeader, beforeNames, after.OrphanNameIndexEntries,
|
||||
c0.OrphanNamesRemoved, c0.OrphanNamesFailed, c0.OrphanNamesUnsettled, nil)
|
||||
return ownedIDs{ids: present, resolved: after.ReadFailures == 0 && len(after.UnreadableTemplateIDs) == 0 &&
|
||||
after.UnreadableNameIndexEntries == 0 && after.UnrecognisedEntries == 0}
|
||||
}
|
||||
|
||||
// cleanTagIndex clears entries whose owner re-scan resolved and holds no
|
||||
// record for the ID, then re-lists the index to prove each clear. Entries of
|
||||
// resources that still exist are theirs, not residue, and are left alone.
|
||||
func (c *Cleanup) cleanTagIndex(ctx context.Context, acct string, kind *kindRun, tagged []string, listed bool, owners map[string]ownedIDs, res *Result) KindReport {
|
||||
if listed {
|
||||
c.clearOrphanTags(ctx, acct, kind, tagged, owners, res)
|
||||
}
|
||||
c.finishKind(kind)
|
||||
return kind.report
|
||||
}
|
||||
|
||||
func (c *Cleanup) clearOrphanTags(ctx context.Context, acct string, kind *kindRun, tagged []string, owners map[string]ownedIDs, res *Result) {
|
||||
// A resource this run reports as not deleted owns its tags even when its
|
||||
// kind's re-scan failed.
|
||||
reported := map[string]bool{}
|
||||
for _, it := range res.Items {
|
||||
if it.Disposition != DispositionDeleted {
|
||||
reported[it.ResourceID] = true
|
||||
}
|
||||
}
|
||||
var cleared []string
|
||||
for _, id := range tagged {
|
||||
owner := owners[idPrefix(id)]
|
||||
switch {
|
||||
case owner.ids[id], reported[id]:
|
||||
kind.found = true
|
||||
case !owner.resolved:
|
||||
kind.incomplete(ec2v1.PlatformReasonOwnerRecordsUnresolved)
|
||||
res.addItem(kind, id, DispositionResidue, ReasonOwnerRecordsUnresolved, 0)
|
||||
default:
|
||||
if err := c.tags.DeleteAllTags(ctx, acct, id); err != nil {
|
||||
slog.WarnContext(ctx, "Platform cleanup: tag-index clear failed", "resourceId", id, "err", err)
|
||||
res.addItem(kind, id, DispositionFailed, ReasonOwnerDeleteFailed, 0)
|
||||
continue
|
||||
}
|
||||
cleared = append(cleared, id)
|
||||
}
|
||||
}
|
||||
after, err := c.tags.ScanAccountResourceIDs(ctx, acct, keyPairIDPrefix, placementGroupIDPrefix, launchTemplateIDPrefix)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "Platform cleanup: tag index re-scan failed", "err", err)
|
||||
kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
|
||||
}
|
||||
for _, id := range cleared {
|
||||
disposition, reason := itemDisposition(id, attempts{deleted: map[string]bool{id: true}}, slices.Contains(after, id), err == nil)
|
||||
res.addItem(kind, id, disposition, reason, 0)
|
||||
}
|
||||
if len(after) > 0 {
|
||||
kind.found = true
|
||||
}
|
||||
}
|
||||
|
||||
func keepAll(set map[string]string, ids []string, reason string) {
|
||||
for _, id := range ids {
|
||||
set[id] = reason
|
||||
}
|
||||
}
|
||||
|
||||
// referenceCheck is the outcome of reading what instance records, spot
|
||||
// requests and launch-template versions name. complete is false when any
|
||||
// reader failed or is absent.
|
||||
type referenceCheck struct {
|
||||
complete bool
|
||||
instances, spot, template *References
|
||||
}
|
||||
|
||||
// readReferences runs every reference check. A check that cannot complete is
|
||||
// never read as "no references".
|
||||
func (c *Cleanup) readReferences(ctx context.Context, acct string) referenceCheck {
|
||||
r := c.refs
|
||||
if r.Instances == nil || r.Spot == nil || r.Templates == nil {
|
||||
slog.WarnContext(ctx, "Platform cleanup: reference reader not configured")
|
||||
return referenceCheck{}
|
||||
}
|
||||
instances, err := r.Instances.AccountInstanceReferences(ctx, acct)
|
||||
if err != nil || instances == nil {
|
||||
slog.WarnContext(ctx, "Platform cleanup: instance reference check failed", "err", err)
|
||||
return referenceCheck{}
|
||||
}
|
||||
launch, err := r.Spot.AccountLaunchReferences(ctx, acct)
|
||||
if err != nil || launch == nil {
|
||||
slog.WarnContext(ctx, "Platform cleanup: spot reference check failed", "err", err)
|
||||
return referenceCheck{}
|
||||
}
|
||||
tmpl, err := r.Templates.AccountTemplateReferences(ctx, acct)
|
||||
if err != nil || tmpl == nil {
|
||||
slog.WarnContext(ctx, "Platform cleanup: launch-template reference check failed", "err", err)
|
||||
return referenceCheck{}
|
||||
}
|
||||
inst := newReferences()
|
||||
inst.merge(instances)
|
||||
spot := newReferences()
|
||||
spot.addKeys(launch.KeyNames...)
|
||||
spot.addGroups(launch.PlacementGroupNames...)
|
||||
template := newReferences()
|
||||
template.addKeys(tmpl.KeyNames...)
|
||||
template.addGroups(tmpl.PlacementGroupNames...)
|
||||
template.addGroupIDs(tmpl.PlacementGroupIDs...)
|
||||
return referenceCheck{complete: true, instances: inst, spot: spot, template: template}
|
||||
}
|
||||
|
||||
func (r referenceCheck) keyReason(name string) string {
|
||||
switch {
|
||||
case r.instances.KeyNames[name]:
|
||||
return ReasonInstanceReference
|
||||
case r.spot.KeyNames[name]:
|
||||
return ReasonSpotRequestReference
|
||||
case r.template.KeyNames[name]:
|
||||
return ReasonLaunchTemplateReference
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r referenceCheck) groupReason(id, name string) string {
|
||||
switch {
|
||||
case r.instances.hasGroup(id, name):
|
||||
return ReasonInstanceReference
|
||||
case r.spot.hasGroup(id, name):
|
||||
return ReasonSpotRequestReference
|
||||
case r.template.hasGroup(id, name):
|
||||
return ReasonLaunchTemplateReference
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func markAll(set map[string]bool, ids []string) {
|
||||
for _, id := range ids {
|
||||
set[id] = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package accountcleanup
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
ec2launchtemplate "github.com/mulgadc/spinifex/spinifex/domains/ec2/launchtemplate"
|
||||
ec2spotinstance "github.com/mulgadc/spinifex/spinifex/domains/ec2/spotinstance"
|
||||
)
|
||||
|
||||
// References are the key-pair and placement-group names, and placement-group
|
||||
// IDs, that other EC2 records still name. They select what to keep and are
|
||||
// never reported.
|
||||
type References struct {
|
||||
KeyNames map[string]bool
|
||||
PlacementGroupNames map[string]bool
|
||||
PlacementGroupIDs map[string]bool
|
||||
}
|
||||
|
||||
// InstanceReferenceReader reads the key-pair and placement-group names the
|
||||
// account's non-terminated instance records carry, stopped ones included. It
|
||||
// is complete or an error, never a partial answer. A later wiring supplies it.
|
||||
type InstanceReferenceReader interface {
|
||||
AccountInstanceReferences(ctx context.Context, accountID string) (*References, error)
|
||||
}
|
||||
|
||||
// SpotReferenceReader reads the names the account's open or active spot
|
||||
// requests would launch with, complete or an error.
|
||||
type SpotReferenceReader interface {
|
||||
AccountLaunchReferences(ctx context.Context, accountID string) (*ec2spotinstance.LaunchReferences, error)
|
||||
}
|
||||
|
||||
// TemplateReferenceReader reads the names and IDs every version of every
|
||||
// live launch template would launch with, complete or an error.
|
||||
type TemplateReferenceReader interface {
|
||||
AccountTemplateReferences(ctx context.Context, accountID string) (*ec2launchtemplate.TemplateReferences, error)
|
||||
}
|
||||
|
||||
var (
|
||||
_ SpotReferenceReader = (*ec2spotinstance.SpotInstanceServiceImpl)(nil)
|
||||
_ TemplateReferenceReader = (*ec2launchtemplate.LaunchTemplateServiceImpl)(nil)
|
||||
)
|
||||
|
||||
// ReferenceReaders are the checks run before key pairs and placement groups
|
||||
// are deleted. A nil reader counts as a failed check.
|
||||
type ReferenceReaders struct {
|
||||
Instances InstanceReferenceReader
|
||||
Spot SpotReferenceReader
|
||||
Templates TemplateReferenceReader
|
||||
}
|
||||
|
||||
func newReferences() *References {
|
||||
return &References{KeyNames: map[string]bool{}, PlacementGroupNames: map[string]bool{}, PlacementGroupIDs: map[string]bool{}}
|
||||
}
|
||||
|
||||
func (r *References) addKeys(names ...string) {
|
||||
for _, name := range names {
|
||||
if name != "" {
|
||||
r.KeyNames[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *References) addGroups(names ...string) {
|
||||
for _, name := range names {
|
||||
if name != "" {
|
||||
r.PlacementGroupNames[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *References) addGroupIDs(ids ...string) {
|
||||
for _, id := range ids {
|
||||
if id != "" {
|
||||
r.PlacementGroupIDs[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *References) merge(o *References) {
|
||||
for name := range o.KeyNames {
|
||||
r.addKeys(name)
|
||||
}
|
||||
for name := range o.PlacementGroupNames {
|
||||
r.addGroups(name)
|
||||
}
|
||||
for id := range o.PlacementGroupIDs {
|
||||
r.addGroupIDs(id)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *References) hasGroup(id, name string) bool {
|
||||
return r.PlacementGroupIDs[id] || r.PlacementGroupNames[name]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package accountcleanup
|
||||
|
||||
import "time"
|
||||
|
||||
// Request names the account to clean. CorrelationRef is an opaque caller
|
||||
// reference echoed for logs; it grants nothing and is not a fence.
|
||||
type Request struct {
|
||||
AccountID string `json:"account_id"`
|
||||
CorrelationRef string `json:"correlation_ref,omitempty"`
|
||||
}
|
||||
|
||||
// Result is what the owner stores hold after one run. Kinds reuse the ec2.v1
|
||||
// platform kind and status vocabulary; Items and Counts carry resource IDs,
|
||||
// fixed reasons and counts only, never names, tags, key material or data.
|
||||
type Result struct {
|
||||
AccountID string `json:"account_id"`
|
||||
CorrelationRef string `json:"correlation_ref,omitempty"`
|
||||
Kinds []KindReport `json:"kinds"`
|
||||
Items []Item `json:"items,omitempty"`
|
||||
Counts []Count `json:"counts,omitempty"`
|
||||
}
|
||||
|
||||
// KindReport is one kind's status after its re-scan. Clean means the re-scan
|
||||
// read every store completely and found nothing; writers are not fenced, so
|
||||
// it is a snapshot, not evidence of absence.
|
||||
type KindReport struct {
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
IncompleteReasons []string `json:"incomplete_reasons,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt time.Time `json:"ended_at"`
|
||||
}
|
||||
|
||||
// Item is one resource by its AWS resource ID. MemberCount is set only for a
|
||||
// placement group blocked by members; instance and request IDs are never reported.
|
||||
type Item struct {
|
||||
ResourceID string `json:"resource_id"`
|
||||
Kind string `json:"kind"`
|
||||
Disposition string `json:"disposition"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
MemberCount int `json:"member_count,omitempty"`
|
||||
}
|
||||
|
||||
// Count is the number of records of one class with one disposition, for
|
||||
// records that have no public identity.
|
||||
type Count struct {
|
||||
Kind string `json:"kind"`
|
||||
Class string `json:"class"`
|
||||
Disposition string `json:"disposition"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// Dispositions. Only a complete owner re-scan that no longer finds a record
|
||||
// makes it deleted.
|
||||
const (
|
||||
DispositionDeleted = "deleted"
|
||||
DispositionBlocked = "blocked"
|
||||
DispositionFailed = "failed"
|
||||
DispositionResidue = "residue"
|
||||
)
|
||||
|
||||
// Reasons are fixed categories, never store error text.
|
||||
const (
|
||||
// ReasonInstanceMembers: a placement group still names instances.
|
||||
ReasonInstanceMembers = "instance-members"
|
||||
// ReasonOwnerDeleteFailed: the owner delete hit a store failure.
|
||||
ReasonOwnerDeleteFailed = "owner-delete-failed"
|
||||
// ReasonStillPresent: the owner reported a delete but the re-scan found it.
|
||||
ReasonStillPresent = "still-present"
|
||||
// ReasonAbsenceUnproven: the re-scan failed, so absence is not shown.
|
||||
ReasonAbsenceUnproven = "absence-unproven"
|
||||
// ReasonUnreadableRecord: a record the owner cannot read is never deleted.
|
||||
ReasonUnreadableRecord = "unreadable-record"
|
||||
// ReasonUnrecognisedRecord: an entry matching no owner record shape.
|
||||
ReasonUnrecognisedRecord = "unrecognised-record"
|
||||
// ReasonOwnerRecordsUnresolved: the record may belong to an unreadable one.
|
||||
ReasonOwnerRecordsUnresolved = "owner-records-unresolved"
|
||||
// ReasonRecentlyWritten: residue younger than the settle window, which may
|
||||
// be a create still in flight.
|
||||
ReasonRecentlyWritten = "recently-written"
|
||||
// ReasonAppearedDuringCleanup: a record the run did not see until its re-scan.
|
||||
ReasonAppearedDuringCleanup = "appeared-during-cleanup"
|
||||
// ReasonInstanceReference: a non-terminated instance record names it.
|
||||
ReasonInstanceReference = "instance-reference"
|
||||
// ReasonSpotRequestReference: an open or active spot request names it.
|
||||
ReasonSpotRequestReference = "spot-request-reference"
|
||||
// ReasonLaunchTemplateReference: a version of a live launch template names it.
|
||||
ReasonLaunchTemplateReference = "launch-template-reference"
|
||||
// ReasonReferenceUnknown: a record EC2 cannot read may name it, so it is
|
||||
// never deleted by account cleanup.
|
||||
ReasonReferenceUnknown = "reference-unknown"
|
||||
// ReasonReferenceCheckFailed: an instance, spot or launch-template reference
|
||||
// check did not complete, so nothing it guards was deleted. Also a kind
|
||||
// incomplete reason.
|
||||
ReasonReferenceCheckFailed = "reference-check-failed"
|
||||
)
|
||||
@@ -227,7 +227,7 @@ func aggregateResults(ctx context.Context, results []nodeLaunchResult, minCount
|
||||
return nil, clientErr
|
||||
}
|
||||
// A launch that was attempted and failed is not a capacity shortage; surface the real cause.
|
||||
if failErr := launchFailure(results); failErr != nil {
|
||||
if failErr := launchFailure(ctx, results); failErr != nil {
|
||||
slog.ErrorContext(ctx, "distributeInstances: launch fell short of minCount", "minCount", minCount, "launched", totalLaunched, "err", failErr)
|
||||
return nil, failErr
|
||||
}
|
||||
@@ -321,7 +321,7 @@ func distributeInstancesSpread(ctx context.Context, input *ec2.RunInstancesInput
|
||||
if clientErr := extractClientError(results); clientErr != nil {
|
||||
return nil, clientErr
|
||||
}
|
||||
if failErr := launchFailure(results); failErr != nil {
|
||||
if failErr := launchFailure(ctx, results); failErr != nil {
|
||||
slog.ErrorContext(ctx, "distributeInstancesSpread: launch fell short of minCount", "minCount", minCount, "launched", totalLaunched, "err", failErr)
|
||||
return nil, failErr
|
||||
}
|
||||
@@ -414,7 +414,7 @@ func distributeInstancesCluster(ctx context.Context, input *ec2.RunInstancesInpu
|
||||
if clientErr := extractClientError(results); clientErr != nil {
|
||||
return nil, clientErr
|
||||
}
|
||||
return nil, results[0].Err
|
||||
return nil, launchFailure(ctx, results)
|
||||
}
|
||||
|
||||
reservation := results[0].Reservation
|
||||
@@ -468,17 +468,26 @@ func extractClientError(results []nodeLaunchResult) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// launchFailure returns the first node launch error, or nil when no node errored.
|
||||
// A sub-minCount launch whose nodes actually errored is not a capacity shortage:
|
||||
// genuine capacity is caught pre-launch in distributeInstances, and a node-side
|
||||
// capacity race is surfaced by extractClientError. Surfacing the real error (RPC
|
||||
// timeout, network/OVN failure) stops it masquerading as
|
||||
// InsufficientInstanceCapacity — the mislabel that derails triage.
|
||||
func launchFailure(results []nodeLaunchResult) error {
|
||||
// launchFailure returns the first real node launch error so an RPC or OVN failure is
|
||||
// not mislabelled as capacity; a node with no launch subscriber for the type cannot
|
||||
// place it, so when only those failed the result is InsufficientInstanceCapacity.
|
||||
func launchFailure(ctx context.Context, results []nodeLaunchResult) error {
|
||||
var noResponders error
|
||||
for _, r := range results {
|
||||
if r.Err != nil {
|
||||
return r.Err
|
||||
if r.Err == nil {
|
||||
continue
|
||||
}
|
||||
if errors.Is(r.Err, nats.ErrNoResponders) {
|
||||
if noResponders == nil {
|
||||
noResponders = r.Err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return r.Err
|
||||
}
|
||||
if noResponders != nil {
|
||||
slog.WarnContext(ctx, "distributeInstances: no launch subscriber on node, reporting InsufficientInstanceCapacity", "err", noResponders)
|
||||
return errors.New(awserrors.ErrorInsufficientInstanceCapacity)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -335,6 +335,32 @@ func TestAggregateResults_TimeoutSurfacedNotMasked(t *testing.T) {
|
||||
assert.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// A node with no subscriber on its launch subject surfaces as capacity, never the raw NATS error.
|
||||
func TestAggregateResults_NoRespondersIsCapacity(t *testing.T) {
|
||||
t.Parallel()
|
||||
noResp := fmt.Errorf("launch on node-1: %w", fmt.Errorf("NATS request to ec2.RunInstances.t3.large.node-1: %w", nats.ErrNoResponders))
|
||||
results := []nodeLaunchResult{
|
||||
{NodeID: "node-1", Err: noResp},
|
||||
}
|
||||
|
||||
_, err := aggregateResults(context.Background(), results, 1, nil, "")
|
||||
require.EqualError(t, err, awserrors.ErrorInsufficientInstanceCapacity)
|
||||
}
|
||||
|
||||
// A real launch failure on another node outranks a no-responders node.
|
||||
func TestAggregateResults_RealFailureOutranksNoResponders(t *testing.T) {
|
||||
t.Parallel()
|
||||
noResp := fmt.Errorf("launch on node-1: %w", nats.ErrNoResponders)
|
||||
timeoutErr := fmt.Errorf("launch on node-2: %w", context.DeadlineExceeded)
|
||||
results := []nodeLaunchResult{
|
||||
{NodeID: "node-1", Err: noResp},
|
||||
{NodeID: "node-2", Err: timeoutErr},
|
||||
}
|
||||
|
||||
_, err := aggregateResults(context.Background(), results, 1, nil, "")
|
||||
assert.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// Short launch with no node errors (nodes returned fewer instances than assigned
|
||||
// without erroring) keeps the generic capacity code.
|
||||
func TestAggregateResults_ShortWithoutNodeErrors(t *testing.T) {
|
||||
@@ -1120,6 +1146,40 @@ func TestDistributeInstancesCluster_InsufficientCapacityOnPinnedNode(t *testing.
|
||||
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, err.Error())
|
||||
}
|
||||
|
||||
// A pinned node with no launch subscriber reports capacity, not the raw NATS error.
|
||||
func TestDistributeInstancesCluster_NoLaunchSubscriberIsCapacity(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc := startTestNATSServer(t)
|
||||
|
||||
statusSub, err := nc.Subscribe("spinifex.node.status", func(msg *nats.Msg) {
|
||||
data, _ := json.Marshal(types.NodeStatusResponse{Node: "node-1", InstanceTypes: []types.InstanceTypeCap{{Name: "t3.micro", Available: 10}}})
|
||||
_ = nc.Publish(msg.Reply, data)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer statusSub.Unsubscribe()
|
||||
|
||||
reserveSub, err := nc.QueueSubscribe("ec2.ReserveClusterNode", "spinifex-workers", func(msg *nats.Msg) {
|
||||
data, _ := json.Marshal(struct {
|
||||
TargetNode string `json:"target_node"`
|
||||
}{TargetNode: "node-1"})
|
||||
_ = msg.Respond(data)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer reserveSub.Unsubscribe()
|
||||
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-test"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
}
|
||||
|
||||
_, err = distributeInstancesCluster(context.Background(), input, nc, "test-account", "my-cluster-group", 1)
|
||||
require.EqualError(t, err, awserrors.ErrorInsufficientInstanceCapacity)
|
||||
}
|
||||
|
||||
func TestDistributeInstancesCluster_PinnedNodeNotInCapacityResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Pinned node has no capacity at all (not in fan-out results) → InsufficientInstanceCapacity
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/cache"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -237,6 +239,44 @@ func TestSynthesis_HonoursInstanceIDFilter(t *testing.T) {
|
||||
require.Equal(t, "i-asked", aws.StringValue(out.InstanceStatuses[0].InstanceId))
|
||||
}
|
||||
|
||||
// Synthesis shares the node path's selection, so a platform-managed VM on a
|
||||
// stale node is hidden from the customer account it is recorded under.
|
||||
func TestSynthesis_HidesManagedSystemVMFromCustomer(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc := startTestNATSServer(t)
|
||||
answerWith(t, nc, "node-active")
|
||||
|
||||
managed := cachedVM("i-ekscp", "node-dead", "az-a", vm.StateRunning)
|
||||
managed.ManagedBy = tags.ManagedByEKS
|
||||
synth := StatusSynthesis{
|
||||
Records: fakeRecords{ready: true, vms: []*vm.VM{managed}},
|
||||
Liveness: fakeLiveness{states: map[string]instancecache.NodeState{"node-dead": instancecache.NodeStale}},
|
||||
}
|
||||
|
||||
out, err := DescribeInstanceStatus(context.Background(), &ec2.DescribeInstanceStatusInput{}, nc, 1, synthAccount, "az-a", synth)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, out.InstanceStatuses)
|
||||
}
|
||||
|
||||
func TestSynthesis_RootSeesManagedSystemVM(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc := startTestNATSServer(t)
|
||||
answerWith(t, nc, "node-active")
|
||||
|
||||
managed := cachedVM("i-lb", "node-dead", "az-a", vm.StateRunning)
|
||||
managed.AccountID = awsidentifiers.GlobalAccountID
|
||||
managed.ManagedBy = tags.ManagedByELBv2
|
||||
synth := StatusSynthesis{
|
||||
Records: fakeRecords{ready: true, vms: []*vm.VM{managed}},
|
||||
Liveness: fakeLiveness{states: map[string]instancecache.NodeState{"node-dead": instancecache.NodeStale}},
|
||||
}
|
||||
|
||||
out, err := DescribeInstanceStatus(context.Background(), &ec2.DescribeInstanceStatusInput{}, nc, 1, awsidentifiers.GlobalAccountID, "az-a", synth)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.InstanceStatuses, 1)
|
||||
require.Equal(t, "i-lb", aws.StringValue(out.InstanceStatuses[0].InstanceId))
|
||||
}
|
||||
|
||||
// A cache that has not finished its first whole-set sync cannot support a
|
||||
// claim about what is missing, so it contributes nothing.
|
||||
func TestSynthesis_ColdCacheContributesNothing(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package image_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
ebsmetadata "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
|
||||
ec2image "github.com/mulgadc/spinifex/spinifex/domains/ec2/image"
|
||||
ec2snapshot "github.com/mulgadc/spinifex/spinifex/domains/ec2/snapshot"
|
||||
ec2volume "github.com/mulgadc/spinifex/spinifex/domains/ec2/volume"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
ebsprovider "github.com/mulgadc/spinifex/spinifex/providers/ebs"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The TestCurrentBehaviour_AccountTermination_ tests pin how volumes,
|
||||
// snapshots and images guard one another today, wired the way the daemon wires
|
||||
// them; a failure means a dependency rule changed.
|
||||
|
||||
const (
|
||||
ctAccountID = "123456789012"
|
||||
ctBucket = "ct-bucket"
|
||||
ctAZ = "ct-az"
|
||||
)
|
||||
|
||||
type storageServices struct {
|
||||
volumes *ec2volume.VolumeServiceImpl
|
||||
snapshots *ec2snapshot.SnapshotServiceImpl
|
||||
images *ec2image.ImageServiceImpl
|
||||
provider *ebsprovider.MemoryProvider
|
||||
metadata *ebsmetadata.Store
|
||||
}
|
||||
|
||||
// newStorageServices shares one object store, one provider and one
|
||||
// volume-to-snapshot index between the three services. As in the daemon, the
|
||||
// image service is given no handle on that index.
|
||||
func newStorageServices(t *testing.T) storageServices {
|
||||
t.Helper()
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
refs, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: ec2snapshot.KVBucketVolumeSnapshots})
|
||||
require.NoError(t, err)
|
||||
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{SnapshotEnumeration: true})
|
||||
cfg := &config.Config{AZ: ctAZ, WalDir: t.TempDir(), Predastore: config.PredastoreConfig{Bucket: ctBucket}}
|
||||
|
||||
volumes := ec2volume.NewVolumeServiceImplWithStore(cfg, store, nc, refs)
|
||||
volumes.SetEBSProvider(provider)
|
||||
snapshots := ec2snapshot.NewSnapshotServiceImplWithStore(cfg, store, nc, refs)
|
||||
snapshots.SetEBSProvider(provider)
|
||||
images := ec2image.NewImageServiceImplWithConfig(cfg, store, nil)
|
||||
images.SetEBSProvider(provider)
|
||||
|
||||
return storageServices{
|
||||
volumes: volumes, snapshots: snapshots, images: images,
|
||||
provider: provider, metadata: ebsmetadata.NewStore(store, ctBucket),
|
||||
}
|
||||
}
|
||||
|
||||
func (s storageServices) seedVolume(t *testing.T, volumeID string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
created, err := s.provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{
|
||||
Versioned: ebsprovider.NewVersioned(),
|
||||
VolumeID: volumeID,
|
||||
CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.metadata.PutVolume(ctx, ebsmetadata.Volume{
|
||||
VolumeID: volumeID, TenantID: ctAccountID, CapacityGiB: 1,
|
||||
State: "available", ProviderHandle: created.Handle,
|
||||
}))
|
||||
}
|
||||
|
||||
func (s storageServices) createImage(t *testing.T, rootVolumeID, name string) string {
|
||||
t.Helper()
|
||||
out, err := s.images.CreateImageFromInstance(ec2image.CreateImageParams{
|
||||
Input: &ec2.CreateImageInput{InstanceId: aws.String("i-0123456789abcdef0"), Name: aws.String(name)},
|
||||
RootVolumeID: rootVolumeID,
|
||||
}, ctAccountID)
|
||||
require.NoError(t, err)
|
||||
return aws.StringValue(out.ImageId)
|
||||
}
|
||||
|
||||
func (s storageServices) deleteVolume(volumeID string) error {
|
||||
_, err := s.volumes.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{VolumeId: aws.String(volumeID)}, ctAccountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// A CreateSnapshot snapshot pins its source volume through the index, but a
|
||||
// CreateImage snapshot is never added to it, so its source volume deletes and
|
||||
// the image keeps naming a volume that no longer exists.
|
||||
func TestCurrentBehaviour_AccountTermination_CreateImageSnapshotDoesNotPinSourceVolume(t *testing.T) {
|
||||
s := newStorageServices(t)
|
||||
ctx := context.Background()
|
||||
|
||||
s.seedVolume(t, "vol-snapshotted")
|
||||
_, err := s.snapshots.CreateSnapshot(ctx, &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-snapshotted")}, ctAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.EqualError(t, s.deleteVolume("vol-snapshotted"), awserrors.ErrorVolumeInUse,
|
||||
"a CreateSnapshot snapshot blocks deleting its source volume")
|
||||
|
||||
s.seedVolume(t, "vol-imaged")
|
||||
imageID := s.createImage(t, "vol-imaged", "ct-image")
|
||||
require.NoError(t, s.deleteVolume("vol-imaged"), "a CreateImage snapshot does not block deleting its source volume")
|
||||
|
||||
_, err = s.metadata.GetVolume(ctx, ctAccountID, "vol-imaged")
|
||||
require.Error(t, err, "the source volume's document is gone")
|
||||
|
||||
images, err := s.images.DescribeImages(ctx, &ec2.DescribeImagesInput{ImageIds: []*string{aws.String(imageID)}}, ctAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, images.Images, 1)
|
||||
assert.Equal(t, ec2.ImageStateAvailable, aws.StringValue(images.Images[0].State), "the image still reports available")
|
||||
|
||||
source, err := s.images.GetAMISourceVolumeID(ctx, imageID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "vol-imaged", source, "a launch from the image still resolves the deleted volume")
|
||||
}
|
||||
|
||||
// DeleteSnapshot consults AMI documents as well as volumes cloned from the
|
||||
// snapshot, so the snapshot under a live image refuses to delete until the
|
||||
// image is deregistered.
|
||||
func TestAccountTermination_DeleteSnapshotRefusesARegisteredImage(t *testing.T) {
|
||||
s := newStorageServices(t)
|
||||
ctx := context.Background()
|
||||
|
||||
s.seedVolume(t, "vol-registered")
|
||||
snap, err := s.snapshots.CreateSnapshot(ctx, &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-registered")}, ctAccountID)
|
||||
require.NoError(t, err)
|
||||
snapshotID := aws.StringValue(snap.SnapshotId)
|
||||
|
||||
registered, err := s.images.RegisterImage(ctx, &ec2.RegisterImageInput{
|
||||
Name: aws.String("ct-registered"),
|
||||
RootDeviceName: aws.String("/dev/sda1"),
|
||||
BlockDeviceMappings: []*ec2.BlockDeviceMapping{{
|
||||
DeviceName: aws.String("/dev/sda1"),
|
||||
Ebs: &ec2.EbsBlockDevice{SnapshotId: aws.String(snapshotID)},
|
||||
}},
|
||||
}, ctAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.snapshots.DeleteSnapshot(ctx, &ec2.DeleteSnapshotInput{SnapshotId: aws.String(snapshotID)}, ctAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidSnapshotInUse, "the snapshot refuses to delete while an image is registered on it")
|
||||
|
||||
_, err = s.metadata.GetSnapshot(ctx, ctAccountID, snapshotID)
|
||||
assert.NoError(t, err, "the snapshot document is still present")
|
||||
_, held := s.provider.Snapshot(snapshotID)
|
||||
assert.True(t, held, "the provider still holds the snapshot")
|
||||
|
||||
_, err = s.images.DeregisterImage(ctx, &ec2.DeregisterImageInput{ImageId: registered.ImageId}, ctAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.snapshots.DeleteSnapshot(ctx, &ec2.DeleteSnapshotInput{SnapshotId: aws.String(snapshotID)}, ctAccountID)
|
||||
require.NoError(t, err, "the snapshot deletes once the image is deregistered")
|
||||
}
|
||||
|
||||
// deleteDuringClone runs a hook once, after the provider has accepted a clone
|
||||
// from a snapshot and before CreateVolume writes the clone's document.
|
||||
type deleteDuringClone struct {
|
||||
ebsprovider.EBSProvider
|
||||
|
||||
hook func()
|
||||
fired bool
|
||||
}
|
||||
|
||||
func (p *deleteDuringClone) CreateVolume(ctx context.Context, req ebsprovider.CreateVolumeRequest) (*ebsprovider.Volume, error) {
|
||||
created, err := p.EBSProvider.CreateVolume(ctx, req)
|
||||
if err == nil && req.SourceSnapshotID != "" && !p.fired {
|
||||
p.fired = true
|
||||
p.hook()
|
||||
}
|
||||
return created, err
|
||||
}
|
||||
|
||||
// DeleteSnapshot's clone check reads volume documents, and CreateVolume writes
|
||||
// the clone's document only after the provider has verified and cloned the
|
||||
// snapshot, so a delete in that window passes the check and strands the clone.
|
||||
func TestCurrentBehaviour_AccountTermination_DeleteSnapshotDuringCloneCreationStrandsTheClone(t *testing.T) {
|
||||
s := newStorageServices(t)
|
||||
ctx := context.Background()
|
||||
createFrom := func(snapshotID string) (*ec2.Volume, error) {
|
||||
return s.volumes.CreateVolume(ctx, &ec2.CreateVolumeInput{
|
||||
AvailabilityZone: aws.String(ctAZ), SnapshotId: aws.String(snapshotID),
|
||||
}, ctAccountID)
|
||||
}
|
||||
deleteSnapshot := func(snapshotID string) error {
|
||||
_, err := s.snapshots.DeleteSnapshot(ctx, &ec2.DeleteSnapshotInput{SnapshotId: aws.String(snapshotID)}, ctAccountID)
|
||||
return err
|
||||
}
|
||||
|
||||
s.seedVolume(t, "vol-settled")
|
||||
settled, err := s.snapshots.CreateSnapshot(ctx, &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-settled")}, ctAccountID)
|
||||
require.NoError(t, err)
|
||||
_, err = createFrom(aws.StringValue(settled.SnapshotId))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, awserrors.IsErrorCode(deleteSnapshot(aws.StringValue(settled.SnapshotId)), awserrors.ErrorInvalidSnapshotInUse),
|
||||
"once the clone's document exists, the delete is refused")
|
||||
|
||||
s.seedVolume(t, "vol-raced")
|
||||
raced, err := s.snapshots.CreateSnapshot(ctx, &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-raced")}, ctAccountID)
|
||||
require.NoError(t, err)
|
||||
snapshotID := aws.StringValue(raced.SnapshotId)
|
||||
|
||||
var raceErr error
|
||||
s.volumes.SetEBSProvider(&deleteDuringClone{EBSProvider: s.provider, hook: func() { raceErr = deleteSnapshot(snapshotID) }})
|
||||
clone, err := createFrom(snapshotID)
|
||||
require.NoError(t, err, "CreateVolume reports success")
|
||||
require.NoError(t, raceErr, "the delete inside the window passes the clone check")
|
||||
|
||||
doc, err := s.metadata.GetVolume(ctx, ctAccountID, aws.StringValue(clone.VolumeId))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, snapshotID, doc.SnapshotID, "the clone's document names the deleted snapshot")
|
||||
_, err = s.metadata.GetSnapshot(ctx, ctAccountID, snapshotID)
|
||||
require.Error(t, err, "the snapshot's document is gone")
|
||||
_, held := s.provider.Snapshot(snapshotID)
|
||||
assert.False(t, held, "the provider no longer holds the snapshot the clone was made from")
|
||||
|
||||
_, err = createFrom(snapshotID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidSnapshotNotFound, "a clone started after the delete is refused")
|
||||
}
|
||||
|
||||
// CreateImage on an instance re-reads the image the instance launched from,
|
||||
// after the provider has taken the snapshot, so once that image is
|
||||
// deregistered the call fails and leaves a provider snapshot with no document.
|
||||
func TestCurrentBehaviour_AccountTermination_CreateImageOfRetainedInstanceNeedsItsLaunchImage(t *testing.T) {
|
||||
s := newStorageServices(t)
|
||||
ctx := context.Background()
|
||||
|
||||
s.seedVolume(t, "vol-launch-source")
|
||||
launchImageID := s.createImage(t, "vol-launch-source", "ct-launch-image")
|
||||
_, err := s.images.DeregisterImage(ctx, &ec2.DeregisterImageInput{ImageId: aws.String(launchImageID)}, ctAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.seedVolume(t, "vol-retained-root")
|
||||
_, err = s.images.CreateImageFromInstance(ec2image.CreateImageParams{
|
||||
Input: &ec2.CreateImageInput{InstanceId: aws.String("i-0123456789abcdef0"), Name: aws.String("ct-after-deregister")},
|
||||
RootVolumeID: "vol-retained-root",
|
||||
SourceImageID: launchImageID,
|
||||
}, ctAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorServerInternal, "the instance's image can no longer be created")
|
||||
|
||||
listed, err := s.provider.ListSnapshots(ctx, ebsprovider.ListSnapshotsRequest{Versioned: ebsprovider.NewVersioned()})
|
||||
require.NoError(t, err)
|
||||
var orphans []string
|
||||
for _, ref := range listed.Snapshots {
|
||||
if ref.SourceVolumeID == "vol-retained-root" {
|
||||
orphans = append(orphans, ref.ID)
|
||||
}
|
||||
}
|
||||
require.Len(t, orphans, 1, "the provider kept the snapshot it took before the image read failed")
|
||||
|
||||
docs, err := s.metadata.ListSnapshotsStrict(ctx, ctAccountID)
|
||||
require.NoError(t, err)
|
||||
for _, doc := range docs {
|
||||
assert.NotEqual(t, orphans[0], doc.SnapshotID, "no document names that snapshot")
|
||||
}
|
||||
}
|
||||
@@ -4507,6 +4507,30 @@ func TestDescribeInstanceStatus_AccountFilteringHidesOtherTenant(t *testing.T) {
|
||||
assert.Empty(t, out.InstanceStatuses)
|
||||
}
|
||||
|
||||
// A platform-managed VM recorded under a customer account stays out of that
|
||||
// customer's status listing, matching DescribeInstances.
|
||||
func TestDescribeInstanceStatus_HidesManagedSystemVMFromCustomer(t *testing.T) {
|
||||
owner := "111122223333"
|
||||
v := runningVM("i-ekscp", owner)
|
||||
v.ManagedBy = tags.ManagedByEKS
|
||||
svc := instanceStatusService(t, "az-a", map[string]*vm.VM{v.ID: v})
|
||||
|
||||
out, err := svc.DescribeInstanceStatus(context.Background(), &ec2.DescribeInstanceStatusInput{}, owner)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.InstanceStatuses, "managed system VM must not appear in customer status listing")
|
||||
}
|
||||
|
||||
func TestDescribeInstanceStatus_RootSeesManagedSystemVM(t *testing.T) {
|
||||
v := runningVM("i-lb", awsidentifiers.GlobalAccountID)
|
||||
v.ManagedBy = tags.ManagedByELBv2
|
||||
svc := instanceStatusService(t, "az-a", map[string]*vm.VM{v.ID: v})
|
||||
|
||||
out, err := svc.DescribeInstanceStatus(context.Background(), &ec2.DescribeInstanceStatusInput{}, awsidentifiers.GlobalAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.InstanceStatuses, 1)
|
||||
assert.Equal(t, "i-lb", *out.InstanceStatuses[0].InstanceId)
|
||||
}
|
||||
|
||||
func TestDescribeInstanceStatus_StoppedExcludedByDefault(t *testing.T) {
|
||||
owner := "111122223333"
|
||||
stopped := runningVM("i-stop", owner)
|
||||
|
||||
@@ -59,7 +59,7 @@ func ParseStatusSelection(input *ec2.DescribeInstanceStatusInput, accountID stri
|
||||
// status is built. Split out so a caller can test membership without paying
|
||||
// for the projection.
|
||||
func (s StatusSelection) Includes(v *vm.VM) bool {
|
||||
if v == nil || !IsInstanceVisible(s.accountID, v.AccountID) {
|
||||
if v == nil || !IsInstanceVisibleToCaller(s.accountID, v) {
|
||||
return false
|
||||
}
|
||||
if len(s.instanceIDs) > 0 && !s.instanceIDs[v.ID] {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package key
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AccountCleanup is what one CleanupAccount run did. It holds key-pair IDs and
|
||||
// counts only; it is the owner's account of its attempts, not proof of
|
||||
// absence, which only a later ScanAccount gives.
|
||||
type AccountCleanup struct {
|
||||
// DeletedKeyPairIDs completed the same owner delete DeleteKeyPair runs.
|
||||
DeletedKeyPairIDs []string
|
||||
// FailedKeyPairIDs had a store failure part way; the metadata remains.
|
||||
FailedKeyPairIDs []string
|
||||
// Referenced maps a kept key-pair ID to the reason the caller's predicate
|
||||
// gave for keeping it.
|
||||
Referenced map[string]string
|
||||
// Orphan public-key objects removed, that failed to delete, or left because
|
||||
// they were written after settledBefore and may belong to an in-flight create.
|
||||
OrphansRemoved, OrphansFailed, OrphansUnsettled int
|
||||
// OrphansReferenced counts public-key objects kept, by predicate reason.
|
||||
OrphansReferenced map[string]int
|
||||
}
|
||||
|
||||
// CleanupAccount deletes every readable key pair of the account through the
|
||||
// owner delete, and removes public-key objects no metadata names once they
|
||||
// were last written before settledBefore. A key pair or public-key object
|
||||
// for whose name referenced returns a reason is kept: IMDS serves the public
|
||||
// key by name to any instance naming it. Unreadable metadata, and public keys that
|
||||
// may belong to it, are left for the owner scan to report. A listing failure
|
||||
// is returned as an error and nothing is deleted.
|
||||
func (s *KeyServiceImpl) CleanupAccount(ctx context.Context, accountID string, settledBefore time.Time, referenced func(keyName string) string) (*AccountCleanup, error) {
|
||||
records, err := s.classifyAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if referenced == nil {
|
||||
referenced = func(string) string { return "" }
|
||||
}
|
||||
out := &AccountCleanup{Referenced: map[string]string{}, OrphansReferenced: map[string]int{}}
|
||||
for _, kp := range records.live {
|
||||
if reason := referenced(kp.name); reason != "" {
|
||||
out.Referenced[kp.id] = reason
|
||||
continue
|
||||
}
|
||||
if err := s.deleteKeyPairRecords(ctx, accountID, kp.name, kp.id); err != nil {
|
||||
out.FailedKeyPairIDs = append(out.FailedKeyPairIDs, kp.id)
|
||||
continue
|
||||
}
|
||||
out.DeletedKeyPairIDs = append(out.DeletedKeyPairIDs, kp.id)
|
||||
}
|
||||
for _, orphan := range records.orphans {
|
||||
if reason := referenced(orphan.name); reason != "" {
|
||||
out.OrphansReferenced[reason]++
|
||||
continue
|
||||
}
|
||||
if orphan.modified == nil || !orphan.modified.Before(settledBefore) {
|
||||
out.OrphansUnsettled++
|
||||
continue
|
||||
}
|
||||
if err := s.deleteObject(ctx, orphan.key); err != nil {
|
||||
slog.WarnContext(ctx, "Key-pair account cleanup: orphan public key delete failed", "err", err)
|
||||
out.OrphansFailed++
|
||||
continue
|
||||
}
|
||||
out.OrphansRemoved++
|
||||
}
|
||||
slog.InfoContext(ctx, "Key-pair account cleanup completed", "accountID", accountID,
|
||||
"deleted", len(out.DeletedKeyPairIDs), "failed", len(out.FailedKeyPairIDs), "referenced", len(out.Referenced),
|
||||
"orphansRemoved", out.OrphansRemoved, "orphansFailed", out.OrphansFailed,
|
||||
"orphansUnsettled", out.OrphansUnsettled)
|
||||
return out, nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
@@ -29,10 +30,49 @@ type AccountScan struct {
|
||||
ReadFailures int
|
||||
}
|
||||
|
||||
// liveKeyPair is a readable metadata record and the name it gives its key.
|
||||
type liveKeyPair struct {
|
||||
id, name string
|
||||
}
|
||||
|
||||
// orphanObject is a public-key object no metadata names, the key name it is
|
||||
// served under, and its last write time when the listing reported one.
|
||||
type orphanObject struct {
|
||||
key, name string
|
||||
modified *time.Time
|
||||
}
|
||||
|
||||
// accountRecords is the private classification ScanAccount and CleanupAccount
|
||||
// share. It holds object keys and names, so it never leaves this package.
|
||||
type accountRecords struct {
|
||||
live []liveKeyPair
|
||||
unreadable []string
|
||||
orphans []orphanObject
|
||||
unresolved int
|
||||
readFailures int
|
||||
}
|
||||
|
||||
// ScanAccount lists and classifies every object under keys/<accountID>/. A
|
||||
// listing failure is returned as an error; per-object read failures are
|
||||
// counted. It reads only objects named as metadata (key-*.json).
|
||||
func (s *KeyServiceImpl) ScanAccount(ctx context.Context, accountID string) (*AccountScan, error) {
|
||||
records, err := s.classifyAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scan := &AccountScan{
|
||||
UnreadableKeyPairIDs: records.unreadable,
|
||||
OrphanPublicKeys: len(records.orphans),
|
||||
UnresolvedPublicKeys: records.unresolved,
|
||||
ReadFailures: records.readFailures,
|
||||
}
|
||||
for _, kp := range records.live {
|
||||
scan.LiveKeyPairIDs = append(scan.LiveKeyPairIDs, kp.id)
|
||||
}
|
||||
return scan, nil
|
||||
}
|
||||
|
||||
func (s *KeyServiceImpl) classifyAccount(ctx context.Context, accountID string) (*accountRecords, error) {
|
||||
prefix := fmt.Sprintf("keys/%s/", accountID)
|
||||
objects, _, err := objectstore.ListAll(ctx, s.store, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(s.bucketName),
|
||||
@@ -42,9 +82,9 @@ func (s *KeyServiceImpl) ScanAccount(ctx context.Context, accountID string) (*Ac
|
||||
return nil, fmt.Errorf("list key-pair objects: %w", err)
|
||||
}
|
||||
|
||||
scan := &AccountScan{}
|
||||
records := &accountRecords{}
|
||||
liveNames := make(map[string]bool)
|
||||
var rest []string
|
||||
var rest []*s3.Object
|
||||
unreadable := make(map[string]bool)
|
||||
for _, obj := range objects {
|
||||
if obj == nil || obj.Key == nil {
|
||||
@@ -53,24 +93,24 @@ func (s *KeyServiceImpl) ScanAccount(ctx context.Context, accountID string) (*Ac
|
||||
name := strings.TrimPrefix(*obj.Key, prefix)
|
||||
id, isJSON := strings.CutSuffix(name, ".json")
|
||||
if !isJSON || !strings.HasPrefix(id, "key-") {
|
||||
rest = append(rest, name)
|
||||
rest = append(rest, obj)
|
||||
continue
|
||||
}
|
||||
metadata, found, readErr := s.readScanMetadata(ctx, *obj.Key)
|
||||
switch {
|
||||
case readErr != nil:
|
||||
scan.ReadFailures++
|
||||
records.readFailures++
|
||||
unreadable[name] = true
|
||||
case !found:
|
||||
// Removed between the listing and the read.
|
||||
case metadata == nil || aws.StringValue(metadata.KeyPairId) != id || aws.StringValue(metadata.KeyName) == "":
|
||||
unreadable[name] = true
|
||||
default:
|
||||
scan.LiveKeyPairIDs = append(scan.LiveKeyPairIDs, id)
|
||||
records.live = append(records.live, liveKeyPair{id: id, name: *metadata.KeyName})
|
||||
liveNames[*metadata.KeyName] = true
|
||||
}
|
||||
if unreadable[name] {
|
||||
rest = append(rest, name)
|
||||
rest = append(rest, obj)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,19 +121,20 @@ func (s *KeyServiceImpl) ScanAccount(ctx context.Context, accountID string) (*Ac
|
||||
delete(unreadable, name)
|
||||
}
|
||||
}
|
||||
for _, name := range rest {
|
||||
for _, obj := range rest {
|
||||
name := strings.TrimPrefix(*obj.Key, prefix)
|
||||
switch {
|
||||
case liveNames[name]:
|
||||
// The public key of a live key pair.
|
||||
case unreadable[name]:
|
||||
scan.UnreadableKeyPairIDs = append(scan.UnreadableKeyPairIDs, strings.TrimSuffix(name, ".json"))
|
||||
records.unreadable = append(records.unreadable, strings.TrimSuffix(name, ".json"))
|
||||
case len(unreadable) > 0:
|
||||
scan.UnresolvedPublicKeys++
|
||||
records.unresolved++
|
||||
default:
|
||||
scan.OrphanPublicKeys++
|
||||
records.orphans = append(records.orphans, orphanObject{key: *obj.Key, name: name, modified: obj.LastModified})
|
||||
}
|
||||
}
|
||||
return scan, nil
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// readScanMetadata reads and decodes one metadata object. A decode failure is
|
||||
|
||||
@@ -33,15 +33,12 @@ func TestScanAccountClassifiesEveryObjectUnderTheAccountPrefix(t *testing.T) {
|
||||
live := importKey(t, svc, ctAccount, "live")
|
||||
dotted := importKey(t, svc, ctAccount, "web.json")
|
||||
|
||||
importKey(t, svc, ctAccount, "deleted")
|
||||
store.failDelete = func(key string) bool { return key == prefix+"deleted" }
|
||||
_, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deleted")}, ctAccount)
|
||||
require.NoError(t, err)
|
||||
store.failDelete = nil
|
||||
// A public key whose metadata is gone, as an interrupted delete leaves it.
|
||||
putObject(t, store, prefix+"deleted", ctPubKey+"\n")
|
||||
|
||||
store.failPut = func(key string) bool { return strings.HasSuffix(key, ".json") }
|
||||
store.failDelete = func(string) bool { return true }
|
||||
_, err = svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{KeyName: aws.String("half")}, ctAccount)
|
||||
_, err := svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{KeyName: aws.String("half")}, ctAccount)
|
||||
require.Error(t, err)
|
||||
store.failPut, store.failDelete = nil, nil
|
||||
|
||||
@@ -50,7 +47,7 @@ func TestScanAccountClassifiesEveryObjectUnderTheAccountPrefix(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.ElementsMatch(t, []string{aws.StringValue(live.KeyPairId), aws.StringValue(dotted.KeyPairId)}, scan.LiveKeyPairIDs)
|
||||
assert.Equal(t, 2, scan.OrphanPublicKeys, "the failed-delete and failed-create public keys")
|
||||
assert.Equal(t, 2, scan.OrphanPublicKeys, "the interrupted-delete and failed-create public keys")
|
||||
assert.Empty(t, scan.UnreadableKeyPairIDs)
|
||||
assert.Zero(t, scan.UnresolvedPublicKeys)
|
||||
assert.Zero(t, scan.ReadFailures)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
@@ -19,8 +20,8 @@ import (
|
||||
)
|
||||
|
||||
// The TestCurrentBehaviour_AccountTermination_ tests pin how key pairs are
|
||||
// stored and what a delete or Describe leaves behind today. They describe
|
||||
// current behaviour, not the target design; a failure means it changed.
|
||||
// stored and what Describe leaves behind today; a failure means it changed.
|
||||
// The TestAccountTermination_ tests pin the corrected owner delete.
|
||||
|
||||
const (
|
||||
ctBucket = "test-bucket"
|
||||
@@ -152,35 +153,97 @@ func TestCurrentBehaviour_AccountTermination_KeyPairStorageLayout(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// Pins current behaviour that differs from AWS: a failed public-key delete is
|
||||
// logged, the metadata delete proceeds and the call reports success. The
|
||||
// leftover object still blocks the name and passes launch validation.
|
||||
func TestCurrentBehaviour_AccountTermination_DeleteKeyPairLeavesPublicKeyWhenItsDeleteFails(t *testing.T) {
|
||||
// A key pair is deleted public key first and metadata last, so a failed step
|
||||
// returns an error and leaves the key pair described and found by name; a
|
||||
// retried delete then removes both objects and frees the name.
|
||||
func TestAccountTermination_DeleteKeyPairKeepsTheKeyPairWhenAStepFails(t *testing.T) {
|
||||
prefix := "keys/" + ctAccount + "/"
|
||||
tests := []struct {
|
||||
name string
|
||||
failOn func(keyPairID string) string
|
||||
left func(keyPairID string) []string
|
||||
}{
|
||||
{"public key delete fails", func(string) string { return prefix + "deploy" },
|
||||
func(id string) []string { return sortedStrings(prefix+"deploy", prefix+id+".json") }},
|
||||
{"metadata delete fails", func(id string) string { return prefix + id + ".json" },
|
||||
func(id string) []string { return []string{prefix + id + ".json"} }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := newFaultyStore()
|
||||
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
|
||||
id := aws.StringValue(importKey(t, svc, ctAccount, "deploy").KeyPairId)
|
||||
failing := tc.failOn(id)
|
||||
store.failDelete = func(key string) bool { return key == failing }
|
||||
|
||||
_, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
assert.Equal(t, tc.left(id), accountObjects(t, store, ctAccount))
|
||||
assert.Equal(t, []string{"deploy"}, describedKeyNames(t, svc, ctAccount))
|
||||
|
||||
store.failDelete = nil
|
||||
out, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id, aws.StringValue(out.KeyPairId))
|
||||
assert.Empty(t, accountObjects(t, store, ctAccount))
|
||||
assert.ErrorContains(t, svc.ValidateKeyPairExists(context.Background(), ctAccount, "deploy"), awserrors.ErrorInvalidKeyPairNotFound)
|
||||
|
||||
_, err = svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
|
||||
assert.NoError(t, err, "the name is free again")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A public key left with no metadata is that name's key: a delete by name
|
||||
// removes it and reports success without a key pair ID, freeing the name.
|
||||
func TestAccountTermination_DeleteKeyPairByNameRemovesALeftoverPublicKey(t *testing.T) {
|
||||
store := newFaultyStore()
|
||||
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
|
||||
imported := importKey(t, svc, ctAccount, "deploy")
|
||||
pubKeyPath := "keys/" + ctAccount + "/deploy"
|
||||
store.failDelete = func(key string) bool { return key == pubKeyPath }
|
||||
putObject(t, store, "keys/"+ctAccount+"/orphan", ctPubKey+"\n")
|
||||
|
||||
out, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
|
||||
out, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("orphan")}, ctAccount)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, aws.BoolValue(out.Return))
|
||||
assert.Equal(t, aws.StringValue(imported.KeyPairId), aws.StringValue(out.KeyPairId))
|
||||
assert.Nil(t, out.KeyPairId)
|
||||
assert.Empty(t, accountObjects(t, store, ctAccount))
|
||||
_, err = svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{KeyName: aws.String("orphan")}, ctAccount)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{pubKeyPath}, accountObjects(t, store, ctAccount), "only the public key object remains")
|
||||
assert.Empty(t, describedKeyNames(t, svc, ctAccount))
|
||||
assert.NoError(t, svc.ValidateKeyPairExists(context.Background(), ctAccount, "deploy"))
|
||||
// A by-name delete that cannot read some metadata cannot prove the name is
|
||||
// absent, so it fails rather than reporting an idempotent success.
|
||||
func TestAccountTermination_DeleteKeyPairByNameFailsWhenMetadataIsUnreadable(t *testing.T) {
|
||||
store := newFaultyStore()
|
||||
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
|
||||
id := aws.StringValue(importKey(t, svc, ctAccount, "deploy").KeyPairId)
|
||||
store.failGet = func(key string) bool { return strings.HasSuffix(key, id+".json") }
|
||||
|
||||
// A retried delete finds no metadata, reports success and removes nothing.
|
||||
store.failDelete = nil
|
||||
_, err = svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{pubKeyPath}, accountObjects(t, store, ctAccount))
|
||||
|
||||
_, err = svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
|
||||
_, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
|
||||
require.Error(t, err)
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidKeyPairDuplicate, code)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
assert.Len(t, accountObjects(t, store, ctAccount), 2, "nothing was deleted")
|
||||
}
|
||||
|
||||
// Undecodable metadata may be the named key pair, so a by-name delete fails
|
||||
// and leaves both the metadata and the same-name public key in place.
|
||||
func TestAccountTermination_DeleteKeyPairByNameFailsOnUndecodableMetadata(t *testing.T) {
|
||||
store := newFaultyStore()
|
||||
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
|
||||
broken := "keys/" + ctAccount + "/key-0badbadbadbadbad0.json"
|
||||
pub := "keys/" + ctAccount + "/deploy"
|
||||
putObject(t, store, broken, "{not json")
|
||||
putObject(t, store, pub, ctPubKey+"\n")
|
||||
|
||||
_, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
assert.Equal(t, sortedStrings(broken, pub), accountObjects(t, store, ctAccount))
|
||||
}
|
||||
|
||||
func sortedStrings(s ...string) []string {
|
||||
slices.Sort(s)
|
||||
return s
|
||||
}
|
||||
|
||||
// A create whose metadata write fails and whose cleanup delete also fails
|
||||
@@ -231,3 +294,79 @@ func TestCurrentBehaviour_AccountTermination_DescribeKeyPairsHidesUnreadableReco
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Account cleanup deletes each readable key pair through the owner delete and
|
||||
// removes a settled public key no metadata names. A younger orphan may be a
|
||||
// create in flight and is left; unreadable metadata is never deleted.
|
||||
func TestAccountCleanup_DeletesKeyPairsAndSettledOrphans(t *testing.T) {
|
||||
store := newFaultyStore()
|
||||
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
|
||||
importKey(t, svc, ctAccount, "deploy")
|
||||
importKey(t, svc, "999988887777", "theirs")
|
||||
putObject(t, store, "keys/"+ctAccount+"/old-orphan", ctPubKey)
|
||||
broken := "keys/" + ctAccount + "/key-0badbadbadbadbad0.json"
|
||||
putObject(t, store, broken, "{not json")
|
||||
|
||||
out, err := svc.CleanupAccount(context.Background(), ctAccount, time.Now().Add(-time.Hour), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.DeletedKeyPairIDs, 1)
|
||||
assert.Zero(t, out.OrphansRemoved, "orphans are left while a public key may belong to unreadable metadata")
|
||||
|
||||
store.Clear()
|
||||
importKey(t, svc, ctAccount, "deploy")
|
||||
putObject(t, store, "keys/"+ctAccount+"/old-orphan", ctPubKey)
|
||||
out, err = svc.CleanupAccount(context.Background(), ctAccount, time.Now().Add(-time.Hour), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, out.OrphansUnsettled, "an orphan written inside the settle window is kept")
|
||||
assert.Equal(t, []string{"keys/" + ctAccount + "/old-orphan"}, accountObjects(t, store, ctAccount))
|
||||
|
||||
out, err = svc.CleanupAccount(context.Background(), ctAccount, time.Now().Add(time.Second), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, out.OrphansRemoved)
|
||||
assert.Empty(t, accountObjects(t, store, ctAccount))
|
||||
}
|
||||
|
||||
// A key pair, and an orphan public key, whose name the reference check names
|
||||
// are kept with the given reason: IMDS serves the public key by name.
|
||||
func TestAccountCleanup_KeepsReferencedKeyPairsAndPublicKeys(t *testing.T) {
|
||||
store := newFaultyStore()
|
||||
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
|
||||
kp := importKey(t, svc, ctAccount, "in-use")
|
||||
importKey(t, svc, ctAccount, "free")
|
||||
putObject(t, store, "keys/"+ctAccount+"/served-orphan", ctPubKey)
|
||||
referenced := func(name string) string {
|
||||
if name == "in-use" || name == "served-orphan" {
|
||||
return "instance-reference"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
out, err := svc.CleanupAccount(context.Background(), ctAccount, time.Now().Add(time.Second), referenced)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{aws.StringValue(kp.KeyPairId): "instance-reference"}, out.Referenced)
|
||||
assert.Len(t, out.DeletedKeyPairIDs, 1)
|
||||
assert.Equal(t, map[string]int{"instance-reference": 1}, out.OrphansReferenced)
|
||||
assert.Zero(t, out.OrphansRemoved)
|
||||
assert.Equal(t, []string{"in-use"}, describedKeyNames(t, svc, ctAccount))
|
||||
assert.Contains(t, accountObjects(t, store, ctAccount), "keys/"+ctAccount+"/served-orphan")
|
||||
}
|
||||
|
||||
// A key pair whose delete fails part way is reported failed with its metadata
|
||||
// kept; the next run completes it.
|
||||
func TestAccountCleanup_InterruptedKeyPairDeleteCompletesOnRerun(t *testing.T) {
|
||||
store := newFaultyStore()
|
||||
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
|
||||
kp := importKey(t, svc, ctAccount, "deploy")
|
||||
store.failDelete = func(key string) bool { return strings.HasSuffix(key, ".json") }
|
||||
|
||||
out, err := svc.CleanupAccount(context.Background(), ctAccount, time.Now(), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{aws.StringValue(kp.KeyPairId)}, out.FailedKeyPairIDs)
|
||||
assert.Equal(t, []string{"deploy"}, describedKeyNames(t, svc, ctAccount))
|
||||
|
||||
store.failDelete = nil
|
||||
out, err = svc.CleanupAccount(context.Background(), ctAccount, time.Now(), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{aws.StringValue(kp.KeyPairId)}, out.DeletedKeyPairIDs)
|
||||
assert.Empty(t, accountObjects(t, store, ctAccount))
|
||||
}
|
||||
|
||||
@@ -500,8 +500,12 @@ func (s *KeyServiceImpl) findKeyPairIdFromKeyName(ctx context.Context, accountID
|
||||
Key: obj.Key,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Debug("Failed to get metadata file", "key", *obj.Key, "err", err)
|
||||
continue
|
||||
if objectstore.IsNoSuchKeyError(err) {
|
||||
continue
|
||||
}
|
||||
// Unread metadata may be the key pair asked for, so absence is unproven.
|
||||
slog.ErrorContext(ctx, "Failed to get metadata file", "key", *obj.Key, "err", err)
|
||||
return "", fmt.Errorf("read metadata %s: %w", *obj.Key, err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(getResult.Body)
|
||||
@@ -509,14 +513,15 @@ func (s *KeyServiceImpl) findKeyPairIdFromKeyName(ctx context.Context, accountID
|
||||
slog.Debug("Failed to close metadata body", "key", *obj.Key, "err", err)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Debug("Failed to read metadata body", "key", *obj.Key, "err", err)
|
||||
continue
|
||||
slog.ErrorContext(ctx, "Failed to read metadata body", "key", *obj.Key, "err", err)
|
||||
return "", fmt.Errorf("read metadata %s: %w", *obj.Key, err)
|
||||
}
|
||||
|
||||
metadata, err := decodeKeyPairMetadata(body)
|
||||
if err != nil {
|
||||
slog.Debug("Failed to unmarshal metadata", "key", *obj.Key, "err", err)
|
||||
continue
|
||||
// Undecodable metadata may be this name's key pair, so absence is unproven.
|
||||
slog.ErrorContext(ctx, "Undecodable key-pair metadata", "key", *obj.Key, "err", err)
|
||||
return "", fmt.Errorf("decode metadata %s: %w", *obj.Key, err)
|
||||
}
|
||||
|
||||
// Check if this metadata matches the keyName
|
||||
@@ -611,7 +616,7 @@ func (s *KeyServiceImpl) describedPublicKey(accountID, keyName string) (string,
|
||||
return fmt.Sprintf("%s %s\n", bytes.TrimSpace(ssh.MarshalAuthorizedKey(publicKey)), keyName), nil
|
||||
}
|
||||
|
||||
// DeleteKeyPair removes a key pair (both public key and metadata from S3).
|
||||
// DeleteKeyPair removes a key pair: its public key, metadata and tag-index entry.
|
||||
func (s *KeyServiceImpl) DeleteKeyPair(ctx context.Context, input *ec2.DeleteKeyPairInput, accountID string) (*ec2.DeleteKeyPairOutput, error) {
|
||||
if input == nil {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
@@ -641,7 +646,7 @@ func (s *KeyServiceImpl) DeleteKeyPair(ctx context.Context, input *ec2.DeleteKey
|
||||
return &ec2.DeleteKeyPairOutput{Return: aws.Bool(true)}, nil
|
||||
}
|
||||
slog.ErrorContext(ctx, "Failed to get keyName from keyPairId", "keyPairId", keyPairID, "err", err)
|
||||
return nil, err
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
} else if input.KeyName != nil && *input.KeyName != "" {
|
||||
// KeyName provided - validate and find the keyPairId
|
||||
@@ -655,50 +660,70 @@ func (s *KeyServiceImpl) DeleteKeyPair(ctx context.Context, input *ec2.DeleteKey
|
||||
|
||||
keyPairID, err = s.findKeyPairIdFromKeyName(ctx, accountID, keyName)
|
||||
if err != nil {
|
||||
// AWS DeleteKeyPair is idempotent — return success for non-existent keys
|
||||
// AWS DeleteKeyPair is idempotent. A public key left with no metadata
|
||||
// is this name's key, so it goes too and the name becomes reusable.
|
||||
if err.Error() == awserrors.ErrorInvalidKeyPairNotFound {
|
||||
if rmErr := s.deleteObject(ctx, publicKeyPath(accountID, keyName)); rmErr != nil {
|
||||
slog.ErrorContext(ctx, "DeleteKeyPair: failed to remove leftover public key", "keyName", keyName, "err", rmErr)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
slog.DebugContext(ctx, "DeleteKeyPair: key pair not found, returning success (idempotent)", "keyName", keyName)
|
||||
return &ec2.DeleteKeyPairOutput{Return: aws.Bool(true)}, nil
|
||||
}
|
||||
slog.ErrorContext(ctx, "Failed to find keyPairId from keyName", "keyName", keyName, "err", err)
|
||||
return nil, err
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Deleting key pair", "keyName", keyName, "keyPairId", keyPairID)
|
||||
|
||||
// Delete public key
|
||||
publicKeyPath := fmt.Sprintf("keys/%s/%s", accountID, keyName)
|
||||
_, err = s.store.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucketName),
|
||||
Key: aws.String(publicKeyPath),
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to delete public key", "path", publicKeyPath, "err", err)
|
||||
// Continue to try deleting metadata even if public key deletion fails
|
||||
}
|
||||
|
||||
// Delete metadata file (stored with keyPairID)
|
||||
metadataPath := fmt.Sprintf("keys/%s/%s.json", accountID, keyPairID)
|
||||
_, err = s.store.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucketName),
|
||||
Key: aws.String(metadataPath),
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to delete metadata", "path", metadataPath, "err", err)
|
||||
if err := s.deleteKeyPairRecords(ctx, accountID, keyName, keyPairID); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
s.clearRecordTags(ctx, accountID, keyPairID)
|
||||
|
||||
slog.InfoContext(ctx, "Key pair deleted successfully", "keyName", keyName, "keyPairId", keyPairID)
|
||||
|
||||
// AWS names the key only when one was actually deleted.
|
||||
return &ec2.DeleteKeyPairOutput{Return: aws.Bool(true), KeyPairId: aws.String(keyPairID)}, nil
|
||||
}
|
||||
|
||||
// deleteKeyPairRecords removes the public key, then the metadata, then the
|
||||
// tag-index entry. Metadata goes last: while it exists the key pair is still
|
||||
// described and found by name or ID, so a retried delete finishes the job.
|
||||
func (s *KeyServiceImpl) deleteKeyPairRecords(ctx context.Context, accountID, keyName, keyPairID string) error {
|
||||
if err := s.deleteObject(ctx, publicKeyPath(accountID, keyName)); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to delete public key", "keyName", keyName, "keyPairId", keyPairID, "err", err)
|
||||
return fmt.Errorf("delete public key: %w", err)
|
||||
}
|
||||
if err := s.deleteObject(ctx, metadataPath(accountID, keyPairID)); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to delete key pair metadata", "keyPairId", keyPairID, "err", err)
|
||||
return fmt.Errorf("delete metadata: %w", err)
|
||||
}
|
||||
s.clearRecordTags(ctx, accountID, keyPairID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteObject deletes one object; an absent object is already deleted.
|
||||
func (s *KeyServiceImpl) deleteObject(ctx context.Context, key string) error {
|
||||
_, err := s.store.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucketName),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil && !objectstore.IsNoSuchKeyError(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func publicKeyPath(accountID, keyName string) string {
|
||||
return fmt.Sprintf("keys/%s/%s", accountID, keyName)
|
||||
}
|
||||
|
||||
func metadataPath(accountID, keyPairID string) string {
|
||||
return fmt.Sprintf("keys/%s/%s.json", accountID, keyPairID)
|
||||
}
|
||||
|
||||
// DescribeKeyPairs lists available key pairs by reading metadata files from S3
|
||||
// describeKeyPairsValidFilters defines the set of filter names accepted by DescribeKeyPairs.
|
||||
var describeKeyPairsValidFilters = map[string]bool{
|
||||
@@ -769,7 +794,7 @@ func (s *KeyServiceImpl) DescribeKeyPairs(ctx context.Context, input *ec2.Descri
|
||||
Key: obj.Key,
|
||||
})
|
||||
if err != nil {
|
||||
slog.DebugContext(ctx, "Failed to get metadata file", "key", *obj.Key, "err", err)
|
||||
slog.WarnContext(ctx, "DescribeKeyPairs: skipping metadata the store cannot read", "key", *obj.Key, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -778,13 +803,14 @@ func (s *KeyServiceImpl) DescribeKeyPairs(ctx context.Context, input *ec2.Descri
|
||||
slog.DebugContext(ctx, "Failed to close metadata body", "key", *obj.Key, "err", err)
|
||||
}
|
||||
if err != nil {
|
||||
slog.DebugContext(ctx, "Failed to read metadata body", "key", *obj.Key, "err", err)
|
||||
slog.WarnContext(ctx, "DescribeKeyPairs: skipping metadata whose body cannot be read", "key", *obj.Key, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// The decoder's message is not logged: it can quote the stored bytes.
|
||||
metadata, err := decodeKeyPairMetadata(body)
|
||||
if err != nil {
|
||||
slog.DebugContext(ctx, "Failed to unmarshal metadata", "key", *obj.Key, "err", err)
|
||||
slog.WarnContext(ctx, "DescribeKeyPairs: skipping undecodable metadata", "key", *obj.Key)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// AccountCleanup is what one CleanupAccountResidue run did. It holds template
|
||||
// IDs and counts only; it is the owner's account of its attempts, not proof of
|
||||
// absence, which only a later ScanAccount gives.
|
||||
type AccountCleanup struct {
|
||||
// KeptTemplateIDs are live templates; account cleanup never deletes them.
|
||||
KeptTemplateIDs []string
|
||||
// Version bodies and name-index entries with no header: removed, failed,
|
||||
// or left because they were written after settledBefore and may belong to
|
||||
// a create still in flight.
|
||||
OrphanBodiesRemoved, OrphanBodiesFailed, OrphanBodiesUnsettled int
|
||||
OrphanNamesRemoved, OrphanNamesFailed, OrphanNamesUnsettled int
|
||||
}
|
||||
|
||||
// CleanupAccountResidue removes the account's version bodies and name-index
|
||||
// entries whose header is absent, once written before settledBefore: without
|
||||
// a header no ID, name or launch can resolve them. Live templates are listed,
|
||||
// not deleted, because not every record that may name one is visible to EC2.
|
||||
// Unreadable headers, their bodies and unrecognised entries are left for the
|
||||
// owner scan to report. A listing failure is returned and nothing is removed.
|
||||
func (s *LaunchTemplateServiceImpl) CleanupAccountResidue(ctx context.Context, accountID string, settledBefore time.Time) (*AccountCleanup, error) {
|
||||
keys, err := kvutil.Keys(ctx, s.kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, fmt.Errorf("list launch-template keys: %w", err)
|
||||
}
|
||||
|
||||
prefix := accountID + "."
|
||||
present := make(map[string]bool)
|
||||
out := &AccountCleanup{}
|
||||
var bodies, names []string
|
||||
for _, key := range keys {
|
||||
rest, ok := strings.CutPrefix(key, prefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case isHeaderKey(key, prefix):
|
||||
entry, err := s.kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
continue
|
||||
}
|
||||
// An unreadable header still owns its bodies and name.
|
||||
present[rest] = true
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var header LaunchTemplateHeader
|
||||
if json.Unmarshal(entry.Value(), &header) == nil && header.LaunchTemplateId == rest {
|
||||
out.KeptTemplateIDs = append(out.KeptTemplateIDs, rest)
|
||||
}
|
||||
case strings.HasPrefix(rest, "name."):
|
||||
names = append(names, key)
|
||||
case versionBodyTemplateID(rest) != "":
|
||||
bodies = append(bodies, key)
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range bodies {
|
||||
if present[versionBodyTemplateID(strings.TrimPrefix(key, prefix))] {
|
||||
continue
|
||||
}
|
||||
tallyOrphan(s.removeSettled(ctx, key, settledBefore, nil), &out.OrphanBodiesRemoved, &out.OrphanBodiesFailed, &out.OrphanBodiesUnsettled)
|
||||
}
|
||||
for _, key := range names {
|
||||
// A name still naming a present header is that template's, not residue.
|
||||
owned := func(value []byte) bool { return present[string(value)] }
|
||||
tallyOrphan(s.removeSettled(ctx, key, settledBefore, owned), &out.OrphanNamesRemoved, &out.OrphanNamesFailed, &out.OrphanNamesUnsettled)
|
||||
}
|
||||
slog.InfoContext(ctx, "Launch-template account residue cleanup completed", "accountID", accountID,
|
||||
"kept", len(out.KeptTemplateIDs), "orphanBodiesRemoved", out.OrphanBodiesRemoved, "orphanNamesRemoved", out.OrphanNamesRemoved)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// orphanOutcome is what removeSettled did with one headerless record.
|
||||
type orphanOutcome int
|
||||
|
||||
const (
|
||||
orphanGone orphanOutcome = iota
|
||||
orphanRemoved
|
||||
orphanFailed
|
||||
orphanUnsettled
|
||||
orphanOwned
|
||||
)
|
||||
|
||||
// removeSettled deletes key at the revision read when it was written before
|
||||
// settledBefore and owned does not claim its value. The value is never decoded
|
||||
// or logged: a version body holds template data and user data.
|
||||
func (s *LaunchTemplateServiceImpl) removeSettled(ctx context.Context, key string, settledBefore time.Time, owned func([]byte) bool) orphanOutcome {
|
||||
entry, err := s.kv.Get(ctx, key)
|
||||
switch {
|
||||
case errors.Is(err, jetstream.ErrKeyNotFound):
|
||||
return orphanGone
|
||||
case err != nil:
|
||||
slog.WarnContext(ctx, "Launch-template account cleanup: orphan read failed", "err", err)
|
||||
return orphanFailed
|
||||
case owned != nil && owned(entry.Value()):
|
||||
return orphanOwned
|
||||
case !entry.Created().Before(settledBefore):
|
||||
return orphanUnsettled
|
||||
}
|
||||
if err := s.kv.Delete(ctx, key, jetstream.LastRevision(entry.Revision())); err != nil {
|
||||
slog.WarnContext(ctx, "Launch-template account cleanup: orphan delete failed", "err", err)
|
||||
return orphanFailed
|
||||
}
|
||||
return orphanRemoved
|
||||
}
|
||||
|
||||
func tallyOrphan(outcome orphanOutcome, removed, failed, unsettled *int) {
|
||||
switch outcome {
|
||||
case orphanRemoved:
|
||||
*removed++
|
||||
case orphanFailed:
|
||||
*failed++
|
||||
case orphanUnsettled:
|
||||
*unsettled++
|
||||
case orphanGone, orphanOwned:
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ package launchtemplate
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
@@ -43,17 +42,17 @@ func TestScanAccountClassifiesLaunchTemplateRecords(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
live := createWithUserData(t, svc, "live")
|
||||
|
||||
// A header removed alone, as a delete interrupted after it leaves the rest.
|
||||
deleted := createWithUserData(t, svc, "deleted")
|
||||
inner := svc.kv
|
||||
deletedHeader, deletedName := kvutil.AccountKey(testAccountID, deleted), nameKey(testAccountID, "deleted")
|
||||
svc.kv = &faultyKV{KeyValue: inner, failDelete: func(key string) bool {
|
||||
return strings.HasPrefix(key, deletedHeader+".v") || key == deletedName
|
||||
}}
|
||||
_, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(deleted)}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, inner.Delete(t.Context(), kvutil.AccountKey(testAccountID, deleted)))
|
||||
|
||||
svc.kv = &faultyKV{KeyValue: inner, failPut: func(key string) bool { return isHeaderKey(key, testAccountID+".") }}
|
||||
_, err = svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
// A create whose header write and clean-up both fail.
|
||||
svc.kv = &faultyKV{KeyValue: inner,
|
||||
failPut: func(key string) bool { return isHeaderKey(key, testAccountID+".") },
|
||||
failDelete: func(string) bool { return true },
|
||||
}
|
||||
_, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("interrupted"),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(ctUserData)))},
|
||||
}, testAccountID)
|
||||
|
||||
+240
-60
@@ -9,7 +9,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
@@ -21,8 +23,8 @@ import (
|
||||
)
|
||||
|
||||
// The TestCurrentBehaviour_AccountTermination_ tests pin how launch templates
|
||||
// are stored and what Describe and Delete leave behind today. They describe
|
||||
// current behaviour, not the target design; a failure means it changed.
|
||||
// are stored and what Describe leaves behind today; a failure means it
|
||||
// changed. The TestAccountTermination_ tests pin the corrected owner delete.
|
||||
|
||||
var errInjected = errors.New("injected store failure")
|
||||
|
||||
@@ -31,10 +33,31 @@ var errInjected = errors.New("injected store failure")
|
||||
type faultyKV struct {
|
||||
jetstream.KeyValue
|
||||
|
||||
failGet func(key string) bool
|
||||
failPut func(key string) bool
|
||||
failDelete func(key string) bool
|
||||
failKeys func() bool
|
||||
failGet func(key string) bool
|
||||
failPut func(key string) bool
|
||||
failDelete func(key string) bool
|
||||
failKeys func() bool
|
||||
failUpdate func(key string) bool
|
||||
beforeDelete func(key string)
|
||||
beforeCreate func(key string)
|
||||
beforeUpdate func(key string)
|
||||
}
|
||||
|
||||
func (k *faultyKV) Create(ctx context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error) {
|
||||
if k.beforeCreate != nil {
|
||||
k.beforeCreate(key)
|
||||
}
|
||||
return k.KeyValue.Create(ctx, key, value, opts...)
|
||||
}
|
||||
|
||||
func (k *faultyKV) Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error) {
|
||||
if k.beforeUpdate != nil {
|
||||
k.beforeUpdate(key)
|
||||
}
|
||||
if k.failUpdate != nil && k.failUpdate(key) {
|
||||
return 0, errInjected
|
||||
}
|
||||
return k.KeyValue.Update(ctx, key, value, revision)
|
||||
}
|
||||
|
||||
func (k *faultyKV) Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error) {
|
||||
@@ -52,6 +75,9 @@ func (k *faultyKV) Put(ctx context.Context, key string, value []byte) (uint64, e
|
||||
}
|
||||
|
||||
func (k *faultyKV) Delete(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error {
|
||||
if k.beforeDelete != nil {
|
||||
k.beforeDelete(key)
|
||||
}
|
||||
if k.failDelete != nil && k.failDelete(key) {
|
||||
return errInjected
|
||||
}
|
||||
@@ -138,40 +164,44 @@ func TestCurrentBehaviour_AccountTermination_LaunchTemplateStorageLayout(t *test
|
||||
assert.Equal(t, ctUserData, storedUserData(t, svc.kv, header+".v2"), "a version copied from v1 keeps its user data")
|
||||
}
|
||||
|
||||
// Pins current behaviour that differs from AWS: the header delete alone
|
||||
// decides success, and a failed name-index or version-body cleanup leaves
|
||||
// those records, user data included, with no header to find them by.
|
||||
func TestCurrentBehaviour_AccountTermination_DeleteLaunchTemplateLeavesRecordsWhenCleanupFails(t *testing.T) {
|
||||
// The header is deleted last, so a failed body, listing, name-index or header
|
||||
// delete returns an error with the template still described, and a retried
|
||||
// delete by ID removes every record, user data included.
|
||||
func TestAccountTermination_DeleteLaunchTemplateKeepsTheTemplateWhenAStepFails(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inject func(kv *faultyKV, header, nameIdx string)
|
||||
residual func(header, nameIdx string) []string
|
||||
name string
|
||||
inject func(kv *faultyKV, header, nameIdx string)
|
||||
left func(header, nameIdx string) []string
|
||||
}{
|
||||
{
|
||||
name: "version body deletes fail",
|
||||
inject: func(kv *faultyKV, header, _ string) {
|
||||
kv.failDelete = func(key string) bool { return key == header+".v1" || key == header+".v2" }
|
||||
},
|
||||
residual: func(header, _ string) []string { return []string{header + ".v1", header + ".v2"} },
|
||||
left: func(header, nameIdx string) []string {
|
||||
return []string{header, header + ".v1", header + ".v2", nameIdx}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "version listing fails",
|
||||
inject: func(kv *faultyKV, _, _ string) { kv.failKeys = func() bool { return true } },
|
||||
left: func(header, nameIdx string) []string {
|
||||
return []string{header, header + ".v1", header + ".v2", nameIdx}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "name index delete fails",
|
||||
inject: func(kv *faultyKV, _, nameIdx string) {
|
||||
kv.failDelete = func(key string) bool { return key == nameIdx }
|
||||
},
|
||||
residual: func(_, nameIdx string) []string { return []string{nameIdx} },
|
||||
left: func(header, nameIdx string) []string { return []string{header, nameIdx} },
|
||||
},
|
||||
{
|
||||
name: "version listing fails after the header delete",
|
||||
name: "header delete fails",
|
||||
inject: func(kv *faultyKV, header, _ string) {
|
||||
headerGone := false
|
||||
kv.failDelete = func(key string) bool {
|
||||
headerGone = headerGone || key == header
|
||||
return false
|
||||
}
|
||||
kv.failKeys = func() bool { return headerGone }
|
||||
kv.failDelete = func(key string) bool { return key == header }
|
||||
},
|
||||
residual: func(header, _ string) []string { return []string{header + ".v1", header + ".v2"} },
|
||||
left: func(header, _ string) []string { return []string{header} },
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
@@ -184,56 +214,85 @@ func TestCurrentBehaviour_AccountTermination_DeleteLaunchTemplateLeavesRecordsWh
|
||||
tc.inject(faulty, header, nameIdx)
|
||||
svc.kv = faulty
|
||||
|
||||
_, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
|
||||
svc.kv = inner
|
||||
assert.Equal(t, sortedKeys(append(tc.left(header, nameIdx), kvutil.VersionKey)...), storedKeys(t, inner))
|
||||
assert.Equal(t, []string{id}, describedTemplateIDs(t, svc, testAccountID))
|
||||
|
||||
out, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id, aws.StringValue(out.LaunchTemplate.LaunchTemplateId))
|
||||
|
||||
svc.kv = inner
|
||||
want := append(tc.residual(header, nameIdx), kvutil.VersionKey)
|
||||
assert.Equal(t, sortedKeys(want...), storedKeys(t, inner))
|
||||
assert.Equal(t, []string{kvutil.VersionKey}, storedKeys(t, inner))
|
||||
assert.Empty(t, describedTemplateIDs(t, svc, testAccountID))
|
||||
if slices.Contains(want, header+".v1") {
|
||||
assert.Equal(t, ctUserData, storedUserData(t, inner, header+".v1"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A create interrupted before its header write leaves the name claim and
|
||||
// version 1 behind. A retry under the same name reclaims the name, and the
|
||||
// first attempt's body is never reachable again.
|
||||
func TestCurrentBehaviour_AccountTermination_InterruptedCreateLeavesAnOrphanBody(t *testing.T) {
|
||||
// A header rewritten and a version added while the delete runs send it back
|
||||
// through the bodies at the new revision, and the late body is removed too.
|
||||
func TestAccountTermination_DeleteLaunchTemplateRestartsWhenTheHeaderChanges(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
inner := svc.kv
|
||||
svc.kv = &faultyKV{KeyValue: inner, failPut: func(key string) bool { return isHeaderKey(key, testAccountID+".") }}
|
||||
|
||||
_, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("web"),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
|
||||
UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(ctUserData))),
|
||||
},
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
|
||||
svc.kv = inner
|
||||
keys := storedKeys(t, inner)
|
||||
require.Len(t, keys, 3, "name index, version 1 and the bucket version key")
|
||||
nameEntry, err := inner.Get(t.Context(), nameKey(testAccountID, "web"))
|
||||
header := testAccountID + "." + id
|
||||
headerEntry, err := inner.Get(t.Context(), header)
|
||||
require.NoError(t, err)
|
||||
orphanBody := testAccountID + "." + string(nameEntry.Value()) + ".v1"
|
||||
assert.Contains(t, keys, orphanBody)
|
||||
assert.Equal(t, ctUserData, storedUserData(t, inner, orphanBody))
|
||||
assert.Empty(t, describedTemplateIDs(t, svc, testAccountID))
|
||||
|
||||
retried := createTemplate(t, svc, "web", "t3.micro")
|
||||
assert.NotEqual(t, string(nameEntry.Value()), aws.StringValue(retried.LaunchTemplateId))
|
||||
assert.Contains(t, storedKeys(t, inner), orphanBody)
|
||||
assert.Equal(t, []string{aws.StringValue(retried.LaunchTemplateId)}, describedTemplateIDs(t, svc, testAccountID))
|
||||
raced := false
|
||||
svc.kv = &faultyKV{KeyValue: inner, beforeDelete: func(key string) {
|
||||
if key != header || raced {
|
||||
return
|
||||
}
|
||||
raced = true
|
||||
_, err := inner.Put(t.Context(), header, headerEntry.Value())
|
||||
require.NoError(t, err)
|
||||
_, err = inner.Put(t.Context(), header+".v3", []byte(`{"version_number":3}`))
|
||||
require.NoError(t, err)
|
||||
}}
|
||||
|
||||
_, err = svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, raced)
|
||||
assert.Equal(t, []string{kvutil.VersionKey}, storedKeys(t, inner))
|
||||
}
|
||||
|
||||
// Describe skips a header it cannot read or decode, and still succeeds. An
|
||||
// undecodable header also cannot be removed through DeleteLaunchTemplate.
|
||||
// A create that fails after claiming the name removes what it wrote, so no
|
||||
// body is orphaned and a retry under the same name succeeds.
|
||||
func TestAccountTermination_FailedCreateRemovesWhatItWrote(t *testing.T) {
|
||||
for _, failing := range []string{"version", "header"} {
|
||||
t.Run(failing+" write fails", func(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
inner := svc.kv
|
||||
svc.kv = &faultyKV{KeyValue: inner, failPut: func(key string) bool {
|
||||
if failing == "header" {
|
||||
return isHeaderKey(key, testAccountID+".")
|
||||
}
|
||||
return versionBodyTemplateID(strings.TrimPrefix(key, testAccountID+".")) != ""
|
||||
}}
|
||||
|
||||
_, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("web"),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
|
||||
UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(ctUserData))),
|
||||
},
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
|
||||
svc.kv = inner
|
||||
assert.Equal(t, []string{kvutil.VersionKey}, storedKeys(t, inner))
|
||||
retried := createTemplate(t, svc, "web", "t3.micro")
|
||||
assert.Equal(t, []string{aws.StringValue(retried.LaunchTemplateId)}, describedTemplateIDs(t, svc, testAccountID))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Describe skips a header it cannot read or decode, and still succeeds. Such
|
||||
// a header cannot be removed through DeleteLaunchTemplate either, and a read
|
||||
// failure is an internal error, never a missing template.
|
||||
func TestCurrentBehaviour_AccountTermination_DescribeLaunchTemplatesHidesUnreadableHeaders(t *testing.T) {
|
||||
const brokenID = "lt-0badbadbadbadbad0"
|
||||
brokenHeader := kvutil.AccountKey(testAccountID, brokenID)
|
||||
@@ -252,7 +311,7 @@ func TestCurrentBehaviour_AccountTermination_DescribeLaunchTemplatesHidesUnreada
|
||||
_, err = svc.kv.Put(t.Context(), brokenHeader, data)
|
||||
require.NoError(t, err)
|
||||
svc.kv = &faultyKV{KeyValue: svc.kv, failGet: func(key string) bool { return key == brokenHeader }}
|
||||
}, awserrors.ErrorInvalidLaunchTemplateIdNotFound},
|
||||
}, awserrors.ErrorServerInternal},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -271,3 +330,124 @@ func TestCurrentBehaviour_AccountTermination_DescribeLaunchTemplatesHidesUnreada
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Account cleanup never deletes a live template: every key of a readable
|
||||
// template stays and its ID is reported kept. Headerless bodies and names are
|
||||
// removed once settled; a record younger than the settle window, and an
|
||||
// unreadable header with its bodies and name, are kept.
|
||||
func TestAccountCleanup_KeepsTemplatesAndRemovesSettledResidue(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
live := createWithUserData(t, svc, "web")
|
||||
liveKeys := accountKeys(t, svc.kv, testAccountID)
|
||||
other := "999988887777"
|
||||
_, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("theirs"), LaunchTemplateData: &ec2.RequestLaunchTemplateData{ImageId: aws.String("ami-123")},
|
||||
}, other)
|
||||
require.NoError(t, err)
|
||||
orphanBody := versionKey(testAccountID, "lt-0orphan000000000", 1)
|
||||
orphanName := nameKey(testAccountID, "gone")
|
||||
brokenHeader := kvutil.AccountKey(testAccountID, "lt-0broken000000000")
|
||||
brokenBody := versionKey(testAccountID, "lt-0broken000000000", 1)
|
||||
brokenName := nameKey(testAccountID, "broken")
|
||||
for key, value := range map[string]string{
|
||||
orphanBody: `{"version_number":1}`, orphanName: "lt-0orphan000000000",
|
||||
brokenHeader: "{not json", brokenBody: `{"version_number":1}`, brokenName: "lt-0broken000000000",
|
||||
} {
|
||||
_, err := svc.kv.Put(t.Context(), key, []byte(value))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
keep := sortedKeys(append(liveKeys, brokenHeader, brokenBody, brokenName)...)
|
||||
|
||||
out, err := svc.CleanupAccountResidue(t.Context(), testAccountID, time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{live}, out.KeptTemplateIDs)
|
||||
assert.Equal(t, 1, out.OrphanBodiesUnsettled)
|
||||
assert.Equal(t, 1, out.OrphanNamesUnsettled)
|
||||
assert.Equal(t, sortedKeys(append(keep, orphanBody, orphanName)...), accountKeys(t, svc.kv, testAccountID))
|
||||
|
||||
out, err = svc.CleanupAccountResidue(t.Context(), testAccountID, time.Now().Add(time.Second))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{live}, out.KeptTemplateIDs)
|
||||
assert.Equal(t, 1, out.OrphanBodiesRemoved)
|
||||
assert.Equal(t, 1, out.OrphanNamesRemoved)
|
||||
assert.Equal(t, keep, accountKeys(t, svc.kv, testAccountID))
|
||||
assert.Equal(t, []string{live}, describedTemplateIDs(t, svc, testAccountID))
|
||||
assert.Len(t, describedTemplateIDs(t, svc, other), 1)
|
||||
}
|
||||
|
||||
// A public delete interrupted at its header leaves the template live, so the
|
||||
// account cleanup keeps it rather than finishing it; the public retry does.
|
||||
func TestAccountCleanup_InterruptedTemplateDeleteIsLeftToThePublicRetry(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
kv := svc.kv
|
||||
svc.kv = &faultyKV{KeyValue: kv, failDelete: func(key string) bool { return key == header }}
|
||||
_, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
|
||||
require.Error(t, err)
|
||||
svc.kv = kv
|
||||
require.Equal(t, []string{header}, accountKeys(t, kv, testAccountID), "only the header, the delete's anchor, remains")
|
||||
|
||||
out, err := svc.CleanupAccountResidue(t.Context(), testAccountID, time.Now().Add(time.Second))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{id}, out.KeptTemplateIDs)
|
||||
assert.Equal(t, []string{header}, accountKeys(t, kv, testAccountID))
|
||||
|
||||
_, err = svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, accountKeys(t, kv, testAccountID))
|
||||
}
|
||||
|
||||
// A listing failure is returned and nothing is deleted.
|
||||
func TestAccountCleanup_TemplateListingFailureDeletesNothing(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
kv := svc.kv
|
||||
svc.kv = &faultyKV{KeyValue: kv, failKeys: func() bool { return true }}
|
||||
|
||||
_, err := svc.CleanupAccountResidue(t.Context(), testAccountID, time.Now())
|
||||
require.Error(t, err)
|
||||
svc.kv = kv
|
||||
assert.Equal(t, []string{id}, describedTemplateIDs(t, svc, testAccountID))
|
||||
}
|
||||
|
||||
func accountKeys(t *testing.T, kv jetstream.KeyValue, account string) []string {
|
||||
t.Helper()
|
||||
var mine []string
|
||||
for _, key := range storedKeys(t, kv) {
|
||||
if strings.HasPrefix(key, account+".") {
|
||||
mine = append(mine, key)
|
||||
}
|
||||
}
|
||||
return mine
|
||||
}
|
||||
|
||||
// A settled orphan the store cannot read or delete is counted failed, never
|
||||
// removed, and is still there for the next run.
|
||||
func TestAccountCleanup_OrphanReadOrDeleteFailureIsCountedFailed(t *testing.T) {
|
||||
for name, fault := range map[string]func(kv *faultyKV, key string){
|
||||
"read": func(kv *faultyKV, key string) { kv.failGet = func(k string) bool { return k == key } },
|
||||
"delete": func(kv *faultyKV, key string) { kv.failDelete = func(k string) bool { return k == key } },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
body := versionKey(testAccountID, "lt-0orphan000000000", 1)
|
||||
nameIdx := nameKey(testAccountID, "gone")
|
||||
for key, value := range map[string]string{body: `{"version_number":1}`, nameIdx: "lt-0orphan000000000"} {
|
||||
_, err := svc.kv.Put(t.Context(), key, []byte(value))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
inner := svc.kv
|
||||
faulty := &faultyKV{KeyValue: inner}
|
||||
fault(faulty, body)
|
||||
svc.kv = faulty
|
||||
|
||||
out, err := svc.CleanupAccountResidue(t.Context(), testAccountID, time.Now().Add(time.Second))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, out.OrphanBodiesFailed)
|
||||
assert.Zero(t, out.OrphanBodiesRemoved)
|
||||
assert.Equal(t, 1, out.OrphanNamesRemoved, "a fault on one record does not stop the rest")
|
||||
assert.Equal(t, []string{body}, accountKeys(t, inner, testAccountID))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ type LaunchTemplateServiceImpl struct {
|
||||
config *config.Config
|
||||
natsConn *nats.Conn
|
||||
kv jetstream.KeyValue
|
||||
|
||||
// Optional: nil leaves the central tag index untouched by delete.
|
||||
centralTags CentralTagStore
|
||||
}
|
||||
|
||||
// NewLaunchTemplateServiceImplWithNATS creates a launch template service with NATS JetStream.
|
||||
@@ -103,7 +106,12 @@ func versionPrefix(accountID, ltID string) string {
|
||||
func (s *LaunchTemplateServiceImpl) getHeaderByID(ctx context.Context, accountID, ltID string) (*LaunchTemplateHeader, jetstream.KeyValueEntry, error) {
|
||||
entry, err := s.kv.Get(ctx, kvutil.AccountKey(accountID, ltID))
|
||||
if err != nil {
|
||||
return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateIdNotFound)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateIdNotFound)
|
||||
}
|
||||
// A header the store cannot return now is not proven absent.
|
||||
slog.ErrorContext(ctx, "Failed to read launch template header", "launchTemplateId", ltID, "err", err)
|
||||
return nil, nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
var h LaunchTemplateHeader
|
||||
if err := json.Unmarshal(entry.Value(), &h); err != nil {
|
||||
@@ -117,12 +125,19 @@ func (s *LaunchTemplateServiceImpl) getHeaderByID(ctx context.Context, accountID
|
||||
func (s *LaunchTemplateServiceImpl) getHeaderByName(ctx context.Context, accountID, name string) (*LaunchTemplateHeader, jetstream.KeyValueEntry, error) {
|
||||
entry, err := s.kv.Get(ctx, nameKey(accountID, name))
|
||||
if err != nil {
|
||||
return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateNameNotFoundException)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateNameNotFoundException)
|
||||
}
|
||||
slog.ErrorContext(ctx, "Failed to read launch template name index", "err", err)
|
||||
return nil, nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
ltID := string(entry.Value())
|
||||
h, hentry, err := s.getHeaderByID(ctx, accountID, ltID)
|
||||
if err != nil {
|
||||
return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateNameNotFoundException)
|
||||
if err.Error() == awserrors.ErrorInvalidLaunchTemplateIdNotFound {
|
||||
return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateNameNotFoundException)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
return h, hentry, nil
|
||||
}
|
||||
@@ -291,6 +306,7 @@ func (s *LaunchTemplateServiceImpl) CreateLaunchTemplate(ctx context.Context, in
|
||||
Data: data,
|
||||
}
|
||||
if err := s.putVersion(ctx, accountID, ltID, &rec); err != nil {
|
||||
s.abandonCreate(ctx, accountID, name, ltID, false)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -304,6 +320,7 @@ func (s *LaunchTemplateServiceImpl) CreateLaunchTemplate(ctx context.Context, in
|
||||
Tags: awstags.Extract(input.TagSpecifications, launchTemplateTagResourceType),
|
||||
}
|
||||
if err := s.putHeader(ctx, accountID, &header); err != nil {
|
||||
s.abandonCreate(ctx, accountID, name, ltID, true)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -314,6 +331,39 @@ func (s *LaunchTemplateServiceImpl) CreateLaunchTemplate(ctx context.Context, in
|
||||
}, nil
|
||||
}
|
||||
|
||||
// abandonCreate removes what a failed create wrote, newest first. A failure
|
||||
// is logged; what remains has no header, and the account cleanup removes it
|
||||
// once it is older than an in-flight create could be.
|
||||
func (s *LaunchTemplateServiceImpl) abandonCreate(ctx context.Context, accountID, name, ltID string, wroteVersion bool) {
|
||||
if wroteVersion {
|
||||
if err := s.kv.Delete(ctx, versionKey(accountID, ltID, 1)); err != nil {
|
||||
slog.ErrorContext(ctx, "CreateLaunchTemplate: failed to remove version 1 after a failed create", "launchTemplateId", ltID, "err", err)
|
||||
}
|
||||
}
|
||||
if err := s.releaseName(ctx, accountID, name, ltID); err != nil {
|
||||
slog.ErrorContext(ctx, "CreateLaunchTemplate: failed to release the name after a failed create", "launchTemplateId", ltID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// releaseName removes the name index only while it still names ltID, at the
|
||||
// revision read, so a name another template now holds is never released.
|
||||
func (s *LaunchTemplateServiceImpl) releaseName(ctx context.Context, accountID, name, ltID string) error {
|
||||
key := nameKey(accountID, name)
|
||||
entry, err := s.kv.Get(ctx, key)
|
||||
switch {
|
||||
case errors.Is(err, jetstream.ErrKeyNotFound):
|
||||
return nil
|
||||
case err != nil:
|
||||
return fmt.Errorf("read name index: %w", err)
|
||||
case string(entry.Value()) != ltID:
|
||||
return nil
|
||||
}
|
||||
if err := s.kv.Delete(ctx, key, jetstream.LastRevision(entry.Revision())); err != nil {
|
||||
return fmt.Errorf("delete name index: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// errNameInUse is AWS's error for a launch template name already taken.
|
||||
func errNameInUse() error {
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidLaunchTemplateNameAlreadyExistsException, "Launch template name already in use.")
|
||||
@@ -377,7 +427,7 @@ func (s *LaunchTemplateServiceImpl) CreateLaunchTemplateVersion(ctx context.Cont
|
||||
if input.LaunchTemplateData == nil {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
header, _, err := s.resolveHeader(ctx, accountID, input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
header, entry, err := s.resolveHeader(ctx, accountID, input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -429,6 +479,13 @@ func (s *LaunchTemplateServiceImpl) CreateLaunchTemplateVersion(ctx context.Cont
|
||||
slog.ErrorContext(ctx, "CreateLaunchTemplateVersion: version-key create failed", "next", next, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if err := s.commitVersion(ctx, accountID, header.LaunchTemplateId, entry); err != nil {
|
||||
s.abandonVersion(ctx, accountID, header.LaunchTemplateId, next)
|
||||
if errors.Is(err, errTemplateGone) {
|
||||
return nil, templateNotFound(input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
}
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
slog.InfoContext(ctx, "CreateLaunchTemplateVersion completed", "launchTemplateId", header.LaunchTemplateId, "version", next, "accountID", accountID)
|
||||
return &ec2.CreateLaunchTemplateVersionOutput{
|
||||
LaunchTemplateVersion: versionToEC2(header, &rec),
|
||||
@@ -439,6 +496,53 @@ func (s *LaunchTemplateServiceImpl) CreateLaunchTemplateVersion(ctx context.Cont
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
// errTemplateGone: the header was deleted while a version was being added.
|
||||
var errTemplateGone = errors.New("launch template deleted")
|
||||
|
||||
// commitVersion rewrites the header's current value under a revision guard
|
||||
// after a new body is written. A delete that removed the header at any earlier
|
||||
// revision makes it fail, so the body is never left without its template.
|
||||
func (s *LaunchTemplateServiceImpl) commitVersion(ctx context.Context, accountID, ltID string, entry jetstream.KeyValueEntry) error {
|
||||
key := kvutil.AccountKey(accountID, ltID)
|
||||
for range maxVersionRetries {
|
||||
_, err := s.kv.Update(ctx, key, entry.Value(), entry.Revision())
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, jetstream.ErrKeyRevisionMismatch) && !errors.Is(err, jetstream.ErrKeyExists) {
|
||||
slog.ErrorContext(ctx, "CreateLaunchTemplateVersion: header commit failed", "launchTemplateId", ltID, "err", err)
|
||||
return err
|
||||
}
|
||||
next, gerr := s.kv.Get(ctx, key)
|
||||
if errors.Is(gerr, jetstream.ErrKeyNotFound) {
|
||||
return errTemplateGone
|
||||
}
|
||||
if gerr != nil {
|
||||
return gerr
|
||||
}
|
||||
entry = next
|
||||
}
|
||||
return errors.New("header commit retries exhausted")
|
||||
}
|
||||
|
||||
// abandonVersion removes a body whose header commit failed. A failure is
|
||||
// logged and leaves a headerless body, which no ID, name or launch resolves.
|
||||
func (s *LaunchTemplateServiceImpl) abandonVersion(ctx context.Context, accountID, ltID string, n int64) {
|
||||
if err := s.kv.Delete(ctx, versionKey(accountID, ltID, n)); err != nil {
|
||||
slog.ErrorContext(ctx, "CreateLaunchTemplateVersion: failed to remove an uncommitted version", "launchTemplateId", ltID, "version", n, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// templateNotFound is the NotFound error for the template the request named.
|
||||
func templateNotFound(ltID, name *string) error {
|
||||
if id := aws.StringValue(ltID); id != "" {
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidLaunchTemplateIdNotFound,
|
||||
"The specified launch template, with template ID %s, does not exist.", id)
|
||||
}
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidLaunchTemplateNameNotFoundException,
|
||||
"The specified launch template, with template name %s, does not exist.", aws.StringValue(name))
|
||||
}
|
||||
|
||||
// --- ModifyLaunchTemplate ---
|
||||
|
||||
// ModifyLaunchTemplate implements the EC2 action. ClientToken is accepted but not stored: this only
|
||||
@@ -481,8 +585,11 @@ func (s *LaunchTemplateServiceImpl) ModifyLaunchTemplate(ctx context.Context, in
|
||||
|
||||
// --- DeleteLaunchTemplate ---
|
||||
|
||||
// DeleteLaunchTemplate deletes a template and every version. A store failure
|
||||
// returns an error with the header still in place, so the template stays
|
||||
// visible and a retried delete finishes it.
|
||||
func (s *LaunchTemplateServiceImpl) DeleteLaunchTemplate(ctx context.Context, input *ec2.DeleteLaunchTemplateInput, accountID string) (*ec2.DeleteLaunchTemplateOutput, error) {
|
||||
header, _, err := s.resolveHeader(ctx, accountID, input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
header, entry, err := s.resolveHeader(ctx, accountID, input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -493,28 +600,92 @@ func (s *LaunchTemplateServiceImpl) DeleteLaunchTemplate(ctx context.Context, in
|
||||
}
|
||||
out := headerToEC2(header, latest)
|
||||
|
||||
// Delete the header first: the template immediately vanishes from every
|
||||
// describe. Version bodies and the name index are best-effort cleanup.
|
||||
if err := s.kv.Delete(ctx, kvutil.AccountKey(accountID, header.LaunchTemplateId)); err != nil {
|
||||
if err := s.deleteTemplate(ctx, accountID, header, entry); err != nil {
|
||||
slog.ErrorContext(ctx, "DeleteLaunchTemplate failed", "launchTemplateId", header.LaunchTemplateId, "accountID", accountID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if err := s.kv.Delete(ctx, nameKey(accountID, header.LaunchTemplateName)); err != nil {
|
||||
slog.WarnContext(ctx, "DeleteLaunchTemplate: name index cleanup failed", "name", header.LaunchTemplateName, "err", err)
|
||||
}
|
||||
nums, err := s.listVersionNumbers(ctx, accountID, header.LaunchTemplateId)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "DeleteLaunchTemplate: version enumeration failed, bodies left in place", "launchTemplateId", header.LaunchTemplateId, "err", err)
|
||||
}
|
||||
for _, n := range nums {
|
||||
if err := s.kv.Delete(ctx, versionKey(accountID, header.LaunchTemplateId, n)); err != nil {
|
||||
slog.WarnContext(ctx, "DeleteLaunchTemplate: version cleanup failed", "launchTemplateId", header.LaunchTemplateId, "version", n, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "DeleteLaunchTemplate completed", "launchTemplateId", header.LaunchTemplateId, "accountID", accountID)
|
||||
return &ec2.DeleteLaunchTemplateOutput{LaunchTemplate: out}, nil
|
||||
}
|
||||
|
||||
// maxHeaderDeleteAttempts bounds restarts when the header changes mid-delete.
|
||||
const maxHeaderDeleteAttempts = 3
|
||||
|
||||
// deleteTemplate removes every version body, then the name index, then the
|
||||
// header at the revision read, and last any body a concurrent version create
|
||||
// added meanwhile. The header goes last so every earlier failure is retryable
|
||||
// through the public delete; a header changed in between restarts the sweep.
|
||||
func (s *LaunchTemplateServiceImpl) deleteTemplate(ctx context.Context, accountID string, header *LaunchTemplateHeader, entry jetstream.KeyValueEntry) error {
|
||||
ltID := header.LaunchTemplateId
|
||||
headerKey := kvutil.AccountKey(accountID, ltID)
|
||||
for attempt := 1; ; attempt++ {
|
||||
if err := s.deleteVersionBodies(ctx, accountID, ltID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.releaseName(ctx, accountID, header.LaunchTemplateName, ltID); err != nil {
|
||||
return err
|
||||
}
|
||||
err := s.kv.Delete(ctx, headerKey, jetstream.LastRevision(entry.Revision()))
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if (!errors.Is(err, jetstream.ErrKeyRevisionMismatch) && !errors.Is(err, jetstream.ErrKeyExists)) || attempt >= maxHeaderDeleteAttempts {
|
||||
return fmt.Errorf("delete header: %w", err)
|
||||
}
|
||||
next, gerr := s.kv.Get(ctx, headerKey)
|
||||
if errors.Is(gerr, jetstream.ErrKeyNotFound) {
|
||||
break
|
||||
}
|
||||
if gerr != nil {
|
||||
return fmt.Errorf("re-read header: %w", gerr)
|
||||
}
|
||||
entry = next
|
||||
}
|
||||
s.clearRecordTags(ctx, accountID, ltID)
|
||||
// A version create in flight fails its header commit and removes its own
|
||||
// body; this sweep catches one that committed nothing before stopping.
|
||||
return s.deleteVersionBodies(ctx, accountID, ltID)
|
||||
}
|
||||
|
||||
// deleteVersionBodies deletes every version body of ltID, stopping at the
|
||||
// first failure so the caller can keep the header for a retry.
|
||||
func (s *LaunchTemplateServiceImpl) deleteVersionBodies(ctx context.Context, accountID, ltID string) error {
|
||||
nums, err := s.listVersionNumbers(ctx, accountID, ltID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list versions: %w", err)
|
||||
}
|
||||
for _, n := range nums {
|
||||
if err := s.kv.Delete(ctx, versionKey(accountID, ltID, n)); err != nil {
|
||||
return fmt.Errorf("delete version %d: %w", n, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CentralTagStore clears a deleted template's entry from the central EC2 tag
|
||||
// index. Implemented by domains/ec2/tags.TagsServiceImpl.
|
||||
type CentralTagStore interface {
|
||||
DeleteAllTags(ctx context.Context, accountID, resourceID string) error
|
||||
}
|
||||
|
||||
// SetCentralTagStore injects the central tag index that delete clears.
|
||||
func (s *LaunchTemplateServiceImpl) SetCentralTagStore(st CentralTagStore) {
|
||||
s.centralTags = st
|
||||
}
|
||||
|
||||
// clearRecordTags drops a deleted template's tag-index entry. It runs only
|
||||
// after the header delete committed; a failure is logged, as a retried delete
|
||||
// would find nothing, and the entry is left as tag residue.
|
||||
func (s *LaunchTemplateServiceImpl) clearRecordTags(ctx context.Context, accountID, ltID string) {
|
||||
if s.centralTags == nil {
|
||||
return
|
||||
}
|
||||
if err := s.centralTags.DeleteAllTags(ctx, accountID, ltID); err != nil {
|
||||
slog.ErrorContext(ctx, "central tag store clear failed", "launchTemplateId", ltID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeleteLaunchTemplateVersions ---
|
||||
|
||||
func (s *LaunchTemplateServiceImpl) DeleteLaunchTemplateVersions(ctx context.Context, input *ec2.DeleteLaunchTemplateVersionsInput, accountID string) (*ec2.DeleteLaunchTemplateVersionsOutput, error) {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package launchtemplate
|
||||
|
||||
//test:in-package — the interleavings are forced by wrapping the service's
|
||||
// unexported KV handle.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// raceDelete runs a whole DeleteLaunchTemplate on the unwrapped bucket, so the
|
||||
// hook that calls it is not re-entered.
|
||||
func raceDelete(t *testing.T, svc *LaunchTemplateServiceImpl, id string) {
|
||||
t.Helper()
|
||||
other := &LaunchTemplateServiceImpl{kv: svc.kv.(*faultyKV).KeyValue}
|
||||
_, err := other.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func newVersionInput(id string) *ec2.CreateLaunchTemplateVersionInput {
|
||||
return &ec2.CreateLaunchTemplateVersionInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{InstanceType: aws.String("t3.small")},
|
||||
}
|
||||
}
|
||||
|
||||
// A version create paused before it writes its body or before it commits the
|
||||
// header, while a whole delete runs, fails NotFound and leaves nothing behind.
|
||||
func TestAccountTermination_VersionCreateRacingADeleteLeavesNoBody(t *testing.T) {
|
||||
for _, pause := range []string{"before-body", "before-commit"} {
|
||||
t.Run(pause, func(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
raced := false
|
||||
race := func(key string, at string) {
|
||||
if raced || pause != at {
|
||||
return
|
||||
}
|
||||
if (at == "before-body" && key == versionKey(testAccountID, id, 3)) || (at == "before-commit" && key == header) {
|
||||
raced = true
|
||||
raceDelete(t, svc, id)
|
||||
}
|
||||
}
|
||||
svc.kv = &faultyKV{
|
||||
KeyValue: svc.kv,
|
||||
beforeCreate: func(key string) { race(key, "before-body") },
|
||||
beforeUpdate: func(key string) { race(key, "before-commit") },
|
||||
}
|
||||
|
||||
_, err := svc.CreateLaunchTemplateVersion(context.Background(), newVersionInput(id), testAccountID)
|
||||
require.True(t, raced)
|
||||
require.Error(t, err)
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdNotFound, code)
|
||||
assert.Empty(t, accountKeys(t, svc.kv, testAccountID), "no headerless body is left")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A version that commits before the delete removes the header moves the
|
||||
// header revision, so the delete restarts and removes the new body too.
|
||||
func TestAccountTermination_DeleteRacingACommittedVersionRemovesIt(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
inner := svc.kv
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
raced := false
|
||||
svc.kv = &faultyKV{KeyValue: inner, beforeDelete: func(key string) {
|
||||
if key != header || raced {
|
||||
return
|
||||
}
|
||||
raced = true
|
||||
other := &LaunchTemplateServiceImpl{kv: inner}
|
||||
_, err := other.CreateLaunchTemplateVersion(context.Background(), newVersionInput(id), testAccountID)
|
||||
require.NoError(t, err)
|
||||
}}
|
||||
|
||||
_, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, raced)
|
||||
assert.Empty(t, accountKeys(t, inner, testAccountID))
|
||||
}
|
||||
|
||||
// A header commit that fails removes the body it guarded and returns an
|
||||
// error; the template keeps exactly the versions it had.
|
||||
func TestAccountTermination_FailedVersionCommitRemovesTheBody(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
before := accountKeys(t, svc.kv, testAccountID)
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
svc.kv = &faultyKV{KeyValue: svc.kv, failUpdate: func(key string) bool { return key == header }}
|
||||
|
||||
_, err := svc.CreateLaunchTemplateVersion(context.Background(), newVersionInput(id), testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
assert.Equal(t, before, accountKeys(t, svc.kv, testAccountID))
|
||||
}
|
||||
|
||||
// A version create racing a default-version change commits at the new header
|
||||
// revision and keeps the change.
|
||||
func TestAccountTermination_VersionCommitKeepsAConcurrentHeaderChange(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
inner := svc.kv
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
raced := false
|
||||
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
|
||||
if key != header || raced {
|
||||
return
|
||||
}
|
||||
raced = true
|
||||
other := &LaunchTemplateServiceImpl{kv: inner}
|
||||
_, err := other.ModifyLaunchTemplate(context.Background(), &ec2.ModifyLaunchTemplateInput{
|
||||
LaunchTemplateId: aws.String(id), DefaultVersion: aws.String("2"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
}}
|
||||
|
||||
out, err := svc.CreateLaunchTemplateVersion(context.Background(), newVersionInput(id), testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, raced)
|
||||
assert.Equal(t, int64(3), aws.Int64Value(out.LaunchTemplateVersion.VersionNumber))
|
||||
h, _, err := svc.getHeaderByID(context.Background(), testAccountID, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), h.DefaultVersionNumber)
|
||||
}
|
||||
|
||||
// Template data carrying a key name and a placement group reaches the
|
||||
// RunInstances input for $Default, $Latest and an explicit version.
|
||||
func TestCurrentBehaviour_AccountTermination_TemplateKeyAndGroupReachRunInstances(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
out, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("web"),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
|
||||
ImageId: aws.String("ami-123"), KeyName: aws.String("v1-key"),
|
||||
Placement: &ec2.LaunchTemplatePlacementRequest{GroupName: aws.String("v1-group")},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
id := aws.StringValue(out.LaunchTemplate.LaunchTemplateId)
|
||||
_, err = svc.CreateLaunchTemplateVersion(context.Background(), &ec2.CreateLaunchTemplateVersionInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
|
||||
ImageId: aws.String("ami-123"), KeyName: aws.String("v2-key"),
|
||||
Placement: &ec2.LaunchTemplatePlacementRequest{GroupName: aws.String("v2-group")},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
for version, want := range map[string]string{"$Default": "v1", "$Latest": "v2", "2": "v2", "1": "v1"} {
|
||||
in := &ec2.RunInstancesInput{LaunchTemplate: &ec2.LaunchTemplateSpecification{LaunchTemplateId: aws.String(id), Version: aws.String(version)}}
|
||||
require.NoError(t, ExpandRunInstances(context.Background(), svc, in, testAccountID), version)
|
||||
assert.Equal(t, want+"-key", aws.StringValue(in.KeyName), version)
|
||||
require.NotNil(t, in.Placement, version)
|
||||
assert.Equal(t, want+"-group", aws.StringValue(in.Placement.GroupName), version)
|
||||
}
|
||||
}
|
||||
|
||||
// The reference reader returns what every version of every live template
|
||||
// names, and nothing from headerless bodies or other accounts.
|
||||
func TestAccountTemplateReferences_ReadsEveryVersionOfLiveTemplates(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
out, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("web"),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{KeyName: aws.String("default-key")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
id := aws.StringValue(out.LaunchTemplate.LaunchTemplateId)
|
||||
_, err = svc.CreateLaunchTemplateVersion(context.Background(), &ec2.CreateLaunchTemplateVersionInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
|
||||
KeyName: aws.String("later-key"),
|
||||
Placement: &ec2.LaunchTemplatePlacementRequest{GroupName: aws.String("later-group"), GroupId: aws.String("pg-0123456789abcdef0")},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("theirs"), LaunchTemplateData: &ec2.RequestLaunchTemplateData{KeyName: aws.String("their-key")},
|
||||
}, "999988887777")
|
||||
require.NoError(t, err)
|
||||
_, err = svc.kv.Put(t.Context(), versionKey(testAccountID, "lt-0orphan000000000", 1), []byte(`{"version_number":1,"data":{"KeyName":"orphan-key"}}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
refs, err := svc.AccountTemplateReferences(t.Context(), testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"default-key", "later-key"}, refs.KeyNames)
|
||||
assert.Equal(t, []string{"later-group"}, refs.PlacementGroupNames)
|
||||
assert.Equal(t, []string{"pg-0123456789abcdef0"}, refs.PlacementGroupIDs)
|
||||
}
|
||||
|
||||
// A header or version the reader cannot read or decode, or a listing
|
||||
// failure, is an error: the unread record may name the resource.
|
||||
func TestAccountTemplateReferences_FailsClosed(t *testing.T) {
|
||||
tests := map[string]func(t *testing.T, svc *LaunchTemplateServiceImpl, id string){
|
||||
"listing fails": func(t *testing.T, svc *LaunchTemplateServiceImpl, _ string) {
|
||||
svc.kv = &faultyKV{KeyValue: svc.kv, failKeys: func() bool { return true }}
|
||||
},
|
||||
"version unreadable": func(t *testing.T, svc *LaunchTemplateServiceImpl, id string) {
|
||||
svc.kv = &faultyKV{KeyValue: svc.kv, failGet: func(key string) bool { return key == versionKey(testAccountID, id, 2) }}
|
||||
},
|
||||
"version undecodable": func(t *testing.T, svc *LaunchTemplateServiceImpl, id string) {
|
||||
_, err := svc.kv.Put(t.Context(), versionKey(testAccountID, id, 2), []byte("{not json"))
|
||||
require.NoError(t, err)
|
||||
},
|
||||
"version without data": func(t *testing.T, svc *LaunchTemplateServiceImpl, id string) {
|
||||
_, err := svc.kv.Put(t.Context(), versionKey(testAccountID, id, 2), []byte(`{"version_number":2}`))
|
||||
require.NoError(t, err)
|
||||
},
|
||||
"header unreadable": func(t *testing.T, svc *LaunchTemplateServiceImpl, id string) {
|
||||
svc.kv = &faultyKV{KeyValue: svc.kv, failGet: func(key string) bool { return key == kvutil.AccountKey(testAccountID, id) }}
|
||||
},
|
||||
"header undecodable": func(t *testing.T, svc *LaunchTemplateServiceImpl, _ string) {
|
||||
_, err := svc.kv.Put(t.Context(), kvutil.AccountKey(testAccountID, "lt-0broken000000000"), []byte("{not json"))
|
||||
require.NoError(t, err)
|
||||
},
|
||||
}
|
||||
for name, seed := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
seed(t, svc, id)
|
||||
refs, err := svc.AccountTemplateReferences(t.Context(), testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, refs)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// TemplateReferences are the key-pair names, placement-group names and
|
||||
// placement-group IDs that the account's live launch-template versions carry.
|
||||
// They stay inside EC2; callers must not report them.
|
||||
type TemplateReferences struct {
|
||||
KeyNames []string
|
||||
PlacementGroupNames []string
|
||||
PlacementGroupIDs []string
|
||||
}
|
||||
|
||||
// AccountTemplateReferences reads every version body of every live template
|
||||
// in the account, since RunInstances may resolve any of them by $Default,
|
||||
// $Latest or number. It is complete or an error: an unlistable bucket, or a
|
||||
// header or body it cannot read or decode, is an error, because the unread
|
||||
// record may name the resource. Headerless bodies resolve from no launch.
|
||||
func (s *LaunchTemplateServiceImpl) AccountTemplateReferences(ctx context.Context, accountID string) (*TemplateReferences, error) {
|
||||
keys, err := kvutil.Keys(ctx, s.kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, fmt.Errorf("list launch-template keys: %w", err)
|
||||
}
|
||||
prefix := accountID + "."
|
||||
var live []string
|
||||
for _, key := range keys {
|
||||
if !isHeaderKey(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.New("launch-template header unreadable")
|
||||
}
|
||||
var header LaunchTemplateHeader
|
||||
if json.Unmarshal(entry.Value(), &header) != nil || prefix+header.LaunchTemplateId != key {
|
||||
return nil, errors.New("launch-template header undecodable")
|
||||
}
|
||||
live = append(live, header.LaunchTemplateId)
|
||||
}
|
||||
|
||||
out := &TemplateReferences{}
|
||||
for _, ltID := range live {
|
||||
bodyPrefix := versionPrefix(accountID, ltID)
|
||||
for _, key := range keys {
|
||||
if !strings.HasPrefix(key, bodyPrefix) {
|
||||
continue
|
||||
}
|
||||
if err := s.addVersionReferences(ctx, key, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addVersionReferences adds what one version body would launch with. The
|
||||
// body is never logged: it holds template data and user data.
|
||||
func (s *LaunchTemplateServiceImpl) addVersionReferences(ctx context.Context, key string, out *TemplateReferences) error {
|
||||
entry, err := s.kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("launch-template version unreadable")
|
||||
}
|
||||
var rec LaunchTemplateVersionRec
|
||||
if json.Unmarshal(entry.Value(), &rec) != nil || rec.Data == nil {
|
||||
return errors.New("launch-template version undecodable")
|
||||
}
|
||||
if name := aws.StringValue(rec.Data.KeyName); name != "" {
|
||||
out.KeyNames = append(out.KeyNames, name)
|
||||
}
|
||||
if p := rec.Data.Placement; p != nil {
|
||||
if name := aws.StringValue(p.GroupName); name != "" {
|
||||
out.PlacementGroupNames = append(out.PlacementGroupNames, name)
|
||||
}
|
||||
if id := aws.StringValue(p.GroupId); id != "" {
|
||||
out.PlacementGroupIDs = append(out.PlacementGroupIDs, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package placementgroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// BlockedGroup is a group the owner refused to delete because its member map
|
||||
// still names instances. Only the count is kept; member IDs never leave.
|
||||
type BlockedGroup struct {
|
||||
GroupID string
|
||||
MemberCount int
|
||||
}
|
||||
|
||||
// AccountCleanup is what one CleanupAccount run did, by group ID. It is the
|
||||
// owner's account of its attempts, not proof of absence, which only a later
|
||||
// ScanAccount gives.
|
||||
type AccountCleanup struct {
|
||||
// DeletedGroupIDs were removed by the owner delete or were already gone.
|
||||
DeletedGroupIDs []string
|
||||
// FailedGroupIDs hit a store failure; the record remains.
|
||||
FailedGroupIDs []string
|
||||
// Blocked groups still hold instances and are never forced.
|
||||
Blocked []BlockedGroup
|
||||
// Referenced maps a kept group ID to the reason the caller's predicate gave.
|
||||
Referenced map[string]string
|
||||
}
|
||||
|
||||
// CleanupAccount deletes every readable, empty placement group of the account
|
||||
// through the same revision-guarded delete DeletePlacementGroup runs. A group
|
||||
// holding instances is reported blocked, and one for whose ID or name
|
||||
// referenced returns a reason is kept; unreadable records are left for the owner scan to report. A
|
||||
// listing failure is returned and nothing is deleted.
|
||||
func (s *PlacementGroupServiceImpl) CleanupAccount(ctx context.Context, accountID string, referenced func(groupID, groupName string) string) (*AccountCleanup, error) {
|
||||
keys, err := kvutil.Keys(ctx, s.kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, fmt.Errorf("list placement-group keys: %w", err)
|
||||
}
|
||||
|
||||
prefix := accountID + "."
|
||||
out := &AccountCleanup{Referenced: map[string]string{}}
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.kv.Get(ctx, key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var record PlacementGroupRecord
|
||||
if err := json.Unmarshal(entry.Value(), &record); err != nil || record.GroupId == "" {
|
||||
continue
|
||||
}
|
||||
if referenced != nil {
|
||||
reason := referenced(record.GroupId, record.GroupName)
|
||||
if reason == "" {
|
||||
reason = referenced(record.GroupId, strings.TrimPrefix(key, prefix))
|
||||
}
|
||||
if reason != "" {
|
||||
out.Referenced[record.GroupId] = reason
|
||||
continue
|
||||
}
|
||||
}
|
||||
members, err := s.deleteGroup(ctx, accountID, key)
|
||||
switch {
|
||||
case err == nil, errors.Is(err, errPGAbsent):
|
||||
out.DeletedGroupIDs = append(out.DeletedGroupIDs, record.GroupId)
|
||||
case errors.Is(err, errPGInUse):
|
||||
out.Blocked = append(out.Blocked, BlockedGroup{GroupID: record.GroupId, MemberCount: members})
|
||||
default:
|
||||
slog.WarnContext(ctx, "Placement-group account cleanup: delete failed", "groupId", record.GroupId, "err", err)
|
||||
out.FailedGroupIDs = append(out.FailedGroupIDs, record.GroupId)
|
||||
}
|
||||
}
|
||||
slog.InfoContext(ctx, "Placement-group account cleanup completed", "accountID", accountID,
|
||||
"deleted", len(out.DeletedGroupIDs), "failed", len(out.FailedGroupIDs), "blocked", len(out.Blocked), "referenced", len(out.Referenced))
|
||||
return out, nil
|
||||
}
|
||||
+168
-18
@@ -21,16 +21,18 @@ import (
|
||||
)
|
||||
|
||||
// The TestCurrentBehaviour_AccountTermination_ tests pin how placement groups
|
||||
// are stored and what Describe and Delete do with them today. They describe
|
||||
// current behaviour, not the target design; a failure means it changed.
|
||||
// are stored and what Describe and Delete do with them today; a failure means
|
||||
// it changed. The TestAccountTermination_ tests pin the revision-guarded delete.
|
||||
|
||||
// hookedKV fails reads of failGet and runs afterGet once, after the first read
|
||||
// of key returns, standing in for a writer on another node.
|
||||
// hookedKV fails reads of failGet and runs afterGet once, after read number
|
||||
// skip+1 of key returns, standing in for a writer on another node.
|
||||
type hookedKV struct {
|
||||
jetstream.KeyValue
|
||||
|
||||
failGet string
|
||||
key string
|
||||
skip int
|
||||
reads int
|
||||
once sync.Once
|
||||
afterGet func()
|
||||
}
|
||||
@@ -41,7 +43,9 @@ func (k *hookedKV) Get(ctx context.Context, key string) (jetstream.KeyValueEntry
|
||||
}
|
||||
entry, err := k.KeyValue.Get(ctx, key)
|
||||
if key == k.key && k.afterGet != nil {
|
||||
k.once.Do(k.afterGet)
|
||||
if k.reads++; k.reads > k.skip {
|
||||
k.once.Do(k.afterGet)
|
||||
}
|
||||
}
|
||||
return entry, err
|
||||
}
|
||||
@@ -92,8 +96,9 @@ func TestCurrentBehaviour_AccountTermination_PlacementGroupStorageLayout(t *test
|
||||
assert.Empty(t, record.NodeInstances)
|
||||
}
|
||||
|
||||
// Describe skips a record it cannot read or decode, and still succeeds. An
|
||||
// undecodable record also cannot be removed through DeletePlacementGroup.
|
||||
// Describe skips a record it cannot read or decode, and still succeeds. Such
|
||||
// a record cannot be removed through DeletePlacementGroup either, and a read
|
||||
// failure is an internal error, never an unknown group.
|
||||
func TestCurrentBehaviour_AccountTermination_DescribePlacementGroupsHidesUnreadableRecords(t *testing.T) {
|
||||
brokenKey := kvutil.AccountKey(testAccountID, "broken")
|
||||
tests := []struct {
|
||||
@@ -108,7 +113,7 @@ func TestCurrentBehaviour_AccountTermination_DescribePlacementGroupsHidesUnreada
|
||||
{"record the store cannot read", func(t *testing.T, svc *PlacementGroupServiceImpl) {
|
||||
createTestGroup(t, svc, "broken", ec2.PlacementStrategySpread)
|
||||
svc.kv = &hookedKV{KeyValue: svc.kv, failGet: brokenKey}
|
||||
}, awserrors.ErrorInvalidPlacementGroupUnknown},
|
||||
}, awserrors.ErrorServerInternal},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -128,10 +133,10 @@ func TestCurrentBehaviour_AccountTermination_DescribePlacementGroupsHidesUnreada
|
||||
}
|
||||
}
|
||||
|
||||
// Pins current behaviour that differs from AWS: the delete reads the record,
|
||||
// checks membership, then deletes without a revision guard. A launch that
|
||||
// records an instance between the two is deleted with the group.
|
||||
func TestCurrentBehaviour_AccountTermination_DeletePlacementGroupDropsAConcurrentMember(t *testing.T) {
|
||||
// The delete commits only at the revision whose membership it checked. A
|
||||
// launch that records an instance between the check and the delete wins: the
|
||||
// delete re-reads, refuses with InUse, and the member stays.
|
||||
func TestAccountTermination_DeletePlacementGroupLosesToAConcurrentMember(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTestGroup(t, svc, "cluster-a", ec2.PlacementStrategyCluster)
|
||||
key := kvutil.AccountKey(testAccountID, "cluster-a")
|
||||
@@ -151,17 +156,162 @@ func TestCurrentBehaviour_AccountTermination_DeletePlacementGroupDropsAConcurren
|
||||
}}
|
||||
|
||||
_, err := svc.DeletePlacementGroup(context.Background(), &ec2.DeletePlacementGroupInput{GroupName: aws.String("cluster-a")}, testAccountID)
|
||||
require.NoError(t, err, "the delete succeeds although the group gained a member")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidPlacementGroupInUse, err.Error())
|
||||
require.NoError(t, reserveErr)
|
||||
require.NoError(t, finalizeErr, "the interleaved launch committed its member")
|
||||
|
||||
_, err = launcher.kv.Get(t.Context(), key)
|
||||
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound)
|
||||
assert.Empty(t, describedGroupNames(t, launcher, testAccountID))
|
||||
entry, err := launcher.kv.Get(t.Context(), key)
|
||||
require.NoError(t, err)
|
||||
var record PlacementGroupRecord
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &record))
|
||||
assert.Equal(t, map[string][]string{"node-1": {"i-0123456789abcdef0"}}, record.NodeInstances)
|
||||
|
||||
// The member's later shutdown finds no group and reports success.
|
||||
// Once the member leaves, the same delete succeeds.
|
||||
_, err = launcher.RemoveInstance(context.Background(), &RemoveInstanceInput{
|
||||
GroupName: "cluster-a", NodeName: "node-1", InstanceID: "i-0123456789abcdef0",
|
||||
}, testAccountID)
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
_, err = launcher.DeletePlacementGroup(context.Background(), &ec2.DeletePlacementGroupInput{GroupName: aws.String("cluster-a")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, describedGroupNames(t, launcher, testAccountID))
|
||||
}
|
||||
|
||||
// A reservation placeholder is a launch in flight, not a member, so the
|
||||
// delete may win; the launch's finalize then finds no group and fails, which
|
||||
// makes the launcher roll its instances back rather than lose them.
|
||||
func TestAccountTermination_DeletePlacementGroupBeforeFinalizeFailsTheLaunch(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTestGroup(t, svc, "cluster-a", ec2.PlacementStrategyCluster)
|
||||
reserved, err := svc.ReserveClusterNode(context.Background(), &ReserveClusterNodeInput{
|
||||
GroupName: "cluster-a", EligibleNodes: []string{"node-1"},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.DeletePlacementGroup(context.Background(), &ec2.DeletePlacementGroupInput{GroupName: aws.String("cluster-a")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.FinalizeClusterInstances(context.Background(), &FinalizeClusterInstancesInput{
|
||||
GroupName: "cluster-a", NodeInstances: map[string][]string{reserved.TargetNode: {"i-0123456789abcdef0"}},
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidPlacementGroupUnknown, err.Error())
|
||||
assert.Empty(t, describedGroupNames(t, svc, testAccountID))
|
||||
}
|
||||
|
||||
// addMember reserves a node in a cluster group and records one instance on it.
|
||||
func addMember(t *testing.T, svc *PlacementGroupServiceImpl, group, instanceID string) error {
|
||||
t.Helper()
|
||||
reserved, err := svc.ReserveClusterNode(context.Background(), &ReserveClusterNodeInput{
|
||||
GroupName: group, EligibleNodes: []string{"node-1"},
|
||||
}, testAccountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = svc.FinalizeClusterInstances(context.Background(), &FinalizeClusterInstancesInput{
|
||||
GroupName: group, NodeInstances: map[string][]string{reserved.TargetNode: {instanceID}},
|
||||
}, testAccountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Account cleanup deletes empty groups through the revision-guarded delete,
|
||||
// reports a group holding instances as blocked with its member count only,
|
||||
// and leaves records it cannot read for the owner scan to report.
|
||||
func TestAccountCleanup_DeletesEmptyGroupsAndBlocksOnMembers(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
empty := createTestGroup(t, svc, "empty", ec2.PlacementStrategySpread)
|
||||
held := createTestGroup(t, svc, "held", ec2.PlacementStrategyCluster)
|
||||
require.NoError(t, addMember(t, svc, "held", "i-0123456789abcdef0"))
|
||||
_, err := svc.kv.Put(t.Context(), kvutil.AccountKey(testAccountID, "broken"), []byte("{not json"))
|
||||
require.NoError(t, err)
|
||||
other := "999988887777"
|
||||
_, err = svc.CreatePlacementGroup(context.Background(), &ec2.CreatePlacementGroupInput{
|
||||
GroupName: aws.String("theirs"), Strategy: aws.String(ec2.PlacementStrategySpread),
|
||||
}, other)
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := svc.CleanupAccount(t.Context(), testAccountID, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{aws.StringValue(empty.GroupId)}, out.DeletedGroupIDs)
|
||||
assert.Equal(t, []BlockedGroup{{GroupID: aws.StringValue(held.GroupId), MemberCount: 1}}, out.Blocked)
|
||||
assert.Empty(t, out.FailedGroupIDs)
|
||||
|
||||
assert.Equal(t, []string{"held"}, describedGroupNames(t, svc, testAccountID))
|
||||
assert.Equal(t, []string{"theirs"}, describedGroupNames(t, svc, other))
|
||||
_, err = svc.kv.Get(t.Context(), kvutil.AccountKey(testAccountID, "broken"))
|
||||
require.NoError(t, err, "an unreadable record is never deleted")
|
||||
}
|
||||
|
||||
// A launch that records a member between the cleanup's membership check and
|
||||
// its delete wins: the cleanup reports the group blocked and the member stays.
|
||||
func TestAccountCleanup_ConcurrentMemberBlocksTheDelete(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
group := createTestGroup(t, svc, "cluster-a", ec2.PlacementStrategyCluster)
|
||||
key := kvutil.AccountKey(testAccountID, "cluster-a")
|
||||
launcher := &PlacementGroupServiceImpl{kv: svc.kv, region: svc.region}
|
||||
var memberErr error
|
||||
// Read 1 is the cleanup's listing read; read 2 is the delete's check.
|
||||
svc.kv = &hookedKV{KeyValue: svc.kv, key: key, skip: 1, afterGet: func() {
|
||||
memberErr = addMember(t, launcher, "cluster-a", "i-0123456789abcdef0")
|
||||
}}
|
||||
|
||||
out, err := svc.CleanupAccount(t.Context(), testAccountID, nil)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, memberErr, "the interleaved launch committed its member")
|
||||
assert.Equal(t, []BlockedGroup{{GroupID: aws.StringValue(group.GroupId), MemberCount: 1}}, out.Blocked)
|
||||
assert.Empty(t, out.DeletedGroupIDs)
|
||||
assert.Equal(t, map[string][]string{"node-1": {"i-0123456789abcdef0"}}, readRecord(t, launcher, "cluster-a").NodeInstances)
|
||||
}
|
||||
|
||||
// A group the caller's reference check names is kept with the given reason,
|
||||
// whether it is matched by its name or its group ID.
|
||||
func TestAccountCleanup_KeepsReferencedGroups(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
named := createTestGroup(t, svc, "named", ec2.PlacementStrategySpread)
|
||||
byID := createTestGroup(t, svc, "by-id", ec2.PlacementStrategySpread)
|
||||
free := createTestGroup(t, svc, "free", ec2.PlacementStrategySpread)
|
||||
referenced := func(id, name string) string {
|
||||
switch {
|
||||
case name == "named":
|
||||
return "spot-request-reference"
|
||||
case id == aws.StringValue(byID.GroupId):
|
||||
return "launch-template-reference"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
out, err := svc.CleanupAccount(t.Context(), testAccountID, referenced)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{
|
||||
aws.StringValue(named.GroupId): "spot-request-reference",
|
||||
aws.StringValue(byID.GroupId): "launch-template-reference",
|
||||
}, out.Referenced)
|
||||
assert.Equal(t, []string{aws.StringValue(free.GroupId)}, out.DeletedGroupIDs)
|
||||
assert.ElementsMatch(t, []string{"named", "by-id"}, describedGroupNames(t, svc, testAccountID))
|
||||
}
|
||||
|
||||
// A listing failure is returned and nothing is deleted.
|
||||
func TestAccountCleanup_ListingFailureDeletesNothing(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTestGroup(t, svc, "empty", ec2.PlacementStrategySpread)
|
||||
kv := svc.kv
|
||||
svc.kv = &listFailingKV{KeyValue: kv}
|
||||
|
||||
_, err := svc.CleanupAccount(t.Context(), testAccountID, nil)
|
||||
require.Error(t, err)
|
||||
svc.kv = kv
|
||||
assert.Equal(t, []string{"empty"}, describedGroupNames(t, svc, testAccountID))
|
||||
}
|
||||
|
||||
// listFailingKV fails every key listing.
|
||||
type listFailingKV struct {
|
||||
jetstream.KeyValue
|
||||
}
|
||||
|
||||
func (k *listFailingKV) ListKeys(context.Context, ...jetstream.WatchOpt) (jetstream.KeyLister, error) {
|
||||
return nil, errors.New("injected listing failure")
|
||||
}
|
||||
|
||||
func (k *listFailingKV) Keys(context.Context, ...jetstream.WatchOpt) ([]string, error) {
|
||||
return nil, errors.New("injected listing failure")
|
||||
}
|
||||
|
||||
@@ -52,6 +52,32 @@ type PlacementGroupServiceImpl struct {
|
||||
natsConn *nats.Conn
|
||||
kv jetstream.KeyValue
|
||||
region string
|
||||
|
||||
// Optional: nil leaves the central tag index untouched by delete.
|
||||
centralTags CentralTagStore
|
||||
}
|
||||
|
||||
// CentralTagStore clears a deleted group's entry from the central EC2 tag
|
||||
// index. Implemented by domains/ec2/tags.TagsServiceImpl.
|
||||
type CentralTagStore interface {
|
||||
DeleteAllTags(ctx context.Context, accountID, resourceID string) error
|
||||
}
|
||||
|
||||
// SetCentralTagStore injects the central tag index that delete clears.
|
||||
func (s *PlacementGroupServiceImpl) SetCentralTagStore(st CentralTagStore) {
|
||||
s.centralTags = st
|
||||
}
|
||||
|
||||
// clearRecordTags drops a deleted group's tag-index entry. It runs only after
|
||||
// the record delete committed; a failure is logged, as the group is gone and a
|
||||
// retried delete would find nothing, and the entry is left as tag residue.
|
||||
func (s *PlacementGroupServiceImpl) clearRecordTags(ctx context.Context, accountID, groupID string) {
|
||||
if s.centralTags == nil || groupID == "" {
|
||||
return
|
||||
}
|
||||
if err := s.centralTags.DeleteAllTags(ctx, accountID, groupID); err != nil {
|
||||
slog.ErrorContext(ctx, "central tag store clear failed", "groupId", groupID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// NewPlacementGroupServiceImplWithNATS creates a placement group service with NATS JetStream.
|
||||
@@ -142,37 +168,19 @@ func (s *PlacementGroupServiceImpl) CreatePlacementGroup(ctx context.Context, in
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeletePlacementGroup deletes a placement group.
|
||||
// DeletePlacementGroup deletes a placement group that holds no instances.
|
||||
func (s *PlacementGroupServiceImpl) DeletePlacementGroup(ctx context.Context, input *ec2.DeletePlacementGroupInput, accountID string) (*ec2.DeletePlacementGroupOutput, error) {
|
||||
if input.GroupName == nil || *input.GroupName == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
groupName := *input.GroupName
|
||||
key := kvutil.AccountKey(accountID, groupName)
|
||||
|
||||
entry, err := s.kv.Get(ctx, key)
|
||||
if err != nil {
|
||||
// Lower case, unlike DescribePlacementGroups' message for the same code.
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidPlacementGroupUnknown, "The placement group '%s' is unknown.", groupName)
|
||||
}
|
||||
|
||||
var record PlacementGroupRecord
|
||||
if err := json.Unmarshal(entry.Value(), &record); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
// Check for running instances
|
||||
instanceCount := 0
|
||||
for _, ids := range record.NodeInstances {
|
||||
instanceCount += len(ids)
|
||||
}
|
||||
if instanceCount > 0 {
|
||||
return nil, errors.New(awserrors.ErrorInvalidPlacementGroupInUse)
|
||||
}
|
||||
|
||||
if err := s.kv.Delete(ctx, key); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
if _, err := s.deleteGroup(ctx, accountID, kvutil.AccountKey(accountID, groupName)); err != nil {
|
||||
if errors.Is(err, errPGAbsent) {
|
||||
// Lower case, unlike DescribePlacementGroups' message for the same code.
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidPlacementGroupUnknown, "The placement group '%s' is unknown.", groupName)
|
||||
}
|
||||
return nil, mapCASError(ctx, "DeletePlacementGroup", groupName, accountID, err)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "DeletePlacementGroup completed", "groupName", groupName, "accountID", accountID)
|
||||
@@ -180,6 +188,26 @@ func (s *PlacementGroupServiceImpl) DeletePlacementGroup(ctx context.Context, in
|
||||
return &ec2.DeletePlacementGroupOutput{}, nil
|
||||
}
|
||||
|
||||
// deleteGroup removes the record at key only while it names no instance, at
|
||||
// the revision that check read, so a reservation committed in between sends
|
||||
// the delete back to re-check. Its tag-index entry is cleared afterwards.
|
||||
// The returned count is the members seen by the last check.
|
||||
func (s *PlacementGroupServiceImpl) deleteGroup(ctx context.Context, accountID, key string) (int, error) {
|
||||
members := 0
|
||||
record, err := kvutil.DeleteIf(ctx, s.kv, key, casConfig(), func(record *PlacementGroupRecord) error {
|
||||
members = memberCount(record.NodeInstances)
|
||||
if members > 0 {
|
||||
return errPGInUse
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return members, err
|
||||
}
|
||||
s.clearRecordTags(ctx, accountID, record.GroupId)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var describePlacementGroupsValidFilters = map[string]bool{
|
||||
"group-id": true,
|
||||
"strategy": true,
|
||||
@@ -324,11 +352,12 @@ func pgMatchesFilters(record *PlacementGroupRecord, filters map[string][]string)
|
||||
const maxCASRetries = 5
|
||||
|
||||
// errPGAbsent and errPGContended mark the two kvutil outcomes that map to
|
||||
// specific AWS errors rather than a generic internal failure. Neither
|
||||
// escapes this file.
|
||||
// specific AWS errors rather than a generic internal failure, and neither
|
||||
// escapes this file. errPGInUse is the AWS error itself.
|
||||
var (
|
||||
errPGAbsent = errors.New("placementgroup: record absent")
|
||||
errPGContended = errors.New("placementgroup: CAS retries exhausted")
|
||||
errPGInUse = errors.New(awserrors.ErrorInvalidPlacementGroupInUse)
|
||||
)
|
||||
|
||||
// mapCASError turns a kvutil failure into the AWS error the caller returns.
|
||||
|
||||
@@ -662,10 +662,10 @@ func snapshotMatchesFilters(cfg ebsmetadata.Snapshot, filters map[string][]strin
|
||||
return awsfilters.MatchesTags(filters, cfg.Tags)
|
||||
}
|
||||
|
||||
// snapshotInUseByVolumes checks if any volume was created from the given
|
||||
// snapshot. A clone records its source in its ebsmetadata document; missing one
|
||||
// here would let a delete strip the chunks a live clone still reads.
|
||||
func (s *SnapshotServiceImpl) snapshotInUseByVolumes(ctx context.Context, snapshotID string) (bool, error) {
|
||||
// snapshotInUseByVolumes reports whether any volume was created from the given
|
||||
// snapshot, and the ID of one owned by accountID if any. A clone records its
|
||||
// source in its ebsmetadata document; missing one would strip chunks it reads.
|
||||
func (s *SnapshotServiceImpl) snapshotInUseByVolumes(ctx context.Context, snapshotID, accountID string) (bool, string, error) {
|
||||
// Whole-cluster, and not scoping this to the snapshot's own account is the
|
||||
// point: launching from a system AMI writes a root volume owned by the
|
||||
// launching tenant whose SnapshotID is the system snapshot, so a clone
|
||||
@@ -675,10 +675,34 @@ func (s *SnapshotServiceImpl) snapshotInUseByVolumes(ctx context.Context, snapsh
|
||||
// the snapshot's chunks, and this answer gates deleting them.
|
||||
volumes, err := s.metadata.ListAllVolumesStrict(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("snapshotInUseByVolumes: failed to list volumes: %w", err)
|
||||
return false, "", fmt.Errorf("snapshotInUseByVolumes: failed to list volumes: %w", err)
|
||||
}
|
||||
inUse := false
|
||||
for _, volume := range volumes {
|
||||
if volume.SnapshotID == snapshotID {
|
||||
if volume.SnapshotID != snapshotID {
|
||||
continue
|
||||
}
|
||||
if volume.TenantID == accountID {
|
||||
return true, volume.VolumeID, nil
|
||||
}
|
||||
inUse = true
|
||||
}
|
||||
return inUse, "", nil
|
||||
}
|
||||
|
||||
// amiInUseBySnapshot checks if any registered AMI names the given snapshot as
|
||||
// its root device. AWS refuses DeleteSnapshot while a registered AMI
|
||||
// references the snapshot, the same way it refuses one backing a volume.
|
||||
func (s *SnapshotServiceImpl) amiInUseBySnapshot(ctx context.Context, snapshotID string) (bool, error) {
|
||||
// Whole-cluster and strict for the same reason as the volume check above:
|
||||
// a system AMI can be registered by an account other than the snapshot's
|
||||
// owner, and an AMI this cannot read is not evidence that none exists.
|
||||
amis, err := s.metadata.ListAMIsStrict(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("amiInUseBySnapshot: failed to list AMIs: %w", err)
|
||||
}
|
||||
for _, ami := range amis {
|
||||
if ami.SnapshotID == snapshotID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
@@ -715,13 +739,29 @@ func (s *SnapshotServiceImpl) DeleteSnapshot(ctx context.Context, input *ec2.Del
|
||||
}
|
||||
|
||||
// Check if any volumes were created from this snapshot
|
||||
inUse, err := s.snapshotInUseByVolumes(ctx, snapshotID)
|
||||
inUse, volumeID, err := s.snapshotInUseByVolumes(ctx, snapshotID, accountID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "DeleteSnapshot failed to check snapshot usage", "snapshotId", snapshotID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if inUse {
|
||||
slog.InfoContext(ctx, "DeleteSnapshot blocked: snapshot in use by volume", "snapshotId", snapshotID)
|
||||
slog.InfoContext(ctx, "DeleteSnapshot blocked: snapshot in use by volume", "snapshotId", snapshotID, "volumeId", volumeID)
|
||||
// The registered message blames AMIs. Name the volume only when the
|
||||
// caller owns it: the scan is whole-cluster.
|
||||
if volumeID == "" {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidSnapshotInUse, "The snapshot '%s' is in use by a volume.", snapshotID)
|
||||
}
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidSnapshotInUse, "The snapshot '%s' is in use by volume '%s'.", snapshotID, volumeID)
|
||||
}
|
||||
|
||||
// Check if any registered AMI names this snapshot as its root device
|
||||
amiInUse, err := s.amiInUseBySnapshot(ctx, snapshotID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "DeleteSnapshot failed to check AMI usage", "snapshotId", snapshotID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if amiInUse {
|
||||
slog.InfoContext(ctx, "DeleteSnapshot blocked: snapshot in use by registered AMI", "snapshotId", snapshotID)
|
||||
return nil, errors.New(awserrors.ErrorInvalidSnapshotInUse)
|
||||
}
|
||||
if s.provider == nil {
|
||||
|
||||
@@ -205,9 +205,10 @@ func TestSnapshotInUseByVolumes_EncryptedVolume(t *testing.T) {
|
||||
VolumeID: "vol-fromsnap", CapacityGiB: 50, SnapshotID: "snap-src", Encrypted: true,
|
||||
})
|
||||
|
||||
inUse, err := svc.snapshotInUseByVolumes(context.Background(), "snap-src")
|
||||
inUse, volumeID, err := svc.snapshotInUseByVolumes(context.Background(), "snap-src", testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, inUse, "encrypted volume's SnapshotID must be visible through the envelope")
|
||||
assert.True(t, inUse)
|
||||
assert.Equal(t, "vol-fromsnap", volumeID, "encrypted volume's SnapshotID must be visible through the envelope")
|
||||
}
|
||||
|
||||
// TestCreateSnapshot_VolumeNotFound tests creating a snapshot from non-existent volume.
|
||||
@@ -459,6 +460,18 @@ func TestDeleteSnapshot_WrongAccount(t *testing.T) {
|
||||
assert.Len(t, result.Snapshots, 1)
|
||||
}
|
||||
|
||||
// clientErrorDetail returns the code and message the gateway renders for err:
|
||||
// a message attached at the call site, else the code's registered EC2 wording.
|
||||
func clientErrorDetail(t *testing.T, err error) (string, string) {
|
||||
t.Helper()
|
||||
code, message, ok := awserrors.ResolveErrorDetail(err)
|
||||
require.True(t, ok, "unregistered error: %v", err)
|
||||
if message == "" {
|
||||
message = awserrors.LookupErrorMessage("ec2", code).Message
|
||||
}
|
||||
return code, message
|
||||
}
|
||||
|
||||
// TestDeleteSnapshot_InUseByVolume tests that deleting a snapshot fails when a volume was created from it.
|
||||
func TestDeleteSnapshot_InUseByVolume(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
@@ -480,7 +493,9 @@ func TestDeleteSnapshot_InUseByVolume(t *testing.T) {
|
||||
SnapshotId: snap.SnapshotId,
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorInvalidSnapshotInUse)
|
||||
code, message := clientErrorDetail(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidSnapshotInUse, code)
|
||||
assert.Equal(t, "The snapshot '"+*snap.SnapshotId+"' is in use by volume 'vol-cloned'.", message)
|
||||
|
||||
// Verify snapshot still exists
|
||||
result, err := svc.DescribeSnapshots(context.Background(), &ec2.DescribeSnapshotsInput{
|
||||
@@ -490,6 +505,139 @@ func TestDeleteSnapshot_InUseByVolume(t *testing.T) {
|
||||
assert.Len(t, result.Snapshots, 1)
|
||||
}
|
||||
|
||||
// TestDeleteSnapshot_InUseByAnotherAccountsVolume tests that the refusal
|
||||
// never names a volume the caller does not own, since the dependency scan is
|
||||
// whole-cluster and an endpoint never confirms another account's IDs.
|
||||
func TestDeleteSnapshot_InUseByAnotherAccountsVolume(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
|
||||
createTestVolume(t, svc, store, "vol-source", 50)
|
||||
snap, err := svc.CreateSnapshot(context.Background(), &ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-source"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
seedVolumeDocument(t, store, ebsmetadata.Volume{
|
||||
VolumeID: "vol-foreign", TenantID: otherAccountID, CapacityGiB: 50, SnapshotID: *snap.SnapshotId,
|
||||
})
|
||||
|
||||
_, err = svc.DeleteSnapshot(context.Background(), &ec2.DeleteSnapshotInput{
|
||||
SnapshotId: snap.SnapshotId,
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
code, message := clientErrorDetail(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidSnapshotInUse, code)
|
||||
assert.NotContains(t, message, "vol-foreign")
|
||||
assert.Equal(t, "The snapshot '"+*snap.SnapshotId+"' is in use by a volume.", message)
|
||||
|
||||
// A volume the caller owns is named even when a foreign one also matches.
|
||||
seedVolumeDocument(t, store, ebsmetadata.Volume{
|
||||
VolumeID: "vol-own", CapacityGiB: 50, SnapshotID: *snap.SnapshotId,
|
||||
})
|
||||
_, err = svc.DeleteSnapshot(context.Background(), &ec2.DeleteSnapshotInput{
|
||||
SnapshotId: snap.SnapshotId,
|
||||
}, testAccountID)
|
||||
_, message = clientErrorDetail(t, err)
|
||||
assert.Equal(t, "The snapshot '"+*snap.SnapshotId+"' is in use by volume 'vol-own'.", message)
|
||||
}
|
||||
|
||||
// TestDeleteSnapshot_InUseByRegisteredAMI tests that deleting a snapshot
|
||||
// fails while an AMI's own CreateImage snapshot still names it, matching AWS.
|
||||
func TestDeleteSnapshot_InUseByRegisteredAMI(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
|
||||
createTestVolume(t, svc, store, "vol-source", 50)
|
||||
snap, err := svc.CreateSnapshot(context.Background(), &ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-source"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, svc.metadata.PutAMI(context.Background(), ebsmetadata.AMI{
|
||||
ImageID: "ami-registered", Name: "ct-ami", SnapshotID: *snap.SnapshotId,
|
||||
}))
|
||||
|
||||
_, err = svc.DeleteSnapshot(context.Background(), &ec2.DeleteSnapshotInput{
|
||||
SnapshotId: snap.SnapshotId,
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
code, message := clientErrorDetail(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidSnapshotInUse, code)
|
||||
assert.Equal(t, "The snapshot that you are trying to delete is in use by one or more AMIs.", message)
|
||||
|
||||
// The provider and control-plane document both survive the refusal.
|
||||
result, err := svc.DescribeSnapshots(context.Background(), &ec2.DescribeSnapshotsInput{
|
||||
SnapshotIds: []*string{snap.SnapshotId},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result.Snapshots, 1)
|
||||
|
||||
require.NoError(t, svc.metadata.DeleteAMI(context.Background(), "ami-registered"))
|
||||
_, err = svc.DeleteSnapshot(context.Background(), &ec2.DeleteSnapshotInput{
|
||||
SnapshotId: snap.SnapshotId,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err, "once the AMI is gone, the delete succeeds")
|
||||
}
|
||||
|
||||
// TestDeleteSnapshot_InUseByRegisteredAMI_AnotherAccount tests that the AMI
|
||||
// check is whole-cluster: a system AMI registered by a different account than
|
||||
// the snapshot's owner still blocks the delete.
|
||||
func TestDeleteSnapshot_InUseByRegisteredAMI_AnotherAccount(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
|
||||
createTestVolume(t, svc, store, "vol-source", 50)
|
||||
snap, err := svc.CreateSnapshot(context.Background(), &ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-source"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, svc.metadata.PutAMI(context.Background(), ebsmetadata.AMI{
|
||||
ImageID: "ami-other-account", Name: "ct-system-ami",
|
||||
ImageOwnerAlias: otherAccountID, SnapshotID: *snap.SnapshotId,
|
||||
}))
|
||||
|
||||
_, err = svc.DeleteSnapshot(context.Background(), &ec2.DeleteSnapshotInput{
|
||||
SnapshotId: snap.SnapshotId,
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorInvalidSnapshotInUse)
|
||||
}
|
||||
|
||||
// TestDeleteSnapshot_AMIReadFailureFailsClosed tests that an AMI document
|
||||
// this cannot decode aborts the delete rather than being treated as absent:
|
||||
// a partial AMI listing is not evidence that no AMI names the snapshot.
|
||||
func TestDeleteSnapshot_AMIReadFailureFailsClosed(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
|
||||
createTestVolume(t, svc, store, "vol-source", 50)
|
||||
snap, err := svc.CreateSnapshot(context.Background(), &ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-source"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := ebsmetadata.AMIKey("ami-corrupt")
|
||||
require.NoError(t, err)
|
||||
_, err = store.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: aws.String("test-bucket"),
|
||||
Key: aws.String(key),
|
||||
Body: strings.NewReader("not-json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.DeleteSnapshot(context.Background(), &ec2.DeleteSnapshotInput{
|
||||
SnapshotId: snap.SnapshotId,
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorServerInternal)
|
||||
|
||||
// Nothing was deleted: the snapshot survives in both the provider and the
|
||||
// control-plane document.
|
||||
result, err := svc.DescribeSnapshots(context.Background(), &ec2.DescribeSnapshotsInput{
|
||||
SnapshotIds: []*string{snap.SnapshotId},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result.Snapshots, 1)
|
||||
}
|
||||
|
||||
// TestDeleteSnapshot_NotFound tests deleting a non-existent snapshot.
|
||||
func TestDeleteSnapshot_NotFound(t *testing.T) {
|
||||
svc, _ := setupTestSnapshotService(t)
|
||||
@@ -1074,7 +1222,7 @@ func TestDeleteSnapshot_Provider_BlockedByCloneRecordedInMetadata(t *testing.T)
|
||||
}))
|
||||
|
||||
_, err = svc.DeleteSnapshot(ctx, &ec2.DeleteSnapshotInput{SnapshotId: aws.String(snapshotID)}, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidSnapshotInUse)
|
||||
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInvalidSnapshotInUse), "got %v", err)
|
||||
|
||||
// Once the clone is gone the snapshot is deletable again.
|
||||
require.NoError(t, svc.metadata.DeleteVolume(ctx, testAccountID, "vol-clone"))
|
||||
|
||||
@@ -270,7 +270,7 @@ func TestDeleteSnapshot_BlockedByACloneInAnotherAccount(t *testing.T) {
|
||||
|
||||
_, err = svc.DeleteSnapshot(ctx, &ec2.DeleteSnapshotInput{SnapshotId: snap.SnapshotId}, awsidentifiers.GlobalAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidSnapshotInUse, err.Error())
|
||||
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInvalidSnapshotInUse), "got %v", err)
|
||||
}
|
||||
|
||||
// The irreversible case. The teardown reaper lists with OwnerIds set to the
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package spotinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// LaunchReferences are the key-pair and placement-group names that an
|
||||
// account's open or active spot requests would launch with. They stay inside
|
||||
// EC2; callers must not report them.
|
||||
type LaunchReferences struct {
|
||||
KeyNames []string
|
||||
PlacementGroupNames []string
|
||||
}
|
||||
|
||||
// AccountLaunchReferences reads every open or active spot request of the
|
||||
// account. It is complete or an error: an unlistable bucket or an unreadable
|
||||
// request is an error, because the unread request may name the resource.
|
||||
func (s *SpotInstanceServiceImpl) AccountLaunchReferences(ctx context.Context, accountID string) (*LaunchReferences, error) {
|
||||
keys, err := kvutil.Keys(ctx, s.activeKV)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, fmt.Errorf("list spot requests: %w", err)
|
||||
}
|
||||
prefix := accountID + "."
|
||||
out := &LaunchReferences{}
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
record, err := s.readRecord(ctx, s.activeKV, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil || record.Request == nil {
|
||||
return nil, errors.New("spot request unreadable")
|
||||
}
|
||||
switch aws.StringValue(record.Request.State) {
|
||||
case ec2.SpotInstanceStateClosed, ec2.SpotInstanceStateCancelled, ec2.SpotInstanceStateFailed:
|
||||
continue
|
||||
}
|
||||
spec := launchSpec(record.Request)
|
||||
if name := aws.StringValue(spec.KeyName); name != "" {
|
||||
out.KeyNames = append(out.KeyNames, name)
|
||||
}
|
||||
if spec.Placement != nil {
|
||||
if name := aws.StringValue(spec.Placement.GroupName); name != "" {
|
||||
out.PlacementGroupNames = append(out.PlacementGroupNames, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package spotinstance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Open and active requests contribute their key and group names; cancelled
|
||||
// requests and other accounts' requests do not.
|
||||
func TestAccountLaunchReferences_ReadsOpenAndActiveRequests(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
active := makeSIR("sir-active01", "i-0000000000000001")
|
||||
active.LaunchSpecification.Placement = &ec2.SpotPlacement{GroupName: aws.String("pg-spread")}
|
||||
open := makeSIR("sir-open0001", "")
|
||||
open.State = aws.String(ec2.SpotInstanceStateOpen)
|
||||
open.LaunchSpecification.KeyName = aws.String("open-key")
|
||||
putSIRs(t, svc, active, open)
|
||||
cancelled := makeSIR("sir-cancel01", "i-0000000000000002")
|
||||
cancelled.LaunchSpecification.KeyName = aws.String("cancelled-key")
|
||||
putSIRs(t, svc, cancelled)
|
||||
_, err := svc.CancelSpotInstanceRequests(t.Context(), &ec2.CancelSpotInstanceRequestsInput{
|
||||
SpotInstanceRequestIds: []*string{aws.String("sir-cancel01")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.PutSpotInstanceRequests(t.Context(), &PutSpotRequestsInput{Requests: []*ec2.SpotInstanceRequest{makeSIR("sir-other001", "")}}, "999988887777")
|
||||
require.NoError(t, err)
|
||||
|
||||
refs, err := svc.AccountLaunchReferences(t.Context(), testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"my-key", "open-key"}, refs.KeyNames)
|
||||
assert.Equal(t, []string{"pg-spread"}, refs.PlacementGroupNames)
|
||||
}
|
||||
|
||||
// An unreadable request may name anything, so the check fails rather than
|
||||
// answer without it.
|
||||
func TestAccountLaunchReferences_UnreadableRequestFailsTheCheck(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
putSIRs(t, svc, makeSIR("sir-active01", "i-0000000000000001"))
|
||||
_, err := svc.activeKV.Put(t.Context(), kvutil.AccountKey(testAccountID, "sir-broken01"), []byte("{not json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.AccountLaunchReferences(t.Context(), testAccountID)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package volume_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
ebsmetadata "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
|
||||
ec2snapshot "github.com/mulgadc/spinifex/spinifex/domains/ec2/snapshot"
|
||||
ec2volume "github.com/mulgadc/spinifex/spinifex/domains/ec2/volume"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
ebsprovider "github.com/mulgadc/spinifex/spinifex/providers/ebs"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The TestCurrentBehaviour_AccountTermination_ tests pin what terminate does
|
||||
// to a DeleteOnTermination volume today; a failure means it changed.
|
||||
|
||||
// A snapshotted DeleteOnTermination volume loses its attachment before the
|
||||
// delete refuses, so it survives as an unattached volume that the leak reaper,
|
||||
// which keys on the attachment, never marks.
|
||||
func TestCurrentBehaviour_AccountTermination_SnapshottedDeleteOnTerminationVolumeIsDetachedAndUnmarked(t *testing.T) {
|
||||
const (
|
||||
accountID = "123456789012"
|
||||
bucket = "ct-bucket"
|
||||
volumeID = "vol-dot-snapshotted"
|
||||
instanceID = "i-0123456789abcdef0"
|
||||
)
|
||||
ctx := context.Background()
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
refs, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: ec2snapshot.KVBucketVolumeSnapshots})
|
||||
require.NoError(t, err)
|
||||
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{})
|
||||
cfg := &config.Config{WalDir: t.TempDir(), Predastore: config.PredastoreConfig{Bucket: bucket}}
|
||||
volumes := ec2volume.NewVolumeServiceImplWithStore(cfg, store, nc, refs)
|
||||
volumes.SetEBSProvider(provider)
|
||||
snapshots := ec2snapshot.NewSnapshotServiceImplWithStore(cfg, store, nc, refs)
|
||||
snapshots.SetEBSProvider(provider)
|
||||
metadata := ebsmetadata.NewStore(store, bucket)
|
||||
|
||||
created, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{
|
||||
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID,
|
||||
CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, metadata.PutVolume(ctx, ebsmetadata.Volume{
|
||||
VolumeID: volumeID, TenantID: accountID, CapacityGiB: 1,
|
||||
State: "available", ProviderHandle: created.Handle, DeleteOnTermination: true,
|
||||
}))
|
||||
_, err = snapshots.CreateSnapshot(ctx, &ec2.CreateSnapshotInput{VolumeId: aws.String(volumeID)}, accountID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, volumes.UpdateVolumeState(accountID, volumeID, "in-use", instanceID, "/dev/sda1"))
|
||||
|
||||
err = volumes.DeleteVolumeOnTerminate(ctx, volumeID, accountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorVolumeInUse, "the snapshot index refuses the terminate delete")
|
||||
|
||||
survivor, err := metadata.GetVolume(ctx, accountID, volumeID)
|
||||
require.NoError(t, err, "the volume survives its instance")
|
||||
assert.Empty(t, survivor.AttachedInstance, "the attachment was cleared before the refusal")
|
||||
assert.Equal(t, "available", survivor.State)
|
||||
|
||||
reaper := volumes.NewVolumeLeakReaper(func() (map[string]bool, error) {
|
||||
return map[string]bool{instanceID: true}, nil
|
||||
})
|
||||
marked, err := reaper.Sweep(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, marked, "with no attachment left, the leak reaper cannot attribute the volume to its instance")
|
||||
|
||||
_, err = volumes.DeleteVolume(ctx, &ec2.DeleteVolumeInput{VolumeId: aws.String(volumeID)}, accountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorVolumeInUse, "an ordinary delete is refused for the same reason")
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
ebsprovider "github.com/mulgadc/spinifex/spinifex/providers/ebs"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// getFailingStore fails the read of one key with an error that is not a
|
||||
// missing key, standing in for a document that is present but unreadable
|
||||
// (e.g. a storage-layer reconstruction failure).
|
||||
type getFailingStore struct {
|
||||
*objectstore.MemoryObjectStore
|
||||
|
||||
failKey string
|
||||
}
|
||||
|
||||
func (s *getFailingStore) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
|
||||
if aws.StringValue(input.Key) == s.failKey {
|
||||
return nil, errors.New("reconstruction failed: shard sizes do not match")
|
||||
}
|
||||
return s.MemoryObjectStore.GetObject(ctx, input)
|
||||
}
|
||||
|
||||
// countingFailAfterStore fails every read of one key from its (succeedCalls+1)th
|
||||
// call onward, deterministically reproducing a document that answers one read
|
||||
// and then goes unreadable on the next, without timing.
|
||||
type countingFailAfterStore struct {
|
||||
*objectstore.MemoryObjectStore
|
||||
|
||||
failKey string
|
||||
succeedCalls int
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *countingFailAfterStore) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
|
||||
if aws.StringValue(input.Key) == s.failKey {
|
||||
s.calls++
|
||||
if s.calls > s.succeedCalls {
|
||||
return nil, errors.New("reconstruction failed: shard sizes do not match")
|
||||
}
|
||||
}
|
||||
return s.MemoryObjectStore.GetObject(ctx, input)
|
||||
}
|
||||
|
||||
// TestDeleteVolume_UnreadableDocumentIsInternalError pins the fix: a volume
|
||||
// document that fails to read for a reason other than absence must answer
|
||||
// InternalError, never InvalidVolume.NotFound. Reporting NotFound here is
|
||||
// what let terminate cleanup and the legacy reaper count a volume whose
|
||||
// state is unknown as already deleted.
|
||||
func TestDeleteVolume_UnreadableDocumentIsInternalError(t *testing.T) {
|
||||
memory := objectstore.NewMemoryObjectStore()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", memory)
|
||||
|
||||
volumeID := "vol-unreadable0000"
|
||||
createVolumeInStoreWithMeta(t, svc, memory, volumeID, ebsmetadata.Volume{
|
||||
VolumeID: volumeID, CapacityGiB: 10, State: "available",
|
||||
})
|
||||
|
||||
failing := &getFailingStore{MemoryObjectStore: memory, failKey: metadataKey(t, volumeID)}
|
||||
svc.store = failing
|
||||
svc.metadata = ebsmetadata.NewStore(failing, "test-bucket")
|
||||
|
||||
_, err := svc.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{
|
||||
VolumeId: aws.String(volumeID),
|
||||
}, testVolAccountID)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, awserrors.ValidErrorCodeFromError(err),
|
||||
"an unreadable document must answer InternalError, not NotFound")
|
||||
assert.NotContains(t, err.Error(), awserrors.ErrorInvalidVolumeNotFound)
|
||||
|
||||
// Nothing was deleted: the document is still there (read through the
|
||||
// underlying store, bypassing the fault), and so is the provider volume.
|
||||
_, getErr := ebsmetadata.NewStore(memory, "test-bucket").GetVolume(context.Background(), testVolAccountID, volumeID)
|
||||
require.NoError(t, getErr, "the control-plane document must survive a failed delete")
|
||||
|
||||
provVol, provErr := svc.provider.GetVolume(context.Background(), ebsprovider.GetVolumeRequest{
|
||||
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID,
|
||||
})
|
||||
require.NoError(t, provErr)
|
||||
require.NotNil(t, provVol, "the provider's data must survive a failed delete")
|
||||
}
|
||||
|
||||
// TestDeleteVolume_AbsentDocumentIsStillNotFound guards the other side of the
|
||||
// fix: absence (NoSuchKey) must keep returning InvalidVolume.NotFound, not be
|
||||
// swept up into the new InternalError branch.
|
||||
func TestDeleteVolume_AbsentDocumentIsStillNotFound(t *testing.T) {
|
||||
svc := newTestVolumeService("ap-southeast-2a")
|
||||
|
||||
_, err := svc.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{
|
||||
VolumeId: aws.String("vol-absent00000001"),
|
||||
}, testVolAccountID)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidVolumeNotFound, err.Error())
|
||||
}
|
||||
|
||||
// TestDeleteVolume_CrossAccountDocumentIsStillNotFound guards the cross-tenant
|
||||
// branch: a document that exists but belongs to another account must keep
|
||||
// returning InvalidVolume.NotFound, exactly as before the fix.
|
||||
func TestDeleteVolume_CrossAccountDocumentIsStillNotFound(t *testing.T) {
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
|
||||
|
||||
createVolumeInStoreWithMeta(t, svc, store, "vol-othertenant000", ebsmetadata.Volume{
|
||||
VolumeID: "vol-othertenant000", CapacityGiB: 10, State: "available", TenantID: "222222222222",
|
||||
})
|
||||
|
||||
_, err := svc.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{
|
||||
VolumeId: aws.String("vol-othertenant000"),
|
||||
}, testVolAccountID)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidVolumeNotFound, err.Error())
|
||||
}
|
||||
|
||||
// TestDeleteVolumeOnTerminate_DeleteReadFailureIsNotSuccess reproduces the
|
||||
// production shape from mulga-salhr: the attachment-clear read inside
|
||||
// UpdateVolumeState succeeds, but the document goes unreadable before
|
||||
// DeleteVolume's own read of the same key. That read failure must come back
|
||||
// as a non-NotFound error, which is exactly the signal
|
||||
// daemon/vm_adapters.go's DeleteVolumes and instance/service_impl.go's
|
||||
// deleteInstanceVolumes use to tell a real failure from an already-gone
|
||||
// volume (!awserrors.IsNotFound(err) && !objectstore.IsNoSuchKeyError(err)).
|
||||
func TestDeleteVolumeOnTerminate_DeleteReadFailureIsNotSuccess(t *testing.T) {
|
||||
memory := objectstore.NewMemoryObjectStore()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", memory)
|
||||
|
||||
volumeID := "vol-stopped-root2"
|
||||
createVolumeInStoreWithMeta(t, svc, memory, volumeID, ebsmetadata.Volume{
|
||||
VolumeID: volumeID,
|
||||
CapacityGiB: 10,
|
||||
State: "available",
|
||||
AttachedInstance: "i-stopped",
|
||||
})
|
||||
|
||||
// The first read of the document (UpdateVolumeState's attachment clear)
|
||||
// succeeds; every read from the second onward (DeleteVolume's own read)
|
||||
// fails, deterministically, with no reliance on timing.
|
||||
failing := &countingFailAfterStore{MemoryObjectStore: memory, failKey: metadataKey(t, volumeID), succeedCalls: 1}
|
||||
svc.store = failing
|
||||
svc.metadata = ebsmetadata.NewStore(failing, "test-bucket")
|
||||
|
||||
err := svc.DeleteVolumeOnTerminate(context.Background(), volumeID, testVolAccountID)
|
||||
|
||||
require.Error(t, err, "a delete-read failure after a successful attachment clear must not be reported as success")
|
||||
assert.False(t, awserrors.IsNotFound(err),
|
||||
"terminate cleanup treats IsNotFound as already-gone; this must not qualify")
|
||||
assert.False(t, objectstore.IsNoSuchKeyError(err),
|
||||
"terminate cleanup treats IsNoSuchKeyError as already-gone; this must not qualify")
|
||||
|
||||
// The attachment was cleared (terminate implies detach), but the document
|
||||
// and the provider volume must both still exist: nothing was deleted.
|
||||
_, getErr := ebsmetadata.NewStore(memory, "test-bucket").GetVolume(context.Background(), testVolAccountID, volumeID)
|
||||
require.NoError(t, getErr, "the control-plane document must survive a failed delete")
|
||||
|
||||
provVol, provErr := svc.provider.GetVolume(context.Background(), ebsprovider.GetVolumeRequest{
|
||||
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID,
|
||||
})
|
||||
require.NoError(t, provErr)
|
||||
require.NotNil(t, provVol, "the provider's data must survive a failed delete")
|
||||
}
|
||||
|
||||
// TestForceDetachVolume_UnreadableDocumentIsInternalError: an attached volume
|
||||
// whose document is present but unreadable must answer InternalError and keep
|
||||
// its attachment; absence and another account's volume stay NotFound.
|
||||
func TestForceDetachVolume_UnreadableDocumentIsInternalError(t *testing.T) {
|
||||
memory := objectstore.NewMemoryObjectStore()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", memory)
|
||||
|
||||
volumeID := "vol-forcedetach000"
|
||||
createVolumeInStoreWithMeta(t, svc, memory, volumeID, ebsmetadata.Volume{
|
||||
VolumeID: volumeID, CapacityGiB: 10, State: "in-use", AttachedInstance: "i-0123456789abcdef0",
|
||||
})
|
||||
|
||||
failing := &getFailingStore{MemoryObjectStore: memory, failKey: metadataKey(t, volumeID)}
|
||||
svc.store = failing
|
||||
svc.metadata = ebsmetadata.NewStore(failing, "test-bucket")
|
||||
|
||||
_, err := svc.ForceDetachVolume(context.Background(), &ec2.DetachVolumeInput{
|
||||
VolumeId: aws.String(volumeID),
|
||||
}, testVolAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, awserrors.ValidErrorCodeFromError(err))
|
||||
|
||||
meta, getErr := ebsmetadata.NewStore(memory, "test-bucket").GetVolume(context.Background(), testVolAccountID, volumeID)
|
||||
require.NoError(t, getErr)
|
||||
assert.Equal(t, "in-use", meta.State, "a failed force-detach must not clear the attachment")
|
||||
assert.Equal(t, "i-0123456789abcdef0", meta.AttachedInstance)
|
||||
|
||||
_, err = svc.ForceDetachVolume(context.Background(), &ec2.DetachVolumeInput{
|
||||
VolumeId: aws.String("vol-doesnotexist00"),
|
||||
}, testVolAccountID)
|
||||
assert.Equal(t, awserrors.ErrorInvalidVolumeNotFound, awserrors.ValidErrorCodeFromError(err))
|
||||
|
||||
_, err = svc.ForceDetachVolume(context.Background(), &ec2.DetachVolumeInput{
|
||||
VolumeId: aws.String(volumeID),
|
||||
}, "999999999999")
|
||||
assert.Equal(t, awserrors.ErrorInvalidVolumeNotFound, awserrors.ValidErrorCodeFromError(err))
|
||||
}
|
||||
@@ -289,6 +289,7 @@ var describeVolumesValidFilters = map[string]bool{
|
||||
"attachment.status": true,
|
||||
"attachment.device": true,
|
||||
"availability-zone": true,
|
||||
"snapshot-id": true,
|
||||
}
|
||||
|
||||
// DescribeVolumes lists EBS volumes by reading config.json files from S3.
|
||||
@@ -401,6 +402,10 @@ func volumeMatchesFilters(vol *ec2.Volume, filters map[string][]string) bool {
|
||||
if vol.AvailabilityZone != nil {
|
||||
field = *vol.AvailabilityZone
|
||||
}
|
||||
case "snapshot-id":
|
||||
if vol.SnapshotId != nil {
|
||||
field = *vol.SnapshotId
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1056,7 +1061,10 @@ func (s *VolumeServiceImpl) ForceDetachVolume(ctx context.Context, input *ec2.De
|
||||
|
||||
meta, err := s.metadata.GetVolume(ctx, accountID, volumeID)
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
|
||||
if objectstore.IsNoSuchKeyError(err) {
|
||||
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
|
||||
}
|
||||
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "get volume metadata %s: %v", volumeID, err)
|
||||
}
|
||||
if meta.TenantID != accountID {
|
||||
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
|
||||
@@ -1091,10 +1099,15 @@ func (s *VolumeServiceImpl) DeleteVolume(ctx context.Context, input *ec2.DeleteV
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// AWS-faithful: an absent volume returns InvalidVolume.NotFound.
|
||||
// AWS-faithful: an absent volume returns InvalidVolume.NotFound. Any other
|
||||
// read failure means the document's state is unknown, so it must not be
|
||||
// reported or treated as already deleted.
|
||||
meta, err := s.metadata.GetVolume(ctx, accountID, volumeID)
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
|
||||
if objectstore.IsNoSuchKeyError(err) {
|
||||
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
|
||||
}
|
||||
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "get volume metadata %s: %v", volumeID, err)
|
||||
}
|
||||
if meta.TenantID != accountID {
|
||||
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
|
||||
|
||||
@@ -2081,6 +2081,40 @@ func TestDescribeVolumes_FilterByAZ(t *testing.T) {
|
||||
assert.Equal(t, "vol-az1", *out.Volumes[0].VolumeId)
|
||||
}
|
||||
|
||||
func TestDescribeVolumes_FilterBySnapshotId(t *testing.T) {
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
|
||||
|
||||
createVolumeInStoreWithMeta(t, svc, store, "vol-fromsnap", ebsmetadata.Volume{
|
||||
VolumeID: "vol-fromsnap", CapacityGiB: 10, State: "available",
|
||||
SnapshotID: "snap-src", TenantID: "000000000001",
|
||||
})
|
||||
createVolumeInStoreWithMeta(t, svc, store, "vol-othersnap", ebsmetadata.Volume{
|
||||
VolumeID: "vol-othersnap", CapacityGiB: 10, State: "available",
|
||||
SnapshotID: "snap-other", TenantID: "000000000001",
|
||||
})
|
||||
createVolumeInStoreWithMeta(t, svc, store, "vol-blank", ebsmetadata.Volume{
|
||||
VolumeID: "vol-blank", CapacityGiB: 10, State: "available", TenantID: "000000000001",
|
||||
})
|
||||
|
||||
describe := func(values ...string) []string {
|
||||
t.Helper()
|
||||
out, err := svc.DescribeVolumes(context.Background(), &ec2.DescribeVolumesInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("snapshot-id"), Values: aws.StringSlice(values)}},
|
||||
}, "000000000001")
|
||||
require.NoError(t, err)
|
||||
ids := make([]string, 0, len(out.Volumes))
|
||||
for _, v := range out.Volumes {
|
||||
ids = append(ids, *v.VolumeId)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"vol-fromsnap"}, describe("snap-src"))
|
||||
assert.ElementsMatch(t, []string{"vol-fromsnap", "vol-othersnap"}, describe("snap-src", "snap-other"))
|
||||
assert.Empty(t, describe("snap-unknown"))
|
||||
}
|
||||
|
||||
func TestDescribeVolumes_FilterMultipleValues_OR(t *testing.T) {
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
|
||||
|
||||
@@ -41,12 +41,12 @@ func AcquireLeader(ctx context.Context, nc *nats.Conn, bucket, holder string) (f
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "reconcile/lock: lease config invalid",
|
||||
"holder", holder, "bucket", bucket, "err", err)
|
||||
"node", holder, "bucket", bucket, "err", err)
|
||||
return nil, false
|
||||
}
|
||||
if !lease.TryAcquire(ctx) {
|
||||
slog.InfoContext(ctx, "reconcile/lock: not leader, skipping reconcile",
|
||||
"holder", holder, "bucket", bucket)
|
||||
"node", holder, "bucket", bucket)
|
||||
return nil, false
|
||||
}
|
||||
return func() { lease.Release(ctx) }, true
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -103,3 +106,34 @@ func TestAcquireLeader_ReleaseDoesNotDeleteSuccessorKey(t *testing.T) {
|
||||
require.NoError(t, err, "stale release deleted the successor's leader key")
|
||||
assert.Equal(t, "node-2", string(entry.Value()), "successor's key must survive a stale release")
|
||||
}
|
||||
|
||||
// A node that loses the election logs itself as node: "holder=node-2" on
|
||||
// node-2's log reads as though node-2 holds the lock.
|
||||
func TestAcquireLeader_NotLeaderLogNamesLocalNode(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
release, ok := AcquireLeader(t.Context(), nc, KVBucketVPCDReconcile, "node-1")
|
||||
require.True(t, ok)
|
||||
defer release()
|
||||
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
|
||||
_, ok = AcquireLeader(t.Context(), nc, KVBucketVPCDReconcile, "node-2")
|
||||
require.False(t, ok)
|
||||
|
||||
var line map[string]any
|
||||
dec := json.NewDecoder(&buf)
|
||||
for dec.More() {
|
||||
var rec map[string]any
|
||||
require.NoError(t, dec.Decode(&rec))
|
||||
if rec["msg"] == "reconcile/lock: not leader, skipping reconcile" {
|
||||
line = rec
|
||||
}
|
||||
}
|
||||
require.NotNil(t, line, "a losing acquire must log that it is skipping")
|
||||
assert.Equal(t, "node-2", line["node"])
|
||||
assert.NotEqual(t, "node-2", line["holder"], "the local node is not the holder")
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import (
|
||||
// context and so cannot inherit the caller's deadline.
|
||||
const releaseTimeout = 5 * time.Second
|
||||
|
||||
var errNotHolder = errors.New("kvlease: another node holds the lease")
|
||||
// heldError reports the node that owns the key when a claim loses to it.
|
||||
type heldError struct{ holder string }
|
||||
|
||||
func (e *heldError) Error() string { return "kvlease: lease held by " + e.holder }
|
||||
|
||||
// BucketFunc returns the KV bucket holding the lease key. It is injected because
|
||||
// each subsystem initialises its own bucket, some with migrations attached.
|
||||
@@ -83,10 +86,11 @@ func (l *Lease) Held() bool {
|
||||
return l.held
|
||||
}
|
||||
|
||||
// logArgs prefixes the lease identity onto a log line's own fields.
|
||||
// logArgs prefixes the lease identity onto a log line's own fields. The local
|
||||
// node logs as "node"; "holder" is reserved for whoever owns the key.
|
||||
func (l *Lease) logArgs(extra ...any) []any {
|
||||
args := make([]any, 0, 4+len(l.cfg.Attrs)+len(extra))
|
||||
args = append(args, "lease", l.cfg.Name, "holder", l.cfg.Holder)
|
||||
args = append(args, "lease", l.cfg.Name, "node", l.cfg.Holder)
|
||||
args = append(args, l.cfg.Attrs...)
|
||||
return append(args, extra...)
|
||||
}
|
||||
@@ -104,6 +108,9 @@ func (l *Lease) TryAcquire(ctx context.Context) bool {
|
||||
}
|
||||
rev, err := l.claim(ctx, kv)
|
||||
if err != nil {
|
||||
if held, ok := errors.AsType[*heldError](err); ok {
|
||||
slog.DebugContext(ctx, "kvlease: held by another node", l.logArgs("holder", held.holder)...)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -142,7 +149,7 @@ func (l *Lease) claim(ctx context.Context, kv jetstream.KeyValue) (uint64, error
|
||||
return 0, gerr
|
||||
}
|
||||
if string(entry.Value()) != l.cfg.Holder {
|
||||
return 0, errNotHolder
|
||||
return 0, &heldError{holder: string(entry.Value())}
|
||||
}
|
||||
return kv.Update(ctx, l.cfg.Key, []byte(l.cfg.Holder), entry.Revision())
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package kvlease_test
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
@@ -388,3 +389,49 @@ func TestRelease_AfterRenewalLossWarns(t *testing.T) {
|
||||
a.Release(t.Context())
|
||||
assert.Contains(t, buf.String(), "already lost before release")
|
||||
}
|
||||
|
||||
// logLines captures default-logger output as decoded JSON records.
|
||||
func logLines(t *testing.T) func() []map[string]any {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
return func() []map[string]any {
|
||||
var out []map[string]any
|
||||
dec := json.NewDecoder(&buf)
|
||||
for dec.More() {
|
||||
var rec map[string]any
|
||||
require.NoError(t, dec.Decode(&rec))
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// "holder" must name whoever owns the key. The local node logs as "node", so a
|
||||
// loser's log does not read as though it won.
|
||||
func TestLogFields_SeparateLocalNodeFromCurrentHolder(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
read := logLines(t)
|
||||
|
||||
a := newTestLease(t, nc, "node-a", nil)
|
||||
require.True(t, a.TryAcquire(t.Context()))
|
||||
t.Cleanup(func() { a.Release(t.Context()) })
|
||||
b := newTestLease(t, nc, "node-b", nil)
|
||||
require.False(t, b.TryAcquire(t.Context()))
|
||||
|
||||
var elected, refused map[string]any
|
||||
for _, rec := range read() {
|
||||
switch {
|
||||
case rec["msg"] == "kvlease: elected" && rec["node"] == "node-a":
|
||||
elected = rec
|
||||
case rec["node"] == "node-b":
|
||||
refused = rec
|
||||
}
|
||||
}
|
||||
require.NotNil(t, elected, "the elected line must carry the local node as node")
|
||||
assert.NotContains(t, elected, "holder")
|
||||
require.NotNil(t, refused, "a refused acquire must log the local node as node")
|
||||
assert.Equal(t, "node-a", refused["holder"], "a refused acquire must report the node that holds the key")
|
||||
}
|
||||
|
||||
@@ -378,7 +378,8 @@ func (b *Bucket) WatchFrom(ctx context.Context, filter string, revision uint64)
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// open picks the kvutil helper matching the configured TTL and replica count.
|
||||
// open attaches to the bucket or creates it through kvutil, which sets the
|
||||
// cluster's replica count on every bucket, TTL-backed or not.
|
||||
func (b *Bucket) open(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
switch {
|
||||
case b.cfg.AttachOnly:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package kvstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/clustersize"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// startJetStreamCluster starts n routed JetStream servers and returns a handle
|
||||
// connected to the first, so a create can actually place n replicas.
|
||||
func startJetStreamCluster(t *testing.T, n int) jetstream.JetStream {
|
||||
t.Helper()
|
||||
ports := make([]int, n)
|
||||
routes := make([]string, n)
|
||||
for i := range ports {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
ports[i] = l.Addr().(*net.TCPAddr).Port
|
||||
require.NoError(t, l.Close())
|
||||
routes[i] = fmt.Sprintf("nats://127.0.0.1:%d", ports[i])
|
||||
}
|
||||
servers := make([]*server.Server, n)
|
||||
for i := range servers {
|
||||
ns, err := server.NewServer(&server.Options{
|
||||
ServerName: fmt.Sprintf("s%d", i+1), JetStream: true, StoreDir: t.TempDir(),
|
||||
Host: "127.0.0.1", Port: -1, NoLog: true, NoSigs: true,
|
||||
Cluster: server.ClusterOpts{Name: "kvstore", Host: "127.0.0.1", Port: ports[i]},
|
||||
Routes: server.RoutesFromStr(strings.Join(routes, ",")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
ns.Start()
|
||||
servers[i] = ns
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, ns := range servers {
|
||||
ns.Shutdown()
|
||||
ns.WaitForShutdown()
|
||||
}
|
||||
})
|
||||
|
||||
nc, err := nats.Connect("", nats.InProcessServer(servers[0]))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(nc.Close)
|
||||
js, err := jetstream.New(nc)
|
||||
require.NoError(t, err)
|
||||
return js
|
||||
}
|
||||
|
||||
// TestBucket_TTLBucketIsReplicatedAcrossTheCluster pins TTL and replica count
|
||||
// together: a TTL bucket created at one replica loses its markers with one node,
|
||||
// and the usage dedupe bucket is exactly that kind of bucket.
|
||||
func TestBucket_TTLBucketIsReplicatedAcrossTheCluster(t *testing.T) {
|
||||
const nodes = 3
|
||||
js := startJetStreamCluster(t, nodes)
|
||||
clustersize.RedeclareForTest(t, nodes)
|
||||
|
||||
bucket := kvstore.NewBucket(js, kvstore.Config{
|
||||
Name: "kvstore-ttl-replicated",
|
||||
History: 1,
|
||||
TTL: 90 * time.Minute,
|
||||
})
|
||||
|
||||
// Wait for a meta leader before the one open, so a retried open cannot find a
|
||||
// one-replica bucket from a timed-out create and raise it into a false pass.
|
||||
var lastErr error
|
||||
ready := assert.Eventually(t, func() bool {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
_, lastErr = js.AccountInfo(ctx)
|
||||
return lastErr == nil
|
||||
}, 30*time.Second, 50*time.Millisecond)
|
||||
require.True(t, ready, "JetStream cluster never elected a meta leader: %v", lastErr)
|
||||
|
||||
kv, err := bucket.KV(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err := kv.Status(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 90*time.Minute, status.TTL(), "Config.TTL must reach the created bucket")
|
||||
bucketStatus, ok := status.(*jetstream.KeyValueBucketStatus)
|
||||
require.True(t, ok, "status is %T", status)
|
||||
assert.Equal(t, nodes, bucketStatus.StreamInfo().Config.Replicas,
|
||||
"a TTL bucket must be created at the cluster's replica count")
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -54,6 +55,15 @@ func adminPathMethod(path string) (string, bool) {
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// ctxAdminRequestID carries the /admin request ID to handlers that log under it.
|
||||
// It correlates log records only and authorises nothing.
|
||||
const ctxAdminRequestID contextKey = "admin.requestID"
|
||||
|
||||
func adminRequestIDFrom(ctx context.Context) string {
|
||||
id, _ := ctx.Value(ctxAdminRequestID).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// adminMaxBodyBytes caps the JSON request body. The largest legitimate request
|
||||
// is a few hundred bytes; the cap exists so an unauthenticated-shaped mistake
|
||||
// cannot make the gateway buffer megabytes.
|
||||
@@ -81,7 +91,7 @@ func (gw *GatewayConfig) Admin_Request(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Amzn-Requestid", requestID)
|
||||
|
||||
method := chi.URLParam(r, "method")
|
||||
ctx := r.Context()
|
||||
ctx := context.WithValue(r.Context(), ctxAdminRequestID, requestID)
|
||||
|
||||
// One handler for every verb rather than a POST route plus a catch-all:
|
||||
// chi resolves the two to the same pattern, and the catch-all wins.
|
||||
|
||||
@@ -121,7 +121,7 @@ func (gw *GatewayConfig) adminDeleteAccount(ctx context.Context, body []byte) (a
|
||||
inventoryCtx, cancel := context.WithTimeout(ctx, accountDeletionInventoryTimeout)
|
||||
defer cancel()
|
||||
|
||||
inventory, err := engine.Inventory(inventoryCtx, req.AccountID)
|
||||
inventory, err := engine.DryRun(inventoryCtx, req.AccountID, adminRequestIDFrom(ctx))
|
||||
if err != nil {
|
||||
return nil, teardownError("DeleteAccount", req.AccountID, err)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user