mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-29 23:46:00 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 152040145e | |||
| 5cdb884197 | |||
| 86321d1606 | |||
| 4dac0bdd9a | |||
| 85d1b07056 | |||
| c2ef9261e7 | |||
| 8cac2c73e4 | |||
| bb6a924961 | |||
| cbe6535bf1 | |||
| aacbed6291 | |||
| 361ceb6cce | |||
| 346115abe8 | |||
| 07e1572b45 | |||
| 0f1fbda24f | |||
| 13d9eacc97 | |||
| 524a28cd6b |
@@ -102,9 +102,9 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot + SQS store image)
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot)
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
run: cd spinifex && make build-microvm-image build-sqs-store-microvm-image
|
||||
run: cd spinifex && make build-microvm-image
|
||||
|
||||
- name: Build amd64 Tarball
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
|
||||
@@ -536,13 +536,6 @@ jobs:
|
||||
log-dir: ${{ env.ARTIFACT_DIR }}
|
||||
title: "E2E failure analysis"
|
||||
|
||||
# The WAN capture is most of the artifact and only matters when a run
|
||||
# failed. Anything short of a clean pass (suite rc, a failed setup or
|
||||
# collect step, a missing rc) keeps it.
|
||||
- name: Drop the packet capture from a passing single-node run
|
||||
if: always() && steps.e2e.outputs.rc == '0' && job.status == 'success'
|
||||
run: rm -f "${ARTIFACT_DIR}"/net-diag/wan.pcap
|
||||
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
@@ -997,13 +990,6 @@ jobs:
|
||||
log-dir: ${{ env.ARTIFACT_DIR }}
|
||||
title: "Multi-Node E2E failure analysis"
|
||||
|
||||
# The WAN captures are 90% of the artifact and only matter when a run
|
||||
# failed. Anything short of a clean pass (suite rc, a failed setup or
|
||||
# collect step, a missing rc) keeps them.
|
||||
- name: Drop the packet captures from a passing multi-node run
|
||||
if: always() && steps.e2e.outputs.rc == '0' && job.status == 'success'
|
||||
run: rm -f "${ARTIFACT_DIR}"/net-diag-node*/wan.pcap
|
||||
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
|
||||
@@ -57,8 +57,8 @@ jobs:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot + SQS store image)
|
||||
run: cd spinifex && make build-microvm-image build-sqs-store-microvm-image
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot)
|
||||
run: cd spinifex && make build-microvm-image
|
||||
|
||||
- name: Build amd64 Tarball
|
||||
env:
|
||||
|
||||
@@ -119,6 +119,9 @@ jobs:
|
||||
- name: Build Spinifex
|
||||
run: make -C spinifex build
|
||||
|
||||
- name: Manifest check + drift lint
|
||||
run: make -C spinifex manifest-check manifest-lint
|
||||
|
||||
- name: Run Tests with Coverage
|
||||
env:
|
||||
SPINIFEX_REQUIRE_CONFORMANCE_TOOLS: "1"
|
||||
|
||||
@@ -21,9 +21,6 @@ build/microvm/vmlinuz
|
||||
build/microvm/initramfs.cpio.gz
|
||||
build/microvm/qboot.rom
|
||||
build/microvm/qboot.LICENSE
|
||||
build/microvm/sqs-store/vmlinuz
|
||||
build/microvm/sqs-store/initramfs.cpio.gz
|
||||
build/microvm/sqs-store/.stage.*/
|
||||
build/qboot/
|
||||
!spinifex/runtime/roles/spinifexui/frontend/dist/
|
||||
tmp/
|
||||
|
||||
@@ -140,23 +140,6 @@ $(MICROVM_ARTIFACTS) &: $(MICROVM_INPUTS)
|
||||
bin/lb-agent:
|
||||
$(MAKE) build-lb-agent
|
||||
|
||||
# The SQS store microVM image: a separate artifact from the LB image above, not
|
||||
# installed or released by any target yet.
|
||||
SQS_STORE_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/sqs-store
|
||||
SQS_STORE_MICROVM_ARTIFACTS := $(SQS_STORE_MICROVM_OUT_DIR)/vmlinuz $(SQS_STORE_MICROVM_OUT_DIR)/initramfs.cpio.gz
|
||||
SQS_STORE_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/sqs-store-microvm-publish.sh $(SQS_STORE_MICROVM_OUT_DIR)/init.sh $(SQS_STORE_MICROVM_OUT_DIR)/inittab $(addprefix $(SQS_STORE_MICROVM_OUT_DIR)/,sqs-store-lib.sh sqs-store-prepare sqs-store-run sqs-store-stop) bin/nats-server
|
||||
|
||||
$(SQS_STORE_MICROVM_ARTIFACTS) &: $(SQS_STORE_MICROVM_INPUTS)
|
||||
./scripts/build-sqs-store-microvm-image.sh
|
||||
|
||||
# The dedicated SQS store server, built from the go.mod-pinned module so the
|
||||
# image ships exactly the reviewed version. Static for the musl guest.
|
||||
bin/nats-server: go.mod go.sum
|
||||
@mkdir -p bin
|
||||
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ github.com/nats-io/nats-server/v2
|
||||
|
||||
build-sqs-store-microvm-image: $(SQS_STORE_MICROVM_ARTIFACTS) ## Build the SQS store microVM kernel and initramfs
|
||||
|
||||
# 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)
|
||||
@@ -168,12 +151,6 @@ $(QBOOT_ARTIFACTS) &: 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-sqs-store-microvm: $(SQS_STORE_MICROVM_ARTIFACTS) ## Install the SQS store microVM image to /usr/share/spinifex/microvm/sqs-store/
|
||||
sudo install -d /usr/share/spinifex/microvm/sqs-store
|
||||
sudo rm -f /usr/share/spinifex/microvm/sqs-store/initramfs.cpio.gz
|
||||
sudo install -m 0644 $(SQS_STORE_MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/sqs-store/vmlinuz
|
||||
sudo install -m 0644 $(SQS_STORE_MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/sqs-store/initramfs.cpio.gz
|
||||
|
||||
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
|
||||
@@ -181,14 +158,15 @@ install-microvm: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Install microVM arti
|
||||
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: lint, vuln, and the unit and e2e-harness tiers.
|
||||
# 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.
|
||||
#
|
||||
# -j2 overlaps the only two heavy gates, lint and test-cover: alone each leaves
|
||||
# most of the machine idle, so pairing them cuts a cold run by about a quarter.
|
||||
# -Otarget keeps each gate's output in one block instead of interleaving them.
|
||||
# manifest-check manifest-lint test-package-check removed, can add back later
|
||||
preflight:
|
||||
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness test-build-scripts
|
||||
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness test-build-scripts manifest-check manifest-lint
|
||||
@echo -e "\n ✅ Preflight passed — safe to commit."
|
||||
|
||||
# Shell suites + shellcheck for build/scripts/, the systemd-unit helpers that
|
||||
@@ -200,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 scripts/lib/distro.sh scripts/lib/stage-sqs-store-microvm.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...."
|
||||
@@ -262,6 +240,23 @@ test-segscan-oracle:
|
||||
@echo -e "\n....Running segscan storage oracle test...."
|
||||
$(_Q)LOG_IGNORE=1 go test -tags=integration,segscanoracle -timeout 120s ./tests/integration/... -run TestSegscanOracle $(_RACEQ)
|
||||
|
||||
# Validate docs/service-interfaces.yaml. Schema check + cross-reference
|
||||
# of services/suites/fixtures + on-disk path existence.
|
||||
manifest-check:
|
||||
@echo -e "\n....Checking service-interfaces.yaml...."
|
||||
@go run ./tests/e2e/manifest-check/cmd/manifest-check -repo-root . -manifest docs/service-interfaces.yaml
|
||||
|
||||
# Drift guards: direct-create fixture lint + NATS subject lint,
|
||||
# ratcheted against tests/e2e/manifest-lint/baseline.txt. Fails only on NEW
|
||||
# drift beyond the baseline.
|
||||
manifest-lint:
|
||||
@echo -e "\n....Linting manifest drift (fixtures + subjects)...."
|
||||
@go run ./tests/e2e/manifest-lint/cmd/manifest-lint -repo-root .
|
||||
|
||||
# Accept current drift into the baseline. Run after an intentional change.
|
||||
manifest-lint-update:
|
||||
@go run ./tests/e2e/manifest-lint/cmd/manifest-lint -repo-root . -update
|
||||
|
||||
# Run unit tests
|
||||
test:
|
||||
@echo -e "\n....Running tests for $(GO_PROJECT_NAME)...."
|
||||
@@ -474,7 +469,6 @@ distro-amd64:
|
||||
echo "[distro-amd64] WARNING: microvm artifacts missing — tarball will not include them"; \
|
||||
echo "[distro-amd64] run 'make build-microvm-image' before 'make distro-amd64'"; \
|
||||
fi
|
||||
./scripts/lib/stage-sqs-store-microvm.sh $(SQS_STORE_MICROVM_OUT_DIR) dist/amd64
|
||||
tar -czf dist/spinifex-$(VERSION)-linux-amd64.tar.gz -C dist/amd64 .
|
||||
sha256sum dist/spinifex-$(VERSION)-linux-amd64.tar.gz > dist/spinifex-$(VERSION)-linux-amd64.tar.gz.sha256
|
||||
|
||||
@@ -493,7 +487,7 @@ distro-arm64:
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: test-package-check build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image build-sqs-store-microvm-image install-microvm install-sqs-store-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
|
||||
.PHONY: test-package-check build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck nilaway \
|
||||
|
||||
@@ -54,7 +54,7 @@ For Terraform, start from the [example workbooks](https://docs.mulgadc.com/docs/
|
||||
|
||||
## Services
|
||||
|
||||
The platform serves **405 AWS API operations** across the services below. See exactly which ones in the [coverage report](https://docs.mulgadc.com/coverage).
|
||||
The platform serves **392 AWS API operations** across the services below. See exactly which ones in the [coverage report](https://docs.mulgadc.com/coverage).
|
||||
|
||||
| Service | What it is | Built on | Status |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -68,10 +68,8 @@ The platform serves **405 AWS API operations** across the services below. See ex
|
||||
| **ECR** | Container registry | Predastore, deduplicated per account | Available |
|
||||
| **ECS** | Container service | Spinifex instances | Available |
|
||||
| **RDS** | Databases | PostgreSQL and MariaDB, with automated backups | Available |
|
||||
| **Route 53** | DNS | [Northstar](https://github.com/mulgadc/northstar): authoritative DNS with hosted zones | Available |
|
||||
| **Bedrock** | AI deployment | vLLM for text generation and Hugging Face TEI for embeddings and reranking | Q4 2026 |
|
||||
| **Lambda** | Serverless functions | | Q1 2027 |
|
||||
| **SQS** | Message queues | | Q1 2027 |
|
||||
|
||||
Roadmap services ship under the same AWS API surface, so code written for AWS today works the moment they land. [Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
|
||||
|
||||
|
||||
@@ -29,11 +29,9 @@ var allowlist = []allowance{
|
||||
|
||||
{From: "agents/rds/agent", To: "domains/rds/instance", Debt: "agents/rds/agent (dependency, narrowed)", Reason: "command protocol, wire and record types; needs a guest/controller wire contract"},
|
||||
|
||||
{From: "cmd/aws-model-coverage", To: "domains/acm/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/ecr/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/route53/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/elbv2/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/rds/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/acm/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM/Route 53 inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/ecr/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM/Route 53 inventories; named exception, must not construct a registration"},
|
||||
{From: "cmd/aws-model-coverage", To: "domains/route53/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM/Route 53 inventories; named exception, must not construct a registration"},
|
||||
|
||||
{From: "runtime/daemon", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "runtime/daemon/eks_cp_control.go and rds_deps.go call the instance adapter; needs an EC2 capability"},
|
||||
|
||||
@@ -126,8 +124,10 @@ var allowlist = []allowance{
|
||||
|
||||
{From: "gateway", To: "domains/eks/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/iam/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/rds/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/sts/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ecs/awsapi", Debt: "gateway (service registration)", Reason: "ECS and ELBv2 dispatch stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/elbv2/awsapi", Debt: "gateway (service registration)", Reason: "ECS and ELBv2 dispatch stays in gateway until the ingress registration seam"},
|
||||
{From: "domains/ecs", To: "domains/ec2/ebs/metadata", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
{From: "domains/ecs", To: "domains/ec2/eip", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
{From: "domains/ecs", To: "domains/ec2/instancetypes", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
// completed move whose line must be deleted.
|
||||
var frozenLegacyPackages = []string{
|
||||
"gateway",
|
||||
"gateway/spx",
|
||||
}
|
||||
|
||||
// retiredLegacyRoots were legacy roots whose every package has moved to its
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/bin/sh
|
||||
mount -t proc proc /proc
|
||||
mount -t sysfs sys /sys
|
||||
mount -t devtmpfs dev /dev
|
||||
|
||||
# virtio_mmio is built-in (CONFIG_VIRTIO_MMIO=y) so virtio-net devices are
|
||||
# already enumerated by the time PID 1 runs — no modprobe needed.
|
||||
# qemu_fw_cfg and virtio_net are =m in stock Alpine linux-virt, so load them.
|
||||
#
|
||||
# modprobe needs a usable modules.dep; when the image is built on a host whose
|
||||
# depmod can't produce one, busybox modprobe silently no-ops and the module
|
||||
# never loads — fw_cfg then never appears and init panics. insmod by explicit
|
||||
# path needs no modules.dep, so fall back to it. Handles .ko and .ko.gz.
|
||||
load_mod() {
|
||||
modprobe "$1" 2>/dev/null && return 0
|
||||
for ko in $(find /lib/modules -name "$1.ko" -o -name "$1.ko.gz" 2>/dev/null); do
|
||||
insmod "$ko" 2>/dev/null && return 0
|
||||
case "$ko" in
|
||||
*.gz) gunzip -c "$ko" >/tmp/mod.ko 2>/dev/null \
|
||||
&& insmod /tmp/mod.ko 2>/dev/null && return 0 ;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
load_mod qemu_fw_cfg || load_mod fw_cfg_sysfs || true
|
||||
# virtio_net depends on net_failover, which depends on failover. The load_mod
|
||||
# insmod fallback loads a single .ko with no dependency resolution, and
|
||||
# modules.dep is absent when the image is built on a kmod-less host (depmod
|
||||
# skipped), so modprobe can't resolve the chain either. Load it explicitly
|
||||
# bottom-up so the NIC comes up regardless of how the image was built —
|
||||
# without this the agent has no network and the LB hangs in provisioning.
|
||||
load_mod failover || true
|
||||
load_mod net_failover || true
|
||||
load_mod virtio_net || true
|
||||
|
||||
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
|
||||
i=0
|
||||
while [ "$i" -lt 25 ]; do
|
||||
[ -f "$NETCFG" ] && break
|
||||
sleep 0.04
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
if [ ! -f "$NETCFG" ]; then
|
||||
# Dump host/guest fw_cfg state to the serial console before init exits so a
|
||||
# "netcfg not available" panic is diagnosable from the captured console log
|
||||
# (module-not-loaded vs qemu-not-exposing-by_name vs wrong machine type).
|
||||
echo "[init] FATAL: fw_cfg netcfg not available" >&2
|
||||
echo "[init] dmi: vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null) product=$(cat /sys/class/dmi/id/product_name 2>/dev/null) bios=$(cat /sys/class/dmi/id/bios_version 2>/dev/null)" >&2
|
||||
echo "[init] qemu_fw_cfg dir present: $([ -d /sys/firmware/qemu_fw_cfg ] && echo yes || echo NO)" >&2
|
||||
echo "[init] modules: $(lsmod 2>/dev/null | awk 'NR>1{print $1}' | tr '\n' ',')" >&2
|
||||
echo "[init] by_name listing:" >&2
|
||||
ls -la /sys/firmware/qemu_fw_cfg/by_name/ 2>&1 | sed 's/^/[init] /' >&2
|
||||
ls -la /sys/firmware/qemu_fw_cfg/by_name/opt/ 2>&1 | sed 's/^/[init] /' >&2
|
||||
echo "[init] dmesg fw_cfg:" >&2
|
||||
dmesg 2>/dev/null | grep -i fw_cfg | sed 's/^/[init] /' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
. "$NETCFG"
|
||||
|
||||
for n in 0 1 2 3 4 5; do
|
||||
eval mac=\$NIC${n}_MAC
|
||||
[ -z "$mac" ] && continue
|
||||
|
||||
iface=""
|
||||
for attempt in 1 2 3 4 5; do
|
||||
iface=$(ip -o link | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $2}' | tr -d :)
|
||||
[ -n "$iface" ] && break
|
||||
sleep 0.02
|
||||
done
|
||||
|
||||
if [ -z "$iface" ]; then
|
||||
echo "[init] WARNING: no interface found for MAC $mac (NIC$n), skipping" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
eval cidr=\$NIC${n}_CIDR
|
||||
eval gw=\$NIC${n}_GW
|
||||
eval default=\$NIC${n}_DEFAULT
|
||||
eval rdst=\$NIC${n}_ROUTE_DST
|
||||
eval rvia=\$NIC${n}_ROUTE_VIA
|
||||
|
||||
ip link set "$iface" up
|
||||
[ -n "$cidr" ] && ip addr add "$cidr" dev "$iface"
|
||||
[ "$default" = "1" ] && [ -n "$gw" ] && ip route add default via "$gw" dev "$iface"
|
||||
[ -n "$rdst" ] && [ -n "$rvia" ] && ip route add "$rdst" via "$rvia" dev "$iface"
|
||||
done
|
||||
|
||||
# The store image takes no lb-agent environment. A CA bundle is optional here:
|
||||
# nothing in this image dials out yet.
|
||||
CA_CERT=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/ca-cert/raw
|
||||
mkdir -p /etc/ssl/certs
|
||||
[ -f "$CA_CERT" ] && cp "$CA_CERT" /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
exec /sbin/init
|
||||
@@ -1,10 +0,0 @@
|
||||
# The power button loads here, not in /init: busybox init has its SIGUSR2
|
||||
# (poweroff) handler installed before sysinit runs, so a press can never reach
|
||||
# the pre-exec shell PID 1. A press before this line is lost; the host re-sends.
|
||||
::sysinit:/sbin/modprobe tiny_power_button power_signal=12
|
||||
::sysinit:/usr/local/lib/sqs-store/sqs-store-prepare
|
||||
::respawn:/usr/local/lib/sqs-store/sqs-store-run
|
||||
::ctrlaltdel:/sbin/reboot
|
||||
# One action: the store stops and its disk unmounts first, and the generic
|
||||
# umount -a -r runs only if the store is gone.
|
||||
::shutdown:/usr/local/lib/sqs-store/sqs-store-stop
|
||||
@@ -1,215 +0,0 @@
|
||||
# sqs-store-lib.sh — shared by the SQS store guest's prepare, run and stop
|
||||
# steps. Sourced, never executed. Every path and command is overridable so the
|
||||
# host test can drive the same code against a fake fw_cfg, sysfs and tools.
|
||||
#
|
||||
# Guest contract, read from fw_cfg under opt/spinifex/sqs-store/:
|
||||
# data-serial virtio-blk serial of the already-formatted ext4 data disk
|
||||
# listen-addr IPv4 address on a configured guest NIC; the only listener
|
||||
# client-nkey NATS user NKey public key allowed to connect; never a seed
|
||||
# Anything absent, malformed or ambiguous refuses the store. Nothing formats.
|
||||
|
||||
SQS_FWCFG=${SQS_FWCFG:-/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/sqs-store}
|
||||
SQS_SYSBLOCK=${SQS_SYSBLOCK:-/sys/block}
|
||||
SQS_DEVDIR=${SQS_DEVDIR:-/dev}
|
||||
# The host test cannot create block nodes, so it relaxes this to -e.
|
||||
SQS_NODE_TEST=${SQS_NODE_TEST:--b}
|
||||
SQS_RUN=${SQS_RUN:-/run/sqs-store}
|
||||
SQS_DATA=${SQS_DATA:-/data}
|
||||
SQS_CONSOLE=${SQS_CONSOLE:-/dev/console}
|
||||
SQS_MOUNTS=${SQS_MOUNTS:-/proc/mounts}
|
||||
SQS_PORT=${SQS_PORT:-4222}
|
||||
SQS_STOP_BUDGET_TENTHS=${SQS_STOP_BUDGET_TENTHS:-120}
|
||||
NATS_BIN=${NATS_BIN:-/usr/local/bin/nats-server}
|
||||
E2FSCK=${E2FSCK:-e2fsck}
|
||||
MOUNT=${MOUNT:-mount}
|
||||
UMOUNT=${UMOUNT:-umount}
|
||||
IP=${IP:-ip}
|
||||
|
||||
sqs_log() { echo "[sqs-store] $*" >> "$SQS_CONSOLE"; }
|
||||
sqs_fatal() { sqs_log "FATAL: $*"; return 1; }
|
||||
|
||||
# sqs_fwcfg <name>: print one fw_cfg value, refusing a missing, empty, oversized
|
||||
# or multi-token file. The value is not echoed on failure.
|
||||
sqs_fwcfg() {
|
||||
f="$SQS_FWCFG/$1/raw"
|
||||
[ -f "$f" ] || { sqs_fatal "fw_cfg $1 not supplied"; return 1; }
|
||||
[ "$(wc -c < "$f")" -le 128 ] || { sqs_fatal "fw_cfg $1 is oversized"; return 1; }
|
||||
v=$(cat "$f")
|
||||
case "$v" in
|
||||
"" | *[[:space:]]*) sqs_fatal "fw_cfg $1 must be exactly one value"; return 1 ;;
|
||||
esac
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
# A volume serial is the volume ID with dashes stripped: vol + 17 hex.
|
||||
sqs_valid_serial() {
|
||||
[ "${#1}" -eq 20 ] || return 1
|
||||
case "$1" in vol*) ;; *) return 1 ;; esac
|
||||
case "${1#vol}" in *[!0-9a-f]*) return 1 ;; esac
|
||||
}
|
||||
|
||||
# A NATS user public NKey: "U" then 55 base32 characters. The checksum is
|
||||
# left to the pinned server's own config test.
|
||||
sqs_valid_user_nkey() {
|
||||
[ "${#1}" -eq 56 ] || return 1
|
||||
case "$1" in U*) ;; *) return 1 ;; esac
|
||||
case "$1" in *[!A-Z2-7]*) return 1 ;; esac
|
||||
}
|
||||
|
||||
sqs_valid_ipv4() {
|
||||
case "$1" in *[!0-9.]* | .* | *. | *..*) return 1 ;; esac
|
||||
old_ifs=$IFS; IFS=.
|
||||
# shellcheck disable=SC2086 # split on dots on purpose
|
||||
set -- $1
|
||||
IFS=$old_ifs
|
||||
[ "$#" -eq 4 ] || return 1
|
||||
for o in "$@"; do
|
||||
[ "${#o}" -le 3 ] && [ "$o" -le 255 ] || return 1
|
||||
case "$o" in 0?*) return 1 ;; esac
|
||||
done
|
||||
}
|
||||
|
||||
# sqs_find_disk <serial>: print the /dev node of the one block device whose
|
||||
# sysfs serial matches exactly. Zero or several matches refuse.
|
||||
sqs_find_disk() {
|
||||
found="" n=0
|
||||
for b in "$SQS_SYSBLOCK"/*; do
|
||||
[ -r "$b/serial" ] || continue
|
||||
[ "$(cat "$b/serial")" = "$1" ] || continue
|
||||
found=${b##*/}
|
||||
n=$((n + 1))
|
||||
done
|
||||
[ "$n" -eq 1 ] || { sqs_fatal "expected one disk with serial $1, found $n"; return 1; }
|
||||
test "$SQS_NODE_TEST" "$SQS_DEVDIR/$found" || { sqs_fatal "disk $found has no block device node"; return 1; }
|
||||
printf '%s' "$SQS_DEVDIR/$found"
|
||||
}
|
||||
|
||||
# sqs_has_addr <ipv4>: the address is assigned to a non-loopback interface.
|
||||
sqs_has_addr() {
|
||||
"$IP" -o -4 addr show 2>/dev/null \
|
||||
| awk -v a="$1" '$2 != "lo" { split($4, p, "/"); if (p[1] == a) f = 1 } END { exit !f }'
|
||||
}
|
||||
|
||||
# sqs_alive <pid>: running or stopped, not exited (a zombie counts as exited).
|
||||
sqs_alive() { [ -r "/proc/$1/status" ] && ! grep -q '^State:[[:space:]]*Z' "/proc/$1/status" 2>/dev/null; }
|
||||
|
||||
# sqs_starttime <pid>: kernel start time (stat field 22). It survives exec, and
|
||||
# pairing it with the pid reduces PID-reuse risk; without a pidfd the check and
|
||||
# the signal stay separate steps, so it is not an absolute guarantee.
|
||||
sqs_starttime() { sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f20; }
|
||||
|
||||
# sqs_same <pid> <starttime>: the recorded process is still alive.
|
||||
sqs_same() { [ -n "$2" ] && sqs_alive "$1" && [ "$(sqs_starttime "$1")" = "$2" ]; }
|
||||
|
||||
# sqs_prepare: validate the contract, check and mount the data disk, write the
|
||||
# server config and mark the store ready. Returns non-zero, unready, on any refusal.
|
||||
sqs_prepare() {
|
||||
rm -f "$SQS_RUN/ready"
|
||||
mkdir -p "$SQS_RUN"
|
||||
serial=$(sqs_fwcfg data-serial) || return 1
|
||||
sqs_valid_serial "$serial" || { sqs_fatal "data-serial is not a volume serial"; return 1; }
|
||||
addr=$(sqs_fwcfg listen-addr) || return 1
|
||||
sqs_valid_ipv4 "$addr" || { sqs_fatal "listen-addr is not an IPv4 address"; return 1; }
|
||||
nkey=$(sqs_fwcfg client-nkey) || return 1
|
||||
sqs_valid_user_nkey "$nkey" || { sqs_fatal "client-nkey is not a user public NKey"; return 1; }
|
||||
sqs_has_addr "$addr" || { sqs_fatal "listen-addr $addr is not on any guest NIC"; return 1; }
|
||||
|
||||
# The server checks the config, including the NKey checksum, before the
|
||||
# disk is touched.
|
||||
umask 077
|
||||
cat > "$SQS_RUN/nats.conf" <<EOF
|
||||
listen: "$addr:$SQS_PORT"
|
||||
server_name: "sqs-store"
|
||||
jetstream {
|
||||
store_dir: "$SQS_DATA"
|
||||
sync_interval: always
|
||||
}
|
||||
authorization {
|
||||
users = [ { nkey: "$nkey" } ]
|
||||
}
|
||||
EOF
|
||||
if ! "$NATS_BIN" -t -c "$SQS_RUN/nats.conf" > "$SQS_RUN/config-test.out" 2>&1; then
|
||||
sqs_fatal "server config test failed: $(sed 's/U[A-Z2-7]\{55\}/<nkey>/g' "$SQS_RUN/config-test.out" | tr '\n' ' ')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
dev=$(sqs_find_disk "$serial") || return 1
|
||||
rc=0
|
||||
"$E2FSCK" -p "$dev" > "$SQS_RUN/fsck.out" 2>&1 || rc=$?
|
||||
sqs_log "e2fsck -p $dev rc=$rc"
|
||||
# 0 clean, 1 corrected: proceed. Anything higher, including a disk that is
|
||||
# not ext4 at all, refuses; the guest never formats.
|
||||
[ "$rc" -le 1 ] || { sqs_fatal "e2fsck refused $dev: $(tr '\n' ' ' < "$SQS_RUN/fsck.out")"; return 1; }
|
||||
mkdir -p "$SQS_DATA"
|
||||
"$MOUNT" -t ext4 -o nodev,nosuid,noexec "$dev" "$SQS_DATA" || { sqs_fatal "mount $dev failed"; return 1; }
|
||||
: > "$SQS_RUN/ready"
|
||||
sqs_log "prepared: $dev mounted at $SQS_DATA, config written for $addr:$SQS_PORT; server not started yet"
|
||||
}
|
||||
|
||||
# sqs_run: the respawned supervisor body. Its identity is recorded before the
|
||||
# stopping check: a stop that misses it set stopping first, so this parks; a
|
||||
# stop that reads it signals this process whether or not it has exec'd yet.
|
||||
sqs_run() {
|
||||
mkdir -p "$SQS_RUN"
|
||||
echo "$$ $(sqs_starttime $$)" > "$SQS_RUN/pid"
|
||||
if [ -e "$SQS_RUN/stopping" ]; then
|
||||
sqs_log "run: shutdown in progress, not starting"
|
||||
return 2
|
||||
fi
|
||||
if [ ! -e "$SQS_RUN/ready" ]; then
|
||||
sqs_log "run: store not prepared, not starting"
|
||||
return 2
|
||||
fi
|
||||
sqs_log "run: starting pid=$$"
|
||||
exec "$NATS_BIN" -c "$SQS_RUN/nats.conf"
|
||||
}
|
||||
|
||||
# sqs_stop: first shutdown action. Block any respawn, SIGTERM the recorded run
|
||||
# process (a server, or a start not yet exec'd), SIGKILL only after the budget,
|
||||
# then sync and unmount. Each signal rechecks the identity; a survivor keeps the disk mounted.
|
||||
sqs_stop() {
|
||||
mkdir -p "$SQS_RUN"
|
||||
: > "$SQS_RUN/stopping"
|
||||
sqs_log "stop: begin"
|
||||
rec=$(cat "$SQS_RUN/pid" 2>/dev/null || true)
|
||||
pid=${rec%% *} st=${rec#* }
|
||||
[ "$st" != "$rec" ] || st=""
|
||||
if [ -n "$pid" ] && sqs_same "$pid" "$st"; then
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
sqs_log "stop: SIGTERM sent to run pid=$pid"
|
||||
i=0
|
||||
while sqs_same "$pid" "$st" && [ "$i" -lt "$SQS_STOP_BUDGET_TENTHS" ]; do sleep 0.1; i=$((i + 1)); done
|
||||
if sqs_same "$pid" "$st"; then
|
||||
sqs_log "stop: run pid=$pid still running after $((SQS_STOP_BUDGET_TENTHS / 10))s, SIGKILL"
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
j=0
|
||||
while sqs_same "$pid" "$st" && [ "$j" -lt 30 ]; do sleep 0.1; j=$((j + 1)); done
|
||||
fi
|
||||
if sqs_same "$pid" "$st"; then
|
||||
sqs_log "stop: run pid=$pid STILL ALIVE, leaving $SQS_DATA mounted"
|
||||
return 1
|
||||
fi
|
||||
sqs_log "stop: store exited after $((i / 10)).$((i % 10))s"
|
||||
else
|
||||
sqs_log "stop: no running store"
|
||||
fi
|
||||
sync
|
||||
if grep -q " $SQS_DATA " "$SQS_MOUNTS" 2>/dev/null; then
|
||||
rc=0
|
||||
"$UMOUNT" "$SQS_DATA" || rc=$?
|
||||
sqs_log "stop: umount $SQS_DATA rc=$rc"
|
||||
fi
|
||||
}
|
||||
|
||||
# sqs_shutdown: the only shutdown action. The generic read-only remount runs
|
||||
# only after the store stopped; a surviving writer keeps every mount as it is.
|
||||
sqs_shutdown() {
|
||||
if ! sqs_stop; then
|
||||
sync
|
||||
sqs_log "shutdown: store survived, skipping umount -a -r"
|
||||
return 1
|
||||
fi
|
||||
rc=0
|
||||
"$UMOUNT" -a -r || rc=$?
|
||||
echo "[init] shutdown: umount -a -r rc=$rc" >> "$SQS_CONSOLE"
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/sh
|
||||
# sysinit: load the disk drivers, then validate the guest contract, check and
|
||||
# mount the data disk and write the server config. A refusal leaves the store
|
||||
# unprepared, so the run step never starts it.
|
||||
. /usr/local/lib/sqs-store/sqs-store-lib.sh
|
||||
modprobe virtio_blk 2>/dev/null || true
|
||||
modprobe ext4 2>/dev/null || true
|
||||
sqs_prepare || sqs_log "store refused; it will not start"
|
||||
exit 0
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Respawned by init. A store that must not run parks here instead of exiting,
|
||||
# so init does not respawn it in a tight loop.
|
||||
. /usr/local/lib/sqs-store/sqs-store-lib.sh
|
||||
sqs_run
|
||||
exec sleep 2147483647
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/sh
|
||||
# The only shutdown action: stop the store and unmount its disk, then run the
|
||||
# generic read-only remount only if the store is gone.
|
||||
. /usr/local/lib/sqs-store/sqs-store-lib.sh
|
||||
sqs_shutdown
|
||||
@@ -0,0 +1,57 @@
|
||||
# spinifex-unit-version: 1
|
||||
[Unit]
|
||||
Description=Spinifex SQS Message Store (dedicated JetStream)
|
||||
PartOf=spinifex.target
|
||||
ConditionPathExists=/etc/spinifex/sqs-store/sqs-store.conf
|
||||
|
||||
[Service]
|
||||
EnvironmentFile=-/etc/spinifex/telemetry.env
|
||||
Type=simple
|
||||
Slice=spinifex-system.slice
|
||||
User=spinifex-sqsstore
|
||||
Group=spinifex
|
||||
ExecStart=/usr/local/bin/spx service sqs-store start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# JetStream store shutdown and raft stepdown have no internal deadline.
|
||||
TimeoutStopSec=20
|
||||
OOMScoreAdjust=-500
|
||||
# Soft Go heap limit for the store; MemoryHigh/Max bound a runaway on top.
|
||||
Environment=GOMEMLIMIT=512MiB
|
||||
MemoryHigh=10%
|
||||
MemoryMax=20%
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/sqs-store/sqs-store.conf
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/sqs-store /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/sqs-store /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
@@ -1,9 +1,9 @@
|
||||
# spinifex-unit-version: 4
|
||||
# spinifex-unit-version: 3
|
||||
[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 ovsdb-server.service ovs-vswitchd.service ovn-controller.service
|
||||
Wants=spinifex-nats.service spinifex-sqs-store.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
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
elbv2awsapi "github.com/mulgadc/spinifex/spinifex/domains/elbv2/awsapi"
|
||||
rdsawsapi "github.com/mulgadc/spinifex/spinifex/domains/rds/awsapi"
|
||||
route53awsapi "github.com/mulgadc/spinifex/spinifex/domains/route53/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
@@ -56,8 +54,6 @@ func compareAll() ([]awsmodel.OperationCoverage, error) {
|
||||
awsapi.ServiceName: awsapi.OperationInventory(),
|
||||
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
|
||||
route53awsapi.ServiceName: route53awsapi.OperationInventory(),
|
||||
elbv2awsapi.InventoryName: elbv2awsapi.OperationInventory(),
|
||||
rdsawsapi.ServiceName: rdsawsapi.OperationInventory(),
|
||||
})
|
||||
// The loop below walks the embedded models, so a served service with no
|
||||
// model would otherwise drop out of the report without a word.
|
||||
|
||||
@@ -8,8 +8,3 @@ administration. It is separate from AWS tenant-facing service contracts.
|
||||
roles and Reed-Solomon configuration. It is an operational status contract,
|
||||
not a generic provider abstraction; do not change its subject or JSON shape
|
||||
incompatibly. Add a new version when that is required.
|
||||
|
||||
`admin.go` holds the `/admin/<Method>` request and response bodies the private admin surface shares with the CLI.
|
||||
`AdminMethodNames` is the list of callable methods, and the gateway router builds its accept set from it.
|
||||
`AccountQuotaRequest`, `AccountQuotaResponse` and `DeleteAccountResponse` stay in the gateway until the `/admin` handlers move, because they embed types the contracts layer may not import.
|
||||
Do not change a field name, JSON tag or omitempty incompatibly; add a new version when that is required.
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package operatorv1
|
||||
|
||||
import "slices"
|
||||
|
||||
// adminMethodNames is the callable /admin/<Method> list. The gateway router
|
||||
// builds its accept set from it, so a name cannot be served without being listed.
|
||||
var adminMethodNames = []string{
|
||||
"CreateAccount",
|
||||
"DeleteAccount",
|
||||
"DescribeAccountDeletion",
|
||||
"GetAccountQuota",
|
||||
"ListAccounts",
|
||||
"PutAccountQuota",
|
||||
}
|
||||
|
||||
// AdminMethodNames returns the callable /admin/<Method> names in sorted order.
|
||||
// Tooling that mints a credential grants these by name, so the list has to be
|
||||
// the one the router serves rather than a copy that can drift out of step.
|
||||
func AdminMethodNames() []string {
|
||||
return slices.Sorted(slices.Values(adminMethodNames))
|
||||
}
|
||||
|
||||
// Deletion job states.
|
||||
const (
|
||||
DeletionStateRunning = "RUNNING"
|
||||
DeletionStateCompleted = "COMPLETED"
|
||||
DeletionStateFailed = "FAILED"
|
||||
)
|
||||
|
||||
// CreateAccountRequest is the /admin/CreateAccount request body.
|
||||
type CreateAccountRequest struct {
|
||||
Name string `json:"name"`
|
||||
ClientToken string `json:"clientToken"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// CreateAccountResponse is the /admin/CreateAccount success body. It is stored
|
||||
// verbatim (with the secret encrypted) so a replay returns an identical answer.
|
||||
type CreateAccountResponse struct {
|
||||
AccountID string `json:"accountId"`
|
||||
AccountName string `json:"accountName"`
|
||||
AdminUser string `json:"adminUser"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
SecretAccessKey string `json:"secretAccessKey"`
|
||||
DefaultVpcID string `json:"defaultVpcId"`
|
||||
}
|
||||
|
||||
// DeleteAccountRequest is the /admin/DeleteAccount request body.
|
||||
type DeleteAccountRequest struct {
|
||||
AccountID string `json:"accountId"`
|
||||
AccountName string `json:"accountName"`
|
||||
ClientToken string `json:"clientToken"`
|
||||
Force bool `json:"force,omitempty"`
|
||||
DryRun bool `json:"dryRun,omitempty"`
|
||||
}
|
||||
|
||||
// DescribeAccountDeletionRequest is the /admin/DescribeAccountDeletion body.
|
||||
type DescribeAccountDeletionRequest struct {
|
||||
AccountID string `json:"accountId"`
|
||||
}
|
||||
|
||||
// AccountSummary is one row of /admin/ListAccounts. It carries no credential
|
||||
// material: the caller can already create and delete accounts, but that is no
|
||||
// reason for a listing to hand out keys.
|
||||
type AccountSummary struct {
|
||||
AccountID string `json:"accountId"`
|
||||
AccountName string `json:"accountName"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
// ListAccountsResponse is the /admin/ListAccounts success body.
|
||||
type ListAccountsResponse struct {
|
||||
Accounts []AccountSummary `json:"accounts"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package operatorv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func goldenJSON(t *testing.T, v any, want string) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, want, string(data))
|
||||
}
|
||||
|
||||
func TestAdminWire_CreateAccountRequest(t *testing.T) {
|
||||
goldenJSON(t, CreateAccountRequest{Name: "acme", ClientToken: "tok-1", Source: "cli"},
|
||||
`{"name":"acme","clientToken":"tok-1","source":"cli"}`)
|
||||
goldenJSON(t, CreateAccountRequest{Name: "acme", ClientToken: "tok-1"},
|
||||
`{"name":"acme","clientToken":"tok-1"}`)
|
||||
}
|
||||
|
||||
func TestAdminWire_CreateAccountResponse(t *testing.T) {
|
||||
goldenJSON(t, CreateAccountResponse{
|
||||
AccountID: "000000000042", AccountName: "acme", AdminUser: "admin",
|
||||
AccessKeyID: "AKIAEXAMPLE", SecretAccessKey: "secret", DefaultVpcID: "vpc-1",
|
||||
}, `{"accountId":"000000000042","accountName":"acme","adminUser":"admin","accessKeyId":"AKIAEXAMPLE","secretAccessKey":"secret","defaultVpcId":"vpc-1"}`)
|
||||
}
|
||||
|
||||
func TestAdminWire_DeleteAccountRequest(t *testing.T) {
|
||||
goldenJSON(t, DeleteAccountRequest{AccountID: "000000000042", AccountName: "acme", ClientToken: "tok-2", Force: true, DryRun: true},
|
||||
`{"accountId":"000000000042","accountName":"acme","clientToken":"tok-2","force":true,"dryRun":true}`)
|
||||
goldenJSON(t, DeleteAccountRequest{AccountID: "000000000042", AccountName: "acme", ClientToken: "tok-2"},
|
||||
`{"accountId":"000000000042","accountName":"acme","clientToken":"tok-2"}`)
|
||||
}
|
||||
|
||||
func TestAdminWire_DescribeAccountDeletionRequest(t *testing.T) {
|
||||
goldenJSON(t, DescribeAccountDeletionRequest{AccountID: "000000000042"}, `{"accountId":"000000000042"}`)
|
||||
}
|
||||
|
||||
func TestAdminWire_DeletionStates(t *testing.T) {
|
||||
require.Equal(t, "RUNNING", DeletionStateRunning)
|
||||
require.Equal(t, "COMPLETED", DeletionStateCompleted)
|
||||
require.Equal(t, "FAILED", DeletionStateFailed)
|
||||
}
|
||||
|
||||
func TestAdminWire_ListAccountsResponse(t *testing.T) {
|
||||
goldenJSON(t, ListAccountsResponse{
|
||||
Accounts: []AccountSummary{
|
||||
{AccountID: "000000000042", AccountName: "acme", Status: "ACTIVE", CreatedAt: "2026-01-02T03:04:05Z"},
|
||||
{AccountID: "000000000043", AccountName: "bolt", Status: "TERMINATING"},
|
||||
},
|
||||
Count: 2,
|
||||
}, `{"accounts":[
|
||||
{"accountId":"000000000042","accountName":"acme","status":"ACTIVE","createdAt":"2026-01-02T03:04:05Z"},
|
||||
{"accountId":"000000000043","accountName":"bolt","status":"TERMINATING"}],"count":2}`)
|
||||
}
|
||||
|
||||
func TestAdminWire_AdminMethodNames(t *testing.T) {
|
||||
require.Equal(t, []string{
|
||||
"CreateAccount", "DeleteAccount", "DescribeAccountDeletion",
|
||||
"GetAccountQuota", "ListAccounts", "PutAccountQuota",
|
||||
}, AdminMethodNames())
|
||||
}
|
||||
+1
-51
@@ -243,22 +243,6 @@ Hot-attaching a volume to a running direct-boot microVM is not supported, becaus
|
||||
|
||||
This covers only requests that are already mounted when the instance starts; nothing yet supplies data volumes to a system instance, and the path is verified with fake-QEMU unit tests, not with a real EBS volume in a guest.
|
||||
|
||||
#### Volume leases
|
||||
|
||||
Before opening an engine on a volume, the Viperblock worker takes a lease: one entry per volume in the `VIPERBLOCK_VOLUME_LEASES` JetStream KV bucket, which expires 45 seconds after its last write (`providers/ebs/viperblock/volume_lease.go`).
|
||||
A renewal becomes eligible once the last confirmed write is 10 seconds old, and the holder checks for that periodically, so the rewrite lands some time after that point.
|
||||
Each rewrite is conditioned on the revision the holder last wrote, and a refused write that finds the entry gone or naming another owner or generation fences the export as a takeover.
|
||||
It may keep writing for 40 seconds after the last write the store acknowledged, measured from when that write was sent, because the server starts the entry's expiry when it applies the write and never earlier.
|
||||
A slow acknowledgement therefore shortens the remaining validity rather than extending it, and an acquisition acknowledged after its validity has already run out is refused as retryable.
|
||||
|
||||
For a holder that cannot renew, a timer is armed for the validity deadline, leaving a nominal 5 seconds before the entry can expire; when it fires, the export's nbdkit is fenced with SIGKILL (`volume_fence.go`).
|
||||
The fence runs on its own goroutine, so Go scheduling, that goroutine and process termination all delay the kill, and enough delay can use up the whole margin; a kill held in uninterruptible sleep, which the fence waits up to 30 seconds for, is the extreme case.
|
||||
Once a lease is lost or has lapsed it stays lost: a renewal that succeeds afterwards is ignored, and another open on the same node cannot share it while the fence runs.
|
||||
|
||||
These rules bound only the holder's own clock.
|
||||
The storage backend does not reject a stale writer, so a paused or partitioned host that resumes after its validity can still write; generation enforcement at the write boundary is required by Q-24 in the umbrella repository's `docs/specs/viperblock/rfc3-1_overview.md` and is not implemented.
|
||||
An export that survives a daemon restart writes without a renewed lease until recovery adopts it, and a survivor that recovery cannot adopt while a guest still uses it is left running without a lease or a fence.
|
||||
|
||||
### Predastore (S3)
|
||||
|
||||
Object storage used for:
|
||||
@@ -290,7 +274,7 @@ storage-provider contract.
|
||||
|
||||
`domains/sqs/message` defines the store-neutral interface the SQS domain reaches its message store through (ADR-0011:S6), with a JetStream adapter in `domains/sqs/message/jetstream` and a shared conformance suite in `domains/sqs/message/conformance`.
|
||||
It is an internal primitive: nothing outside the SQS domain uses it, no daemon or service is wired to it, and it does not itself deliver an SQS API.
|
||||
The dedicated JetStream server ADR-0011 describes is not deployed by this code, and Spinifex ships no host service, unit, user or firewall rule for it; the guest that could run it is described under *SQS store system instance (not wired)*.
|
||||
The dedicated JetStream server ADR-0011 describes is not deployed by this code.
|
||||
|
||||
The interface covers durable and idempotent append, body and metadata reads, conditional state writes and deletes, per-namespace counts, namespace enumeration, an ordered change feed, a bounded paginated scan, namespace deletion and a sweep of tombstoned bodies.
|
||||
A namespace carries an immutable message-shard and state-shard allocation chosen when the queue is created, so a later change in shard counts never moves an existing queue.
|
||||
@@ -309,40 +293,6 @@ If that consumer delivers nothing for about two seconds while the stream holds a
|
||||
|
||||
Coverage is unit and conformance tests on in-process R1 and R3 servers; there is no end-to-end or multi-host validation of this code, and earlier multi-host evidence predates the final feed.
|
||||
|
||||
### SQS store system instance (not wired)
|
||||
|
||||
Spinifex can build, install, launch, restart and give storage to a dedicated SQS store microVM, but nothing calls any of it.
|
||||
No SQS API, domain service, admin command or daemon start-up path creates a store, so none of this is in production use.
|
||||
|
||||
The guest image is built by `make build-sqs-store-microvm-image`, separate from the load balancer image, and bundles the `nats-server` version pinned in `go.mod` plus the ext4 tools.
|
||||
Release tarballs carry it as `microvm-sqs-store/`, and `setup.sh` installs it to `/usr/share/spinifex/microvm/sqs-store/`.
|
||||
A release without a complete image removes any previously installed one, so a launch is then refused rather than booting a stale image.
|
||||
|
||||
At boot the guest reads three firmware config values: the data disk's serial, an IPv4 address on one of its interfaces, and one NATS user NKey public key.
|
||||
It mounts exactly one matching disk at `/data` after `e2fsck -p`, refuses a missing, duplicate, unformatted or unrepairable disk, and never formats.
|
||||
JetStream keeps its data on `/data` with `sync_interval: always` (ADR-0011:S3), and the server listens only on that address and accepts only that key.
|
||||
On shutdown the store is stopped before `/data` is unmounted; a store that survives SIGKILL leaves every mount in place.
|
||||
|
||||
A launch uses the versioned subject `system.LaunchSQSStore.v1`, which a daemon without this code does not serve, so it fails with no responder instead of booting another guest.
|
||||
The legacy `system.LaunchInstance` path refuses the store kind on both send and receive.
|
||||
Every input check runs before capacity is reserved, and the data volume must be an available system-account volume.
|
||||
It is mounted through the normal EBS path and detached, never deleted, on terminate, which is the protection standard EBS gives today and nothing stronger.
|
||||
CPU mitigations stay enabled for the store, unlike the load balancer.
|
||||
A successful launch means the VM is running, not that NATS is ready.
|
||||
|
||||
The instance record keeps the store's non-secret guest settings so a restart on the node that launched it rewrites the firmware config files.
|
||||
A relaunch on any other node is refused, and cross-node recovery never claims a store.
|
||||
Both checks exist only in daemons with this code: an older daemon can still claim and relaunch a store elsewhere, and an explicit start served by another node claims the instance before its relaunch is refused.
|
||||
|
||||
`domains/sqs/storevolume` prepares a fresh data volume, because the guest never formats.
|
||||
It checks for `mkfs.ext4` and `qemu-img` before allocating, creates the volume in `creating`, writes an empty ext4 image to it over NBD on the local node, unpublishes it, and only then makes it `available`.
|
||||
While a volume is `creating`, attach, delete, modify, force-detach and snapshot are refused.
|
||||
A volume is deleted only after a confirmed unpublish; any publish error, an unconfirmed unpublish or a failed release keeps it and reports its ID, with its state reported as unknown after a failed release.
|
||||
|
||||
Evidence is local only: unit tests, real QEMU boots against disposable scratch disks, and one run of the preparer against the `qemunbdd` provider whose volume kept acknowledged writes across a reboot.
|
||||
Viperblock, volume encryption, the EC2 volume record path, multiple nodes and host reachability of the guest address are not tested.
|
||||
Credential issuance and rotation, store placement and NATS readiness checks do not exist, and there is no failover: a lost node leaves its store unavailable.
|
||||
|
||||
## Configuration
|
||||
|
||||
Cluster configuration (`spinifex/bootstrap/config/config.go`):
|
||||
|
||||
@@ -351,15 +351,8 @@ while the branch is in flight; they do not describe the umbrella repository's
|
||||
| `spinifex#1269` | `spinifex/handlers/elbv2` | `spinifex/domains/elbv2` (package `elbv2`) | A mechanical whole-package relocation under the 2026-10-26 ADR-0001 amendment: the ELBv2 control plane keeps its files, tests and current dependencies, and importers alias it `elbv2domain`. No identifier, API, NATS subject, KV bucket, metric, error string or behaviour changes. Its allowlist entries are now sourced from `domains/elbv2`. Q-30's data-plane boundary stays deferred, recorded as `agents/elbv2` (Q-30 data-plane boundary) debt. |
|
||||
| `spinifex#1271` | `spinifex/handlers/ecs` | `spinifex/domains/ecs` (package `ecs`) | A mechanical whole-package relocation: ADR-0001's `handlers/ecs` row targets `domains/ecs`, the files are already organised by resource, and resource packages are introduced only where callers need a compiler boundary. The ECS control plane keeps its files, tests and current dependencies alongside the already-extracted `domains/ecs/taskdefinition`, and importers alias it `ecsdomain`. No identifier, API, NATS subject, KV bucket, metric, error string or behaviour changes. Its allowlist entries are now sourced from `domains/ecs`. `handlers` has no package left and is a retired legacy root; R5 holds `domains/ecs` to the interim rule's ban on importing the daemon. |
|
||||
| `spinifex#1273` | `spinifex/gateway/{ecs,elbv2}` | `spinifex/domains/{ecs,elbv2}/awsapi` (`awsapi`, imported as `ecsawsapi`, `elbv2awsapi`) | A mechanical relocation of the ECS and ELBv2 AWS action adapters to their ADR-0001 domain homes, as `spinifex#1265` did for IAM, STS, EKS and RDS; APIs, NATS subjects, KV buckets and behaviour are unchanged. The root `gateway` dispatch still imports each package, recorded as `gateway` (service registration) debt, and `domains/admission/quota` keeps its live ELBv2 counter import, now of `domains/elbv2/awsapi`. |
|
||||
| `spinifex#1336` | `spinifex/gateway/tagging.go` (`Tagging_Request`, `taggingActions`, `taggingActionFromTarget`) and the `GatewayConfig.TaggingResources` field | `spinifex/domains/tagging/awsapi` (`NewRegistration`, `Deps`, `Actions`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | Resource Groups Tagging registers through the ADR-0002 seam like ACM and Route 53. The check order is kept: action, scope, authorization, account, body, aggregator, so a denied caller still gets `AccessDenied` before any body error and an unwired aggregator answers `ServerInternal`. The `checkPolicyResources` stand-in of `*` for every action is unchanged. The legacy switch case, the `supportedServices` entry and the `jsonErrorService` case are removed, and the registration declares the JSON 1.1 envelope. No allowlist row existed for this path. The nil-NATS check moves to the gateway-wide gate, which answers `ServiceUnavailableException` before dispatch as it did for every service. `cmd/aws-model-coverage` output is byte-identical because there is no embedded Tagging model. The characterization tests that pin the pipeline behaviour were committed first and pass unchanged against the registration; only the `withTagging` test helper changed. |
|
||||
| `spinifex#1336` | `spinifex/gateway/elbv2.go` (`ELBv2_Request`, `elbv2Actions`, `elbv2Handler`, `accountLoadBalancerLimit`) | `spinifex/domains/elbv2/awsapi` (`NewRegistration`, `Deps`, `Quota`, `ActionNames`, `OperationInventory`), `spinifex/domains/admission/quota` (`Service.LoadBalancerLimit`), `spinifex/ingress/aws/query` (`ParseArgs`), composed by `spinifex/runtime/roles/awsgw` | ELBv2 registers through the ADR-0002 seam as the first AWS Query service. The check order is kept: query parse, action, account, input parse, scope, authorization, NATS, dispatch, so a denied caller still gets `AccessDenied` before the handler runs. The gateway members ELBv2 read are now supplied as `Deps`: `NATSConn` as `Deps.NATS`, `Quota` as the `Deps.Quota` interface declared in `awsapi` because `domains/admission/quota` imports `awsapi`, `Region` as `Invocation.Region`, and the policy gate as `Invocation.Authorize`. The `DescribeAccountLimits` cap resolution moves to `Service.LoadBalancerLimit` unchanged, and a nil `Deps.Quota` enforces nothing and reports the AWS default. AWSGW now wires the registry after it builds the quota service, because the registration captures it. The registration declares the XML envelope and the inventory name `elasticloadbalancingv2`, so `cmd/aws-model-coverage` keys it explicitly and its output is byte-identical. `gateway.ParseAWSQueryArgs` delegates to `query.ParseArgs`. The legacy switch case, the `supportedServices` entry, the inventory entry and the `gateway -> domains/elbv2/awsapi` allowlist row are removed. `cmd/aws-model-coverage` imports `domains/elbv2/awsapi` for the inventory, under a fourth named-exception allowlist row whose reason is now generic for all four. Finding, not fixed here: the XML for `DescribeAccountLimits` and `DescribeLoadBalancers` lists member fields in a different order from run to run because the marshal is map-ordered, so the characterization tests pin decoded content rather than bytes. The characterization tests that pin the pipeline behaviour were committed first and pass unchanged against the registration; only the `withELBv2` and `serveELBv2` test helpers and the setup of two older gateway tests changed. |
|
||||
| `spinifex#1337` | `spinifex/gateway/spinifex.go` (`Spinifex_Request`, `spinifexAdminActions`) | `spinifex/operator/admin` (`NewRegistration`, `Deps`, `ModelAccess`, `ActionNames`), composed by `spinifex/runtime/roles/awsgw` | The `spinifex` Query service registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, authorization with the wildcard resource, account, the super-admin account gate, then the action, so a denied caller learns nothing about which actions exist. The gateway members the actions read are now `Deps`: `Version` and `Commit` strings, `NATSConn` as `Deps.NATS`, `RootCAs`, `DiscoverActiveNodes` as `Deps.ActiveNodes`, `BedrockAccessAdmin` as the `ModelAccess` interface, and the credential and access resolvers as `Deps.Credentials` and `Deps.Access`. The query actions need only the caller account, so the actor is not widened. The cluster-unavailable answer stays in the shared request path ahead of authorization. `/admin/{method}` stays on its chi route in `gateway/admin*.go` until the actor carries `UserName`. |
|
||||
| `spinifex#1344` | `spinifex/gateway/rds.go` (`RDS_Request`, `rdsCaller`) | `spinifex/domains/rds/awsapi` (`NewRegistration`, `Deps`, `Quota`, `CallerFromActor`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | RDS registers through the ADR-0002 seam with the XML envelope it had. The check order is kept: query parse, action, account, the principal-class gate for the internal agent actions, scope, authorization, NATS, quota and dispatch, so a denied caller still gets `AccessDenied` before the handler runs and no customer grant is evaluated against an agent action. The gateway members RDS read are now supplied as `Deps`: `NATSConn` as `Deps.NATS`, `ExpectedNodes` as `Deps.ExpectedNodes`, `Quota.EnforceRDSInstances` as the `Deps.Quota` interface, `Region` as `Invocation.Region` and the policy gate as `Invocation.Authorize`. A nil `Deps.Quota` skips the DB-instance cap. The caller comes from `dispatch.Actor`: the session name for an assumed-role session and the user name otherwise, and the role name from `Actor.RoleName()`, which parses the underlying role ARN and never the session name. The account comes from `Invocation.AccountID`, so a caller with no composable ARN still reaches the policy check and is refused there as before. The legacy switch case, the `supportedServices` entry, the inventory entry and the `gateway -> domains/rds/awsapi` allowlist row are removed, and `cmd/aws-model-coverage` reads the inventory under a fifth named-exception allowlist row. The characterization tests that pin the pipeline behaviour were committed first with spinifex#1336 and pass unchanged against the registration; only the `withRDS` and `serveRDS` test helpers and the setup of the direct-call RDS tests changed. |
|
||||
| `#1277` | `spinifex/accountteardown` | `spinifex/operator/accountlifecycle` (package `accountlifecycle`, imported as `accountteardown`) | A mechanical whole-package relocation under the 2026-10-27 ADR-0001 amendment: the account deletion coordinator keeps its APIs, NATS subjects, KV access and behaviour; importers keep the `accountteardown` name as an alias, so only their import paths change. Replacing its direct domain and adapter cleanup calls with owner capabilities stays deferred debt. |
|
||||
| `#1285` | `spinifex/gateway/requestaudit.go` and `slogRequestLogger` from `spinifex/gateway/gateway.go` | `spinifex/ingress/aws/audit` (package `audit`) | The first generic-ingress extraction from the root gateway: the request-audit middleware and access-log middleware move unchanged in behaviour. The gateway's authentication flow and dispatch still populate the record, now through the exported `audit.From` and `Record` setters at the same call sites; the package imports only `foundation/telemetry` and `ingress/http`. Its unit tests moved with it; the tests that run it behind SigV4 stay in `gateway`. |
|
||||
| `spinifex#1334` | `spinifex/gateway/spx` (`spx.go`, `storage.go`, `promote_image.go` and their tests) | `spinifex/operator/admin` (package `admin`) | The version, node, VM and storage-status gather helpers and the image-promote NATS request move unchanged with no shim; the `spinifex` service dispatch that calls them stays in `gateway/spinifex.go` until the `spinifex` registration slice. `operator/admin` already imports `domains/ec2/image` and `contracts/operator/v1`, so the move adds no edge and the layering ratchet needed no allowlist row. The `gateway/spx` frozen-legacy entry is removed. |
|
||||
| `spinifex#1334` | `spinifex/gateway` (`CreateAccountRequest`/`Response`, `DeleteAccountRequest`, `DescribeAccountDeletionRequest`, `DeletionState*`, `AccountSummary`, `ListAccountsResponse`, `AdminMethodNames`) | `contracts/operator/v1` (`admin.go`) | The `/admin` bodies the CLI shares with the gateway move unchanged in field names, JSON tags and omitempty, pinned by golden JSON written against the gateway types first and moved with them. `AdminMethodNames` is now owned by the contract and the gateway builds its `adminMethods` accept set from it, with a test that the router accepts exactly that list. `AccountQuotaRequest`/`AccountQuotaResponse` (embed `quota.Overrides` from `domains/admission/quota`) and `DeleteAccountResponse` (embeds `accountlifecycle.Result`) stay in `gateway`: `contracts` may import only `foundation` and `contracts`, so they are settled when the `/admin` handlers move to `operator/admin`. `accountlifecycle.Stage` serialises as an integer, not its name; the golden test pins that. |
|
||||
| `spinifex#1334` | `spinifex/gateway/oidc_discovery.go` (`eksAccountKV`, the discovery-document and JWKS read and construction) | `spinifex/domains/eks/cluster` (`OIDCPublisher`, `OIDCDiscoveryDocument`), composed by `spinifex/runtime/roles/awsgw` | The EKS domain now reads its own account bucket and builds the per-cluster discovery document and JWKS responses (ADR-0005:S4), and the gateway only maps the unauthenticated `/oidc/eks/...` routes onto an `OIDCPublisher` interface it is handed. Responses are byte-identical, pinned by `oidc_discovery_current_behaviour_test.go`, whose assertions are unchanged. The gateway no longer imports `domains/eks/cluster` in production code. `STS` keeps its own `ClusterJWKSResolver` (`OIDCJWKSReader`), untouched. Recorded debt, not repaired here: a stored JWKS is served verbatim with 200 even when it is not valid JSON. |
|
||||
The EC2 contract row is intentionally different from the directory moves: it creates
|
||||
a compatibility boundary. `ec2.cmd.*` retains its deployed one-token NATS
|
||||
subscription and permission shape, while callers use
|
||||
@@ -445,7 +438,7 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
- `runtime/roles/vpcd` (placement): the role still mixes process/role supervision with network-controller responsibilities. `lease_reaper.go` (orphaned external-pool DHCP lease sweep), `ovn_controller_watchdog.go` (OVN controller liveness and restart), `host_eip_loop.go` (host-EIP reconcile loop) and `lease_datapath_reconcile.go` (lease datapath reconcile) are periodic network-control loops, not role wiring; `vpcd.go` itself also carries bridge-mode detection and verification, chassis-list parsing, external-CIDR resolution, DHCP manager startup and lease/IP-change handling, and gateway/host-EIP allocator and neighbour flush/prime construction alongside `Service.Start`. The future placement question is whether these belong in `domains/network`. Removal condition: an accepted decision on network-controller placement, and extraction of those loops and helpers behind the role.
|
||||
- ECR control-plane registration: `awsapi.Deps.NATS` is a raw `*nats.Conn`, not a narrow capability. Every NATS-backed `Handler` passes it to `domains/ecr.NewNATSMetaStore`, which calls `natsmsg.NATSRequest`, so narrowing it means changing the exported `Handler` type, every handler and the meta store; it must not be broadened further meanwhile.
|
||||
- ACM control-plane registration: `domains/acm/awsapi.Deps.NATS` (`registration.go`) is likewise a raw `*nats.Conn` carried by every `Handler`, for the same reason; it must not be broadened further meanwhile.
|
||||
- `cmd/aws-model-coverage` imports `ingress/aws/dispatch`, `gateway` and the `awsapi` package of each registered service (`domains/ecr/awsapi`, `domains/acm/awsapi`, `domains/route53/awsapi`, `domains/elbv2/awsapi`, `domains/rds/awsapi`) to read their declared inventories. It is a non-serving tool and the only named exception to the ADR-0002 composition invariant; it must not construct a registration.
|
||||
- `cmd/aws-model-coverage` imports `ingress/aws/dispatch`, `domains/ecr/awsapi`, `domains/acm/awsapi`, `domains/route53/awsapi` and `gateway` to read ECR's, ACM's and Route 53's declared inventories. It is a non-serving tool and the only named exception to the ADR-0002 composition invariant; it must not construct a registration.
|
||||
- Ingress pipeline (ADR-0002 phase 4): the request-audit record, the trace action enricher, the throttle key functions and the gateway request-context keys stay in `gateway` until the auth/IAM and request-context boundary is designed. SigV4 authentication writes the audit record and context keys from four places, so moving them now would export exactly the setters ADR-0002 says must not be exported solely to make a directory move possible.
|
||||
- NAT projection delivery is inconsistent and is preserved as found: EIP `AssociateAddress` sends `vpc.add-nat` unacknowledged (`AnnounceNATAdd`), skipping the 45s OVN barrier every other add path waits on, and `DeleteNetworkInterface` sends `vpc.delete-nat` unacknowledged (`AnnounceNATDelete`), skipping the ack and the ErrNoResponders retry. The reconciler's orphan sweep is the only backstop for both. Clears when those call sites use `AddNAT`/`AddNATBestEffort` and `RemoveNAT`, a reliability change of its own.
|
||||
- `vpc.add-system-egress` has a `vpcd` subscriber and a contract entry but no producer (its former `domains/ec2/eip` publisher is gone); `domains/eks/cluster` still publishes the delete. Either a producer is missing or the add route is dead; it needs an owner decision.
|
||||
@@ -466,7 +459,7 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
- `operator/cli` (dependency): the command tree still imports domain and service implementations directly (`handlers/{iam,eks}`, `domains/admission/quota`, `gateway`, `domains/ochre`, `vpcd`, the Viperblock adapter and runtime roles). The ADR's `cmd/spinifex` current-to-target map row requires command packages to reach domains through public capabilities; splitting supported use cases into `operator/*` packages is later work.
|
||||
- `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` (service registration): the root `gateway` dispatch for IAM, STS, EKS, RDS and ECS imports `domains/{iam,sts,eks,rds,ecs}/awsapi`, which only AWSGW may wire. It clears when each service registers through the ADR-0002 ingress registration seam; as with EC2, moving the dispatch must not be done as a package move.
|
||||
- `gateway` (service registration): the root `gateway` dispatch for IAM, STS, EKS, RDS, ECS and ELBv2 imports `domains/{iam,sts,eks,rds,ecs,elbv2}/awsapi`, which only AWSGW may wire. It clears when each service registers through the ADR-0002 ingress registration seam; as with EC2, moving the dispatch must not be done as a package move.
|
||||
- `operator/accountlifecycle` (dependency): its EC2 reapers call `domains/ec2/awsapi/*` action adapters directly, as they did under `gateway/ec2`. The amended ADR-0001 `accountteardown` row keeps replacing these with domain-owned cleanup capabilities as deferred debt; it was not a relocation prerequisite. 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. It also holds the StageAccount settings-seal reaper, a consumer of `contracts/ec2/v1`'s `ec2.v1.SealAccountSettings` and per-node `ec2.v1.AccountSettingsSealCapability.<node>` subjects, served by `domains/ec2/account` through the daemon; the reaper reads no KV and imports no domain package, reaching both subjects only through NATS and the configured node set `NewClusterEngine` already receives.
|
||||
- `domains/admission/quota` (dependency, moved from `handlers/quota` unchanged): `live.go` imports `domains/elbv2/awsapi` and `domains/rds/instance` 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 `domains/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.
|
||||
@@ -683,8 +676,8 @@ series above; "needs decision" is open.
|
||||
| `gateway/bedrock` | — | Done. The whole package moved into `domains/ochre` in `spinifex#1266` with its package-private core and tests intact and no behaviour change. Separating the action adapters into `domains/ochre/awsapi` remains deferred, recorded as `domains/ochre` (action adapters) debt above. |
|
||||
| `gateway/{iam,sts,eks,rds}` | — | Done. The action adapters moved to `domains/{iam,sts,eks,rds}/awsapi` in `spinifex#1265` with no API, NATS subject, storage or behaviour change. ADR-0001 does not make ADR-0002 registration a prerequisite for relocating a domain's adapters, as the EC2 adapter moves already showed; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
|
||||
| `gateway/{ecs,elbv2}` | — | Done. The action adapters moved to `domains/{ecs,elbv2}/awsapi` in `spinifex#1273` with no API, NATS subject, storage or behaviour change; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
|
||||
| `gateway/spx` | — | Done. The gather helpers, storage-status config and image-promote request moved to `operator/admin` with no behaviour change and the package is removed. The `spinifex` Query dispatch that calls them now lives in `operator/admin` as a seam registration; `/admin/{method}` stays in the root `gateway` until the actor carries `UserName`. |
|
||||
| `gateway/tagging` | — | Done. `domains/tagging` owns the read-only cross-resource `GetResources` aggregation and its API semantics (`0d830e901`, `aedd0b2c4`); AWSGW composes the owner listers, the EC2 tag index and ELBv2 (`f3545e3ea`); the dispatch moved to `domains/tagging/awsapi` and the root `gateway` no longer serves Tagging. The package is removed, and its two allowlist rows and frozen-legacy entry are gone. Owner tag mutation capabilities remain future direction, so the RDS and ECS tag-writing edges above stay open. |
|
||||
| `gateway/spx` | `promote_image.go` and `spx.go` dispatch (`storage.go` already consumes `contracts/operator/v1`). | Needs decision: which package owns operator-plane action dispatch registration — an `ingress/aws`-style seam for the operator plane, or `gateway` stays its home. |
|
||||
| `gateway/tagging` | — | Done. `domains/tagging` owns the read-only cross-resource `GetResources` aggregation and its API semantics (`0d830e901`, `aedd0b2c4`); AWSGW composes the owner listers, the EC2 tag index and ELBv2 (`f3545e3ea`); the root `gateway` keeps only the legacy dispatch in `gateway/tagging.go`. The package is removed, and its two allowlist rows and frozen-legacy entry are gone. Owner tag mutation capabilities remain future direction, so the RDS and ECS tag-writing edges above stay open. |
|
||||
| `services/viperblockd` | — | Done. The adapter is `providers/ebs/viperblock` (with the read-only `dirty` reader), the shared vocabulary is `contracts/viperblockd/legacy/v1`, and the role is `runtime/roles/viperblockd`. `services/` is empty. |
|
||||
| `utils` | — | Done and deleted. `ec2_subject.go` went to `contracts/ec2/v1`, the vpcd protocol to `contracts/network/v1` with the typed client in `domains/network/projection`, and the master-key loader to `domains/ec2/ebs/encryption`. |
|
||||
| `vpcd` | — | Done. The IMDS local-file reader and record-space opener moved to `domains/ec2/instance/state` and are injected through `guestmetadata.LocalStateReader`/`RecordLoader` (recorded above), so `vpcd` no longer imports `daemon` directly; its inter-process boundary now exists (`contracts/network/v1`, with `domains/network/subscribers` as the consumer and `domains/network/projection` as the producer client). The package moved unchanged to `runtime/roles/vpcd` (recorded below), and the legacy directory is removed. |
|
||||
@@ -703,7 +696,7 @@ ADR-0001 completion is structural, so none of these is cleared by remediating se
|
||||
| Class | Paths | Status |
|
||||
|---|---|---|
|
||||
| No missing boundary (candidate moves) | `handlers/ecs/bus` → `contracts/ecs/v1` (`a130f6b8e`); `admin/images_promote.go` → `domains/ec2/image` (`263c58167`); `admin` bootstrap account identifiers → `foundation/aws/identifiers` (`3fdaa54d9`); `admin` credential-format generators → `foundation/aws/credentials` `0368e861a`, retiring `admin` | Done. |
|
||||
| One narrow missing boundary | Done: `handlers/sts` EKS JWKS lookup → `handlers/eks` `ClusterJWKSResolver` (`254d898a3`); `gateway/tagging` → `domains/tagging` with AWSGW-composed listers (`0d830e901`, `f3545e3ea`, `aedd0b2c4`); `gateway/spx` → `operator/admin`. Open: the owner of the `spinifex` service and `/admin` dispatch registration | Each needs one decision or one capability, then a mechanical move. |
|
||||
| One narrow missing boundary | Done: `handlers/sts` EKS JWKS lookup → `handlers/eks` `ClusterJWKSResolver` (`254d898a3`); `gateway/tagging` → `domains/tagging` with AWSGW-composed listers (`0d830e901`, `f3545e3ea`, `aedd0b2c4`). Open: `gateway/spx` (owner of operator-plane dispatch registration) | Each needs one decision or one capability, then a mechanical move. |
|
||||
| Broader boundary or follows another package | `gateway` root (ADR-0002 auth and request-context phase) | Recorded debt with removal conditions above. |
|
||||
|
||||
Per-consumer IAM capabilities are the widest unblocker: they gate STS's IAM reads, Ochre's IAM reads, the ELBv2 role path and the ECS, EKS and RDS ensurers.
|
||||
|
||||
@@ -73,6 +73,7 @@ The cluster plane is peer-scoped. On a single-NIC node the planes collapse onto
|
||||
| Ports | Serving |
|
||||
|---|---|
|
||||
| TCP 4222, 4248 | NATS client and cluster routes |
|
||||
| TCP 4223, 4249 | SQS message store client and cluster routes |
|
||||
| TCP 4432 | Formation, then the daemon cluster manager |
|
||||
| TCP and UDP 5300 | Internal DNS |
|
||||
| TCP 6641, 6642 | OVN northbound and southbound |
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"schema_version": 1,
|
||||
"model_source": {
|
||||
"repository": "github.com/aws/api-models-aws",
|
||||
"commit": "e10d6a1c5ac36af7c0c61d1f67b3c3ebbe41de49",
|
||||
"commit_date": "2026-10-28"
|
||||
"commit": "9b1a4929274443d8671ea8acfda7afbe856e29e9",
|
||||
"commit_date": "2026-09-25"
|
||||
},
|
||||
"services": [
|
||||
{
|
||||
@@ -300,7 +300,6 @@
|
||||
"DeleteCapacityManagerDataExport",
|
||||
"DeleteCarrierGateway",
|
||||
"DeleteClientVpnEndpoint",
|
||||
"DeleteClientVpnEndpointAuthorizationPolicy",
|
||||
"DeleteClientVpnRoute",
|
||||
"DeleteCoipCidr",
|
||||
"DeleteCoipPool",
|
||||
@@ -682,9 +681,7 @@
|
||||
"GetCapacityManagerMetricData",
|
||||
"GetCapacityManagerMetricDimensions",
|
||||
"GetCapacityManagerMonitoredTagKeys",
|
||||
"GetCapacityManagerSupportabilityScores",
|
||||
"GetCapacityReservationUsage",
|
||||
"GetClientVpnEndpointAuthorizationPolicy",
|
||||
"GetCoipPoolUsage",
|
||||
"GetConsoleOutput",
|
||||
"GetConsoleScreenshot",
|
||||
@@ -698,7 +695,6 @@
|
||||
"GetHostReservationPurchasePreview",
|
||||
"GetImageAncestry",
|
||||
"GetImageBlockPublicAccessState",
|
||||
"GetImageFindings",
|
||||
"GetInstanceMetadataDefaults",
|
||||
"GetInstanceTpmEkPub",
|
||||
"GetInstanceTypesFromInstanceRequirements",
|
||||
@@ -738,7 +734,6 @@
|
||||
"GetSnapshotBlockPublicAccessState",
|
||||
"GetSpotPlacementScores",
|
||||
"GetSubnetCidrReservations",
|
||||
"GetTransitGatewayAttachmentNatConfiguration",
|
||||
"GetTransitGatewayAttachmentPropagations",
|
||||
"GetTransitGatewayMeteringPolicyEntries",
|
||||
"GetTransitGatewayMulticastDomainAssociations",
|
||||
@@ -771,7 +766,6 @@
|
||||
"ModifyCapacityReservation",
|
||||
"ModifyCapacityReservationFleet",
|
||||
"ModifyClientVpnEndpoint",
|
||||
"ModifyClientVpnEndpointAuthorizationPolicy",
|
||||
"ModifyDefaultCreditSpecification",
|
||||
"ModifyEbsDefaultKmsKeyId",
|
||||
"ModifyFleet",
|
||||
@@ -820,7 +814,6 @@
|
||||
"ModifyTrafficMirrorFilterRule",
|
||||
"ModifyTrafficMirrorSession",
|
||||
"ModifyTransitGateway",
|
||||
"ModifyTransitGatewayAttachmentNatConfiguration",
|
||||
"ModifyTransitGatewayMeteringPolicy",
|
||||
"ModifyTransitGatewayPolicyTableEntry",
|
||||
"ModifyTransitGatewayPrefixListReference",
|
||||
@@ -1342,7 +1335,6 @@
|
||||
"DeleteCapacityManagerDataExport",
|
||||
"DeleteCarrierGateway",
|
||||
"DeleteClientVpnEndpoint",
|
||||
"DeleteClientVpnEndpointAuthorizationPolicy",
|
||||
"DeleteClientVpnRoute",
|
||||
"DeleteCoipCidr",
|
||||
"DeleteCoipPool",
|
||||
@@ -1661,9 +1653,7 @@
|
||||
"GetCapacityManagerMetricData",
|
||||
"GetCapacityManagerMetricDimensions",
|
||||
"GetCapacityManagerMonitoredTagKeys",
|
||||
"GetCapacityManagerSupportabilityScores",
|
||||
"GetCapacityReservationUsage",
|
||||
"GetClientVpnEndpointAuthorizationPolicy",
|
||||
"GetCoipPoolUsage",
|
||||
"GetConsoleScreenshot",
|
||||
"GetDeclarativePoliciesReportSummary",
|
||||
@@ -1675,7 +1665,6 @@
|
||||
"GetHostReservationPurchasePreview",
|
||||
"GetImageAncestry",
|
||||
"GetImageBlockPublicAccessState",
|
||||
"GetImageFindings",
|
||||
"GetInstanceMetadataDefaults",
|
||||
"GetInstanceTpmEkPub",
|
||||
"GetInstanceTypesFromInstanceRequirements",
|
||||
@@ -1712,7 +1701,6 @@
|
||||
"GetSnapshotBlockPublicAccessState",
|
||||
"GetSpotPlacementScores",
|
||||
"GetSubnetCidrReservations",
|
||||
"GetTransitGatewayAttachmentNatConfiguration",
|
||||
"GetTransitGatewayAttachmentPropagations",
|
||||
"GetTransitGatewayMeteringPolicyEntries",
|
||||
"GetTransitGatewayMulticastDomainAssociations",
|
||||
@@ -1744,7 +1732,6 @@
|
||||
"ModifyCapacityReservation",
|
||||
"ModifyCapacityReservationFleet",
|
||||
"ModifyClientVpnEndpoint",
|
||||
"ModifyClientVpnEndpointAuthorizationPolicy",
|
||||
"ModifyDefaultCreditSpecification",
|
||||
"ModifyEbsDefaultKmsKeyId",
|
||||
"ModifyFleet",
|
||||
@@ -1786,7 +1773,6 @@
|
||||
"ModifyTrafficMirrorFilterRule",
|
||||
"ModifyTrafficMirrorSession",
|
||||
"ModifyTransitGateway",
|
||||
"ModifyTransitGatewayAttachmentNatConfiguration",
|
||||
"ModifyTransitGatewayMeteringPolicy",
|
||||
"ModifyTransitGatewayPolicyTableEntry",
|
||||
"ModifyTransitGatewayPrefixListReference",
|
||||
@@ -1911,7 +1897,6 @@
|
||||
"DescribeImages",
|
||||
"DescribePullThroughCacheRules",
|
||||
"DescribeRegistry",
|
||||
"DescribeReplicationBackfillResults",
|
||||
"DescribeRepositories",
|
||||
"DescribeRepositoryCreationTemplates",
|
||||
"GetAccountSetting",
|
||||
@@ -1927,7 +1912,6 @@
|
||||
"ListImageReferrers",
|
||||
"ListImages",
|
||||
"ListPullTimeUpdateExclusions",
|
||||
"ListReplicationBackfills",
|
||||
"ListTagsForResource",
|
||||
"PutAccountSetting",
|
||||
"PutImage",
|
||||
@@ -1942,8 +1926,6 @@
|
||||
"SetRepositoryPolicy",
|
||||
"StartImageScan",
|
||||
"StartLifecyclePolicyPreview",
|
||||
"StartReplicationBackfill",
|
||||
"StopReplicationBackfill",
|
||||
"TagResource",
|
||||
"UntagResource",
|
||||
"UpdateImageStorageClass",
|
||||
@@ -2049,18 +2031,14 @@
|
||||
"DescribeImageReplicationStatus",
|
||||
"DescribeImageSigningStatus",
|
||||
"DescribePullThroughCacheRules",
|
||||
"DescribeReplicationBackfillResults",
|
||||
"DescribeRepositoryCreationTemplates",
|
||||
"GetAccountSetting",
|
||||
"GetSigningConfiguration",
|
||||
"ListImageReferrers",
|
||||
"ListPullTimeUpdateExclusions",
|
||||
"ListReplicationBackfills",
|
||||
"PutAccountSetting",
|
||||
"PutSigningConfiguration",
|
||||
"RegisterPullTimeUpdateExclusion",
|
||||
"StartReplicationBackfill",
|
||||
"StopReplicationBackfill",
|
||||
"UpdateImageStorageClass",
|
||||
"UpdatePullThroughCacheRule",
|
||||
"UpdateRepositoryCreationTemplate",
|
||||
@@ -2080,13 +2058,11 @@
|
||||
"note": "",
|
||||
"operations": {
|
||||
"modelled": [
|
||||
"AssociateGatewayWithCluster",
|
||||
"ContinueServiceDeployment",
|
||||
"CreateCapacityProvider",
|
||||
"CreateCluster",
|
||||
"CreateDaemon",
|
||||
"CreateExpressGatewayService",
|
||||
"CreateGateway",
|
||||
"CreateService",
|
||||
"CreateTaskSet",
|
||||
"DeleteAccountSetting",
|
||||
@@ -2096,7 +2072,6 @@
|
||||
"DeleteDaemon",
|
||||
"DeleteDaemonTaskDefinition",
|
||||
"DeleteExpressGatewayService",
|
||||
"DeleteGateway",
|
||||
"DeleteService",
|
||||
"DeleteTaskDefinitions",
|
||||
"DeleteTaskSet",
|
||||
@@ -2110,16 +2085,12 @@
|
||||
"DescribeDaemonRevisions",
|
||||
"DescribeDaemonTaskDefinition",
|
||||
"DescribeExpressGatewayService",
|
||||
"DescribeGateway",
|
||||
"DescribeGatewayResources",
|
||||
"DescribeGatewayRoute",
|
||||
"DescribeServiceDeployments",
|
||||
"DescribeServiceRevisions",
|
||||
"DescribeServices",
|
||||
"DescribeTaskDefinition",
|
||||
"DescribeTaskSets",
|
||||
"DescribeTasks",
|
||||
"DisassociateGatewayFromCluster",
|
||||
"DiscoverPollEndpoint",
|
||||
"ExecuteCommand",
|
||||
"GetTaskProtection",
|
||||
@@ -2130,9 +2101,6 @@
|
||||
"ListDaemonDeployments",
|
||||
"ListDaemonTaskDefinitions",
|
||||
"ListDaemons",
|
||||
"ListGatewayClusterAssociations",
|
||||
"ListGatewayRoutes",
|
||||
"ListGateways",
|
||||
"ListServiceDeployments",
|
||||
"ListServices",
|
||||
"ListServicesByNamespace",
|
||||
@@ -2163,7 +2131,6 @@
|
||||
"UpdateContainerInstancesState",
|
||||
"UpdateDaemon",
|
||||
"UpdateExpressGatewayService",
|
||||
"UpdateGateway",
|
||||
"UpdateService",
|
||||
"UpdateServicePrimaryTaskSet",
|
||||
"UpdateTaskProtection",
|
||||
@@ -2252,18 +2219,15 @@
|
||||
],
|
||||
"unsupported": [],
|
||||
"missing": [
|
||||
"AssociateGatewayWithCluster",
|
||||
"ContinueServiceDeployment",
|
||||
"CreateDaemon",
|
||||
"CreateExpressGatewayService",
|
||||
"CreateGateway",
|
||||
"CreateTaskSet",
|
||||
"DeleteAccountSetting",
|
||||
"DeleteAttributes",
|
||||
"DeleteDaemon",
|
||||
"DeleteDaemonTaskDefinition",
|
||||
"DeleteExpressGatewayService",
|
||||
"DeleteGateway",
|
||||
"DeleteTaskDefinitions",
|
||||
"DeleteTaskSet",
|
||||
"DescribeDaemon",
|
||||
@@ -2271,13 +2235,9 @@
|
||||
"DescribeDaemonRevisions",
|
||||
"DescribeDaemonTaskDefinition",
|
||||
"DescribeExpressGatewayService",
|
||||
"DescribeGateway",
|
||||
"DescribeGatewayResources",
|
||||
"DescribeGatewayRoute",
|
||||
"DescribeServiceDeployments",
|
||||
"DescribeServiceRevisions",
|
||||
"DescribeTaskSets",
|
||||
"DisassociateGatewayFromCluster",
|
||||
"DiscoverPollEndpoint",
|
||||
"ExecuteCommand",
|
||||
"GetTaskProtection",
|
||||
@@ -2285,9 +2245,6 @@
|
||||
"ListDaemonDeployments",
|
||||
"ListDaemonTaskDefinitions",
|
||||
"ListDaemons",
|
||||
"ListGatewayClusterAssociations",
|
||||
"ListGatewayRoutes",
|
||||
"ListGateways",
|
||||
"ListServiceDeployments",
|
||||
"PutAccountSettingDefault",
|
||||
"PutAttributes",
|
||||
@@ -2300,7 +2257,6 @@
|
||||
"UpdateContainerAgent",
|
||||
"UpdateDaemon",
|
||||
"UpdateExpressGatewayService",
|
||||
"UpdateGateway",
|
||||
"UpdateServicePrimaryTaskSet",
|
||||
"UpdateTaskProtection",
|
||||
"UpdateTaskSet"
|
||||
@@ -3143,7 +3099,6 @@
|
||||
"ApplyPendingMaintenanceAction",
|
||||
"AuthorizeDBSecurityGroupIngress",
|
||||
"BacktrackDBCluster",
|
||||
"CancelDBCapacityReservation",
|
||||
"CancelExportTask",
|
||||
"CopyDBClusterParameterGroup",
|
||||
"CopyDBClusterSnapshot",
|
||||
@@ -3152,7 +3107,6 @@
|
||||
"CopyOptionGroup",
|
||||
"CreateBlueGreenDeployment",
|
||||
"CreateCustomDBEngineVersion",
|
||||
"CreateDBCapacityReservation",
|
||||
"CreateDBCluster",
|
||||
"CreateDBClusterEndpoint",
|
||||
"CreateDBClusterParameterGroup",
|
||||
@@ -3196,7 +3150,6 @@
|
||||
"DescribeAccountAttributes",
|
||||
"DescribeBlueGreenDeployments",
|
||||
"DescribeCertificates",
|
||||
"DescribeDBCapacityReservations",
|
||||
"DescribeDBClusterAutomatedBackups",
|
||||
"DescribeDBClusterBacktracks",
|
||||
"DescribeDBClusterEndpoints",
|
||||
@@ -3251,7 +3204,6 @@
|
||||
"ModifyCertificates",
|
||||
"ModifyCurrentDBClusterCapacity",
|
||||
"ModifyCustomDBEngineVersion",
|
||||
"ModifyDBCapacityReservation",
|
||||
"ModifyDBCluster",
|
||||
"ModifyDBClusterEndpoint",
|
||||
"ModifyDBClusterParameterGroup",
|
||||
@@ -3402,7 +3354,6 @@
|
||||
"ApplyPendingMaintenanceAction",
|
||||
"AuthorizeDBSecurityGroupIngress",
|
||||
"BacktrackDBCluster",
|
||||
"CancelDBCapacityReservation",
|
||||
"CancelExportTask",
|
||||
"CopyDBClusterParameterGroup",
|
||||
"CopyDBClusterSnapshot",
|
||||
@@ -3411,7 +3362,6 @@
|
||||
"CopyOptionGroup",
|
||||
"CreateBlueGreenDeployment",
|
||||
"CreateCustomDBEngineVersion",
|
||||
"CreateDBCapacityReservation",
|
||||
"CreateDBClusterEndpoint",
|
||||
"CreateDBClusterParameterGroup",
|
||||
"CreateDBClusterSnapshot",
|
||||
@@ -3442,7 +3392,6 @@
|
||||
"DescribeAccountAttributes",
|
||||
"DescribeBlueGreenDeployments",
|
||||
"DescribeCertificates",
|
||||
"DescribeDBCapacityReservations",
|
||||
"DescribeDBClusterAutomatedBackups",
|
||||
"DescribeDBClusterBacktracks",
|
||||
"DescribeDBClusterEndpoints",
|
||||
@@ -3484,7 +3433,6 @@
|
||||
"ModifyCertificates",
|
||||
"ModifyCurrentDBClusterCapacity",
|
||||
"ModifyCustomDBEngineVersion",
|
||||
"ModifyDBCapacityReservation",
|
||||
"ModifyDBClusterEndpoint",
|
||||
"ModifyDBClusterParameterGroup",
|
||||
"ModifyDBClusterSnapshotAttribute",
|
||||
|
||||
@@ -28,21 +28,6 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ
|
||||
|
||||
An AMI cannot be shared with another account or made public. `ModifyImageAttribute` refuses `LaunchPermission`, `UserIds`, `UserGroups`, `OrganizationArns`, `OrganizationalUnitArns` and `OperationType`, and `ResetImageAttribute` resets only `description`, not `launchPermission`. Each refusal is `InvalidParameterValue`. Terraform's `aws_ami_launch_permission` therefore cannot be created. `description` is the only image attribute that can be modified or reset.
|
||||
|
||||
### RegisterImage Registers From a Snapshot
|
||||
|
||||
`RegisterImage` takes a root EBS snapshot in `BlockDeviceMappings` and writes an image that points at it.
|
||||
`EnaSupport` is supported, with `true` and `false` both accepted and stored on the image.
|
||||
`DescribeImages` returns `EnaSupport` when the image has one and leaves the member out when it was never set, as AWS does.
|
||||
`CopyImage` carries the value to the copy, and `CreateImage` sets it to `true` because every instance reports ENA support.
|
||||
`RunInstances` does not read the flag, so an image registered with `EnaSupport=false` is not held back from instance types that need ENA on AWS, since guests use virtio networking either way.
|
||||
|
||||
`RegisterImage` refuses `ImageLocation`, `KernelId`, `RamdiskId`, `TpmSupport`, `ImdsSupport` and `SriovNetSupport`, and any `VirtualizationType` other than `hvm`.
|
||||
Each refusal is `InvalidParameterValue`, and the message names the parameter and the value, for example `Value (aki-123) for parameter KernelId is invalid. KernelId is not supported.`
|
||||
The same form is used for a bad `Architecture`, `BootMode` or `Name` length.
|
||||
A missing `Name`, `BlockDeviceMappings` or root snapshot answers `MissingParameter` and names what is missing.
|
||||
Registering with `--ena-support`, `--no-ena-support` and neither, copying the image, and three refusals were run on a three-node cluster with the AWS CLI.
|
||||
Launching an instance from an `EnaSupport` image was not run, so the launch path is unchanged by construction and not by test.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|
||||
@@ -5,18 +5,3 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ
|
||||
### AMI Launch Permissions Are Refused
|
||||
|
||||
An AMI cannot be shared with another account or made public. `ModifyImageAttribute` refuses `LaunchPermission`, `UserIds`, `UserGroups`, `OrganizationArns`, `OrganizationalUnitArns` and `OperationType`, and `ResetImageAttribute` resets only `description`, not `launchPermission`. Each refusal is `InvalidParameterValue`. Terraform's `aws_ami_launch_permission` therefore cannot be created. `description` is the only image attribute that can be modified or reset.
|
||||
|
||||
### RegisterImage Registers From a Snapshot
|
||||
|
||||
`RegisterImage` takes a root EBS snapshot in `BlockDeviceMappings` and writes an image that points at it.
|
||||
`EnaSupport` is supported, with `true` and `false` both accepted and stored on the image.
|
||||
`DescribeImages` returns `EnaSupport` when the image has one and leaves the member out when it was never set, as AWS does.
|
||||
`CopyImage` carries the value to the copy, and `CreateImage` sets it to `true` because every instance reports ENA support.
|
||||
`RunInstances` does not read the flag, so an image registered with `EnaSupport=false` is not held back from instance types that need ENA on AWS, since guests use virtio networking either way.
|
||||
|
||||
`RegisterImage` refuses `ImageLocation`, `KernelId`, `RamdiskId`, `TpmSupport`, `ImdsSupport` and `SriovNetSupport`, and any `VirtualizationType` other than `hvm`.
|
||||
Each refusal is `InvalidParameterValue`, and the message names the parameter and the value, for example `Value (aki-123) for parameter KernelId is invalid. KernelId is not supported.`
|
||||
The same form is used for a bad `Architecture`, `BootMode` or `Name` length.
|
||||
A missing `Name`, `BlockDeviceMappings` or root snapshot answers `MissingParameter` and names what is missing.
|
||||
Registering with `--ena-support`, `--no-ena-support` and neither, copying the image, and three refusals were run on a three-node cluster with the AWS CLI.
|
||||
Launching an instance from an `EnaSupport` image was not run, so the launch path is unchanged by construction and not by test.
|
||||
|
||||
@@ -52,46 +52,6 @@ Measured on a three-node cluster with a 5 second interval and thresholds of 2:
|
||||
A TCP connection held through a Network Load Balancer survived four configuration reloads while the other backend changed state, because the agent applies nginx with a graceful reload and HAProxy with a graceful restart.
|
||||
The HAProxy configuration with the backup servers was accepted by a live Application Load Balancer, but its rendered text was not dumped and checked by hand.
|
||||
|
||||
### Forwarding headers
|
||||
|
||||
An Application Load Balancer sets three headers on every HTTP and HTTPS request it forwards.
|
||||
`X-Forwarded-For` carries the client address, `X-Forwarded-Proto` is `http` or `https` for the listener that accepted the request, and `X-Forwarded-Port` is that listener's port.
|
||||
`X-Forwarded-Proto` and `X-Forwarded-Port` are always overwritten, so a value sent by the client never reaches the target.
|
||||
Network Load Balancers do not touch the stream.
|
||||
|
||||
`routing.http.xff_header_processing.mode` decides what happens to `X-Forwarded-For`:
|
||||
|
||||
| Mode | Behaviour |
|
||||
| --- | --- |
|
||||
| `append` (default) | Keeps what the client sent and adds the client address last, as one comma-separated line such as `203.0.113.9, 192.0.2.10`. |
|
||||
| `preserve` | Forwards the client's value unchanged and adds nothing. The header is absent when the client sent none. |
|
||||
| `remove` | Drops the header, including any client value. |
|
||||
|
||||
`routing.http.xff_client_port.enabled` (default `false`) appends the client's source port in `append` mode, as `192.0.2.10:54321`.
|
||||
An IPv6 client is written `[2001:db8::1]:54321`.
|
||||
The setting has no effect in `preserve` and `remove` modes.
|
||||
|
||||
`ModifyLoadBalancerAttributes` accepts only `append`, `preserve` or `remove` for the mode and `true` or `false` for the client port, and rejects anything else with `ValidationError`.
|
||||
A change re-renders the load balancer configuration, and a call that leaves both values as they were does not.
|
||||
In a live test each change reached the target within one 3 second poll.
|
||||
|
||||
A client that sends `X-Forwarded-For` more than once has its lines joined into the single comma-separated value.
|
||||
At most eight client lines are joined, and any beyond the eighth are dropped, while the client address is still added last.
|
||||
|
||||
Every other `routing.http.*` attribute is stored and returned by `DescribeLoadBalancerAttributes` but has no effect on the data plane.
|
||||
|
||||
Measured on a three-node cluster, with the caller on the first node:
|
||||
|
||||
| Listener | `X-Forwarded-For` the target saw |
|
||||
| --- | --- |
|
||||
| Internet-facing HTTP | The node's real address, `192.168.0.120` |
|
||||
| Internet-facing HTTPS | The same address, with `https` and port 443 |
|
||||
| Internal HTTP | The calling instance's private address |
|
||||
| Internet-facing HTTP with a client value of `203.0.113.9` | `203.0.113.9, 192.168.0.120` on one line |
|
||||
|
||||
A packet capture on the load balancer's interface showed HAProxy receiving the real client address, so nothing translates it first.
|
||||
Behaviour against AWS was not compared, and the cases above were not run with an IPv6 client.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|
||||
@@ -29,43 +29,3 @@ Measured on a three-node cluster with a 5 second interval and thresholds of 2:
|
||||
|
||||
A TCP connection held through a Network Load Balancer survived four configuration reloads while the other backend changed state, because the agent applies nginx with a graceful reload and HAProxy with a graceful restart.
|
||||
The HAProxy configuration with the backup servers was accepted by a live Application Load Balancer, but its rendered text was not dumped and checked by hand.
|
||||
|
||||
### Forwarding headers
|
||||
|
||||
An Application Load Balancer sets three headers on every HTTP and HTTPS request it forwards.
|
||||
`X-Forwarded-For` carries the client address, `X-Forwarded-Proto` is `http` or `https` for the listener that accepted the request, and `X-Forwarded-Port` is that listener's port.
|
||||
`X-Forwarded-Proto` and `X-Forwarded-Port` are always overwritten, so a value sent by the client never reaches the target.
|
||||
Network Load Balancers do not touch the stream.
|
||||
|
||||
`routing.http.xff_header_processing.mode` decides what happens to `X-Forwarded-For`:
|
||||
|
||||
| Mode | Behaviour |
|
||||
| --- | --- |
|
||||
| `append` (default) | Keeps what the client sent and adds the client address last, as one comma-separated line such as `203.0.113.9, 192.0.2.10`. |
|
||||
| `preserve` | Forwards the client's value unchanged and adds nothing. The header is absent when the client sent none. |
|
||||
| `remove` | Drops the header, including any client value. |
|
||||
|
||||
`routing.http.xff_client_port.enabled` (default `false`) appends the client's source port in `append` mode, as `192.0.2.10:54321`.
|
||||
An IPv6 client is written `[2001:db8::1]:54321`.
|
||||
The setting has no effect in `preserve` and `remove` modes.
|
||||
|
||||
`ModifyLoadBalancerAttributes` accepts only `append`, `preserve` or `remove` for the mode and `true` or `false` for the client port, and rejects anything else with `ValidationError`.
|
||||
A change re-renders the load balancer configuration, and a call that leaves both values as they were does not.
|
||||
In a live test each change reached the target within one 3 second poll.
|
||||
|
||||
A client that sends `X-Forwarded-For` more than once has its lines joined into the single comma-separated value.
|
||||
At most eight client lines are joined, and any beyond the eighth are dropped, while the client address is still added last.
|
||||
|
||||
Every other `routing.http.*` attribute is stored and returned by `DescribeLoadBalancerAttributes` but has no effect on the data plane.
|
||||
|
||||
Measured on a three-node cluster, with the caller on the first node:
|
||||
|
||||
| Listener | `X-Forwarded-For` the target saw |
|
||||
| --- | --- |
|
||||
| Internet-facing HTTP | The node's real address, `192.168.0.120` |
|
||||
| Internet-facing HTTPS | The same address, with `https` and port 443 |
|
||||
| Internal HTTP | The calling instance's private address |
|
||||
| Internet-facing HTTP with a client value of `203.0.113.9` | `203.0.113.9, 192.168.0.120` on one line |
|
||||
|
||||
A packet capture on the load balancer's interface showed HAProxy receiving the real client address, so nothing translates it first.
|
||||
Behaviour against AWS was not compared, and the cases above were not run with an IPv6 client.
|
||||
|
||||
@@ -4,7 +4,7 @@ Generated by `make aws-model-coverage`. This file is not published: it carries t
|
||||
|
||||
**Not implemented** is the candidate work. **Not applicable** is declared in `pages.json` and is not work: those operations describe something this platform will not have.
|
||||
|
||||
Pinned model source: `github.com/aws/api-models-aws@e10d6a1c5ac3, 2026-10-28`.
|
||||
Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
|
||||
|
||||
## acm
|
||||
|
||||
@@ -46,7 +46,7 @@ Pinned model source: `github.com/aws/api-models-aws@e10d6a1c5ac3, 2026-10-28`.
|
||||
|
||||
## ec2
|
||||
|
||||
`2016-11-15` — 127 of 811 modelled operations implemented (15.7%), 0 stubbed, 39 not applicable, 0 registered outside the pinned model.
|
||||
`2016-11-15` — 127 of 804 modelled operations implemented (15.8%), 0 stubbed, 39 not applicable, 0 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
@@ -190,7 +190,6 @@ Pinned model source: `github.com/aws/api-models-aws@e10d6a1c5ac3, 2026-10-28`.
|
||||
- `DeleteApplicationStatusCheck`
|
||||
- `DeleteCapacityManagerDataExport`
|
||||
- `DeleteClientVpnEndpoint`
|
||||
- `DeleteClientVpnEndpointAuthorizationPolicy`
|
||||
- `DeleteClientVpnRoute`
|
||||
- `DeleteCoipCidr`
|
||||
- `DeleteCoipPool`
|
||||
@@ -485,9 +484,7 @@ Pinned model source: `github.com/aws/api-models-aws@e10d6a1c5ac3, 2026-10-28`.
|
||||
- `GetCapacityManagerMetricData`
|
||||
- `GetCapacityManagerMetricDimensions`
|
||||
- `GetCapacityManagerMonitoredTagKeys`
|
||||
- `GetCapacityManagerSupportabilityScores`
|
||||
- `GetCapacityReservationUsage`
|
||||
- `GetClientVpnEndpointAuthorizationPolicy`
|
||||
- `GetCoipPoolUsage`
|
||||
- `GetConsoleScreenshot`
|
||||
- `GetDeclarativePoliciesReportSummary`
|
||||
@@ -499,7 +496,6 @@ Pinned model source: `github.com/aws/api-models-aws@e10d6a1c5ac3, 2026-10-28`.
|
||||
- `GetHostReservationPurchasePreview`
|
||||
- `GetImageAncestry`
|
||||
- `GetImageBlockPublicAccessState`
|
||||
- `GetImageFindings`
|
||||
- `GetInstanceMetadataDefaults`
|
||||
- `GetInstanceTpmEkPub`
|
||||
- `GetInstanceTypesFromInstanceRequirements`
|
||||
@@ -536,7 +532,6 @@ Pinned model source: `github.com/aws/api-models-aws@e10d6a1c5ac3, 2026-10-28`.
|
||||
- `GetSnapshotBlockPublicAccessState`
|
||||
- `GetSpotPlacementScores`
|
||||
- `GetSubnetCidrReservations`
|
||||
- `GetTransitGatewayAttachmentNatConfiguration`
|
||||
- `GetTransitGatewayAttachmentPropagations`
|
||||
- `GetTransitGatewayMeteringPolicyEntries`
|
||||
- `GetTransitGatewayMulticastDomainAssociations`
|
||||
@@ -568,7 +563,6 @@ Pinned model source: `github.com/aws/api-models-aws@e10d6a1c5ac3, 2026-10-28`.
|
||||
- `ModifyCapacityReservation`
|
||||
- `ModifyCapacityReservationFleet`
|
||||
- `ModifyClientVpnEndpoint`
|
||||
- `ModifyClientVpnEndpointAuthorizationPolicy`
|
||||
- `ModifyDefaultCreditSpecification`
|
||||
- `ModifyEbsDefaultKmsKeyId`
|
||||
- `ModifyFleet`
|
||||
@@ -607,7 +601,6 @@ Pinned model source: `github.com/aws/api-models-aws@e10d6a1c5ac3, 2026-10-28`.
|
||||
- `ModifyTrafficMirrorFilterRule`
|
||||
- `ModifyTrafficMirrorSession`
|
||||
- `ModifyTransitGateway`
|
||||
- `ModifyTransitGatewayAttachmentNatConfiguration`
|
||||
- `ModifyTransitGatewayMeteringPolicy`
|
||||
- `ModifyTransitGatewayPolicyTableEntry`
|
||||
- `ModifyTransitGatewayPrefixListReference`
|
||||
@@ -769,7 +762,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
|
||||
## ecr
|
||||
|
||||
`2015-09-21` — 23 of 62 modelled operations implemented (37.1%), 10 stubbed, 0 not applicable, 3 registered outside the pinned model.
|
||||
`2015-09-21` — 23 of 58 modelled operations implemented (39.7%), 10 stubbed, 0 not applicable, 3 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
@@ -783,18 +776,14 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
- `DescribeImageReplicationStatus`
|
||||
- `DescribeImageSigningStatus`
|
||||
- `DescribePullThroughCacheRules`
|
||||
- `DescribeReplicationBackfillResults`
|
||||
- `DescribeRepositoryCreationTemplates`
|
||||
- `GetAccountSetting`
|
||||
- `GetSigningConfiguration`
|
||||
- `ListImageReferrers`
|
||||
- `ListPullTimeUpdateExclusions`
|
||||
- `ListReplicationBackfills`
|
||||
- `PutAccountSetting`
|
||||
- `PutSigningConfiguration`
|
||||
- `RegisterPullTimeUpdateExclusion`
|
||||
- `StartReplicationBackfill`
|
||||
- `StopReplicationBackfill`
|
||||
- `UpdateImageStorageClass`
|
||||
- `UpdatePullThroughCacheRule`
|
||||
- `UpdateRepositoryCreationTemplate`
|
||||
@@ -830,22 +819,19 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
|
||||
## ecs
|
||||
|
||||
`2014-11-13` — 31 of 88 modelled operations implemented (35.2%), 5 stubbed, 0 not applicable, 3 registered outside the pinned model.
|
||||
`2014-11-13` — 31 of 77 modelled operations implemented (40.3%), 5 stubbed, 0 not applicable, 3 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
- `AssociateGatewayWithCluster`
|
||||
- `ContinueServiceDeployment`
|
||||
- `CreateDaemon`
|
||||
- `CreateExpressGatewayService`
|
||||
- `CreateGateway`
|
||||
- `CreateTaskSet`
|
||||
- `DeleteAccountSetting`
|
||||
- `DeleteAttributes`
|
||||
- `DeleteDaemon`
|
||||
- `DeleteDaemonTaskDefinition`
|
||||
- `DeleteExpressGatewayService`
|
||||
- `DeleteGateway`
|
||||
- `DeleteTaskDefinitions`
|
||||
- `DeleteTaskSet`
|
||||
- `DescribeDaemon`
|
||||
@@ -853,13 +839,9 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
- `DescribeDaemonRevisions`
|
||||
- `DescribeDaemonTaskDefinition`
|
||||
- `DescribeExpressGatewayService`
|
||||
- `DescribeGateway`
|
||||
- `DescribeGatewayResources`
|
||||
- `DescribeGatewayRoute`
|
||||
- `DescribeServiceDeployments`
|
||||
- `DescribeServiceRevisions`
|
||||
- `DescribeTaskSets`
|
||||
- `DisassociateGatewayFromCluster`
|
||||
- `DiscoverPollEndpoint`
|
||||
- `ExecuteCommand`
|
||||
- `GetTaskProtection`
|
||||
@@ -867,9 +849,6 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
- `ListDaemonDeployments`
|
||||
- `ListDaemonTaskDefinitions`
|
||||
- `ListDaemons`
|
||||
- `ListGatewayClusterAssociations`
|
||||
- `ListGatewayRoutes`
|
||||
- `ListGateways`
|
||||
- `ListServiceDeployments`
|
||||
- `PutAccountSettingDefault`
|
||||
- `PutAttributes`
|
||||
@@ -882,7 +861,6 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
|
||||
- `UpdateContainerAgent`
|
||||
- `UpdateDaemon`
|
||||
- `UpdateExpressGatewayService`
|
||||
- `UpdateGateway`
|
||||
- `UpdateServicePrimaryTaskSet`
|
||||
- `UpdateTaskProtection`
|
||||
- `UpdateTaskSet`
|
||||
@@ -1107,7 +1085,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
|
||||
|
||||
## rds
|
||||
|
||||
`2014-10-31` — 27 of 168 modelled operations implemented (16.1%), 0 stubbed, 4 not applicable, 5 registered outside the pinned model.
|
||||
`2014-10-31` — 27 of 164 modelled operations implemented (16.5%), 0 stubbed, 4 not applicable, 5 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
@@ -1117,7 +1095,6 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
|
||||
- `ApplyPendingMaintenanceAction`
|
||||
- `AuthorizeDBSecurityGroupIngress`
|
||||
- `BacktrackDBCluster`
|
||||
- `CancelDBCapacityReservation`
|
||||
- `CancelExportTask`
|
||||
- `CopyDBClusterParameterGroup`
|
||||
- `CopyDBClusterSnapshot`
|
||||
@@ -1126,7 +1103,6 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
|
||||
- `CopyOptionGroup`
|
||||
- `CreateBlueGreenDeployment`
|
||||
- `CreateCustomDBEngineVersion`
|
||||
- `CreateDBCapacityReservation`
|
||||
- `CreateDBClusterEndpoint`
|
||||
- `CreateDBClusterParameterGroup`
|
||||
- `CreateDBClusterSnapshot`
|
||||
@@ -1157,7 +1133,6 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
|
||||
- `DescribeAccountAttributes`
|
||||
- `DescribeBlueGreenDeployments`
|
||||
- `DescribeCertificates`
|
||||
- `DescribeDBCapacityReservations`
|
||||
- `DescribeDBClusterAutomatedBackups`
|
||||
- `DescribeDBClusterBacktracks`
|
||||
- `DescribeDBClusterEndpoints`
|
||||
@@ -1199,7 +1174,6 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
|
||||
- `ModifyCertificates`
|
||||
- `ModifyCurrentDBClusterCapacity`
|
||||
- `ModifyCustomDBEngineVersion`
|
||||
- `ModifyDBCapacityReservation`
|
||||
- `ModifyDBClusterEndpoint`
|
||||
- `ModifyDBClusterParameterGroup`
|
||||
- `ModifyDBClusterSnapshotAttribute`
|
||||
|
||||
@@ -99,6 +99,9 @@ behavior in other words.
|
||||
| 4432 | Formation server | HTTPS | Cluster (bootstrap only) | Cluster join coordination; active only while a join token is valid. Binds the node's `--bind` (lan) address. See *Formation port lifecycle* below. | Short-lived bearer token + TLS¹ |
|
||||
| 4222 | spinifex-nats (client) | NATS + TLS | Cluster | Internal service bus for EC2/EBS/VPC/S3 handlers | Token + mutual TLS (cluster CA) |
|
||||
| 4248 | spinifex-nats (cluster) | NATS + TLS | Cluster | Inter-node NATS federation | Token + mutual TLS (cluster CA) |
|
||||
| 4223 | spinifex-sqs-store (client) | NATS + TLS | Cluster | SQS message data, separate from the control-plane bus | Store-only token + TLS |
|
||||
| 4249 | spinifex-sqs-store (cluster) | NATS + TLS | Cluster | Inter-node SQS store replication | Store-only token + mutual TLS (cluster CA) |
|
||||
| 8223 | spinifex-sqs-store (monitor) | HTTP | Loopback | varz and jsz for the store | Loopback only |
|
||||
| 5300 | northstar | DNS (UDP + TCP) | Cluster | Forward target for every node's per-instance DNS shim, dialled cross-node. Binds the wildcard by design. | None — restrict by firewall |
|
||||
| 6660 | predastore (blob node) | QUIC / UDP | Cluster | Erasure-coded object shard transport between hosts. Multi-node clusters only — see *Predastore ports* below. | TLS 1.3, server certificate verified against the cluster CA |
|
||||
| 7660 | predastore (meta node) | QUIC / UDP | Cluster | Raft consensus over global state — buckets and the object index — between hosts. Multi-node clusters only. | TLS 1.3, server certificate verified against the cluster CA |
|
||||
@@ -221,7 +224,7 @@ table inet spinifex_filter {
|
||||
|
||||
# Cluster, from peer nodes only. Replace with your nodes' lan-plane
|
||||
# addresses — a CIDR does not generalise to nodes on different subnets.
|
||||
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
|
||||
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } tcp dport { 4222, 4223, 4248, 4249, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
|
||||
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } udp dport { 5300, 6660, 7660 } accept
|
||||
|
||||
# Encap, from peer chassis only. Replace with your nodes' vpc-plane
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
# Service interface manifest.
|
||||
#
|
||||
# Source of truth for "which services produce/consume which NATS subjects"
|
||||
# and "which e2e suites cover which services". Consumed by
|
||||
# tests/e2e/select-suites (Bead 4) to derive targeted suite selection from
|
||||
# a PR diff.
|
||||
#
|
||||
# See docs/development/improvements/e2e-targeted-suite-selection.md.
|
||||
#
|
||||
# Editing rules
|
||||
# -------------
|
||||
# - subscribes / publishes: subjects this service receives / sends. Both
|
||||
# lists use the literal subject string, or a "<prefix>.*" wildcard for
|
||||
# pattern subscriptions (e.g. "ebs.snapshot.*").
|
||||
# - depends_on: subjects this service requires from peers. Used by the
|
||||
# selector to compute "if X changes, which suites might break".
|
||||
# - Subjects must match what is actually referenced in source. The lint
|
||||
# in Bead 5 greps `"(ec2|vpc|ebs|s3|iam|elbv2|spinifex)\.[a-zA-Z._-]+"`
|
||||
# and fails if any subject is absent from this manifest.
|
||||
|
||||
version: 1
|
||||
|
||||
services:
|
||||
|
||||
awsgw:
|
||||
path: spinifex/runtime/roles/awsgw
|
||||
# TLS frontend, SigV4 auth. Pure router: HTTP → NATS request/response.
|
||||
# No subscribes; only publishes (via NATSRequest).
|
||||
subscribes: []
|
||||
publishes:
|
||||
- "ec2.*"
|
||||
- "elbv2.*"
|
||||
- "acm.*"
|
||||
- "route53.*"
|
||||
- "iam.*"
|
||||
- "rds.*"
|
||||
- "ecs.*"
|
||||
- "ochre.vector.*"
|
||||
depends_on:
|
||||
- "ec2.*"
|
||||
- "elbv2.*"
|
||||
- "acm.*"
|
||||
- "route53.*"
|
||||
- "iam.*"
|
||||
- "rds.*"
|
||||
- "ecs.*"
|
||||
- "ochre.vector.*"
|
||||
|
||||
spinifex_daemon:
|
||||
path: spinifex/runtime/roles/spinifex
|
||||
# Orchestrator. Owns most ec2.* + all elbv2.* + iam event publication.
|
||||
# Translates VPC / EBS / S3 operations into vpc.* / ebs.* / s3.* and
|
||||
# forwards to vpcd / viperblockd / predastore.
|
||||
#
|
||||
# Source layout: spinifex/runtime/roles/spinifex (entry), spinifex/runtime/daemon
|
||||
# (subscribe registration), spinifex/domains/<domain> and the legacy
|
||||
# spinifex/handlers/{ecs,eks,elbv2,iam,rds} packages (per-subject handler
|
||||
# bodies). Selector treats these paths as belonging to this service via
|
||||
# the `additional_paths` field.
|
||||
additional_paths:
|
||||
- spinifex/runtime/daemon
|
||||
- spinifex/domains/ec2/account
|
||||
- spinifex/domains/ec2/eigw
|
||||
- spinifex/domains/ec2/eip
|
||||
- spinifex/domains/ec2/igw
|
||||
- spinifex/domains/ec2/image
|
||||
- spinifex/domains/ec2/instance
|
||||
- spinifex/domains/ec2/key
|
||||
- spinifex/domains/ec2/launchtemplate
|
||||
- spinifex/domains/ec2/natgw
|
||||
- spinifex/domains/ec2/placementgroup
|
||||
- spinifex/domains/ec2/routetable
|
||||
- spinifex/domains/ec2/snapshot
|
||||
- spinifex/domains/ec2/spotinstance
|
||||
- spinifex/domains/ec2/tags
|
||||
- spinifex/domains/ec2/volume
|
||||
- spinifex/domains/ec2/volumestate
|
||||
- spinifex/domains/ec2/vpc
|
||||
- spinifex/domains/elbv2
|
||||
- spinifex/domains/acm
|
||||
- spinifex/domains/route53
|
||||
- spinifex/domains/iam
|
||||
- spinifex/domains/ecs
|
||||
- spinifex/domains/rds/instance
|
||||
- spinifex/domains/rds/subnetgroup
|
||||
- spinifex/domains/rds/parametergroup
|
||||
- spinifex/domains/rds/engine
|
||||
- spinifex/domains/ecs/taskdefinition
|
||||
- spinifex/domains/eks/cluster
|
||||
- spinifex/domains/eks/access
|
||||
- spinifex/domains/eks/addon
|
||||
# Guest add-on wire contract (subject, report, staged-manifest body).
|
||||
- contracts/eks/v1
|
||||
# ECS agent/scheduler Layer-2 bus wire contract (subjects, message types).
|
||||
- contracts/ecs/v1
|
||||
- spinifex/domains/ochre/vector
|
||||
subscribes:
|
||||
# EC2 instance lifecycle.
|
||||
- "ec2.RunInstances"
|
||||
- "ec2.DescribeInstances"
|
||||
- "ec2.DescribeStoppedInstances"
|
||||
- "ec2.DescribeTerminatedInstances"
|
||||
- "ec2.DescribeInstanceAttribute"
|
||||
- "ec2.DescribeInstanceStatus"
|
||||
- "ec2.DescribeInstanceTypes"
|
||||
- "ec2.ModifyInstanceAttribute"
|
||||
- "ec2.ModifyVolume"
|
||||
- "ec2.start"
|
||||
- "ec2.terminate"
|
||||
- "ec2.launch"
|
||||
# contracts/ec2/v1 owns the command payload and this subject vocabulary.
|
||||
# The wildcard is intentionally one final instance-ID token, not `>`.
|
||||
- "ec2.cmd.*"
|
||||
# EC2 VPC / network ops handled by daemon before delegation to vpcd.
|
||||
- "ec2.CreateVpc"
|
||||
- "ec2.DescribeVpcs"
|
||||
- "ec2.CreateSubnet"
|
||||
- "ec2.CreateInternetGateway"
|
||||
- "ec2.CreateKeyPair"
|
||||
- "ec2.CreateImage"
|
||||
- "ec2.DescribePlacementGroups"
|
||||
# Cluster coordination subjects.
|
||||
- "ec2.ReserveClusterNode"
|
||||
- "ec2.FinalizeClusterInstances"
|
||||
# An EIP association reaches whichever node holds the instance, which is
|
||||
# rarely the node that served the request (fan-out, no queue group).
|
||||
- "ec2.eni.public-ip-changed"
|
||||
# contracts/cluster/v1 owns the node status/capacity payload and subject.
|
||||
- "spinifex.node.status"
|
||||
# contracts/cluster/v1 owns the VM inventory payload and subject.
|
||||
- "spinifex.node.vms"
|
||||
- "spinifex.nodes.discover"
|
||||
- "spinifex.cluster.shutdown.progress"
|
||||
# Coordinated cluster-shutdown phases (fan-out, no queue group).
|
||||
- "spinifex.cluster.shutdown.gate"
|
||||
- "spinifex.cluster.shutdown.drain"
|
||||
- "spinifex.cluster.shutdown.storage"
|
||||
- "spinifex.cluster.shutdown.persist"
|
||||
- "spinifex.cluster.shutdown.infra"
|
||||
# Operator-plane Predastore topology contract (contracts/operator/v1).
|
||||
- "spinifex.storage.config"
|
||||
# ELBv2 control plane (domains/elbv2/service_nats.go topic map).
|
||||
- "elbv2.CreateLoadBalancer"
|
||||
- "elbv2.DeleteLoadBalancer"
|
||||
- "elbv2.DescribeLoadBalancers"
|
||||
- "elbv2.CreateTargetGroup"
|
||||
- "elbv2.DeleteTargetGroup"
|
||||
- "elbv2.DescribeTargetGroups"
|
||||
- "elbv2.RegisterTargets"
|
||||
- "elbv2.DeregisterTargets"
|
||||
- "elbv2.DescribeTargetHealth"
|
||||
- "elbv2.CreateListener"
|
||||
- "elbv2.DeleteListener"
|
||||
- "elbv2.DescribeListeners"
|
||||
# ACM-alike certificate store (domains/acm; subscribed in daemon.go).
|
||||
- "acm.ImportCertificate"
|
||||
- "acm.DescribeCertificate"
|
||||
- "acm.ListCertificates"
|
||||
- "acm.DeleteCertificate"
|
||||
# Route 53 hosted zones (domains/route53; subscribed in daemon.go).
|
||||
- "route53.*"
|
||||
# Operator-only zone activation and delegation, sent by spx admin dns.
|
||||
- "route53.admin.*"
|
||||
- "elbv2.AddListenerCertificates"
|
||||
- "elbv2.RemoveListenerCertificates"
|
||||
- "elbv2.DescribeListenerCertificates"
|
||||
- "elbv2.DescribeSSLPolicies"
|
||||
# RDS control plane (domains/rds/instance/subjects.go). One wildcard rather than
|
||||
# 30 literals: every action subject is registered from a constant, so a
|
||||
# per-subject list would be a hand-mirror of that file with nothing
|
||||
# checking it.
|
||||
- "rds.*"
|
||||
# ECS control plane (domains/ecs/service_nats.go, registered in
|
||||
# daemon.go). One wildcard, as for RDS: every action subject is a literal
|
||||
# in those two files, so a per-subject list would only mirror them.
|
||||
- "ecs.*"
|
||||
# Layer-2 agent bus: register and health are wildcard subscriptions over
|
||||
# {account}.{db-instance}, so the subject is built at runtime and only the
|
||||
# prefix is declarable.
|
||||
- "rds.bus.*"
|
||||
# ECS Layer-2 scheduler/agent bus (contracts/ecs/v1): account, cluster and
|
||||
# instance/task IDs are runtime-built, so only the prefix is declarable.
|
||||
# Only the leader holds these subscriptions, so there is no queue group.
|
||||
- "ecs.bus.*"
|
||||
# Ochre vector store tenant surface (domains/ochre/vector/api.go),
|
||||
# gated behind config.OchreVectorConfig.Enabled (default off).
|
||||
- "ochre.vector.createIndex"
|
||||
- "ochre.vector.deleteIndex"
|
||||
- "ochre.vector.listIndexes"
|
||||
- "ochre.vector.ingest"
|
||||
- "ochre.vector.describeJob"
|
||||
- "ochre.vector.query"
|
||||
- "ochre.vector.listJobs"
|
||||
- "ochre.vector.stopJob"
|
||||
# Fence route owned by contracts/viperblockd/legacy/v1: viperblockd
|
||||
# announces a torn-down export so this node stops the guest that was
|
||||
# using it. "ebs.fenced" is the single-node form; a named node is
|
||||
# addressed as "ebs.<node>.fenced".
|
||||
- "ebs.fenced"
|
||||
publishes:
|
||||
# → vpcd
|
||||
- "vpc.create"
|
||||
- "vpc.delete"
|
||||
- "vpc.create-subnet"
|
||||
- "vpc.delete-subnet"
|
||||
- "vpc.create-sg"
|
||||
- "vpc.delete-sg"
|
||||
- "vpc.update-sg"
|
||||
- "vpc.create-port"
|
||||
- "vpc.delete-port"
|
||||
- "vpc.update-port-sgs"
|
||||
# EC2-to-network projection owned by contracts/ec2/v1. The network
|
||||
# subscriber realizes it in OVN without reading EC2 private state.
|
||||
- "vpc.igw-attach"
|
||||
- "vpc.igw-detach"
|
||||
- "vpc.add-nat-gateway"
|
||||
- "vpc.delete-nat-gateway"
|
||||
- "vpc.add-nat"
|
||||
- "vpc.delete-nat"
|
||||
- "vpc.dhcp.acquire"
|
||||
- "vpc.dhcp.release"
|
||||
# Per-subnet route-table projections owned by domains/ec2/routetable:
|
||||
# IGW default-route reachability and the egress gate/ungate decision it
|
||||
# implies, published fire-and-forget on every route-table mutation.
|
||||
- "vpc.add-igw-route"
|
||||
- "vpc.delete-igw-route"
|
||||
- "vpc.gate-subnet-egress"
|
||||
- "vpc.ungate-subnet-egress"
|
||||
# Egress-only reroute removal for a single system instance
|
||||
# (domains/eks/cluster). Fire-and-forget. vpc.add-system-egress has no
|
||||
# current producer.
|
||||
- "vpc.delete-system-egress"
|
||||
# → viperblockd
|
||||
# The migrated legacy ebs.config and ebs.sync payloads and routes are
|
||||
# owned by contracts/viperblockd/legacy/v1. They are transitional and
|
||||
# distinct from the generic providers/ebs protocol; other legacy ebs.*
|
||||
# routes remain to be migrated separately.
|
||||
- "ebs.mount"
|
||||
- "ebs.unmount"
|
||||
- "ebs.delete"
|
||||
- "ebs.sync"
|
||||
- "ebs.snapshot.*"
|
||||
- "ebs.config"
|
||||
- "ebs.config.*"
|
||||
# Abandon route owned by contracts/viperblockd/legacy/v1: the daemon
|
||||
# asks viperblockd to tear an export down without sealing. "ebs.abandon"
|
||||
# is the single-node form; a named node addresses "ebs.<node>.abandon".
|
||||
- "ebs.abandon"
|
||||
# → predastore (S3 / AMI / keypair store)
|
||||
- "s3.*"
|
||||
# → every daemon, so the one holding the instance reports the new address
|
||||
- "ec2.eni.public-ip-changed"
|
||||
# IAM lifecycle event
|
||||
- "iam.account.created"
|
||||
# → the in-guest rds-agent: set-password and the like, published on the
|
||||
# per-instance command subject and answered on its reply subject.
|
||||
- "rds.bus.*"
|
||||
# ECS service-reconcile self-wake (contracts/ecs/v1): the worker that
|
||||
# recorded a task-state change is rarely the leader, so this fans the
|
||||
# wake out and the leader's own "ecs.bus.*" subscription picks it up.
|
||||
- "ecs.bus.*"
|
||||
depends_on:
|
||||
- "vpc.*"
|
||||
- "ebs.*"
|
||||
- "s3.*"
|
||||
|
||||
vpcd:
|
||||
path: spinifex/runtime/roles/vpcd
|
||||
# OVN-backed VPC / subnet / SG / NAT / IGW data plane.
|
||||
subscribes:
|
||||
- "vpc.create"
|
||||
- "vpc.delete"
|
||||
- "vpc.create-subnet"
|
||||
- "vpc.delete-subnet"
|
||||
- "vpc.create-sg"
|
||||
- "vpc.delete-sg"
|
||||
- "vpc.update-sg"
|
||||
- "vpc.create-port"
|
||||
- "vpc.delete-port"
|
||||
- "vpc.update-port-sgs"
|
||||
- "vpc.igw-attach"
|
||||
- "vpc.igw-detach"
|
||||
- "vpc.add-nat-gateway"
|
||||
- "vpc.delete-nat-gateway"
|
||||
- "vpc.add-nat"
|
||||
- "vpc.delete-nat"
|
||||
- "vpc.dhcp.acquire"
|
||||
- "vpc.dhcp.release"
|
||||
- "vpc.add-igw-route"
|
||||
- "vpc.delete-igw-route"
|
||||
- "vpc.gate-subnet-egress"
|
||||
- "vpc.ungate-subnet-egress"
|
||||
- "vpc.add-system-egress"
|
||||
- "vpc.delete-system-egress"
|
||||
publishes:
|
||||
- "vpc.port-status"
|
||||
- "vpc.dhcp.lease-changed"
|
||||
- "vpc.dhcp.owner-check"
|
||||
depends_on: []
|
||||
|
||||
viperblockd:
|
||||
path: spinifex/runtime/roles/viperblockd
|
||||
# EBS data plane. Backed by the viperblock storage engine in the
|
||||
# mulga/viperblock submodule. Submodule pointer bump → selector forces
|
||||
# "run all" (contract changes can be invisible at the spinifex source
|
||||
# level).
|
||||
# The migrated legacy ebs.config and ebs.sync routes are versioned by
|
||||
# contracts/viperblockd/legacy/v1, not by providers/ebs. Other legacy
|
||||
# ebs.* routes remain to be migrated separately.
|
||||
# The dirty-marker JetStream KV bucket VIPERBLOCK_VOLUME_DIRTY is owned
|
||||
# by the same contract; not a NATS subject, so not listed below.
|
||||
#
|
||||
# Source layout: spinifex/runtime/roles/viperblockd (role: process lifecycle,
|
||||
# NATS subscription wiring) and spinifex/providers/ebs/viperblock (the
|
||||
# Viperblock EBS provider adapter the role wires up). Selector treats the
|
||||
# adapter path as belonging to this service via `additional_paths`.
|
||||
additional_paths:
|
||||
- spinifex/providers/ebs/viperblock
|
||||
subscribes:
|
||||
- "ebs.mount"
|
||||
- "ebs.unmount"
|
||||
- "ebs.delete"
|
||||
- "ebs.sync"
|
||||
- "ebs.snapshot.*"
|
||||
- "ebs.config"
|
||||
- "ebs.config.*"
|
||||
# Abandon route (contracts/viperblockd/legacy/v1): request/reply from
|
||||
# the daemon, answered by the Viperblock adapter on the named node.
|
||||
- "ebs.abandon"
|
||||
publishes:
|
||||
- "ebs.mount.response"
|
||||
# Fence route (contracts/viperblockd/legacy/v1): announced after the
|
||||
# Viperblock adapter tears an export down because its lease moved.
|
||||
- "ebs.fenced"
|
||||
- "ebs.unmount.response"
|
||||
depends_on: []
|
||||
|
||||
predastore:
|
||||
path: spinifex/runtime/roles/predastore
|
||||
# S3 / AMI / keypair storage. External submodule under mulga/predastore;
|
||||
# this dir contains only the spinifex-side adapter. Submodule pointer
|
||||
# bump → selector forces "run all".
|
||||
subscribes:
|
||||
- "s3.*"
|
||||
publishes: []
|
||||
depends_on: []
|
||||
|
||||
nats:
|
||||
path: spinifex/runtime/roles/nats
|
||||
# NATS broker bootstrap / cluster config. Not a NATS pub/sub
|
||||
# participant. Any change here is infra-path; selector forces
|
||||
# "run all".
|
||||
subscribes: []
|
||||
publishes: []
|
||||
depends_on: []
|
||||
|
||||
spinifexui:
|
||||
path: spinifex/runtime/roles/spinifexui
|
||||
# Web UI. Talks to awsgw over HTTP, never NATS. Diffs limited to this
|
||||
# dir do not need any e2e suite to run.
|
||||
subscribes: []
|
||||
publishes: []
|
||||
depends_on: []
|
||||
covers_no_suite: true
|
||||
|
||||
|
||||
# Fixture → service mapping. Each Ensure* helper in
|
||||
# tests/e2e/harness/fixtures.go exercises this set of services; the
|
||||
# Bead 4 selector AST-scans test files for harness.Ensure* call sites
|
||||
# and resolves coverage via this block.
|
||||
fixtures:
|
||||
|
||||
EnsureKeyPair:
|
||||
services: [spinifex_daemon, predastore]
|
||||
|
||||
EnsureAMI:
|
||||
services: [spinifex_daemon, predastore, viperblockd]
|
||||
|
||||
EnsureDefaultVPC:
|
||||
services: [vpcd, spinifex_daemon]
|
||||
|
||||
EnsureSubnet:
|
||||
services: [vpcd, spinifex_daemon]
|
||||
|
||||
EnsureSG:
|
||||
services: [vpcd, spinifex_daemon]
|
||||
|
||||
EnsureInstance:
|
||||
services: [spinifex_daemon, viperblockd, vpcd]
|
||||
|
||||
EnsureVolume:
|
||||
services: [spinifex_daemon, viperblockd]
|
||||
|
||||
EnsureSnapshot:
|
||||
services: [spinifex_daemon, viperblockd, predastore]
|
||||
|
||||
EnsureNATGateway:
|
||||
services: [vpcd, spinifex_daemon]
|
||||
|
||||
EnsureLoadBalancer:
|
||||
services: [spinifex_daemon, awsgw]
|
||||
|
||||
|
||||
suites:
|
||||
|
||||
e2e-cert:
|
||||
path: tests/e2e/cert
|
||||
covers: [awsgw, spinifex_daemon, vpcd, predastore]
|
||||
|
||||
e2e-lb:
|
||||
path: tests/e2e/lb
|
||||
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
|
||||
# Exercises full ELBv2 control plane (domains/elbv2) + data plane
|
||||
# (LB agent on the system VM). ELBv2 is part of spinifex_daemon in
|
||||
# this manifest; revisit if it splits into a standalone service.
|
||||
|
||||
e2e-single:
|
||||
path: tests/e2e/single
|
||||
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
|
||||
# Full single-node lifecycle: VPC, AMI, keypair, instance, volume,
|
||||
# snapshot, IAM. Currently runs all phases sequentially under one
|
||||
# `TestSingleNode`. Bead 3 (single-fixture-migration) splits this
|
||||
# into per-test fixture-driven coverage so the selector can pick
|
||||
# subtests, not just whole suites.
|
||||
|
||||
e2e-iam:
|
||||
path: tests/e2e/iam
|
||||
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
|
||||
# "Everything IAM": the IAM/STS control plane plus the instance-identity
|
||||
# tests (instance-profile association + IMDS), which boot guest VMs — OVN
|
||||
# via vpcd, AMI via predastore, root volume via viperblockd, lifecycle via
|
||||
# spinifex_daemon. Single-mode, VM-booting leg.
|
||||
|
||||
e2e-ecr:
|
||||
path: tests/e2e/ecr
|
||||
covers: [awsgw, spinifex_daemon, predastore]
|
||||
# ECR control plane (domains/ecr, gateway/ecr*) + OCI data plane: blobs and
|
||||
# manifests in predastore, control + auth bridge in awsgw, KV via
|
||||
# spinifex_daemon. docker/crane/skopeo data-plane subtests skip when the
|
||||
# client binary or registry-host resolution is absent.
|
||||
|
||||
e2e-bedrock:
|
||||
path: tests/e2e/bedrock
|
||||
covers: [awsgw]
|
||||
# Ochre (AWS-Bedrock) control plane: static catalog list/get served
|
||||
# straight from awsgw (gateway/bedrock.go, domains/ochre/) — no NATS
|
||||
# hop to spinifex_daemon. Converse (Anthropic-direct / self-host vLLM)
|
||||
# subtests are gated on backend availability and skip by default.
|
||||
|
||||
e2e-ecs:
|
||||
path: tests/e2e/ecs
|
||||
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
|
||||
# ECS data plane end-to-end: cluster + a real container instance launched
|
||||
# from the spinifex-ecs-node AMI (boots, registers over the gateway, runs
|
||||
# tasks through containerd), standalone tasks in awsvpc/bridge/host modes,
|
||||
# and an awsvpc service fronted by an ELBv2 target group. Boots a guest VM,
|
||||
# so vpcd (OVN + awsvpc ENI), viperblockd (root volume), predastore (AMI)
|
||||
# are all in the path.
|
||||
|
||||
e2e-rds:
|
||||
path: tests/e2e/rds
|
||||
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
|
||||
# RDS create/describe/connect: CreateDBInstance boots a dual-NIC DB VM from
|
||||
# the engine AMI (predastore) with a Viperblock data volume (viperblockd) and
|
||||
# a customer ENI (vpcd), and the reconciler flips it to available on the
|
||||
# in-guest agent's first healthy heartbeat over awsgw. Every test deletes the
|
||||
# instances it creates, so the suite needs no gate beyond SPINIFEX_E2E.
|
||||
|
||||
e2e-ddil:
|
||||
path: tests/e2e/ddil
|
||||
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
|
||||
# DDIL = degraded / disconnected / intermittent / low-bandwidth.
|
||||
# Exercises the full daemon stack under network failure injection;
|
||||
# any service touching the wire path is in scope.
|
||||
|
||||
e2e-route53:
|
||||
path: tests/e2e/route53
|
||||
covers: [awsgw, spinifex_daemon, predastore]
|
||||
# Hosted zones end to end: delegation check, publish to the northstar zone
|
||||
# store in predastore, and every node answering before a change is INSYNC.
|
||||
|
||||
# Suites below are bash-only today. Entries activate as the Go ports
|
||||
# land (Bead 2 fixtures unblock new-shape ports for multinode etc.).
|
||||
#
|
||||
# e2e-multinode:
|
||||
# path: spinifex/tests/e2e/multinode
|
||||
# covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
|
||||
# e2e-reboot:
|
||||
# path: spinifex/tests/e2e/reboot
|
||||
# covers: [spinifex_daemon, viperblockd]
|
||||
# e2e-tofu:
|
||||
# path: spinifex/tests/e2e/tofu
|
||||
# covers: [awsgw, spinifex_daemon, vpcd]
|
||||
# e2e-bm:
|
||||
# path: spinifex/tests/e2e/baremetal
|
||||
# covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
|
||||
#
|
||||
# e2e-natuplink activates once CI can provision an external_mode=nat
|
||||
# node (setup-ovn.sh --nat-uplink + spx admin init --external-mode=nat).
|
||||
# e2e-natuplink:
|
||||
# path: spinifex/tests/e2e/natuplink
|
||||
# covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
|
||||
|
||||
|
||||
# Special-case rules consumed by the selector (Bead 4). Hard-coded in
|
||||
# the tool, listed here for visibility.
|
||||
#
|
||||
# infra_paths_force_all:
|
||||
# - spinifex/runtime/roles/nats/**
|
||||
# - spinifex/tests/e2e/harness/**
|
||||
# - spinifex/tests/e2e/lib/**
|
||||
# - spinifex/.github/workflows/e2e-go.yml
|
||||
# - spinifex/.github/scripts/**
|
||||
# - spinifex/Makefile
|
||||
# - mulga/scripts/tofu-cluster/**
|
||||
#
|
||||
# submodule_force_all:
|
||||
# - mulga/predastore (submodule pointer)
|
||||
# - mulga/viperblock (submodule pointer)
|
||||
#
|
||||
# many_services_force_all_threshold: 3 # ≥ N services touched → run all
|
||||
@@ -143,6 +143,7 @@ The create/describe/connect path against a live cluster: a DB VM that actually b
|
||||
- **`ddil`** — Denied, Disrupted, Intermittent, Limited network scenarios.
|
||||
- **`teardown`** — sweeps leftovers.
|
||||
- **`harness`** — shared primitives, not a suite. `harness/vpc_egress.go` carries the VPC/subnet/IGW/NAT-gateway fixtures the egress scenarios build on.
|
||||
- **`manifest-check`, `manifest-lint`** — manifest validation, not cluster behaviour.
|
||||
|
||||
## Keeping this honest
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ require (
|
||||
github.com/mulgadc/viperblock v1.23.0
|
||||
github.com/nats-io/nats-server/v2 v2.15.1
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
github.com/nats-io/nkeys v0.4.16
|
||||
github.com/opencontainers/runtime-spec v1.3.0
|
||||
github.com/oracle/oci-go-sdk/v65 v65.128.0
|
||||
github.com/ovn-kubernetes/libovsdb v0.9.0
|
||||
@@ -162,6 +161,7 @@ require (
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
|
||||
@@ -77,8 +77,8 @@ func TestEC2ErrorCatalogMetadataAndClasses(t *testing.T) {
|
||||
if catalog.metadata.VerifiedOn == "" {
|
||||
t.Fatal("verifiedOn is empty")
|
||||
}
|
||||
if len(catalog.codes) != 115 {
|
||||
t.Fatalf("catalog code count = %d, want 115", len(catalog.codes))
|
||||
if len(catalog.codes) != 114 {
|
||||
t.Fatalf("catalog code count = %d, want 114", len(catalog.codes))
|
||||
}
|
||||
if catalog.codes["InvalidParameterValue"] != ec2ErrorClassClient {
|
||||
t.Fatal("InvalidParameterValue is not classified as client")
|
||||
|
||||
@@ -5,6 +5,6 @@ package awsmodel
|
||||
// ModelCommit is the github.com/aws/api-models-aws commit the embedded
|
||||
// models were taken from, and ModelCommitDate the day it was committed.
|
||||
const (
|
||||
ModelCommit = "e10d6a1c5ac36af7c0c61d1f67b3c3ebbe41de49"
|
||||
ModelCommitDate = "2026-10-28"
|
||||
ModelCommit = "9b1a4929274443d8671ea8acfda7afbe856e29e9"
|
||||
ModelCommitDate = "2026-09-25"
|
||||
)
|
||||
|
||||
@@ -21,13 +21,13 @@ func TestLoadAllModels(t *testing.T) {
|
||||
shapes int
|
||||
}{
|
||||
ACM: {"2015-12-08", 40, 239},
|
||||
EC2: {"2016-11-15", 811, 4297},
|
||||
ECR: {"2015-09-21", 62, 426},
|
||||
ECS: {"2014-11-13", 88, 734},
|
||||
EKS: {"2017-11-01", 70, 458},
|
||||
EC2: {"2016-11-15", 804, 4245},
|
||||
ECR: {"2015-09-21", 58, 404},
|
||||
ECS: {"2014-11-13", 77, 643},
|
||||
EKS: {"2017-11-01", 70, 450},
|
||||
ElasticLoadBalancingV2: {"2015-12-01", 51, 418},
|
||||
IAM: {"2010-05-08", 180, 624},
|
||||
RDS: {"2014-10-31", 168, 816},
|
||||
RDS: {"2014-10-31", 164, 791},
|
||||
Route53: {"2013-04-01", 71, 409},
|
||||
S3: {"2006-03-01", 112, 710},
|
||||
STS: {"2011-06-15", 11, 101},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -50,7 +50,6 @@
|
||||
"InvalidCapacityReservationId.Malformed",
|
||||
"InvalidCapacityReservationId.NotFound",
|
||||
"InvalidEgressOnlyInternetGatewayId.NotFound",
|
||||
"InvalidGatewayID.NotFound",
|
||||
"InvalidGroup.Duplicate",
|
||||
"InvalidGroup.NotFound",
|
||||
"InvalidGroup.Reserved",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -111,7 +111,7 @@ func GenerateRequests(service Service, operationName string, options RequestOpti
|
||||
}
|
||||
|
||||
for _, member := range append([]string{""}, optional...) {
|
||||
if unguessable[member] && options.Fixtures[operationName+"."+member] == "" && options.Fixtures[member] == "" {
|
||||
if unguessable[member] {
|
||||
continue
|
||||
}
|
||||
value, _, err := generate()
|
||||
@@ -157,8 +157,7 @@ func GenerateRequests(service Service, operationName string, options RequestOpti
|
||||
|
||||
// unguessable members take values the model cannot describe: a pagination
|
||||
// token the service issued, or a filter name only its documentation lists.
|
||||
// They get rejection requests, and an acceptance request only when a fixture
|
||||
// supplies a value.
|
||||
// They get rejection requests but no acceptance request.
|
||||
var unguessable = map[string]bool{
|
||||
"Filter": true,
|
||||
"Filters": true,
|
||||
|
||||
@@ -337,20 +337,6 @@ func TestGenerateRequestsSkipsAcceptanceForUnguessableMembers(t *testing.T) {
|
||||
require.NotEmpty(t, markerRejections, "a pagination token still gets rejection requests")
|
||||
}
|
||||
|
||||
func TestGenerateRequestsAcceptsAFixtureForAnUnguessableMember(t *testing.T) {
|
||||
options := testRequestOptions
|
||||
options.Fixtures = map[string]string{"ListRoles.Marker": "fixture-marker"}
|
||||
plan, err := GenerateRequests(IAM, "ListRoles", options)
|
||||
require.NoError(t, err)
|
||||
var markers []any
|
||||
for _, request := range plan.Cases {
|
||||
if request.Acceptance() && request.Member == "Marker" {
|
||||
markers = append(markers, request.Input["Marker"])
|
||||
}
|
||||
}
|
||||
require.Equal(t, []any{"fixture-marker"}, markers)
|
||||
}
|
||||
|
||||
func TestGenerateRequestsUsesResourceHints(t *testing.T) {
|
||||
tests := []struct {
|
||||
service Service
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Node installation, dev-environment lifecycle, guest image builds, and verificati
|
||||
| `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, writes their `ovn-ctl` flags to `/etc/default/ovn-central` (Debian) or `/etc/sysconfig/ovn-northd` (EL), and records the role in `/etc/spinifex/ovn-role`. With clustered databases it returns only once this node's NB and SB members are back in their RAFT clusters under a known leader, and fails after `OVN_RAFT_REJOIN_WAIT` seconds (default 60) otherwise, so reruns one node at a time never take two members of a cluster down. A later run without `--management` refuses while that file, or the `OVN_CTL_OPTS` line older nodes carry in `/etc/default/ovn-central`, records the role. It adds `maxsize 100M` to the OVS and OVN logrotate files, plus `rotate 5` where the file sets no count, and rotates as root, matching the daemons. On a host whose IPsec stack is libreswan it enables, stops and masks `openvswitch-ipsec.service` and `ipsec.service` for the daemon to unmask when IPsec is on, unless `openvswitch-ipsec.service` is already running; elsewhere it enables and starts `openvswitch-ipsec.service`, unless the daemon has masked it to keep IPsec off. 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, starting `ovn-controller` again when an OVS stop took it down; 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. Before touching any host it refuses hosts of different OS families (`distro_os_family`) or `spx` versions, and `--ipsec on` when any host uses libreswan. 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. |
|
||||
| `build-oracle-appliance.sh` | Turns an Oracle Linux 10.2 system reachable over SSH with sudo into a sealed gold image, then powers it off. It updates OL and reboots into the newest UEK R8, sets SELinux permissive, installs Spinifex without initialising it, and leaves `spinifex.target` and the NATS watchdog disabled. It creates the `spinifex` login with a default password that must be changed at first login, installs `appliance/firstboot.sh` as `spinifex-appliance-firstboot.service`, and wipes the system's identity (machine-id, SSH host keys, OVS chassis, NetworkManager profiles, logs). On each unit's first boot that service puts the first physical NIC with carrier on a DHCP `br-wan`, names the host `spinifex-<mac>`, runs `setup-ovn.sh --management` and `spx admin init --nodes 1`, and shows the IP and login on the console and in `/etc/motd`. `--channel`, `--version` and `--tarball` pick what to install. |
|
||||
| `build-oracle-appliance.sh` | Turns an Oracle Linux 10.2 system reachable over SSH with passwordless sudo into a sealed gold image, then powers it off. It updates OL and reboots into the newest UEK R8, sets SELinux permissive, installs Spinifex without initialising it, and leaves `spinifex.target` and the NATS watchdog disabled. It creates the `spinifex` login with a default password that must be changed at first login, installs `appliance/firstboot.sh` as `spinifex-appliance-firstboot.service`, and wipes the system's identity (machine-id, SSH host keys, OVS chassis, NetworkManager profiles, logs). On each unit's first boot that service puts the first physical NIC with carrier on a DHCP `br-wan`, names the host `spinifex-<mac>`, runs `setup-ovn.sh --management` and `spx admin init --nodes 1`, and shows the IP and login on the console and in `/etc/motd`. `--channel`, `--version` and `--tarball` pick what to install. |
|
||||
| `clone-deps.sh` | Clones or updates the viperblock and predastore checkouts alongside spinifex for cross-repo development. |
|
||||
|
||||
### `lib/distro.sh`: host-OS capability probes
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# prepare.sh appliance spinifex login, first-boot service, console placeholder
|
||||
# prepare.sh seal wipe this system's identity, then power off
|
||||
# INSTALL_SPINIFEX_CHANNEL, INSTALL_SPINIFEX_VERSION and INSTALL_SPINIFEX_TARBALL
|
||||
# pick the release tarball to install. BUILD_USER is the SSH user to lock at seal.
|
||||
# pass through to the installer. BUILD_USER is the SSH user to lock at seal.
|
||||
set -euo pipefail
|
||||
|
||||
# Shared by every unit built from this image; first login forces a change.
|
||||
@@ -64,24 +64,19 @@ stage_update() {
|
||||
|
||||
stage_install() {
|
||||
[ ! -e /etc/spinifex/spinifex.toml ] || die "/etc/spinifex/spinifex.toml exists; this system has been initialised, start from a clean OL install"
|
||||
# install.mulgadc.com serves the latest stable setup.sh, which may predate
|
||||
# Oracle Linux support, so run the setup.sh inside the release tarball.
|
||||
local dir tarball
|
||||
dir=$(mktemp -d)
|
||||
local args=()
|
||||
[ -n "${INSTALL_SPINIFEX_CHANNEL:-}" ] && args+=(--channel "$INSTALL_SPINIFEX_CHANNEL")
|
||||
[ -n "${INSTALL_SPINIFEX_VERSION:-}" ] && args+=(--version "$INSTALL_SPINIFEX_VERSION")
|
||||
if [ -n "${INSTALL_SPINIFEX_TARBALL:-}" ]; then
|
||||
tarball=$INSTALL_SPINIFEX_TARBALL
|
||||
# A local build: the payload carries the setup.sh of the checkout the
|
||||
# tarball was built from, and setup.sh reads the tarball from the env.
|
||||
[ -f "$HERE/setup.sh" ] || die "no setup.sh in the payload for INSTALL_SPINIFEX_TARBALL"
|
||||
INSTALL_SPINIFEX_SKIP_NEWGRP=1 bash "$HERE/setup.sh" "${args[@]}"
|
||||
else
|
||||
local url="https://install.mulgadc.com/download/${INSTALL_SPINIFEX_VERSION:-${INSTALL_SPINIFEX_CHANNEL:-latest}}/amd64"
|
||||
tarball=$dir/spinifex.tar.gz
|
||||
log "downloading $url"
|
||||
curl -fsSL -o "$tarball" "$url"
|
||||
curl -fsSL -o "$dir/checksum.sha256" "$url.sha256"
|
||||
[ "$(awk '{print $1}' "$dir/checksum.sha256")" = "$(sha256sum "$tarball" | awk '{print $1}')" ] \
|
||||
|| die "checksum mismatch for $url"
|
||||
curl -fsSL -o /tmp/spinifex-setup.sh https://install.mulgadc.com
|
||||
INSTALL_SPINIFEX_SKIP_NEWGRP=1 bash /tmp/spinifex-setup.sh "${args[@]}"
|
||||
rm -f /tmp/spinifex-setup.sh
|
||||
fi
|
||||
tar -xzf "$tarball" -C "$dir" ./setup.sh
|
||||
INSTALL_SPINIFEX_TARBALL=$tarball INSTALL_SPINIFEX_SKIP_NEWGRP=1 bash "$dir/setup.sh"
|
||||
rm -rf "$dir"
|
||||
# The installer enables these on a live host. The ISO image ships them off
|
||||
# and first boot enables them after init; a clone that started them first
|
||||
# would run every service with no config.
|
||||
@@ -101,14 +96,6 @@ stage_appliance() {
|
||||
chmod 0440 /etc/sudoers.d/spinifex-admin
|
||||
visudo -cf /etc/sudoers.d/spinifex-admin >/dev/null
|
||||
|
||||
# OL's pwquality gives one try per login, then ends the session, so say the
|
||||
# rules up front. spx admin banner keeps /etc/motd content above its own
|
||||
# section, and agetty shows /etc/issue.d beside the banner in /etc/issue.
|
||||
local hint="First login: the new password needs 8+ characters and must not contain 'spinifex'."
|
||||
printf '%s\n' "$hint" > /etc/motd
|
||||
install -d /etc/issue.d
|
||||
printf '%s\n\n' "$hint" > /etc/issue.d/spinifex-password.issue
|
||||
|
||||
# sshd takes the first value it reads, so this sorts ahead of the
|
||||
# 50-cloud-init.conf that templates ship with passwords off.
|
||||
printf 'PasswordAuthentication yes\n' > /etc/ssh/sshd_config.d/10-spinifex-appliance.conf
|
||||
@@ -167,7 +154,7 @@ stage_seal() {
|
||||
home=$(getent passwd "$BUILD_USER" | cut -d: -f6)
|
||||
rm -f "$home/.ssh/authorized_keys" "$home/.bash_history"
|
||||
passwd -l "$BUILD_USER" >/dev/null
|
||||
rm -f /etc/sudoers.d/90-cloud-init-users /etc/sudoers.d/90-spinifex-build
|
||||
rm -f "/etc/sudoers.d/90-cloud-init-users"
|
||||
fi
|
||||
rm -f /root/.ssh/authorized_keys /root/.bash_history /home/spinifex/.bash_history
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
# --version <tag> install a specific release
|
||||
# --tarball <path> install a local release tarball instead
|
||||
#
|
||||
# Ports, keys and jump hosts come from ~/.ssh/config. <user> needs sudo;
|
||||
# without passwordless sudo, sudo asks for its password once.
|
||||
# Ports, keys and jump hosts come from ~/.ssh/config. <user> needs
|
||||
# passwordless sudo.
|
||||
set -euo pipefail
|
||||
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
@@ -59,7 +59,8 @@ push_payload() {
|
||||
rsh "rm -rf $REMOTE_DIR && mkdir -p $REMOTE_DIR"
|
||||
tar -C "$PAYLOAD_SRC" -cf - . | rsh "tar -C $REMOTE_DIR -xf -"
|
||||
if [ -n "$TARBALL" ]; then
|
||||
scp "${SSH_OPTS[@]}" -q "$TARBALL" "$TARGET:$REMOTE_DIR/"
|
||||
# setup.sh from this checkout installs the tarball built from it.
|
||||
scp "${SSH_OPTS[@]}" -q "$HERE/setup.sh" "$TARBALL" "$TARGET:$REMOTE_DIR/"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -74,17 +75,7 @@ wait_for_ssh() {
|
||||
die "$TARGET did not come back over SSH after the reboot"
|
||||
}
|
||||
|
||||
rsh true || die "cannot SSH to $TARGET; set its key in ~/.ssh/config (IdentityFile) or ssh-add it"
|
||||
# The stages run sudo with no terminal, so grant passwordless sudo for the
|
||||
# build when the user lacks it. Seal removes the grant.
|
||||
if ! rsh "sudo -n true" 2>/dev/null; then
|
||||
log "Granting $BUILD_USER passwordless sudo for the build; sudo asks for its password once"
|
||||
ssh "${SSH_OPTS[@]}" -t "$TARGET" "echo '$BUILD_USER ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/90-spinifex-build >/dev/null && sudo chmod 0440 /etc/sudoers.d/90-spinifex-build" \
|
||||
|| die "could not grant $BUILD_USER passwordless sudo on the build system"
|
||||
rsh "sudo -n true" || die "$BUILD_USER still has no passwordless sudo on the build system"
|
||||
fi
|
||||
# A minimal OL install has no tar, which the payload copy and the installer need.
|
||||
rsh "command -v tar >/dev/null || sudo dnf install -y -q tar" || die "cannot install tar on the build system"
|
||||
rsh "sudo -n true" || die "$BUILD_USER needs passwordless sudo on the build system"
|
||||
|
||||
log "Copying the payload to $TARGET"
|
||||
push_payload
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
#!/bin/bash
|
||||
# build-sqs-store-microvm-image.sh — build the SQS store microVM kernel +
|
||||
# initramfs: the dedicated nats-server, ext4 support, the store's guest steps
|
||||
# and a clean power-down. The guest never formats a disk; build/microvm/
|
||||
# sqs-store/sqs-store-lib.sh states its fw_cfg contract. The LB image
|
||||
# (build-microvm-image.sh) is separate and unchanged.
|
||||
#
|
||||
# Inputs:
|
||||
# $REPO_ROOT/build/microvm/sqs-store/init.sh, inittab
|
||||
# $REPO_ROOT/build/microvm/sqs-store/sqs-store-{lib.sh,prepare,run,stop}
|
||||
# $REPO_ROOT/bin/nats-server (make bin/nats-server, the go.mod-pinned version)
|
||||
#
|
||||
# Outputs:
|
||||
# $REPO_ROOT/build/microvm/sqs-store/vmlinuz
|
||||
# $REPO_ROOT/build/microvm/sqs-store/initramfs.cpio.gz
|
||||
#
|
||||
# Rootfs strategy (first match wins):
|
||||
# 1. apk available on host → install directly into a temp chroot (Alpine CI/CD)
|
||||
# 2. docker available → run alpine container, export filesystem
|
||||
# 3. podman available → same via podman
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BUILD_DIR="$REPO_ROOT/build/microvm/sqs-store"
|
||||
INIT_SH="$BUILD_DIR/init.sh"
|
||||
INITTAB="$BUILD_DIR/inittab"
|
||||
NATS_SERVER_BIN="$REPO_ROOT/bin/nats-server"
|
||||
|
||||
ALPINE_VERSION="${ALPINE_VERSION:-3.24}"
|
||||
ALPINE_MIRROR="${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}"
|
||||
# e2fsprogs: mkfs.ext4/e2fsck for a guest that mounts an ext4 data volume.
|
||||
# kmod: the image's own depmod/modprobe rebuild module metadata below.
|
||||
ALPINE_PACKAGES="busybox linux-virt iproute2 ca-certificates e2fsprogs kmod"
|
||||
# Modules kept by name on top of the virtio/fw_cfg/failover globs, each with its
|
||||
# full depends= and softdep closure. tiny_power_button turns the ACPI power
|
||||
# button (QMP system_powerdown) into a signal busybox init handles as poweroff.
|
||||
MICROVM_EXTRA_MODULES="${MICROVM_EXTRA_MODULES:-ext4 tiny_power_button}"
|
||||
|
||||
echo "[build-sqs-store-microvm-image] repo root: $REPO_ROOT"
|
||||
echo "[build-sqs-store-microvm-image] build dir: $BUILD_DIR"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
# --- Verify non-negotiable tools ---
|
||||
# fakeroot is mandatory: device nodes are written into the cpio archive inside a
|
||||
# faked-root environment so no real device node is ever created on the host
|
||||
# filesystem (which previously required sudo mknod and risked clobbering host
|
||||
# /dev/null on cleanup).
|
||||
for tool in cpio gzip find fakeroot; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "ERROR: required tool not found: $tool" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Verify the dedicated nats-server is the go.mod-pinned build ---
|
||||
# A stale or host-installed binary would ship an unreviewed server version.
|
||||
NATS_SERVER_PIN=$(awk '$1 == "github.com/nats-io/nats-server/v2" { print $2; exit }' "$REPO_ROOT/go.mod")
|
||||
if [ -z "$NATS_SERVER_PIN" ]; then
|
||||
echo "ERROR: no github.com/nats-io/nats-server/v2 requirement in $REPO_ROOT/go.mod" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -x "$NATS_SERVER_BIN" ]; then
|
||||
echo "ERROR: $NATS_SERVER_BIN not found — run 'make bin/nats-server'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! file -b "$NATS_SERVER_BIN" | grep -q "statically linked"; then
|
||||
echo "ERROR: $NATS_SERVER_BIN is not statically linked — it cannot run on the musl guest" >&2
|
||||
exit 1
|
||||
fi
|
||||
nats_server_version=$("$NATS_SERVER_BIN" --version 2>&1 | awk '{ print $NF; exit }')
|
||||
if [ "$nats_server_version" != "$NATS_SERVER_PIN" ]; then
|
||||
echo "ERROR: $NATS_SERVER_BIN reports $nats_server_version, go.mod pins $NATS_SERVER_PIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[build-sqs-store-microvm-image] nats-server: $nats_server_version (matches go.mod)"
|
||||
|
||||
# --- Create chroot ---
|
||||
CHROOT_DIR=$(mktemp -d)
|
||||
# Guard: every destructive op below targets "$CHROOT_DIR/...". An empty or
|
||||
# unexpected value would aim rm -rf at the host (e.g. /dev). Refuse anything that
|
||||
# is not a freshly-created temp dir.
|
||||
case "${CHROOT_DIR:?mktemp -d returned empty}" in
|
||||
/tmp/*|/var/tmp/*|"${TMPDIR:-/nonexistent}"/*) ;;
|
||||
*) echo "ERROR: refusing to use unexpected CHROOT_DIR: $CHROOT_DIR" >&2; exit 1 ;;
|
||||
esac
|
||||
# Outputs are written here first, beside $BUILD_DIR so publishing is a rename.
|
||||
STAGE_DIR=$(mktemp -d "$BUILD_DIR/.stage.XXXXXX")
|
||||
CONTAINER_TOOL=""
|
||||
CONTAINER_CID=""
|
||||
cleanup() {
|
||||
echo "[build-sqs-store-microvm-image] cleaning up chroot: $CHROOT_DIR"
|
||||
rm -rf "$CHROOT_DIR"
|
||||
rm -rf "${STAGE_DIR:?}"
|
||||
if [ -n "$CONTAINER_CID" ] && [ -n "$CONTAINER_TOOL" ]; then
|
||||
echo "[build-sqs-store-microvm-image] cleaning up container: $CONTAINER_CID"
|
||||
$CONTAINER_TOOL rm -f "$CONTAINER_CID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# --- Populate rootfs ---
|
||||
# Strategy 1: native apk (Alpine hosts, CI)
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
echo "[build-sqs-store-microvm-image] rootfs: native apk"
|
||||
mkdir -p \
|
||||
"$CHROOT_DIR/etc/apk" \
|
||||
"$CHROOT_DIR/lib/apk/db" \
|
||||
"$CHROOT_DIR/var/cache/apk"
|
||||
cat > "$CHROOT_DIR/etc/apk/repositories" <<EOF
|
||||
${ALPINE_MIRROR}/v${ALPINE_VERSION}/main
|
||||
${ALPINE_MIRROR}/v${ALPINE_VERSION}/community
|
||||
EOF
|
||||
apk add \
|
||||
--root "$CHROOT_DIR" \
|
||||
--arch x86_64 \
|
||||
--no-cache \
|
||||
--allow-untrusted \
|
||||
--initdb \
|
||||
$ALPINE_PACKAGES
|
||||
|
||||
# Strategy 2: docker -> BuildKit local export.
|
||||
# Do NOT use `docker export` here. On Docker's overlay2 driver it leaks the
|
||||
# container's overlay mount (the mount survives `docker rm`), orphaning layers
|
||||
# under /var/lib/docker that only a daemon restart reclaims; on a busy CI host
|
||||
# this grows without bound. `buildx --output type=local` writes the image
|
||||
# filesystem straight to a directory and releases every mount, so nothing leaks.
|
||||
elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||
echo "[build-sqs-store-microvm-image] rootfs: docker buildx --output type=local (alpine:${ALPINE_VERSION})"
|
||||
BUILD_CTX=$(mktemp -d)
|
||||
cat > "$BUILD_CTX/Dockerfile" <<DOCKERFILE
|
||||
FROM alpine:${ALPINE_VERSION}
|
||||
RUN apk add --no-cache ${ALPINE_PACKAGES}
|
||||
DOCKERFILE
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--output "type=local,dest=${CHROOT_DIR}" \
|
||||
"$BUILD_CTX"
|
||||
rm -rf "$BUILD_CTX"
|
||||
|
||||
# Strategy 3: podman -> run + export. Podman's store does not exhibit the docker
|
||||
# overlay2 export-mount leak, and podman has no buildx.
|
||||
elif command -v podman >/dev/null 2>&1; then
|
||||
echo "[build-sqs-store-microvm-image] rootfs: podman export (alpine:${ALPINE_VERSION})"
|
||||
CONTAINER_TOOL=podman
|
||||
CONTAINER_CID=$(podman run -d \
|
||||
"alpine:${ALPINE_VERSION}" \
|
||||
sh -c "apk add --no-cache ${ALPINE_PACKAGES}")
|
||||
|
||||
exit_code=$(podman wait "$CONTAINER_CID")
|
||||
if [ "$exit_code" != "0" ]; then
|
||||
echo "ERROR: package installation failed in container (exit $exit_code)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
podman export "$CONTAINER_CID" | tar -x -C "$CHROOT_DIR"
|
||||
|
||||
else
|
||||
echo "ERROR: no rootfs build tool available." >&2
|
||||
echo " Install one of: apk (Alpine), docker, podman" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- /dev: empty mountpoint only ---
|
||||
# tar extraction without root silently creates 0-byte regular files for device
|
||||
# nodes. Drop them and leave an empty /dev mountpoint. The two static nodes the
|
||||
# kernel needs (/dev/console to wire init's stdio, /dev/null) are written into
|
||||
# the cpio archive under fakeroot at pack time — NOT created on the host. init
|
||||
# mounts devtmpfs over /dev as its first action for everything else at runtime.
|
||||
rm -rf "${CHROOT_DIR:?}/dev"
|
||||
mkdir "$CHROOT_DIR/dev"
|
||||
|
||||
# --- Place init script ---
|
||||
echo "[build-sqs-store-microvm-image] installing init.sh as /init..."
|
||||
if [ ! -f "$INIT_SH" ]; then
|
||||
echo "ERROR: $INIT_SH not found — cannot build initramfs" >&2
|
||||
exit 1
|
||||
fi
|
||||
install -m 0755 "$INIT_SH" "$CHROOT_DIR/init"
|
||||
|
||||
# --- Place inittab ---
|
||||
# Required: without it busybox init runs its default table, not the power-down.
|
||||
if [ ! -f "$INITTAB" ]; then
|
||||
echo "ERROR: $INITTAB not found — cannot build initramfs" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[build-sqs-store-microvm-image] installing inittab..."
|
||||
install -m 0644 "$INITTAB" "$CHROOT_DIR/etc/inittab"
|
||||
|
||||
# --- Place the store's guest steps ---
|
||||
echo "[build-sqs-store-microvm-image] installing store guest steps..."
|
||||
mkdir -p "$CHROOT_DIR/usr/local/lib/sqs-store"
|
||||
for step in sqs-store-lib.sh sqs-store-prepare sqs-store-run sqs-store-stop; do
|
||||
if [ ! -f "$BUILD_DIR/$step" ]; then
|
||||
echo "ERROR: $BUILD_DIR/$step not found — cannot build initramfs" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
install -m 0644 "$BUILD_DIR/sqs-store-lib.sh" "$CHROOT_DIR/usr/local/lib/sqs-store/"
|
||||
install -m 0755 "$BUILD_DIR/sqs-store-prepare" "$BUILD_DIR/sqs-store-run" "$BUILD_DIR/sqs-store-stop" \
|
||||
"$CHROOT_DIR/usr/local/lib/sqs-store/"
|
||||
|
||||
# --- Copy nats-server binary ---
|
||||
echo "[build-sqs-store-microvm-image] copying nats-server binary..."
|
||||
mkdir -p "$CHROOT_DIR/usr/local/bin"
|
||||
install -m 0755 "$NATS_SERVER_BIN" "$CHROOT_DIR/usr/local/bin/nats-server"
|
||||
|
||||
# --- Create required directories for init ---
|
||||
mkdir -p \
|
||||
"$CHROOT_DIR/proc" \
|
||||
"$CHROOT_DIR/sys" \
|
||||
"$CHROOT_DIR/dev" \
|
||||
"$CHROOT_DIR/etc/ssl/certs"
|
||||
|
||||
# --- Locate kernel ---
|
||||
KERNEL_IMG=$(find "$CHROOT_DIR/boot" -name "vmlinuz*" | sort | tail -1)
|
||||
if [ -z "$KERNEL_IMG" ]; then
|
||||
echo "ERROR: no vmlinuz found in $CHROOT_DIR/boot" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[build-sqs-store-microvm-image] kernel image: $KERNEL_IMG"
|
||||
|
||||
KERNEL_VER=$(basename "$KERNEL_IMG" | sed 's/vmlinuz-//')
|
||||
KERNEL_CONFIG="$CHROOT_DIR/boot/config-${KERNEL_VER}"
|
||||
|
||||
# --- Assert fw_cfg module ---
|
||||
if ! find "$CHROOT_DIR/lib/modules" \
|
||||
-name "qemu_fw_cfg.ko*" \
|
||||
-o -name "fw_cfg_sysfs.ko*" \
|
||||
| grep -q .; then
|
||||
if ! grep -q "CONFIG_FW_CFG_SYSFS=y" "$KERNEL_CONFIG" 2>/dev/null; then
|
||||
echo "ERROR: qemu_fw_cfg module missing from initramfs — fw_cfg reads will fail at boot" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[build-sqs-store-microvm-image] fw_cfg: built-in (CONFIG_FW_CFG_SYSFS=y)"
|
||||
else
|
||||
echo "[build-sqs-store-microvm-image] fw_cfg: module found in lib/modules"
|
||||
fi
|
||||
|
||||
# --- Assert ext4 userspace tools (data volume) ---
|
||||
for fs_tool in mkfs.ext4 e2fsck; do
|
||||
if [ ! -x "$CHROOT_DIR/sbin/$fs_tool" ] && [ ! -x "$CHROOT_DIR/usr/sbin/$fs_tool" ]; then
|
||||
echo "ERROR: $fs_tool missing from image — guest cannot check or create an ext4 data volume" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "[build-sqs-store-microvm-image] e2fsprogs: mkfs.ext4 + e2fsck present"
|
||||
|
||||
# --- Strip kernel modules (Phase 2 keep-list approach) ---
|
||||
# Only modules needed by a virtio-mmio microvm guest are kept:
|
||||
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
|
||||
# *fw_cfg*.ko* — QEMU fw_cfg sysfs driver (delivers boot blobs)
|
||||
# *failover*.ko* — net_failover + failover (virtio_net deps for live migration)
|
||||
# $MICROVM_EXTRA_MODULES — ext4 by default, plus dependency closure
|
||||
# All other loadable modules are removed. Drivers compiled into the kernel
|
||||
# (CONFIG_VIRTIO_MMIO=y etc.) are unaffected — they live in vmlinuz, not here.
|
||||
|
||||
# module_path prints a module's kernel/ path from the stock modules.dep, by
|
||||
# name or modules.alias; it fails when the module is not loadable.
|
||||
module_path() {
|
||||
local kdir="$1" name="${2//-/_}" path alias_target
|
||||
path=$(awk -F: -v n="$name" '{ f = $1; sub(/.*\//, "", f); sub(/\.ko(\.gz|\.xz|\.zst)?$/, "", f);
|
||||
gsub(/-/, "_", f); if (f == n) { print $1; exit } }' "$kdir/modules.dep")
|
||||
if [ -z "$path" ] && [ -f "$kdir/modules.alias" ]; then
|
||||
alias_target=$(awk -v n="$2" '$1 == "alias" && $2 == n { print $3; exit }' "$kdir/modules.alias")
|
||||
[ -n "$alias_target" ] && [ "$alias_target" != "$2" ] && path=$(module_path "$kdir" "$alias_target")
|
||||
fi
|
||||
[ -n "$path" ] && printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
# module_builtin reports whether a module name is compiled into the kernel.
|
||||
module_builtin() {
|
||||
local kdir="$1" name="${2//-/_}"
|
||||
[ -f "$kdir/modules.builtin" ] || return 1
|
||||
awk -v n="$name" '{ f = $0; sub(/.*\//, "", f); sub(/\.ko$/, "", f); gsub(/-/, "_", f);
|
||||
if (f == n) { found = 1; exit } } END { exit !found }' "$kdir/modules.builtin"
|
||||
}
|
||||
|
||||
# image_kmod runs the image's own kmod (depmod, modprobe) through its musl
|
||||
# loader, so module metadata matches the guest's modprobe without a host kmod.
|
||||
image_kmod() {
|
||||
"$CHROOT_DIR/lib/ld-musl-x86_64.so.1" --library-path "$CHROOT_DIR/lib:$CHROOT_DIR/usr/lib" \
|
||||
--argv0 "$1" "$CHROOT_DIR/bin/kmod" "${@:2}"
|
||||
}
|
||||
|
||||
echo "[build-sqs-store-microvm-image] stripping kernel modules to virtio+fw_cfg+${MICROVM_EXTRA_MODULES} only..."
|
||||
for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
|
||||
kver="$(basename "$kver_dir")"
|
||||
kernel_dir="$kver_dir/kernel"
|
||||
[ -d "$kernel_dir" ] || continue
|
||||
|
||||
if [ ! -s "$kver_dir/modules.dep" ]; then
|
||||
echo "ERROR: stock ${kver_dir}modules.dep missing — cannot resolve module dependencies" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extra modules start the closure; the glob families are kept as found.
|
||||
keep_list=$(mktemp)
|
||||
: > "$keep_list"
|
||||
for name in $MICROVM_EXTRA_MODULES; do
|
||||
if ! path=$(module_path "$kver_dir" "$name"); then
|
||||
if module_builtin "$kver_dir" "$name"; then continue; fi
|
||||
echo "ERROR: module $name is neither loadable nor built into $kver" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$path" >> "$keep_list"
|
||||
done
|
||||
|
||||
# Close over modules.dep hard dependencies and modules.softdep pre:/post:
|
||||
# names until the set stops growing. A softdep must be loadable or built in.
|
||||
while :; do
|
||||
before=$(sort -u "$keep_list" | wc -l)
|
||||
for path in $(sort -u "$keep_list"); do
|
||||
awk -F': ' -v p="$path" '$1 == p { n = split($2, d, " "); for (i = 1; i <= n; i++) print d[i] }' \
|
||||
"$kver_dir/modules.dep" >> "$keep_list"
|
||||
mod=$(basename "$path"); mod="${mod%%.ko*}"; mod="${mod//-/_}"
|
||||
[ -f "$kver_dir/modules.softdep" ] || continue
|
||||
for soft in $(awk -v n="$mod" '{ m = $2; gsub(/-/, "_", m) }
|
||||
$1 == "softdep" && m == n { for (i = 3; i <= NF; i++) if ($i != "pre:" && $i != "post:") print $i }' \
|
||||
"$kver_dir/modules.softdep"); do
|
||||
if soft_path=$(module_path "$kver_dir" "$soft"); then
|
||||
echo "$soft_path" >> "$keep_list"
|
||||
elif ! module_builtin "$kver_dir" "$soft"; then
|
||||
echo "ERROR: softdep $soft of $mod is neither loadable nor built into $kver" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
after=$(sort -u "$keep_list" | wc -l)
|
||||
[ "$before" -eq "$after" ] && break
|
||||
done
|
||||
|
||||
extra_names=$(sort -u "$keep_list" | while read -r p; do m=$(basename "$p"); m="${m%%.ko*}"; echo "${m//-/_}"; done)
|
||||
(cd "$kver_dir" && find kernel -name "virtio*.ko*" -o -name "*fw_cfg*.ko*" \
|
||||
-o -name "*failover*.ko*") >> "$keep_list"
|
||||
|
||||
# Copy the keeper set out, preserving each kernel/ relative path.
|
||||
tmp_keep=$(mktemp -d)
|
||||
while read -r rel; do
|
||||
rel="${rel#kernel/}"
|
||||
mkdir -p "$tmp_keep/$(dirname "$rel")"
|
||||
cp "$kernel_dir/$rel" "$tmp_keep/$rel"
|
||||
done < <(sort -u "$keep_list")
|
||||
rm -f "$keep_list"
|
||||
|
||||
# Replace kernel module tree with the keeper set.
|
||||
rm -rf "$kernel_dir"
|
||||
mkdir -p "$kernel_dir"
|
||||
# Restore each kept file into the same relative path.
|
||||
(cd "$tmp_keep" && find . -name "*.ko*" | while read -r f; do
|
||||
dest_dir="$kernel_dir/$(dirname "${f#./}")"
|
||||
mkdir -p "$dest_dir"
|
||||
cp "$f" "$dest_dir/"
|
||||
done)
|
||||
rm -rf "$tmp_keep"
|
||||
|
||||
# Decompress kept modules to plain .ko. Alpine ships .ko.gz, but a host
|
||||
# kmod that lacks gzip support (e.g. Debian trixie's kmod 34) silently
|
||||
# produces an EMPTY modules.dep from compressed modules — the guest's
|
||||
# modprobe then no-ops and qemu_fw_cfg never loads. Plain .ko sidesteps the
|
||||
# host-kmod dependency entirely and the guest needs no decompressor either.
|
||||
find "$kernel_dir" -name "*.ko.gz" -exec gunzip -f {} +
|
||||
|
||||
# Independent closure gate: each extra-closure module's own .modinfo depends=
|
||||
# must name a kept module or a built-in, so a resolver bug fails here.
|
||||
kept_names=$(find "$kernel_dir" -name "*.ko" -exec basename {} .ko \; | tr '-' '_' | sort -u)
|
||||
while read -r ko; do
|
||||
ko_name=$(basename "$ko" .ko); ko_name="${ko_name//-/_}"
|
||||
grep -qx "$ko_name" <<< "$extra_names" || continue
|
||||
deps=$(tr '\0' '\n' < "$ko" | grep -a -m1 '^depends=' | cut -d= -f2 | tr ',' ' ' || true)
|
||||
for dep in $deps; do
|
||||
dep="${dep//-/_}"
|
||||
if ! grep -qx "$dep" <<< "$kept_names" && ! module_builtin "$kver_dir" "$dep"; then
|
||||
echo "ERROR: $(basename "$ko") depends on $dep, which is neither kept nor built in" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done < <(find "$kernel_dir" -name "*.ko")
|
||||
for name in $MICROVM_EXTRA_MODULES; do
|
||||
if ! grep -qx "${name//-/_}" <<< "$kept_names" && ! module_builtin "$kver_dir" "$name"; then
|
||||
echo "ERROR: required module $name missing from the stripped module tree" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "[build-sqs-store-microvm-image] extra module closure: $(echo "$extra_names" | tr '\n' ' ')"
|
||||
echo "[build-sqs-store-microvm-image] modules kept: $(echo "$kept_names" | tr '\n' ' ')"
|
||||
|
||||
# Rebuild module metadata with the image's own kmod, the binary the guest's
|
||||
# modprobe runs, so no host depmod is needed. Stock indexes still name .ko.gz
|
||||
# files, so they are removed first and a failed rebuild cannot ship them.
|
||||
for f in dep dep.bin alias alias.bin softdep weakdep symbols symbols.bin \
|
||||
builtin.bin builtin.alias.bin devname; do
|
||||
rm -f "$kver_dir/modules.$f"
|
||||
done
|
||||
if ! image_kmod depmod -b "$CHROOT_DIR" "$kver"; then
|
||||
echo "ERROR: image depmod failed for $kver" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -s "$kver_dir/modules.dep" ] || [ ! -s "$kver_dir/modules.dep.bin" ]; then
|
||||
echo "ERROR: ${kver_dir}modules.dep or modules.dep.bin missing or empty after depmod" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Resolution gate: the image's modprobe must resolve every required module
|
||||
# to plain .ko files present in the stripped tree.
|
||||
for name in $MICROVM_EXTRA_MODULES qemu_fw_cfg virtio_net; do
|
||||
module_builtin "$kver_dir" "$name" && continue
|
||||
if ! plan=$(image_kmod modprobe -d "$CHROOT_DIR" -S "$kver" --show-depends "$name"); then
|
||||
echo "ERROR: image modprobe cannot resolve $name for $kver" >&2
|
||||
exit 1
|
||||
fi
|
||||
while read -r verb path _; do
|
||||
[ "$verb" = "insmod" ] || continue
|
||||
case "$path" in
|
||||
"$CHROOT_DIR/lib/modules/$kver/kernel/"*.ko) [ -f "$path" ] && continue ;;
|
||||
esac
|
||||
echo "ERROR: modprobe plan for $name names $path, not a kept plain .ko" >&2
|
||||
exit 1
|
||||
done <<< "$plan"
|
||||
echo "[build-sqs-store-microvm-image] modprobe $name: $(awk '{ n = split($2, a, "/"); printf "%s ", a[n] }' <<< "$plan")"
|
||||
done
|
||||
done
|
||||
|
||||
# --- Strip package-manager artifacts (not needed at runtime) ---
|
||||
echo "[build-sqs-store-microvm-image] stripping package manager artifacts..."
|
||||
rm -rf \
|
||||
"$CHROOT_DIR/lib/apk" \
|
||||
"$CHROOT_DIR/var/cache/apk" \
|
||||
"$CHROOT_DIR/etc/apk" \
|
||||
"$CHROOT_DIR/usr/share/man" \
|
||||
"$CHROOT_DIR/usr/share/doc" \
|
||||
"$CHROOT_DIR/usr/share/apk" \
|
||||
"$CHROOT_DIR/usr/include" \
|
||||
"$CHROOT_DIR/usr/lib/pkgconfig"
|
||||
|
||||
# --- Strip debug symbols from binaries ---
|
||||
# nats-server in /usr/local/bin is left as built (-s -w), so the shipped bytes
|
||||
# are the ones whose version was checked above.
|
||||
echo "[build-sqs-store-microvm-image] stripping debug symbols from binaries..."
|
||||
find "$CHROOT_DIR/usr/sbin" "$CHROOT_DIR/usr/bin" \
|
||||
"$CHROOT_DIR/sbin" "$CHROOT_DIR/bin" \
|
||||
-type f 2>/dev/null | while read -r bin; do
|
||||
strip --strip-all "$bin" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# --- Copy vmlinuz out before stripping /boot from chroot ---
|
||||
VMLINUZ_OUT="$STAGE_DIR/vmlinuz"
|
||||
echo "[build-sqs-store-microvm-image] copying kernel: $VMLINUZ_OUT"
|
||||
cp "$KERNEL_IMG" "$VMLINUZ_OUT"
|
||||
|
||||
# Drop /boot from the chroot — vmlinuz, Alpine's stock initramfs-virt, System.map
|
||||
# and kernel config all live here (~25 MiB). None are needed at runtime: the
|
||||
# guest already has the kernel loaded by QEMU's -kernel, and Alpine's initramfs
|
||||
# is unused because we replace it with our own /init. Leaving /boot in the cpio
|
||||
# nearly doubles uncompressed initramfs size and forces a 512 MiB guest just to
|
||||
# survive decompression.
|
||||
rm -rf "${CHROOT_DIR:?}/boot"
|
||||
|
||||
# --- Build initramfs ---
|
||||
INITRAMFS_OUT="$STAGE_DIR/initramfs.cpio.gz"
|
||||
echo "[build-sqs-store-microvm-image] building initramfs: $INITRAMFS_OUT"
|
||||
# Create the static device nodes and pack the archive inside a single fakeroot
|
||||
# session: mknod/cpio see faked device entries and write real char-device
|
||||
# records into the cpio, while the host filesystem never gets an actual node.
|
||||
fakeroot sh -c '
|
||||
cd "$1" || exit 1
|
||||
mknod -m 600 dev/console c 5 1
|
||||
mknod -m 666 dev/null c 1 3
|
||||
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
|
||||
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
|
||||
|
||||
# --- Gate the staged pair, then publish it ---
|
||||
# Nothing reaches $BUILD_DIR until every gate passes, so a failed build cannot
|
||||
# leave a pair the grouped make target would take as up to date.
|
||||
"$REPO_ROOT/scripts/lib/sqs-store-microvm-publish.sh" "$STAGE_DIR" "$BUILD_DIR"
|
||||
echo ""
|
||||
echo "[build-sqs-store-microvm-image] done."
|
||||
@@ -447,7 +447,7 @@ done
|
||||
# Matched on the service argv so the ssh shell running this cannot match itself.
|
||||
for host in "${HOSTS[@]}"; do
|
||||
$DRY_RUN && { echo " [dry-run] $host: wait for every 'spx service' process to exit"; continue; }
|
||||
on "$host" "for u in spinifex-predastore spinifex-viperblock spinifex-nats; do
|
||||
on "$host" "for u in spinifex-predastore spinifex-viperblock spinifex-sqs-store spinifex-nats; do
|
||||
systemctl is-active --quiet \$u && sudo systemctl stop \$u
|
||||
done
|
||||
for _ in \$(seq 1 45); do
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
#!/bin/sh
|
||||
# sqs-store-guest_test.sh — the SQS store guest steps start the store only on
|
||||
# a valid contract and one checked, mounted disk, never format, and stop the
|
||||
# store before unmounting it.
|
||||
#
|
||||
# Drives build/microvm/sqs-store/sqs-store-lib.sh against a fake fw_cfg, sysfs
|
||||
# and stub tools. The stop cases signal only test-owned children.
|
||||
# The case settings below are read by the sourced library, not by this file.
|
||||
# shellcheck disable=SC2034
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)
|
||||
LIB=${SQS_GUEST_LIB:-"$ROOT/build/microvm/sqs-store/sqs-store-lib.sh"}
|
||||
TMP=$(mktemp -d)
|
||||
CHILDREN=""
|
||||
cleanup() {
|
||||
for p in $CHILDREN; do kill -KILL "$p" 2>/dev/null || true; done
|
||||
rm -rf "$TMP"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
check() { if eval "$2"; then pass "$1"; else fail "$1"; fi; }
|
||||
|
||||
echo "-- build/microvm/sqs-store/sqs-store-lib.sh"
|
||||
|
||||
SERIAL=vol0123456789abcdef0
|
||||
NKEY=UAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB
|
||||
ADDR=10.11.12.13
|
||||
|
||||
# Stub tools record each call to calls.log; their exit codes come from files.
|
||||
mkdir -p "$TMP/bin"
|
||||
cat > "$TMP/bin/e2fsck" <<'EOF'
|
||||
#!/bin/sh
|
||||
echo "e2fsck $*" >> "$T/calls.log"
|
||||
exit "$(cat "$T/fsck.rc")"
|
||||
EOF
|
||||
cat > "$TMP/bin/mount" <<'EOF'
|
||||
#!/bin/sh
|
||||
echo "mount $*" >> "$T/calls.log"
|
||||
exit "$(cat "$T/mount.rc")"
|
||||
EOF
|
||||
cat > "$TMP/bin/umount" <<'EOF'
|
||||
#!/bin/sh
|
||||
echo "umount $*" >> "$T/calls.log"
|
||||
EOF
|
||||
for f in mkfs mkfs.ext4 mke2fs; do
|
||||
printf '#!/bin/sh\necho "mkfs %s $*" >> "$T/calls.log"\n' "$f" > "$TMP/bin/$f"
|
||||
done
|
||||
cat > "$TMP/bin/ip" <<'EOF'
|
||||
#!/bin/sh
|
||||
cat "$T/ip.out"
|
||||
EOF
|
||||
cat > "$TMP/bin/nats-server" <<'EOF'
|
||||
#!/bin/sh
|
||||
echo "nats-server $*" >> "$T/calls.log"
|
||||
[ "$1" = -t ] && exit "$(cat "$T/configtest.rc")"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$TMP/bin"/*
|
||||
|
||||
# fresh <name>: a case directory with a valid contract and one matching disk
|
||||
# among others. Callers then break one thing.
|
||||
fresh() {
|
||||
T="$TMP/$1"
|
||||
export T
|
||||
mkdir -p "$T/fwcfg" "$T/sys" "$T/dev" "$T/run" "$T/data"
|
||||
for k in data-serial listen-addr client-nkey; do mkdir -p "$T/fwcfg/$k"; done
|
||||
printf '%s\n' "$SERIAL" > "$T/fwcfg/data-serial/raw"
|
||||
printf '%s\n' "$ADDR" > "$T/fwcfg/listen-addr/raw"
|
||||
printf '%s\n' "$NKEY" > "$T/fwcfg/client-nkey/raw"
|
||||
disk vda vol00000000000000000
|
||||
disk vdb "$SERIAL"
|
||||
echo 0 > "$T/fsck.rc"
|
||||
echo 0 > "$T/mount.rc"
|
||||
echo 0 > "$T/configtest.rc"
|
||||
: > "$T/calls.log"
|
||||
: > "$T/console"
|
||||
printf '1: lo inet 127.0.0.1/8 scope host lo\n2: eth0 inet %s/24 brd 10.11.12.255 scope global eth0\n' "$ADDR" > "$T/ip.out"
|
||||
}
|
||||
disk() { mkdir -p "$T/sys/$1"; printf '%s\n' "$2" > "$T/sys/$1/serial"; : > "$T/dev/$1"; }
|
||||
|
||||
# prepare: run sqs_prepare in a subshell with every path pointed at the case.
|
||||
prepare() {
|
||||
(
|
||||
SQS_FWCFG="$T/fwcfg" SQS_SYSBLOCK="$T/sys" SQS_DEVDIR="$T/dev" SQS_NODE_TEST=-e
|
||||
SQS_RUN="$T/run" SQS_DATA="$T/data" SQS_CONSOLE="$T/console"
|
||||
E2FSCK="$TMP/bin/e2fsck" MOUNT="$TMP/bin/mount" IP="$TMP/bin/ip" NATS_BIN="$TMP/bin/nats-server"
|
||||
PATH="$TMP/bin:$PATH"
|
||||
export PATH
|
||||
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
|
||||
. "$LIB"
|
||||
sqs_prepare
|
||||
) > /dev/null 2>&1
|
||||
}
|
||||
refused() { [ ! -e "$T/run/ready" ] && grep -q FATAL "$T/console"; }
|
||||
never_mounted() { ! grep -q '^mount ' "$T/calls.log"; }
|
||||
never_formatted() { ! grep -q '^mkfs ' "$T/calls.log"; }
|
||||
never_checked() { ! grep -q '^e2fsck ' "$T/calls.log"; }
|
||||
|
||||
# --- A valid contract prepares the store ---
|
||||
fresh valid
|
||||
check "valid contract is prepared" 'prepare && [ -e "$T/run/ready" ]'
|
||||
check "the matching disk is checked, then mounted at the data dir" \
|
||||
'grep -qx "e2fsck -p $T/dev/vdb" "$T/calls.log" && grep -qx "mount -t ext4 -o nodev,nosuid,noexec $T/dev/vdb $T/data" "$T/calls.log"'
|
||||
check "config listens only on the supplied address" \
|
||||
'grep -c "listen" "$T/run/nats.conf" | grep -qx 1 && grep -qx "listen: \"$ADDR:4222\"" "$T/run/nats.conf"'
|
||||
check "config admits only the supplied user NKey" 'grep -q "users = \[ { nkey: \"$NKEY\" } \]" "$T/run/nats.conf"'
|
||||
check "config syncs JetStream on the data disk" \
|
||||
'grep -q "store_dir: \"$T/data\"" "$T/run/nats.conf" && grep -q "sync_interval: always" "$T/run/nats.conf"'
|
||||
check "config is tested by the server before ready" 'grep -qx "nats-server -t -c $T/run/nats.conf" "$T/calls.log"'
|
||||
check "no auth material on the console" '! grep -q "$NKEY" "$T/console"'
|
||||
check "prepare reports the disk and config, not a listener" 'grep -q "prepared: $T/dev/vdb mounted" "$T/console" && ! grep -qi "listening" "$T/console"'
|
||||
|
||||
fresh fsck-corrected; echo 1 > "$T/fsck.rc"
|
||||
check "e2fsck exit 1 (corrected) proceeds" 'prepare && [ -e "$T/run/ready" ]'
|
||||
|
||||
# --- Disk refusals: nothing mounted, nothing formatted ---
|
||||
fresh no-disk; rm -rf "$T/sys/vdb"
|
||||
check "missing disk is refused before any check or mount" '! prepare && refused && never_checked && never_mounted'
|
||||
|
||||
fresh wrong-disk; printf 'vol0fffffffffffffff0\n' > "$T/fwcfg/data-serial/raw"
|
||||
check "disk with another serial is refused" '! prepare && refused && never_checked && never_mounted'
|
||||
|
||||
fresh two-disks; disk vdc "$SERIAL"
|
||||
check "two disks with the serial are refused" '! prepare && refused && never_checked && never_mounted'
|
||||
|
||||
fresh bad-serial; printf 'vol-0123456789abcdef\n' > "$T/fwcfg/data-serial/raw"
|
||||
check "malformed serial is refused" '! prepare && refused && never_mounted'
|
||||
|
||||
fresh no-node; rm -f "$T/dev/vdb"
|
||||
check "disk without a device node is refused" '! prepare && refused && never_mounted'
|
||||
|
||||
for rc in 2 4 8; do
|
||||
fresh "fsck-$rc"; echo "$rc" > "$T/fsck.rc"
|
||||
check "e2fsck exit $rc is refused without mount or format" '! prepare && refused && never_mounted && never_formatted'
|
||||
done
|
||||
|
||||
fresh mount-fail; echo 32 > "$T/mount.rc"
|
||||
check "failed mount is refused" '! prepare && refused'
|
||||
|
||||
# --- Auth and listener refusals happen before the disk is touched ---
|
||||
fresh no-nkey; rm -rf "$T/fwcfg/client-nkey"
|
||||
check "missing NKey is refused before the disk" '! prepare && refused && never_checked && never_mounted'
|
||||
|
||||
for bad in seed lower short two empty; do
|
||||
fresh "nkey-$bad"
|
||||
case $bad in
|
||||
seed) v=SUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ;;
|
||||
lower) v=uaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab ;;
|
||||
short) v=UAAAA ;;
|
||||
two) v="$NKEY
|
||||
$NKEY" ;;
|
||||
empty) v="" ;;
|
||||
esac
|
||||
printf '%s\n' "$v" > "$T/fwcfg/client-nkey/raw"
|
||||
check "NKey ($bad) is refused before the disk, without echoing it" \
|
||||
'! prepare && refused && never_checked && { [ -z "$v" ] || ! grep -qF "$v" "$T/console"; }'
|
||||
done
|
||||
|
||||
fresh nkey-checksum; echo 1 > "$T/configtest.rc"
|
||||
check "NKey the server rejects is refused before the disk" '! prepare && refused && never_checked && never_mounted'
|
||||
|
||||
fresh no-addr; rm -rf "$T/fwcfg/listen-addr"
|
||||
check "missing listen address is refused" '! prepare && refused && never_mounted'
|
||||
|
||||
fresh addr-absent; printf '1: lo inet 127.0.0.1/8 scope host lo\n2: eth0 inet 10.11.12.99/24 scope global eth0\n' > "$T/ip.out"
|
||||
check "listen address not on a guest NIC is refused" '! prepare && refused && never_mounted'
|
||||
|
||||
fresh addr-loopback; printf '127.0.0.1\n' > "$T/fwcfg/listen-addr/raw"
|
||||
check "loopback listen address is refused" '! prepare && refused && never_mounted'
|
||||
|
||||
for bad in 0.0.0.0x 1.2.3 1.2.3.256 01.2.3.4; do
|
||||
fresh "addr-$bad"; printf '%s\n' "$bad" > "$T/fwcfg/listen-addr/raw"
|
||||
check "malformed listen address $bad is refused" '! prepare && refused && never_mounted'
|
||||
done
|
||||
|
||||
# --- Run: the store starts only when prepared and not stopping ---
|
||||
run_step() {
|
||||
(
|
||||
SQS_RUN="$T/run" SQS_CONSOLE="$T/console" NATS_BIN="$TMP/bin/nats-server"
|
||||
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
|
||||
. "$LIB"
|
||||
sqs_run
|
||||
) > /dev/null 2>&1
|
||||
}
|
||||
fresh run-ready; prepare; : > "$T/calls.log"
|
||||
check "a prepared store starts the server" 'run_step && grep -qx "nats-server -c $T/run/nats.conf" "$T/calls.log"'
|
||||
fresh run-unready
|
||||
check "an unprepared store never starts the server" '! run_step && ! grep -q "^nats-server" "$T/calls.log"'
|
||||
fresh run-stopping; prepare; : > "$T/calls.log"; : > "$T/run/stopping"
|
||||
check "a stopping store never starts the server" '! run_step && ! grep -q "^nats-server" "$T/calls.log"'
|
||||
|
||||
# --- Stop: SIGTERM the verified store, then unmount ---
|
||||
STORE="$TMP/bin/fake-store"
|
||||
cp "$(command -v sleep)" "$STORE"
|
||||
stop_step() {
|
||||
(
|
||||
SQS_RUN="$T/run" SQS_DATA="$T/data" SQS_CONSOLE="$T/console" SQS_MOUNTS="$T/mounts"
|
||||
UMOUNT="$TMP/bin/umount" NATS_BIN="$STORE" SQS_STOP_BUDGET_TENTHS=10
|
||||
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
|
||||
. "$LIB"
|
||||
sqs_stop
|
||||
) > /dev/null 2>&1
|
||||
}
|
||||
gone() { [ ! -r "/proc/$1/status" ] || grep -q '^State:[[:space:]]*Z' "/proc/$1/status"; }
|
||||
# record <pid>: write the identity sqs_run would record for that process.
|
||||
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
|
||||
record() { echo "$1 $( . "$LIB"; sqs_starttime "$1" )" > "$T/run/pid"; }
|
||||
|
||||
fresh stop-orderly; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
|
||||
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
|
||||
stop_step
|
||||
check "stop sends SIGTERM and the store exits" 'gone "$pid" && grep -q "SIGTERM sent" "$T/console" && ! grep -q SIGKILL "$T/console"'
|
||||
check "stop blocks respawn and unmounts after the store exits" \
|
||||
'[ -e "$T/run/stopping" ] && grep -qx "umount $T/data" "$T/calls.log"'
|
||||
|
||||
fresh stop-hung; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
|
||||
(trap '' TERM; exec "$STORE" 60) & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
|
||||
stop_step
|
||||
check "store ignoring SIGTERM is killed after the budget, then unmounted" \
|
||||
'gone "$pid" && grep -q SIGKILL "$T/console" && grep -qx "umount $T/data" "$T/calls.log"'
|
||||
|
||||
fresh stop-reused; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
|
||||
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; echo "$pid 1" > "$T/run/pid"
|
||||
stop_step
|
||||
check "a recorded PID with another start time is never signalled" '! gone "$pid" && grep -q "no running store" "$T/console"'
|
||||
kill -KILL "$pid"
|
||||
|
||||
# A start held after its stopping check and before its exec: the run step
|
||||
# blocks writing "run: starting" to a FIFO console until the test reads it.
|
||||
fresh stop-pending-start; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
|
||||
: > "$T/run/ready"
|
||||
mkfifo "$T/run-console"
|
||||
printf '#!/bin/sh\n: > "%s/started"\nexec "%s" 60\n' "$T" "$STORE" > "$TMP/bin/pending-nats"
|
||||
chmod +x "$TMP/bin/pending-nats"
|
||||
SQS_RUN="$T/run" SQS_CONSOLE="$T/run-console" NATS_BIN="$TMP/bin/pending-nats" \
|
||||
sh -c '. "$1"; sqs_run' sh "$LIB" & pid=$!; CHILDREN="$CHILDREN $pid"
|
||||
i=0
|
||||
while [ "$i" -lt 100 ] && ! grep -q "^$pid " "$T/run/pid" 2>/dev/null; do i=$((i + 1)); sleep 0.05; done
|
||||
check "pending start is held before its exec" 'grep -q "^$pid " "$T/run/pid" && ! gone "$pid" && [ ! -e "$T/started" ]'
|
||||
stop_step
|
||||
# Release the FIFO: a start that survived the stop would exec the server now.
|
||||
cat "$T/run-console" > /dev/null 2>&1 &
|
||||
reader=$!; CHILDREN="$CHILDREN $reader"
|
||||
i=0
|
||||
while [ "$i" -lt 20 ] && ! gone "$pid"; do i=$((i + 1)); sleep 0.05; done
|
||||
check "stop ends a pending start before it can exec the server" 'gone "$pid" && [ ! -e "$T/started" ]'
|
||||
check "stop unmounts only after the pending start is gone" \
|
||||
'grep -q "store exited" "$T/console" && grep -qx "umount $T/data" "$T/calls.log"'
|
||||
kill -KILL "$reader" 2>/dev/null || true
|
||||
|
||||
# --- Shutdown: the generic remount runs only once the store is gone ---
|
||||
# shutdown_step [survivor-pid]: with a pid, that process is reported alive
|
||||
# whatever happens to it, as a writer stuck past SIGKILL would be.
|
||||
shutdown_step() {
|
||||
(
|
||||
SQS_RUN="$T/run" SQS_DATA="$T/data" SQS_CONSOLE="$T/console" SQS_MOUNTS="$T/mounts"
|
||||
UMOUNT="$TMP/bin/umount" SQS_STOP_BUDGET_TENTHS=5
|
||||
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
|
||||
. "$LIB"
|
||||
if [ -n "${1:-}" ]; then sqs_same() { [ "$1" = "$survivor" ]; }; survivor=$1; fi
|
||||
sqs_shutdown
|
||||
) > /dev/null 2>&1
|
||||
}
|
||||
|
||||
fresh shutdown-orderly; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
|
||||
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
|
||||
check "orderly shutdown succeeds" 'shutdown_step'
|
||||
check "orderly shutdown unmounts the data disk, then runs umount -a -r" \
|
||||
'[ "$(grep "^umount" "$T/calls.log" | tr "\n" "|")" = "umount $T/data|umount -a -r|" ]'
|
||||
|
||||
fresh shutdown-survivor; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
|
||||
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
|
||||
check "shutdown with a surviving store reports failure" '! shutdown_step "$pid"'
|
||||
check "a surviving store leaves the data disk and every other mount alone" \
|
||||
'! grep -q "^umount" "$T/calls.log" && grep -q "STILL ALIVE" "$T/console" && grep -q "skipping umount -a -r" "$T/console"'
|
||||
|
||||
fresh stop-unmounted; : > "$T/mounts"
|
||||
stop_step
|
||||
check "stop with no data mount does not unmount" '! grep -q "^umount" "$T/calls.log"'
|
||||
|
||||
if [ "$fails" -ne 0 ]; then
|
||||
echo " $fails failure(s)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/bin/bash
|
||||
# sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR — gate a staged SQS store
|
||||
# microVM kernel + initramfs and move them into OUT_DIR only if every gate passes.
|
||||
#
|
||||
# A failed gate leaves OUT_DIR as it was: a previous valid pair stays, and an
|
||||
# older pair stays older than the build inputs, so make rebuilds next time.
|
||||
set -euo pipefail
|
||||
|
||||
STAGE_DIR="${1:?usage: sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR}"
|
||||
OUT_DIR="${2:?usage: sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR}"
|
||||
VMLINUZ="$STAGE_DIR/vmlinuz"
|
||||
INITRAMFS="$STAGE_DIR/initramfs.cpio.gz"
|
||||
tag="[build-sqs-store-microvm-image]"
|
||||
|
||||
for f in "$VMLINUZ" "$INITRAMFS"; do
|
||||
if [ ! -s "$f" ]; then
|
||||
echo "ERROR: staged artifact $f missing or empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Content gate: read the packed archive, not the chroot ---
|
||||
if ! archive_list=$(zcat "$INITRAMFS" | cpio --quiet -t); then
|
||||
echo "ERROR: $INITRAMFS is not a readable gzip cpio archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
for want in init etc/inittab usr/local/bin/nats-server dev/console dev/null \
|
||||
usr/local/lib/sqs-store/sqs-store-lib.sh usr/local/lib/sqs-store/sqs-store-prepare \
|
||||
usr/local/lib/sqs-store/sqs-store-run usr/local/lib/sqs-store/sqs-store-stop; do
|
||||
if ! grep -qx "$want" <<< "$archive_list"; then
|
||||
echo "ERROR: $want missing from $INITRAMFS" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
for fs_tool in mkfs.ext4 e2fsck; do
|
||||
if ! grep -qxE "(usr/)?sbin/$fs_tool" <<< "$archive_list"; then
|
||||
echo "ERROR: $fs_tool missing from $INITRAMFS" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# Module files may spell a name with dashes (tiny-power-button.ko). Normalise
|
||||
# once: piping into grep -q under pipefail fails when grep exits on a match.
|
||||
module_list=${archive_list//-/_}
|
||||
for want_ko in ext4 tiny_power_button; do
|
||||
if ! grep -q "/$want_ko\.ko\$" <<< "$module_list"; then
|
||||
echo "ERROR: $want_ko missing from $INITRAMFS" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if grep -qE '(^|/)(lb-agent|haproxy|nginx)$' <<< "$archive_list"; then
|
||||
echo "ERROR: LB data-plane binaries found in the SQS store image" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$tag content gate: PASS"
|
||||
|
||||
echo ""
|
||||
echo "$tag staged artifacts:"
|
||||
ls -lh "$VMLINUZ" "$INITRAMFS"
|
||||
echo ""
|
||||
|
||||
# --- Artifact size gate (50 MiB total) ---
|
||||
ARTIFACT_MAX_MiB="${MICROVM_ARTIFACT_MAX_MiB:-50}"
|
||||
total_bytes=$(( $(stat -c %s "$VMLINUZ") + $(stat -c %s "$INITRAMFS") ))
|
||||
max_bytes=$(( ARTIFACT_MAX_MiB * 1024 * 1024 ))
|
||||
total_mib=$(( (total_bytes + 1048575) / 1048576 ))
|
||||
if [ "$total_bytes" -gt "$max_bytes" ]; then
|
||||
echo "ERROR: artifact size ${total_mib} MiB exceeds ${ARTIFACT_MAX_MiB} MiB gate" >&2
|
||||
echo " Set MICROVM_ARTIFACT_MAX_MiB=N to override during development." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$tag artifact size gate: PASS (${total_mib} MiB / ${ARTIFACT_MAX_MiB} MiB)"
|
||||
|
||||
# --- Guest-RAM initramfs gate (uncompressed) ---
|
||||
# The kernel unpacks the whole initramfs into RAM before init, so the
|
||||
# UNCOMPRESSED size — not the gzip artifact — sets the guest memory floor.
|
||||
UNCOMPRESSED_MAX_MiB="${MICROVM_INITRAMFS_UNCOMPRESSED_MAX_MiB:-96}"
|
||||
uncompressed_bytes=$(zcat "$INITRAMFS" | wc -c)
|
||||
uncompressed_mib=$(( (uncompressed_bytes + 1048575) / 1048576 ))
|
||||
umax_bytes=$(( UNCOMPRESSED_MAX_MiB * 1024 * 1024 ))
|
||||
if [ "$uncompressed_bytes" -gt "$umax_bytes" ]; then
|
||||
echo "ERROR: uncompressed initramfs ${uncompressed_mib} MiB exceeds ${UNCOMPRESSED_MAX_MiB} MiB guest-RAM gate" >&2
|
||||
echo " The kernel unpacks this into guest RAM before init; a larger" >&2
|
||||
echo " footprint panics before userspace. Slim the initramfs, or raise the" >&2
|
||||
echo " instance memory AND MICROVM_INITRAMFS_UNCOMPRESSED_MAX_MiB together." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$tag initramfs guest-RAM gate: PASS (${uncompressed_mib} MiB / ${UNCOMPRESSED_MAX_MiB} MiB uncompressed)"
|
||||
|
||||
# --- Publish ---
|
||||
# Two renames are not atomic as a pair, so the old initramfs goes first and the
|
||||
# new one lands last. Stopped in between, the initramfs is absent: no mixed pair
|
||||
# exists and the grouped make target is missing, so make rebuilds.
|
||||
mkdir -p "$OUT_DIR"
|
||||
rm -f "$OUT_DIR/initramfs.cpio.gz"
|
||||
mv -f "$VMLINUZ" "$OUT_DIR/vmlinuz"
|
||||
mv -f "$INITRAMFS" "$OUT_DIR/initramfs.cpio.gz"
|
||||
echo "$tag published: $OUT_DIR/vmlinuz $OUT_DIR/initramfs.cpio.gz"
|
||||
@@ -1,196 +0,0 @@
|
||||
#!/bin/sh
|
||||
# sqs-store-microvm-publish_test.sh — a staged SQS store microVM pair reaches
|
||||
# the output directory only when every gate passes, and a failed build cannot
|
||||
# leave a pair that make then takes as up to date.
|
||||
#
|
||||
# Artifacts are small fake archives listing the gated paths; no image is built.
|
||||
set -eu
|
||||
|
||||
LIB_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
|
||||
PUBLISH="$LIB_DIR/sqs-store-microvm-publish.sh"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
|
||||
echo "-- lib/sqs-store-microvm-publish.sh"
|
||||
|
||||
for tool in cpio gzip make; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo " FAIL - required tool not found: $tool"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# stage <dir> [omit-path] — a fake staged pair; omit-path is left out of the archive.
|
||||
stage() {
|
||||
dir=$1 omit=${2:-}
|
||||
root="$TMP/root.$$.$(date +%s%N)"
|
||||
for p in init etc/inittab usr/local/bin/nats-server sbin/mkfs.ext4 sbin/e2fsck \
|
||||
usr/local/lib/sqs-store/sqs-store-lib.sh usr/local/lib/sqs-store/sqs-store-prepare \
|
||||
usr/local/lib/sqs-store/sqs-store-run usr/local/lib/sqs-store/sqs-store-stop \
|
||||
dev/console dev/null lib/modules/6.12-virt/kernel/fs/ext4/ext4.ko \
|
||||
lib/modules/6.12-virt/kernel/drivers/acpi/tiny-power-button.ko; do
|
||||
[ "$p" = "$omit" ] && continue
|
||||
mkdir -p "$root/$(dirname "$p")"
|
||||
echo "$p" > "$root/$p"
|
||||
done
|
||||
# pad=N appends N files after the gated ones, as a real image's listing has.
|
||||
if [ "${pad:-0}" -gt 0 ]; then
|
||||
mkdir -p "$root/zz"
|
||||
(cd "$root/zz" && seq "$pad" | xargs touch)
|
||||
fi
|
||||
mkdir -p "$dir"
|
||||
(cd "$root" && find . -mindepth 1 | sed 's|^\./||' | sort | cpio --quiet -o -H newc) | gzip > "$dir/initramfs.cpio.gz"
|
||||
echo kernel > "$dir/vmlinuz"
|
||||
rm -rf "$root"
|
||||
}
|
||||
|
||||
# fingerprint <dir> — content and mtime of both outputs, or "absent".
|
||||
fingerprint() {
|
||||
for f in "$1/vmlinuz" "$1/initramfs.cpio.gz"; do
|
||||
if [ -e "$f" ]; then
|
||||
printf '%s %s ' "$(cksum < "$f")" "$(stat -c %Y "$f")"
|
||||
else
|
||||
printf 'absent '
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# --- A valid pair is published and leaves the stage ---
|
||||
stage "$TMP/ok/stage"
|
||||
if bash "$PUBLISH" "$TMP/ok/stage" "$TMP/ok/out" >/dev/null 2>&1 \
|
||||
&& [ -s "$TMP/ok/out/vmlinuz" ] && [ -s "$TMP/ok/out/initramfs.cpio.gz" ] \
|
||||
&& [ ! -e "$TMP/ok/stage/vmlinuz" ] && [ ! -e "$TMP/ok/stage/initramfs.cpio.gz" ]; then
|
||||
pass "valid pair is published"
|
||||
else
|
||||
fail "valid pair is published"
|
||||
fi
|
||||
|
||||
# --- A large listing with the modules early still passes the module gate ---
|
||||
pad=20000 stage "$TMP/large/stage"
|
||||
if bash "$PUBLISH" "$TMP/large/stage" "$TMP/large/out" >/dev/null 2>&1; then
|
||||
pass "large archive with early modules is published"
|
||||
else
|
||||
fail "large archive with early modules is published"
|
||||
fi
|
||||
|
||||
# --- Each failing gate keeps the previous valid pair exactly ---
|
||||
for case in nats-server store-stop ext4 size; do
|
||||
d="$TMP/keep-$case"
|
||||
stage "$d/prev"
|
||||
bash "$PUBLISH" "$d/prev" "$d/out" >/dev/null 2>&1
|
||||
before=$(fingerprint "$d/out")
|
||||
max=50
|
||||
case $case in
|
||||
nats-server) stage "$d/stage" usr/local/bin/nats-server ;;
|
||||
store-stop) stage "$d/stage" usr/local/lib/sqs-store/sqs-store-stop ;;
|
||||
ext4) stage "$d/stage" lib/modules/6.12-virt/kernel/fs/ext4/ext4.ko ;;
|
||||
size) stage "$d/stage"; max=0 ;;
|
||||
esac
|
||||
if MICROVM_ARTIFACT_MAX_MiB=$max bash "$PUBLISH" "$d/stage" "$d/out" >/dev/null 2>&1; then
|
||||
fail "$case gate failure is refused"
|
||||
elif [ "$(fingerprint "$d/out")" = "$before" ]; then
|
||||
pass "$case gate failure keeps the previous pair"
|
||||
else
|
||||
fail "$case gate failure keeps the previous pair"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- With no previous pair, a failed gate publishes nothing ---
|
||||
stage "$TMP/none/stage" usr/local/bin/nats-server
|
||||
if bash "$PUBLISH" "$TMP/none/stage" "$TMP/none/out" >/dev/null 2>&1; then
|
||||
fail "failed gate with no previous pair is refused"
|
||||
elif [ "$(fingerprint "$TMP/none/out")" = "absent absent " ]; then
|
||||
pass "failed gate with no previous pair publishes nothing"
|
||||
else
|
||||
fail "failed gate with no previous pair publishes nothing"
|
||||
fi
|
||||
|
||||
# --- make cannot take a failed rebuild's pair as up to date ---
|
||||
# The same grouped-target shape as the Makefile; the builder stages a pair
|
||||
# missing nats-server once BAD exists, the way a failed real build would.
|
||||
m="$TMP/make"
|
||||
mkdir -p "$m/out"
|
||||
cat > "$m/build.sh" <<EOF
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
rm -rf "$m/stage"
|
||||
. "$m/build.sh.lib"
|
||||
if [ -e "$m/BAD" ]; then
|
||||
stage "$m/stage" usr/local/bin/nats-server
|
||||
else
|
||||
stage "$m/stage"
|
||||
fi
|
||||
bash "$PUBLISH" "$m/stage" "$m/out"
|
||||
EOF
|
||||
{
|
||||
echo "TMP=\"$TMP\""
|
||||
sed -n '/^stage() {/,/^}/p' "$0"
|
||||
} > "$m/build.sh.lib"
|
||||
printf 'out/vmlinuz out/initramfs.cpio.gz &: input\n\tsh build.sh\n' > "$m/Makefile"
|
||||
echo v1 > "$m/input"
|
||||
if (cd "$m" && make -s out/vmlinuz >/dev/null 2>&1) && [ -s "$m/out/vmlinuz" ]; then
|
||||
pass "make builds the first valid pair"
|
||||
else
|
||||
fail "make builds the first valid pair"
|
||||
echo " $fails failure(s)"; exit 1
|
||||
fi
|
||||
good=$(fingerprint "$m/out")
|
||||
touch "$m/BAD"
|
||||
# Age the published pair so the input is strictly newer, without a future mtime.
|
||||
past="@$(( $(date +%s) - 10 ))"
|
||||
touch -d "$past" "$m/out/vmlinuz" "$m/out/initramfs.cpio.gz"
|
||||
touch "$m/input"
|
||||
good=$(fingerprint "$m/out")
|
||||
if (cd "$m" && make -s out/vmlinuz >/dev/null 2>&1); then
|
||||
fail "make reports the failed rebuild"
|
||||
else
|
||||
pass "make reports the failed rebuild"
|
||||
fi
|
||||
if (cd "$m" && make -q out/vmlinuz out/initramfs.cpio.gz); then
|
||||
fail "after a failed rebuild make still sees the pair as stale"
|
||||
else
|
||||
pass "after a failed rebuild make still sees the pair as stale"
|
||||
fi
|
||||
if [ "$(fingerprint "$m/out")" = "$good" ]; then
|
||||
pass "failed rebuild leaves the previous valid pair"
|
||||
else
|
||||
fail "failed rebuild leaves the previous valid pair"
|
||||
fi
|
||||
|
||||
# --- A publish stopped after the kernel lands leaves no mixed pair ---
|
||||
# A stub mv lets the first rename through and fails the second: the point at
|
||||
# which an interrupt would leave a new kernel beside an old initramfs.
|
||||
i="$TMP/interrupt"
|
||||
mkdir -p "$i/bin"
|
||||
real_mv=$(command -v mv)
|
||||
{
|
||||
echo '#!/bin/sh'
|
||||
echo "if [ -e '$i/moved' ]; then exit 1; fi"
|
||||
echo ": > '$i/moved'"
|
||||
echo "exec '$real_mv' \"\$@\""
|
||||
} > "$i/bin/mv"
|
||||
chmod +x "$i/bin/mv"
|
||||
stage "$i/prev"
|
||||
bash "$PUBLISH" "$i/prev" "$i/out" >/dev/null 2>&1
|
||||
stage "$i/stage"
|
||||
echo new-kernel > "$i/stage/vmlinuz"
|
||||
if PATH="$i/bin:$PATH" bash "$PUBLISH" "$i/stage" "$i/out" >/dev/null 2>&1; then
|
||||
fail "interrupted publish is reported"
|
||||
elif [ "$(cat "$i/out/vmlinuz")" = new-kernel ] && [ ! -e "$i/out/initramfs.cpio.gz" ]; then
|
||||
pass "interrupted publish leaves no initramfs beside the new kernel"
|
||||
else
|
||||
fail "interrupted publish leaves no initramfs beside the new kernel"
|
||||
fi
|
||||
printf 'out/vmlinuz out/initramfs.cpio.gz &: input\n\ttrue\n' > "$i/Makefile"
|
||||
touch -d "@$(( $(date +%s) - 10 ))" "$i/input"
|
||||
if (cd "$i" && make -q out/vmlinuz out/initramfs.cpio.gz); then
|
||||
fail "make does not accept the interrupted publish"
|
||||
else
|
||||
pass "make does not accept the interrupted publish"
|
||||
fi
|
||||
|
||||
[ "$fails" -eq 0 ] || { echo " $fails failure(s)"; exit 1; }
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/sh
|
||||
# stage-sqs-store-microvm.sh <image-dir> <dist-root>: copy a built SQS store
|
||||
# microVM image into a release tree as <dist-root>/microvm-sqs-store. Without a
|
||||
# complete image it stages nothing and says so, as the load balancer image does.
|
||||
set -eu
|
||||
src=$1 dest=$2/microvm-sqs-store
|
||||
# An image staged by an earlier build must not ship with this one. Only the
|
||||
# two image files are removed; anything else in the directory is left alone.
|
||||
rm -f "$dest/vmlinuz" "$dest/initramfs.cpio.gz"
|
||||
if [ -s "$src/vmlinuz" ] && [ -s "$src/initramfs.cpio.gz" ]; then
|
||||
echo "[distro] staging the SQS store microVM image into the tarball"
|
||||
mkdir -p "$dest"
|
||||
cp "$src/vmlinuz" "$src/initramfs.cpio.gz" "$dest/"
|
||||
else
|
||||
[ ! -d "$dest" ] || rmdir "$dest" 2> /dev/null || true
|
||||
echo "[distro] WARNING: SQS store microVM image missing; the tarball will not include it"
|
||||
echo "[distro] run 'make build-sqs-store-microvm-image' first"
|
||||
fi
|
||||
@@ -190,7 +190,7 @@ run sudo systemctl reset-failed 'spinifex-*' 2>/dev/null || true
|
||||
# the wipe and before the verification, failing the reset on a file the script
|
||||
# had already deleted. update-nodes.sh carries the same wait.
|
||||
if ! $DRY_RUN; then
|
||||
for unit in spinifex-predastore spinifex-viperblock spinifex-nats; do
|
||||
for unit in spinifex-predastore spinifex-viperblock spinifex-sqs-store spinifex-nats; do
|
||||
if systemctl is-active --quiet "$unit"; then
|
||||
sudo systemctl stop "$unit" || true
|
||||
fi
|
||||
@@ -428,6 +428,10 @@ if ! $KEEP_DATA && [ -d "$DATA_DIR/nats/jetstream" ]; then
|
||||
log "removing the JetStream store"
|
||||
run sudo rm -rf "$DATA_DIR/nats/jetstream"
|
||||
fi
|
||||
if ! $KEEP_DATA && [ -d "$DATA_DIR/sqs-store/jetstream" ]; then
|
||||
log "removing the SQS store JetStream data"
|
||||
run sudo rm -rf "$DATA_DIR/sqs-store/jetstream"
|
||||
fi
|
||||
|
||||
# Directories may survive — a mountpoint cannot be removed, and neither can the
|
||||
# path leading down to one. Files may not: a surviving file is state that would
|
||||
|
||||
+20
-38
@@ -283,6 +283,7 @@ create_service_users() {
|
||||
# Create per-service users with correct home directories
|
||||
declare -A SERVICE_HOMES=(
|
||||
[nats]="/var/lib/spinifex/nats"
|
||||
[sqsstore]="/var/lib/spinifex/sqs-store"
|
||||
[gw]="/var/lib/spinifex/awsgw"
|
||||
[daemon]="/var/lib/spinifex/spinifex"
|
||||
[storage]="/var/lib/spinifex/predastore"
|
||||
@@ -291,7 +292,7 @@ create_service_users() {
|
||||
[vpcd]="/var/lib/spinifex"
|
||||
[ui]="/var/lib/spinifex"
|
||||
)
|
||||
for svc in nats gw daemon storage northstar viperblock vpcd ui; do
|
||||
for svc in nats sqsstore gw daemon storage northstar viperblock vpcd ui; do
|
||||
local user="spinifex-${svc}"
|
||||
if ! id "$user" > /dev/null 2>&1; then
|
||||
$SUDO useradd --system --no-create-home \
|
||||
@@ -323,7 +324,7 @@ create_service_users() {
|
||||
# Daemon consumes viperblock's NBD socket — join the producer-typed group.
|
||||
$SUDO usermod -aG spinifex-viperblock spinifex-daemon
|
||||
|
||||
info "Service users created (spinifex-{nats,gw,daemon,storage,northstar,viperblock,vpcd,ui})"
|
||||
info "Service users created (spinifex-{nats,sqsstore,gw,daemon,storage,northstar,viperblock,vpcd,ui})"
|
||||
}
|
||||
|
||||
# --- Install scoped sudoers rules ---
|
||||
@@ -700,7 +701,7 @@ table inet spinifex_filter {
|
||||
# inventory and running instances to anyone who asks. 8660 is
|
||||
# predastore's unauthenticated /healthz and /readyz, which name the
|
||||
# peers a gate cannot reach, so it belongs here for the same reason.
|
||||
ip saddr $spinifex_peers tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
|
||||
ip saddr $spinifex_peers tcp dport { 4222, 4223, 4248, 4249, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
|
||||
ip saddr $spinifex_peers udp dport { 5300, 6660, 7660 } accept
|
||||
|
||||
# Encap plane, peer-scoped: Geneve, IKE, NAT-T and ESP. Geneve is a
|
||||
@@ -1542,41 +1543,6 @@ install_files() {
|
||||
$SUDO cp "$EXTRACT_DIR/microvm/"* /usr/share/spinifex/microvm/
|
||||
info " /usr/share/spinifex/microvm/*"
|
||||
fi
|
||||
install_sqs_store_image
|
||||
}
|
||||
|
||||
# The SQS store image ships beside the load balancer's, not inside it: an older
|
||||
# installer copies microvm/* without -r and would stop at a subdirectory.
|
||||
install_sqs_store_image() {
|
||||
local src="$EXTRACT_DIR/microvm-sqs-store" f
|
||||
local dest="${SPINIFEX_MICROVM_DIR:-/usr/share/spinifex/microvm}/sqs-store"
|
||||
# A previously installed image belongs to an older release, and this
|
||||
# release's daemon must not boot it. Only the two image files are removed.
|
||||
if [ ! -d "$src" ]; then
|
||||
remove_sqs_store_image "$dest"
|
||||
warn "This release has no SQS store microVM image; SQS store launches will be refused"
|
||||
return 0
|
||||
fi
|
||||
for f in vmlinuz initramfs.cpio.gz; do
|
||||
if [ ! -s "$src/$f" ]; then
|
||||
remove_sqs_store_image "$dest"
|
||||
fatal "The SQS store microVM image in this release is incomplete: $f is missing"
|
||||
fi
|
||||
done
|
||||
# The initramfs goes last, so an interrupted install leaves no pair the
|
||||
# daemon would boot: it refuses a launch while either file is missing.
|
||||
$SUDO install -d "$dest"
|
||||
$SUDO rm -f "$dest/initramfs.cpio.gz"
|
||||
$SUDO install -m 0644 "$src/vmlinuz" "$dest/vmlinuz"
|
||||
$SUDO install -m 0644 "$src/initramfs.cpio.gz" "$dest/initramfs.cpio.gz"
|
||||
info " $dest/vmlinuz, $dest/initramfs.cpio.gz"
|
||||
}
|
||||
|
||||
# Running store VMs keep their already-loaded kernel; the files are read at boot.
|
||||
remove_sqs_store_image() {
|
||||
[ -e "$1/initramfs.cpio.gz" ] || [ -e "$1/vmlinuz" ] || return 0
|
||||
$SUDO rm -f "$1/initramfs.cpio.gz" "$1/vmlinuz"
|
||||
warn "Removed the SQS store microVM image left by an earlier install from $1"
|
||||
}
|
||||
|
||||
# --- Create directories ---
|
||||
@@ -1632,6 +1598,12 @@ TMPEOF
|
||||
$SUDO chown "spinifex-nats:$SPINIFEX_GROUP" /etc/spinifex/nats
|
||||
$SUDO chmod 0750 /etc/spinifex/nats
|
||||
|
||||
# The SQS store holds its own token: 0700 keeps every other spinifex-*
|
||||
# service in the shared group out.
|
||||
$SUDO mkdir -p /etc/spinifex/sqs-store
|
||||
$SUDO chown "spinifex-sqsstore:$SPINIFEX_GROUP" /etc/spinifex/sqs-store
|
||||
$SUDO chmod 0700 /etc/spinifex/sqs-store
|
||||
|
||||
$SUDO mkdir -p /etc/spinifex/predastore
|
||||
$SUDO chown "spinifex-storage:$SPINIFEX_GROUP" /etc/spinifex/predastore
|
||||
$SUDO chmod 0750 /etc/spinifex/predastore
|
||||
@@ -1659,6 +1631,10 @@ TMPEOF
|
||||
$SUDO chown "spinifex-nats:$SPINIFEX_GROUP" /var/lib/spinifex/nats
|
||||
$SUDO chmod 0700 /var/lib/spinifex/nats
|
||||
|
||||
$SUDO mkdir -p /var/lib/spinifex/sqs-store
|
||||
$SUDO chown "spinifex-sqsstore:$SPINIFEX_GROUP" /var/lib/spinifex/sqs-store
|
||||
$SUDO chmod 0700 /var/lib/spinifex/sqs-store
|
||||
|
||||
$SUDO mkdir -p /var/lib/spinifex/spinifex
|
||||
$SUDO chown "spinifex-daemon:$SPINIFEX_GROUP" /var/lib/spinifex/spinifex
|
||||
$SUDO chmod 0700 /var/lib/spinifex/spinifex
|
||||
@@ -1740,6 +1716,7 @@ fix_file_ownership() {
|
||||
# Per-service data dirs — recursive chown so existing files are accessible
|
||||
for entry in \
|
||||
nats:spinifex-nats \
|
||||
sqs-store:spinifex-sqsstore \
|
||||
predastore:spinifex-storage \
|
||||
northstar:spinifex-northstar \
|
||||
spinifex:spinifex-daemon \
|
||||
@@ -1758,6 +1735,11 @@ fix_file_ownership() {
|
||||
$SUDO chown -R "spinifex-nats:$SPINIFEX_GROUP" /etc/spinifex/nats \
|
||||
|| fatal "Failed to set ownership on /etc/spinifex/nats"
|
||||
fi
|
||||
if [ -d /etc/spinifex/sqs-store ]; then
|
||||
$SUDO chown -R "spinifex-sqsstore:$SPINIFEX_GROUP" /etc/spinifex/sqs-store \
|
||||
|| fatal "Failed to set ownership on /etc/spinifex/sqs-store"
|
||||
$SUDO chmod 0700 /etc/spinifex/sqs-store
|
||||
fi
|
||||
if [ -d /etc/spinifex/predastore ]; then
|
||||
$SUDO chown -R "spinifex-storage:$SPINIFEX_GROUP" /etc/spinifex/predastore \
|
||||
|| fatal "Failed to set ownership on /etc/spinifex/predastore"
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
#!/bin/sh
|
||||
# setup_sqs_store_image_test.sh — tests for shipping the SQS store microVM
|
||||
# image: staging it into a release tree and installing it with setup.sh.
|
||||
#
|
||||
# setup.sh is sourced with INSTALL_SPINIFEX_LIB_ONLY=1 so main never runs, and
|
||||
# installs into a temporary SPINIFEX_MICROVM_DIR with SUDO cleared.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
|
||||
SETUP="$SCRIPT_DIR/setup.sh"
|
||||
STAGE="$SCRIPT_DIR/lib/stage-sqs-store-microvm.sh"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
|
||||
echo "-- SQS store microVM image release and install"
|
||||
|
||||
# image <dir> <tag>: a fake built image whose bytes name its tag.
|
||||
image() {
|
||||
mkdir -p "$1"
|
||||
printf 'kernel-%s\n' "$2" > "$1/vmlinuz"
|
||||
printf 'initramfs-%s\n' "$2" > "$1/initramfs.cpio.gz"
|
||||
}
|
||||
|
||||
# release <dir>: a release tree with the load balancer image, as distro-amd64 builds it.
|
||||
release() {
|
||||
rm -rf "$1"
|
||||
mkdir -p "$1/microvm"
|
||||
printf 'lb-kernel\n' > "$1/microvm/vmlinuz"
|
||||
printf 'lb-initramfs\n' > "$1/microvm/initramfs.cpio.gz"
|
||||
printf 'qboot\n' > "$1/microvm/qboot.rom"
|
||||
}
|
||||
|
||||
# install_image <extract-dir> <microvm-dir> [path-prefix]: runs
|
||||
# install_sqs_store_image and sets RC and OUT.
|
||||
install_image() {
|
||||
RC=0
|
||||
OUT=$(PATH="${3:-}$PATH" EXTRACT_DIR="$1" SPINIFEX_MICROVM_DIR="$2" INSTALL_SPINIFEX_LIB_ONLY=1 \
|
||||
bash -c '. "$1"; SUDO=""; install_sqs_store_image' _ "$SETUP" 2>&1) || RC=$?
|
||||
}
|
||||
|
||||
same() { cmp -s "$1" "$2"; }
|
||||
|
||||
# --- staging ---
|
||||
image "$TMP/built" v1
|
||||
release "$TMP/rel"
|
||||
sh "$STAGE" "$TMP/built" "$TMP/rel" > "$TMP/stage.out"
|
||||
if same "$TMP/built/vmlinuz" "$TMP/rel/microvm-sqs-store/vmlinuz" \
|
||||
&& same "$TMP/built/initramfs.cpio.gz" "$TMP/rel/microvm-sqs-store/initramfs.cpio.gz"; then
|
||||
pass "stages the built image beside the load balancer's"
|
||||
else
|
||||
fail "staged image differs from the build"
|
||||
fi
|
||||
if [ ! -e "$TMP/rel/microvm/sqs-store" ] && [ "$(ls "$TMP/rel/microvm" | wc -l)" -eq 3 ]; then
|
||||
pass "leaves the load balancer's microvm directory as it was"
|
||||
else
|
||||
fail "load balancer directory changed: $(ls "$TMP/rel/microvm")"
|
||||
fi
|
||||
|
||||
release "$TMP/rel-none"
|
||||
mkdir -p "$TMP/half"; printf 'k\n' > "$TMP/half/vmlinuz"
|
||||
OUT=$(sh "$STAGE" "$TMP/half" "$TMP/rel-none")
|
||||
if [ ! -e "$TMP/rel-none/microvm-sqs-store" ] && echo "$OUT" | grep -q 'WARNING: SQS store microVM image missing'; then
|
||||
pass "stages nothing from an incomplete build, and says so"
|
||||
else
|
||||
fail "incomplete build staged or unannounced: $OUT"
|
||||
fi
|
||||
|
||||
# --- an older installer meets a release that carries the store image ---
|
||||
# It copies microvm/* without -r; the sibling directory must not reach it.
|
||||
mkdir -p "$TMP/old-dest"
|
||||
if cp "$TMP/rel/microvm/"* "$TMP/old-dest/" 2> "$TMP/old.err" && [ ! -e "$TMP/old-dest/sqs-store" ]; then
|
||||
pass "an older installer's copy still succeeds and ignores the store image"
|
||||
else
|
||||
fail "older installer copy failed: $(cat "$TMP/old.err")"
|
||||
fi
|
||||
mkdir -p "$TMP/nested/microvm/sqs-store" "$TMP/nested-dest"
|
||||
printf 'x\n' > "$TMP/nested/microvm/vmlinuz"
|
||||
if cp "$TMP/nested/microvm/"* "$TMP/nested-dest/" 2> /dev/null; then
|
||||
fail "control: a nested directory did not break the older copy, so the test proves nothing"
|
||||
else
|
||||
pass "control: a nested directory would have broken the older copy"
|
||||
fi
|
||||
|
||||
# --- install ---
|
||||
install_image "$TMP/rel" "$TMP/mv"
|
||||
if [ "$RC" = 0 ] && same "$TMP/built/vmlinuz" "$TMP/mv/sqs-store/vmlinuz" \
|
||||
&& same "$TMP/built/initramfs.cpio.gz" "$TMP/mv/sqs-store/initramfs.cpio.gz"; then
|
||||
pass "installs the staged image where the daemon looks"
|
||||
else
|
||||
fail "install rc=$RC: $OUT"
|
||||
fi
|
||||
mode=$(stat -c '%a' "$TMP/mv/sqs-store/initramfs.cpio.gz" 2> /dev/null || echo none)
|
||||
if [ "$mode" = 644 ]; then pass "installs the image 0644"; else fail "initramfs mode $mode"; fi
|
||||
|
||||
image "$TMP/built2" v2
|
||||
release "$TMP/rel2"
|
||||
sh "$STAGE" "$TMP/built2" "$TMP/rel2" > /dev/null
|
||||
install_image "$TMP/rel2" "$TMP/mv"
|
||||
if [ "$RC" = 0 ] && same "$TMP/built2/vmlinuz" "$TMP/mv/sqs-store/vmlinuz" \
|
||||
&& same "$TMP/built2/initramfs.cpio.gz" "$TMP/mv/sqs-store/initramfs.cpio.gz"; then
|
||||
pass "an upgrade replaces both files"
|
||||
else
|
||||
fail "upgrade rc=$RC: $OUT"
|
||||
fi
|
||||
|
||||
# A release without the store image: nothing installed, and a clear warning.
|
||||
install_image "$TMP/rel-none" "$TMP/mv-none"
|
||||
if [ "$RC" = 0 ] && [ ! -e "$TMP/mv-none/sqs-store" ] && echo "$OUT" | grep -q 'no SQS store microVM image; SQS store launches will be refused'; then
|
||||
pass "a release without the image installs nothing and warns"
|
||||
else
|
||||
fail "release without image: rc=$RC exists=$([ -e "$TMP/mv-none/sqs-store" ] && echo yes || echo no) $OUT"
|
||||
fi
|
||||
|
||||
# installed <dir>: a store image from an earlier install, plus an unrelated file.
|
||||
installed() {
|
||||
image "$1/sqs-store" old
|
||||
printf 'keep\n' > "$1/sqs-store/operator-note"
|
||||
}
|
||||
gone() { [ ! -e "$1/sqs-store/vmlinuz" ] && [ ! -e "$1/sqs-store/initramfs.cpio.gz" ]; }
|
||||
kept() { [ "$(cat "$1/sqs-store/operator-note" 2> /dev/null)" = keep ]; }
|
||||
|
||||
# Upgrading to a release without the image must not leave the old one usable.
|
||||
installed "$TMP/mv-up"
|
||||
install_image "$TMP/rel-none" "$TMP/mv-up"
|
||||
if [ "$RC" = 0 ] && gone "$TMP/mv-up" && kept "$TMP/mv-up" \
|
||||
&& echo "$OUT" | grep -q 'Removed the SQS store microVM image left by an earlier install' \
|
||||
&& echo "$OUT" | grep -q 'SQS store launches will be refused'; then
|
||||
pass "a release without the image removes the earlier one, keeps other files, and warns"
|
||||
else
|
||||
fail "release without image over an installed one: rc=$RC $OUT"
|
||||
fi
|
||||
|
||||
# An incomplete store image refuses and removes the earlier pair, because the
|
||||
# binaries this release installed before it would otherwise boot the old one.
|
||||
installed "$TMP/mv-bad"
|
||||
cp -r "$TMP/rel2" "$TMP/rel-bad"
|
||||
rm "$TMP/rel-bad/microvm-sqs-store/initramfs.cpio.gz"
|
||||
install_image "$TMP/rel-bad" "$TMP/mv-bad"
|
||||
if [ "$RC" != 0 ] && echo "$OUT" | grep -q 'incomplete: initramfs.cpio.gz is missing' \
|
||||
&& gone "$TMP/mv-bad" && kept "$TMP/mv-bad"; then
|
||||
pass "an incomplete image is refused and the earlier pair is removed"
|
||||
else
|
||||
fail "incomplete image: rc=$RC $OUT"
|
||||
fi
|
||||
|
||||
# Restaging from a missing or incomplete build drops an image staged earlier.
|
||||
for case in missing incomplete; do
|
||||
image "$TMP/built-$case" new
|
||||
release "$TMP/rel-$case"
|
||||
sh "$STAGE" "$TMP/built" "$TMP/rel-$case" > /dev/null
|
||||
if [ "$case" = missing ]; then rm -r "$TMP/built-$case"; else rm "$TMP/built-$case/initramfs.cpio.gz"; fi
|
||||
OUT=$(sh "$STAGE" "$TMP/built-$case" "$TMP/rel-$case")
|
||||
if [ ! -e "$TMP/rel-$case/microvm-sqs-store" ] && echo "$OUT" | grep -q 'tarball will not include it'; then
|
||||
pass "restaging from a $case build ships no earlier image"
|
||||
else
|
||||
fail "restaging from a $case build left: $(ls -R "$TMP/rel-$case/microvm-sqs-store" 2> /dev/null)"
|
||||
fi
|
||||
done
|
||||
mkdir -p "$TMP/rel-other/microvm-sqs-store"
|
||||
image "$TMP/rel-other/microvm-sqs-store" old
|
||||
printf 'keep\n' > "$TMP/rel-other/microvm-sqs-store/note"
|
||||
sh "$STAGE" "$TMP/no-such-build" "$TMP/rel-other" > /dev/null
|
||||
if [ ! -e "$TMP/rel-other/microvm-sqs-store/vmlinuz" ] && [ "$(cat "$TMP/rel-other/microvm-sqs-store/note")" = keep ]; then
|
||||
pass "restaging removes only the image files"
|
||||
else
|
||||
fail "restaging touched more than the image files"
|
||||
fi
|
||||
|
||||
# An install that stops at the initramfs leaves no pair the daemon would boot.
|
||||
mkdir -p "$TMP/bin"
|
||||
cat > "$TMP/bin/install" << 'EOF'
|
||||
#!/bin/sh
|
||||
for a in "$@"; do last=$a; done
|
||||
case "$last" in */initramfs.cpio.gz) echo "install: stopped" >&2; exit 1 ;; esac
|
||||
exec /usr/bin/install "$@"
|
||||
EOF
|
||||
chmod +x "$TMP/bin/install"
|
||||
install_image "$TMP/rel" "$TMP/mv" "$TMP/bin:"
|
||||
if [ "$RC" != 0 ] && same "$TMP/built/vmlinuz" "$TMP/mv/sqs-store/vmlinuz" && [ ! -e "$TMP/mv/sqs-store/initramfs.cpio.gz" ]; then
|
||||
pass "an interrupted install leaves the initramfs missing, not mismatched"
|
||||
else
|
||||
fail "interrupted install: rc=$RC initramfs=$([ -e "$TMP/mv/sqs-store/initramfs.cpio.gz" ] && echo present || echo missing)"
|
||||
fi
|
||||
|
||||
[ "$fails" = 0 ] || { echo "FAIL: $fails"; exit 1; }
|
||||
echo " setup_sqs_store_image_test ok"
|
||||
@@ -237,7 +237,7 @@ fi
|
||||
log "stopping services"
|
||||
run sudo systemctl stop spinifex.target 2>/dev/null || true
|
||||
if ! $DRY_RUN; then
|
||||
for unit in spinifex-predastore spinifex-viperblock spinifex-nats; do
|
||||
for unit in spinifex-predastore spinifex-viperblock spinifex-sqs-store spinifex-nats; do
|
||||
if systemctl is-active --quiet "$unit"; then
|
||||
sudo systemctl stop "$unit" || true
|
||||
fi
|
||||
@@ -499,7 +499,7 @@ fi
|
||||
# userdel warns about a missing home. Order here is cosmetic, not correctness.
|
||||
# ---------------------------------------------------------------------------
|
||||
log "removing service users and groups"
|
||||
for svc in nats gw daemon storage northstar viperblock vpcd ui; do
|
||||
for svc in nats sqsstore gw daemon storage northstar viperblock vpcd ui; do
|
||||
user="spinifex-${svc}"
|
||||
if id -u "$user" >/dev/null 2>&1; then
|
||||
run sudo userdel "$user" 2>/dev/null || log " NOTE: could not remove user $user"
|
||||
@@ -635,7 +635,7 @@ if ! $DRY_RUN; then
|
||||
for u in "${RESTART_UNITS[@]}"; do
|
||||
systemctl is-failed --quiet "$u" 2>/dev/null && LEFT+=("${u%.service}.service is failed")
|
||||
done
|
||||
for svc in nats gw daemon storage northstar viperblock vpcd ui; do
|
||||
for svc in nats sqsstore gw daemon storage northstar viperblock vpcd ui; do
|
||||
id -u "spinifex-${svc}" >/dev/null 2>&1 && LEFT+=("user spinifex-${svc}")
|
||||
done
|
||||
if command -v nft >/dev/null 2>&1 && sudo nft list table inet spinifex_filter >/dev/null 2>&1; then
|
||||
|
||||
@@ -3,7 +3,6 @@ package quota
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
@@ -114,23 +113,3 @@ func (s *Service) EnforceRDSInstances(ctx context.Context, natsConn *nats.Conn,
|
||||
}
|
||||
return exceeds(len(out.DBInstances), want, limits.RDSInstances)
|
||||
}
|
||||
|
||||
// LoadBalancerLimit is the load balancer cap DescribeAccountLimits reports.
|
||||
func (s *Service) LoadBalancerLimit(ctx context.Context, accountID string) (int, error) {
|
||||
// An exempt account has no cap enforced against it, so the AWS default is a
|
||||
// truer answer than a configured limit nothing consults.
|
||||
if s.Exempt(accountID) {
|
||||
return elbv2awsapi.DefaultLoadBalancerLimit, nil
|
||||
}
|
||||
_, limits, err := s.GetAccountQuota(ctx, accountID)
|
||||
if err != nil {
|
||||
if awserrors.IsErrorCode(err, awserrors.ErrorBlocked) {
|
||||
return 0, err
|
||||
}
|
||||
slog.ErrorContext(ctx, "DescribeAccountLimits: failed to read account quota", "accountID", accountID, "err", err)
|
||||
return 0, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
return limits.LoadBalancers, nil
|
||||
}
|
||||
|
||||
var _ elbv2awsapi.Quota = (*Service)(nil)
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
elbv2awsapi "github.com/mulgadc/spinifex/spinifex/domains/elbv2/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
)
|
||||
|
||||
@@ -83,25 +82,3 @@ func TestEnforceLiveExemptShortCircuits(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBalancerLimit_ExemptAccountReportsAWSDefault(t *testing.T) {
|
||||
var disabled *Service
|
||||
got, err := disabled.LoadBalancerLimit(t.Context(), "123456789012")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBalancerLimit: %v", err)
|
||||
}
|
||||
if got != elbv2awsapi.DefaultLoadBalancerLimit {
|
||||
t.Errorf("limit = %d, want the AWS default %d", got, elbv2awsapi.DefaultLoadBalancerLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBalancerLimit_EnabledAccountReportsConfiguredLimit(t *testing.T) {
|
||||
svc := New(Limits{Enabled: true, LoadBalancers: 7}, nil)
|
||||
got, err := svc.LoadBalancerLimit(t.Context(), "123456789012")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBalancerLimit: %v", err)
|
||||
}
|
||||
if got != 7 {
|
||||
t.Errorf("limit = %d, want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -135,36 +134,3 @@ func TestPutAccountQuotaWithoutBucket(t *testing.T) {
|
||||
svc := New(baseLimits(), nil)
|
||||
require.Error(t, svc.PutAccountQuota(t.Context(), "000000000002", Overrides{VCPUs: ptr(32)}, "operator"))
|
||||
}
|
||||
|
||||
// The cap DescribeAccountLimits reports follows the account's stored override
|
||||
// and falls back to the configured baseline for an account without one.
|
||||
func TestLoadBalancerLimit_FollowsPerAccountOverride(t *testing.T) {
|
||||
svc, _ := newOverrideService(t)
|
||||
const upgraded, plain = "000000000002", "000000000003"
|
||||
require.NoError(t, svc.PutAccountQuota(t.Context(), upgraded, Overrides{LoadBalancers: ptr(31)}, "operator"))
|
||||
|
||||
got, err := svc.LoadBalancerLimit(t.Context(), upgraded)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 31, got)
|
||||
|
||||
got, err = svc.LoadBalancerLimit(t.Context(), plain)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, baseLimits().LoadBalancers, got)
|
||||
}
|
||||
|
||||
// A sealed account is refused with Blocked, and an unreadable record is an
|
||||
// InternalError, never a made-up number.
|
||||
func TestLoadBalancerLimit_RefusesSealedAndUnreadableRecords(t *testing.T) {
|
||||
svc, kv := newOverrideService(t)
|
||||
const sealed, corrupt = "000000000004", "000000000005"
|
||||
_, err := kv.Put(t.Context(), sealed, []byte(`{"state":"sealed"}`))
|
||||
require.NoError(t, err)
|
||||
_, err = kv.Put(t.Context(), corrupt, []byte(`not json`))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.LoadBalancerLimit(t.Context(), sealed)
|
||||
require.EqualError(t, err, awserrors.ErrorBlocked)
|
||||
|
||||
_, err = svc.LoadBalancerLimit(t.Context(), corrupt)
|
||||
require.EqualError(t, err, awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
ec2image "github.com/mulgadc/spinifex/spinifex/domains/ec2/image"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
@@ -27,86 +27,74 @@ var validRegisterImageBootModes = map[string]bool{
|
||||
}
|
||||
|
||||
// ValidateRegisterImageInput rejects inputs that ask for behaviour spinifex
|
||||
// doesn't have (PV, S3 bundles, kernel/ramdisk, IMDS/TPM hints) rather than
|
||||
// silently discarding them. Registration is pointer-only: caller supplies a
|
||||
// snapshot and we write a config.json that references it.
|
||||
// doesn't have (PV, S3 bundles, kernel/ramdisk, IMDS/TPM/ENA hints) rather
|
||||
// than silently discarding them. Registration is pointer-only: caller supplies
|
||||
// a snapshot and we write a config.json that references it.
|
||||
func ValidateRegisterImageInput(input *ec2.RegisterImageInput) error {
|
||||
if input == nil {
|
||||
return awserrors.Errorf(awserrors.ErrorMissingParameter, "The request must contain the parameter Name")
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
if input.Name == nil || *input.Name == "" {
|
||||
return missingParam("Name")
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if n := len(*input.Name); n < 3 || n > 128 {
|
||||
return invalidParam(awserrors.ErrorInvalidAMINameMalformed, "Name", *input.Name, "AMI names must be between 3 and 128 characters long.")
|
||||
return errors.New(awserrors.ErrorInvalidAMINameMalformed)
|
||||
}
|
||||
|
||||
if aws.StringValue(input.ImageLocation) != "" {
|
||||
return unsupportedParam("ImageLocation", *input.ImageLocation)
|
||||
if input.ImageLocation != nil && *input.ImageLocation != "" {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
if arch := aws.StringValue(input.Architecture); arch != "" && !validRegisterImageArchitectures[arch] {
|
||||
return invalidParam(awserrors.ErrorInvalidParameterValue, "Architecture", arch, "Supported values are x86_64, arm64 and i386.")
|
||||
if input.Architecture != nil && *input.Architecture != "" {
|
||||
if !validRegisterImageArchitectures[*input.Architecture] {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
}
|
||||
|
||||
if virt := aws.StringValue(input.VirtualizationType); virt != "" && virt != "hvm" {
|
||||
return invalidParam(awserrors.ErrorInvalidParameterValue, "VirtualizationType", virt, "Only hvm is supported.")
|
||||
if input.VirtualizationType != nil && *input.VirtualizationType != "" && *input.VirtualizationType != "hvm" {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
if mode := aws.StringValue(input.BootMode); mode != "" && !validRegisterImageBootModes[mode] {
|
||||
return invalidParam(awserrors.ErrorInvalidParameterValue, "BootMode", mode, "Supported values are bios, uefi and uefi-preferred.")
|
||||
if input.BootMode != nil && *input.BootMode != "" {
|
||||
if !validRegisterImageBootModes[*input.BootMode] {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
}
|
||||
if aws.StringValue(input.KernelId) != "" {
|
||||
return unsupportedParam("KernelId", *input.KernelId)
|
||||
if input.KernelId != nil && *input.KernelId != "" {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if aws.StringValue(input.RamdiskId) != "" {
|
||||
return unsupportedParam("RamdiskId", *input.RamdiskId)
|
||||
if input.RamdiskId != nil && *input.RamdiskId != "" {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if aws.StringValue(input.TpmSupport) != "" {
|
||||
return unsupportedParam("TpmSupport", *input.TpmSupport)
|
||||
if input.TpmSupport != nil && *input.TpmSupport != "" {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if aws.StringValue(input.ImdsSupport) != "" {
|
||||
return unsupportedParam("ImdsSupport", *input.ImdsSupport)
|
||||
if input.ImdsSupport != nil && *input.ImdsSupport != "" {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if aws.StringValue(input.SriovNetSupport) != "" {
|
||||
return unsupportedParam("SriovNetSupport", *input.SriovNetSupport)
|
||||
if input.EnaSupport != nil {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.SriovNetSupport != nil && *input.SriovNetSupport != "" {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
if len(input.BlockDeviceMappings) == 0 {
|
||||
return missingParam("BlockDeviceMapping")
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
root := selectRootBlockDeviceMapping(input.BlockDeviceMappings, input.RootDeviceName)
|
||||
if root == nil || root.Ebs == nil || root.Ebs.SnapshotId == nil || *root.Ebs.SnapshotId == "" {
|
||||
if name := aws.StringValue(input.RootDeviceName); name != "" && root == nil {
|
||||
return awserrors.Errorf(awserrors.ErrorMissingParameter,
|
||||
"Value (%s) for parameter RootDeviceName is invalid. No block device mapping has that DeviceName.", name)
|
||||
}
|
||||
return missingParam("SnapshotId in the root block device mapping")
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if !strings.HasPrefix(*root.Ebs.SnapshotId, "snap-") {
|
||||
return invalidParam(awserrors.ErrorInvalidSnapshotIDMalformed, "SnapshotId", *root.Ebs.SnapshotId, "Expected: 'snap-...'.")
|
||||
return errors.New(awserrors.ErrorInvalidSnapshotIDMalformed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// missingParam is EC2's wording for an absent required parameter.
|
||||
func missingParam(name string) error {
|
||||
return awserrors.Errorf(awserrors.ErrorMissingParameter, "The request must contain the parameter %s", name)
|
||||
}
|
||||
|
||||
// invalidParam is EC2's wording for a parameter value it refuses.
|
||||
func invalidParam(code, name, value, reason string) error {
|
||||
return awserrors.Errorf(code, "Value (%s) for parameter %s is invalid. %s", value, name, reason)
|
||||
}
|
||||
|
||||
// unsupportedParam refuses a parameter spinifex has no behaviour for.
|
||||
func unsupportedParam(name, value string) error {
|
||||
return invalidParam(awserrors.ErrorInvalidParameterValue, name, value, name+" is not supported.")
|
||||
}
|
||||
|
||||
// selectRootBlockDeviceMapping returns the BDM matching RootDeviceName if set,
|
||||
// else the first BDM carrying an EBS snapshot reference.
|
||||
func selectRootBlockDeviceMapping(mappings []*ec2.BlockDeviceMapping, rootDeviceName *string) *ec2.BlockDeviceMapping {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validRegisterImageInput() *ec2.RegisterImageInput {
|
||||
@@ -34,37 +33,30 @@ func TestValidateRegisterImageInput(t *testing.T) {
|
||||
input *ec2.RegisterImageInput
|
||||
wantErr bool
|
||||
errMsg string
|
||||
// wantIn is the text the message must carry, so the caller can see which
|
||||
// parameter was refused.
|
||||
wantIn string
|
||||
}{
|
||||
{
|
||||
name: "NilInput",
|
||||
input: nil,
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorMissingParameter,
|
||||
wantIn: "parameter Name",
|
||||
},
|
||||
{
|
||||
name: "MissingName",
|
||||
mutate: func(i *ec2.RegisterImageInput) { i.Name = nil },
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorMissingParameter,
|
||||
wantIn: "parameter Name",
|
||||
},
|
||||
{
|
||||
name: "EmptyName",
|
||||
mutate: func(i *ec2.RegisterImageInput) { i.Name = aws.String("") },
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorMissingParameter,
|
||||
wantIn: "parameter Name",
|
||||
},
|
||||
{
|
||||
name: "ShortName",
|
||||
mutate: func(i *ec2.RegisterImageInput) { i.Name = aws.String("ab") },
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidAMINameMalformed,
|
||||
wantIn: "for parameter Name is invalid",
|
||||
},
|
||||
{
|
||||
name: "LongName",
|
||||
@@ -77,14 +69,12 @@ func TestValidateRegisterImageInput(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidAMINameMalformed,
|
||||
wantIn: "for parameter Name is invalid",
|
||||
},
|
||||
{
|
||||
name: "MissingBlockDeviceMappings",
|
||||
mutate: func(i *ec2.RegisterImageInput) { i.BlockDeviceMappings = nil },
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorMissingParameter,
|
||||
wantIn: "parameter BlockDeviceMapping",
|
||||
},
|
||||
{
|
||||
name: "MissingSnapshotIdInRootBDM",
|
||||
@@ -93,7 +83,6 @@ func TestValidateRegisterImageInput(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorMissingParameter,
|
||||
wantIn: "parameter SnapshotId",
|
||||
},
|
||||
{
|
||||
name: "MalformedSnapshotId",
|
||||
@@ -102,7 +91,6 @@ func TestValidateRegisterImageInput(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidSnapshotIDMalformed,
|
||||
wantIn: "Value (not-a-snapshot) for parameter SnapshotId is invalid",
|
||||
},
|
||||
{
|
||||
name: "RootDeviceNameWithNoMatchingBDM",
|
||||
@@ -111,7 +99,6 @@ func TestValidateRegisterImageInput(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorMissingParameter,
|
||||
wantIn: "Value (/dev/xvda) for parameter RootDeviceName is invalid",
|
||||
},
|
||||
{
|
||||
name: "InvalidArchitecture",
|
||||
@@ -120,7 +107,6 @@ func TestValidateRegisterImageInput(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidParameterValue,
|
||||
wantIn: "Value (riscv) for parameter Architecture is invalid",
|
||||
},
|
||||
{
|
||||
name: "ParavirtualVirtualizationRejected",
|
||||
@@ -129,7 +115,6 @@ func TestValidateRegisterImageInput(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidParameterValue,
|
||||
wantIn: "Value (paravirtual) for parameter VirtualizationType is invalid",
|
||||
},
|
||||
{
|
||||
name: "ImageLocationRejected",
|
||||
@@ -138,7 +123,6 @@ func TestValidateRegisterImageInput(t *testing.T) {
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidParameterValue,
|
||||
wantIn: "for parameter ImageLocation is invalid. ImageLocation is not supported",
|
||||
},
|
||||
{
|
||||
name: "BootModeAcceptedBios",
|
||||
@@ -160,52 +144,42 @@ func TestValidateRegisterImageInput(t *testing.T) {
|
||||
mutate: func(i *ec2.RegisterImageInput) { i.BootMode = aws.String("legacy-bios") },
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidParameterValue,
|
||||
wantIn: "Value (legacy-bios) for parameter BootMode is invalid",
|
||||
},
|
||||
{
|
||||
name: "KernelIdRejected",
|
||||
mutate: func(i *ec2.RegisterImageInput) { i.KernelId = aws.String("aki-123") },
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidParameterValue,
|
||||
wantIn: "Value (aki-123) for parameter KernelId is invalid. KernelId is not supported",
|
||||
},
|
||||
{
|
||||
name: "RamdiskIdRejected",
|
||||
mutate: func(i *ec2.RegisterImageInput) { i.RamdiskId = aws.String("ari-123") },
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidParameterValue,
|
||||
wantIn: "Value (ari-123) for parameter RamdiskId is invalid. RamdiskId is not supported",
|
||||
},
|
||||
{
|
||||
name: "TpmSupportRejected",
|
||||
mutate: func(i *ec2.RegisterImageInput) { i.TpmSupport = aws.String("v2.0") },
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidParameterValue,
|
||||
wantIn: "Value (v2.0) for parameter TpmSupport is invalid. TpmSupport is not supported",
|
||||
},
|
||||
{
|
||||
name: "ImdsSupportRejected",
|
||||
mutate: func(i *ec2.RegisterImageInput) { i.ImdsSupport = aws.String("v2.0") },
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidParameterValue,
|
||||
wantIn: "Value (v2.0) for parameter ImdsSupport is invalid. ImdsSupport is not supported",
|
||||
},
|
||||
{
|
||||
name: "EnaSupportAcceptedTrue",
|
||||
name: "EnaSupportRejected",
|
||||
mutate: func(i *ec2.RegisterImageInput) { i.EnaSupport = aws.Bool(true) },
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "EnaSupportAcceptedFalse",
|
||||
mutate: func(i *ec2.RegisterImageInput) { i.EnaSupport = aws.Bool(false) },
|
||||
wantErr: false,
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidParameterValue,
|
||||
},
|
||||
{
|
||||
name: "SriovNetSupportRejected",
|
||||
mutate: func(i *ec2.RegisterImageInput) { i.SriovNetSupport = aws.String("simple") },
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidParameterValue,
|
||||
wantIn: "Value (simple) for parameter SriovNetSupport is invalid. SriovNetSupport is not supported",
|
||||
},
|
||||
{
|
||||
name: "ValidWithExplicitArch",
|
||||
@@ -256,12 +230,8 @@ func TestValidateRegisterImageInput(t *testing.T) {
|
||||
|
||||
err := ValidateRegisterImageInput(input)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
||||
require.True(t, ok, "the error must carry a registered code")
|
||||
assert.Equal(t, tt.errMsg, code)
|
||||
assert.NotEmpty(t, msg, "the response must say what was refused")
|
||||
assert.Contains(t, msg, tt.wantIn)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.errMsg, err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -273,10 +243,8 @@ func TestRegisterImage_GatewayValidationFailureReturnsEarly(t *testing.T) {
|
||||
// nil natsConn is fine here: validation rejects the input before any
|
||||
// NATS round-trip is attempted, so we exercise the early-exit path.
|
||||
_, err := RegisterImage(context.Background(), &ec2.RegisterImageInput{}, nil, "000000000001")
|
||||
require.Error(t, err)
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorMissingParameter, code)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
|
||||
}
|
||||
|
||||
func TestSelectRootBlockDeviceMapping_NoRootDeviceName(t *testing.T) {
|
||||
|
||||
@@ -104,7 +104,6 @@ type AMI struct {
|
||||
VolumeSizeGiB uint64 `json:"volume_size_gib"`
|
||||
SnapshotID string `json:"snapshot_id,omitempty"`
|
||||
BootMode string `json:"boot_mode,omitempty"`
|
||||
EnaSupport *bool `json:"ena_support,omitempty"`
|
||||
Distro string `json:"distro,omitempty"`
|
||||
DistroFamily string `json:"distro_family,omitempty"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
|
||||
@@ -193,32 +193,3 @@ func TestCreateImageFromInstance_DoesNotInheritSourceDigest(t *testing.T) {
|
||||
assert.Equal(t, "uefi", ami.BootMode, "the source AMI must have been read")
|
||||
assert.Nil(t, ami.SourceDigest)
|
||||
}
|
||||
|
||||
// AWS takes an image's EnaSupport from the instance it was made from, and every
|
||||
// instance here reports it, so the source AMI's value is not inherited.
|
||||
func TestCreateImageFromInstance_SetsEnaSupportFromInstance(t *testing.T) {
|
||||
svc, _, provider := setupProviderSnapshotImageService(t)
|
||||
doc := seedProviderVolume(t, svc, provider, "vol-ena001", 8)
|
||||
require.NoError(t, svc.MetadataStore().PutAMI(context.Background(), ebsmetadata.AMI{
|
||||
ImageID: "ami-src-ena",
|
||||
Name: "no-ena",
|
||||
ImageOwnerAlias: testAccountID,
|
||||
EnaSupport: aws.Bool(false),
|
||||
}))
|
||||
|
||||
out, err := svc.CreateImageFromInstance(CreateImageParams{
|
||||
Input: &ec2.CreateImageInput{
|
||||
InstanceId: aws.String("i-ena001"),
|
||||
Name: aws.String("from-no-ena"),
|
||||
},
|
||||
RootVolumeID: doc.VolumeID,
|
||||
SourceImageID: "ami-src-ena",
|
||||
IsRunning: false,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
ami, err := svc.MetadataStore().GetAMI(context.Background(), aws.StringValue(out.ImageId))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ami.EnaSupport)
|
||||
assert.True(t, *ami.EnaSupport)
|
||||
}
|
||||
|
||||
@@ -324,11 +324,6 @@ func renderVisibleImage(amiMeta ebsmetadata.AMI, input *ec2.DescribeImagesInput,
|
||||
image.BootMode = aws.String(amiMeta.BootMode)
|
||||
}
|
||||
|
||||
// EnaSupport is optional on RegisterImage; AWS omits it when never set.
|
||||
if amiMeta.EnaSupport != nil {
|
||||
image.EnaSupport = aws.Bool(*amiMeta.EnaSupport)
|
||||
}
|
||||
|
||||
if bdms := synthesizeRootBlockDeviceMapping(amiMeta, encryptedAtRest); bdms != nil {
|
||||
image.RootDeviceName = aws.String("/dev/sda1")
|
||||
image.BlockDeviceMappings = bdms
|
||||
@@ -501,10 +496,8 @@ func (s *ImageServiceImpl) CreateImageFromInstance(params CreateImageParams, acc
|
||||
RootDeviceType: ec2.DeviceTypeEbs,
|
||||
ImageOwnerAlias: accountID,
|
||||
BootMode: sourceAMI.BootMode,
|
||||
// Every instance reports ENA support, and AWS takes the image's from the instance.
|
||||
EnaSupport: aws.Bool(true),
|
||||
Distro: sourceAMI.Distro,
|
||||
DistroFamily: sourceAMI.DistroFamily,
|
||||
Distro: sourceAMI.Distro,
|
||||
DistroFamily: sourceAMI.DistroFamily,
|
||||
// Snapshot succeeded before this point, so the image is complete.
|
||||
State: "available",
|
||||
}
|
||||
@@ -908,7 +901,6 @@ func (s *ImageServiceImpl) CopyImage(ctx context.Context, input *ec2.CopyImageIn
|
||||
ImageOwnerAlias: accountID,
|
||||
CreationDate: time.Now().UTC(),
|
||||
BootMode: srcMeta.BootMode,
|
||||
EnaSupport: copyBoolPtr(srcMeta.EnaSupport),
|
||||
Distro: srcMeta.Distro,
|
||||
DistroFamily: srcMeta.DistroFamily,
|
||||
Tags: tags,
|
||||
@@ -1118,7 +1110,6 @@ func (s *ImageServiceImpl) RegisterImage(ctx context.Context, input *ec2.Registe
|
||||
CreationDate: time.Now().UTC(),
|
||||
Tags: tags,
|
||||
BootMode: aws.StringValue(input.BootMode),
|
||||
EnaSupport: copyBoolPtr(input.EnaSupport),
|
||||
// The referenced snapshot already exists (checked above), so the
|
||||
// image is complete as soon as this config is written.
|
||||
State: "available",
|
||||
@@ -1133,15 +1124,6 @@ func (s *ImageServiceImpl) RegisterImage(ctx context.Context, input *ec2.Registe
|
||||
return &ec2.RegisterImageOutput{ImageId: aws.String(amiID)}, nil
|
||||
}
|
||||
|
||||
// copyBoolPtr returns a pointer to a copy of *b, or nil, so two AMI records
|
||||
// never share one flag.
|
||||
func copyBoolPtr(b *bool) *bool {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return aws.Bool(*b)
|
||||
}
|
||||
|
||||
// pickRootSnapshotBDM returns the BDM matching RootDeviceName (if set) that
|
||||
// carries a non-empty EBS snapshot reference, else the first such BDM.
|
||||
func pickRootSnapshotBDM(mappings []*ec2.BlockDeviceMapping, rootDeviceName *string) *ec2.BlockDeviceMapping {
|
||||
|
||||
@@ -2,13 +2,11 @@ package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1331,42 +1329,6 @@ func TestRegisterImage_HappyPath(t *testing.T) {
|
||||
assert.Equal(t, testAccountID, *img.OwnerId)
|
||||
}
|
||||
|
||||
func TestRegisterImage_EnaSupport(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in *bool
|
||||
want *bool
|
||||
}{
|
||||
{"true is stored and described", aws.Bool(true), aws.Bool(true)},
|
||||
{"false is stored and described", aws.Bool(false), aws.Bool(false)},
|
||||
{"unset is omitted", nil, nil},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc, store := setupTestImageService(t)
|
||||
snapID := fmt.Sprintf("snap-ena%03d", i)
|
||||
putTestSnapshotConfig(t, store, snapID, 8, testAccountID)
|
||||
|
||||
input := validRegisterImageServiceInput(snapID)
|
||||
input.EnaSupport = tt.in
|
||||
out, err := svc.RegisterImage(context.Background(), input, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
desc, err := svc.DescribeImages(context.Background(), &ec2.DescribeImagesInput{
|
||||
ImageIds: []*string{out.ImageId},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, desc.Images, 1)
|
||||
if tt.want == nil {
|
||||
assert.Nil(t, desc.Images[0].EnaSupport, "an image registered without EnaSupport must omit the member")
|
||||
return
|
||||
}
|
||||
require.NotNil(t, desc.Images[0].EnaSupport)
|
||||
assert.Equal(t, *tt.want, *desc.Images[0].EnaSupport)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterImage_DuplicateName(t *testing.T) {
|
||||
svc, store := setupTestImageService(t)
|
||||
|
||||
@@ -1836,41 +1798,12 @@ func TestCopyImage_InheritsSourceFields(t *testing.T) {
|
||||
assert.Equal(t, uint64(32), newMeta.VolumeSizeGiB)
|
||||
assert.Equal(t, "ebs", newMeta.RootDeviceType)
|
||||
assert.Equal(t, "uefi", newMeta.BootMode, "CopyImage must propagate BootMode from source")
|
||||
assert.Nil(t, newMeta.EnaSupport, "an unset EnaSupport stays unset on the copy")
|
||||
assert.Equal(t, "debian-12", newMeta.Distro, "CopyImage must propagate Distro from source")
|
||||
assert.Equal(t, "debian", newMeta.DistroFamily, "cloud-init family detection reads DistroFamily")
|
||||
assert.Equal(t, digest, newMeta.SourceDigest, "a zero-copy share still holds the source artifact")
|
||||
assert.False(t, newMeta.CreationDate.Before(before), "CreationDate must be refreshed on copy, not inherited")
|
||||
}
|
||||
|
||||
func TestCopyImage_CarriesEnaSupport(t *testing.T) {
|
||||
for _, want := range []bool{true, false} {
|
||||
t.Run(strconv.FormatBool(want), func(t *testing.T) {
|
||||
svc, store := setupTestImageService(t)
|
||||
putTestSnapshotDoc(t, store, ebsmetadata.Snapshot{
|
||||
SnapshotID: "snap-ena-src", VolumeID: "vol-ena", VolumeSize: 8,
|
||||
State: "completed", Progress: "100%", OwnerID: testAccountID,
|
||||
})
|
||||
putTestAMIConfigWithSnapshot(t, store, "ami-ena-src", "ena-source", testAccountID, "snap-ena-src", ebsmetadata.AMI{
|
||||
Architecture: "x86_64",
|
||||
PlatformDetails: "Linux/UNIX",
|
||||
Virtualization: "hvm",
|
||||
VolumeSizeGiB: 8,
|
||||
RootDeviceType: "ebs",
|
||||
EnaSupport: aws.Bool(want),
|
||||
})
|
||||
|
||||
out, err := svc.CopyImage(context.Background(), validCopyImageServiceInput("ami-ena-src", "ena-copy"), testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
newMeta, err := svc.GetAMIConfig(context.Background(), *out.ImageId)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, newMeta.EnaSupport)
|
||||
assert.Equal(t, want, *newMeta.EnaSupport)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyImage_NewSnapshotSharesSourceVolumeID(t *testing.T) {
|
||||
svc, store := setupTestImageService(t)
|
||||
|
||||
|
||||
@@ -11,11 +11,6 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
)
|
||||
|
||||
// instanceEnaSupport is what every instance reports for ENA. Guests use virtio
|
||||
// NICs, and DescribeInstances, RunInstances and DescribeInstanceAttribute all
|
||||
// answer from this one value.
|
||||
const instanceEnaSupport = true
|
||||
|
||||
// InstanceProjection carries the caller-scoped values ProjectInstance needs.
|
||||
// All fields are plain data so the projection stays pure and table-testable.
|
||||
type InstanceProjection struct {
|
||||
@@ -174,7 +169,6 @@ func ProjectInstance(v *vm.VM, cfg InstanceProjection) (inst *ec2.Instance, stat
|
||||
// aws_instance ever settled. Interfaces are copied rather than mutated
|
||||
// because instanceCopy shares their pointers with the stored instance.
|
||||
instanceCopy.SourceDestCheck = aws.Bool(true)
|
||||
instanceCopy.EnaSupport = aws.Bool(instanceEnaSupport)
|
||||
if len(instanceCopy.NetworkInterfaces) > 0 {
|
||||
nics := make([]*ec2.InstanceNetworkInterface, 0, len(instanceCopy.NetworkInterfaces))
|
||||
for _, nic := range instanceCopy.NetworkInterfaces {
|
||||
|
||||
@@ -309,29 +309,6 @@ func TestProjectInstance_EnclaveOptionsAlwaysDisabled(t *testing.T) {
|
||||
assert.False(t, aws.BoolValue(stopped.EnclaveOptions.Enabled))
|
||||
}
|
||||
|
||||
// TestProjectInstance_EnaSupportAlwaysTrue covers DescribeInstances agreeing
|
||||
// with DescribeInstanceAttribute, including for a stored instance that
|
||||
// predates the field and one that carries a stale false.
|
||||
func TestProjectInstance_EnaSupportAlwaysTrue(t *testing.T) {
|
||||
stale := fullVM(vm.StateRunning)
|
||||
stale.Instance.EnaSupport = aws.Bool(false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
vm *vm.VM
|
||||
cfg InstanceProjection
|
||||
}{
|
||||
{"running", fullVM(vm.StateRunning), runningCfg()},
|
||||
{"stopped", fullVM(vm.StateStopped), stoppedCfg(80, "stopped")},
|
||||
{"stale", stale, runningCfg()},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, _ := ProjectInstance(tt.vm, tt.cfg)
|
||||
require.NotNil(t, got.EnaSupport, tt.name)
|
||||
assert.True(t, *got.EnaSupport, tt.name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInstanceIDFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -365,7 +365,6 @@ func (s *InstanceServiceImpl) RunInstance(input *ec2.RunInstancesInput) (*vm.VM,
|
||||
// them null, so a caller comparing the two read a disagreement.
|
||||
ec2Instance.SetRootDeviceName(ebsmetadata.RootDeviceName)
|
||||
ec2Instance.SetRootDeviceType(ec2.DeviceTypeEbs)
|
||||
ec2Instance.SetEnaSupport(instanceEnaSupport)
|
||||
|
||||
// Stamp the metadata-options block so DescribeInstances reports the posture;
|
||||
// the hop limit and the IMDSv1 opt-in are request-driven.
|
||||
@@ -3348,7 +3347,7 @@ func (s *InstanceServiceImpl) DescribeInstanceAttribute(ctx context.Context, inp
|
||||
output.EbsOptimized = &ec2.AttributeBooleanValue{Value: &val}
|
||||
|
||||
case ec2.InstanceAttributeNameEnaSupport:
|
||||
val := instanceEnaSupport
|
||||
val := true
|
||||
output.EnaSupport = &ec2.AttributeBooleanValue{Value: &val}
|
||||
|
||||
case ec2.InstanceAttributeNameSourceDestCheck:
|
||||
|
||||
@@ -122,20 +122,6 @@ func TestRunInstance_RootDeviceFieldsPopulated(t *testing.T) {
|
||||
assert.Equal(t, ec2.DeviceTypeEbs, aws.StringValue(ec2Instance.RootDeviceType))
|
||||
}
|
||||
|
||||
func TestRunInstance_ReportsEnaSupport(t *testing.T) {
|
||||
svc := &InstanceServiceImpl{
|
||||
instanceTypes: map[string]*ec2.InstanceTypeInfo{"t3.micro": {InstanceType: aws.String("t3.micro")}},
|
||||
}
|
||||
|
||||
_, ec2Instance, err := svc.RunInstance(&ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-0abcdef1234567890"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ec2Instance.EnaSupport)
|
||||
assert.True(t, *ec2Instance.EnaSupport)
|
||||
}
|
||||
|
||||
func TestVolumeTagsFromSpec(t *testing.T) {
|
||||
specs := []*ec2.TagSpecification{
|
||||
{ResourceType: aws.String("instance"), Tags: []*ec2.Tag{{Key: aws.String("Name"), Value: aws.String("node")}}},
|
||||
|
||||
@@ -7,9 +7,10 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The two ED25519 fingerprints AWS itself returned in us-east-1 on 2026-07-22, one
|
||||
// per write path. They are recorded observations, not derivations, and pin the
|
||||
// rendering without the key material that produced them.
|
||||
// The two ED25519 fingerprints AWS itself returned on 2026-07-22 (account
|
||||
// 744090693897, us-east-1), one per write path. They are recorded observations,
|
||||
// not derivations, and they pin the rendering without needing the key material
|
||||
// that produced them.
|
||||
const (
|
||||
awsImportedED25519Fingerprint = "8ZVMcTKqdR5hxMjZI3t6b09EPIOE5QA6G9/Z63ejNBM="
|
||||
awsCreatedED25519Fingerprint = "odzmoB4gyxi1jn9vGD4c+bOpt1XUX1JUyaHaWHHCO2w="
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A volume still being prepared has no settled contents, so it cannot be
|
||||
// captured; once available the same volume snapshots normally.
|
||||
func TestCreateSnapshot_RefusesACreatingVolume(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
ctx := context.Background()
|
||||
seedProviderVolume(t, svc, "vol-preparing", 4)
|
||||
seedVolumeDocument(t, store, ebsmetadata.Volume{
|
||||
VolumeID: "vol-preparing", TenantID: testAccountID, CapacityGiB: 4,
|
||||
State: "creating", AvailabilityZone: "us-east-1a",
|
||||
})
|
||||
|
||||
_, err := svc.CreateSnapshot(ctx, &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-preparing")}, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorIncorrectState)
|
||||
snaps, err := svc.metadata.ListSnapshotsStrict(ctx, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, snaps)
|
||||
|
||||
meta, err := svc.metadata.GetVolume(ctx, testAccountID, "vol-preparing")
|
||||
require.NoError(t, err)
|
||||
meta.State = "available"
|
||||
require.NoError(t, svc.metadata.PutVolume(ctx, meta))
|
||||
_, err = svc.CreateSnapshot(ctx, &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-preparing")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -204,10 +204,6 @@ func (s *SnapshotServiceImpl) CreateSnapshot(ctx context.Context, input *ec2.Cre
|
||||
if volume.CapacityGiB == 0 {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
// A volume still being prepared has no settled contents to capture.
|
||||
if volume.State == string(ebsprovider.VolumeStateCreating) {
|
||||
return nil, errors.New(awserrors.ErrorIncorrectState)
|
||||
}
|
||||
|
||||
// Flush the writes buffered by whichever node serves the volume, so the
|
||||
// provider reads a current checkpoint. An attached volume that cannot be
|
||||
|
||||
@@ -86,11 +86,6 @@ type SystemInstanceInput struct {
|
||||
// endpoint serves the profile's role credentials. Empty leaves the VM
|
||||
// without a profile (the agent then needs static keys).
|
||||
IamInstanceProfileArn string `json:"iam_instance_profile_arn,omitempty"`
|
||||
|
||||
// Kind selects a non-load-balancer direct-boot guest; empty is the load
|
||||
// balancer. SQSStore carries the KindSQSStore guest's volume and key.
|
||||
Kind Kind `json:"kind,omitempty"`
|
||||
SQSStore *SQSStoreInput `json:"sqs_store,omitempty"`
|
||||
}
|
||||
|
||||
// ExtraENIInput describes an additional pre-created ENI to attach to a system
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package systeminstance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/nats-io/nkeys"
|
||||
)
|
||||
|
||||
// Kind selects the guest a direct-boot system instance runs. The empty kind
|
||||
// is the load balancer; every other kind is launched only on its own subject.
|
||||
type Kind string
|
||||
|
||||
// KindSQSStore is the dedicated SQS store guest on a persistent data volume.
|
||||
const KindSQSStore Kind = "sqs-store"
|
||||
|
||||
// SQSStoreLaunchSubjectRoot is the subject root for SQS store launches. It is
|
||||
// versioned and separate from system.LaunchInstance so a daemon that predates
|
||||
// it has no subscriber and the launch fails instead of booting another guest.
|
||||
const SQSStoreLaunchSubjectRoot = "system.LaunchSQSStore.v1"
|
||||
|
||||
// SQSStoreLaunchSubject is the queue subject for an instance type; with a node
|
||||
// ID it is that node's targeted subject.
|
||||
func SQSStoreLaunchSubject(instanceType, nodeID string) string {
|
||||
if nodeID == "" {
|
||||
return fmt.Sprintf("%s.%s", SQSStoreLaunchSubjectRoot, instanceType)
|
||||
}
|
||||
return fmt.Sprintf("%s.%s.%s", SQSStoreLaunchSubjectRoot, instanceType, nodeID)
|
||||
}
|
||||
|
||||
// SQSStoreInput is what an SQS store guest needs beyond its network: an
|
||||
// existing, already-formatted system-account volume and the one NATS user
|
||||
// public key the store accepts. It never carries a private key or seed.
|
||||
type SQSStoreInput struct {
|
||||
DataVolumeID string `json:"data_volume_id"`
|
||||
ClientNKey string `json:"client_nkey"`
|
||||
}
|
||||
|
||||
// ErrInvalidSQSStoreInput is returned for a malformed SQS store launch.
|
||||
var ErrInvalidSQSStoreInput = errors.New("sysinstance: invalid SQS store launch")
|
||||
|
||||
// volumeIDPattern is the platform's volume ID shape. The guest finds its disk
|
||||
// by the ID without its dash, which must fit the 20-byte virtio-blk serial.
|
||||
var volumeIDPattern = regexp.MustCompile(`^vol-[0-9a-f]{17}$`)
|
||||
|
||||
// ValidateSQSStore checks an SQS store launch's shape. It reads nothing, so a
|
||||
// valid result says only that the volume and key are well formed.
|
||||
func (in *SystemInstanceInput) ValidateSQSStore() error {
|
||||
switch {
|
||||
case in.Kind != KindSQSStore:
|
||||
return fmt.Errorf("%w: kind %q is not %q", ErrInvalidSQSStoreInput, in.Kind, KindSQSStore)
|
||||
case in.BootMode != "" && in.BootMode != BootDirect:
|
||||
return fmt.Errorf("%w: boot mode %q, the store boots direct only", ErrInvalidSQSStoreInput, in.BootMode)
|
||||
case in.SQSStore == nil:
|
||||
return fmt.Errorf("%w: no data volume or client key", ErrInvalidSQSStoreInput)
|
||||
case !volumeIDPattern.MatchString(in.SQSStore.DataVolumeID):
|
||||
return fmt.Errorf("%w: data volume ID %q is malformed", ErrInvalidSQSStoreInput, in.SQSStore.DataVolumeID)
|
||||
case !nkeys.IsValidPublicUserKey(in.SQSStore.ClientNKey):
|
||||
// The value is not echoed: a caller that sent a seed here must not
|
||||
// have it written to a log.
|
||||
return fmt.Errorf("%w: client key is not a NATS user public key", ErrInvalidSQSStoreInput)
|
||||
case in.LBAgentEnv != "" || in.CACert != "" || in.Scheme != "":
|
||||
return fmt.Errorf("%w: load balancer settings on an SQS store launch", ErrInvalidSQSStoreInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefuseNonLoadBalancerKind keeps every non-load-balancer guest off the
|
||||
// legacy launch path, which would boot the load balancer image instead.
|
||||
func (in *SystemInstanceInput) RefuseNonLoadBalancerKind() error {
|
||||
if in.Kind != "" || in.SQSStore != nil {
|
||||
return fmt.Errorf("%w: kind %q must use its own launch subject", ErrInvalidSQSStoreInput, in.Kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package systeminstance
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
identifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/nats-io/nkeys"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validSQSStoreInput(t *testing.T) (*SystemInstanceInput, []byte) {
|
||||
t.Helper()
|
||||
kp, err := nkeys.CreateUser()
|
||||
require.NoError(t, err)
|
||||
pub, err := kp.PublicKey()
|
||||
require.NoError(t, err)
|
||||
seed, err := kp.Seed()
|
||||
require.NoError(t, err)
|
||||
return &SystemInstanceInput{
|
||||
InstanceType: "sys.micro",
|
||||
Kind: KindSQSStore,
|
||||
SQSStore: &SQSStoreInput{DataVolumeID: "vol-0123456789abcdef0", ClientNKey: pub},
|
||||
}, seed
|
||||
}
|
||||
|
||||
func TestValidateSQSStore(t *testing.T) {
|
||||
in, _ := validSQSStoreInput(t)
|
||||
require.NoError(t, in.ValidateSQSStore())
|
||||
|
||||
_, seed := validSQSStoreInput(t)
|
||||
account, err := nkeys.CreateAccount()
|
||||
require.NoError(t, err)
|
||||
accountPub, err := account.PublicKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := map[string]func(*SystemInstanceInput){
|
||||
"no kind": func(in *SystemInstanceInput) { in.Kind = "" },
|
||||
"other kind": func(in *SystemInstanceInput) { in.Kind = "lambda" },
|
||||
"ami boot": func(in *SystemInstanceInput) { in.BootMode = BootAMI },
|
||||
"no store input": func(in *SystemInstanceInput) { in.SQSStore = nil },
|
||||
"no volume": func(in *SystemInstanceInput) { in.SQSStore.DataVolumeID = "" },
|
||||
"malformed volume": func(in *SystemInstanceInput) { in.SQSStore.DataVolumeID = "vol-XYZ" },
|
||||
"no key": func(in *SystemInstanceInput) { in.SQSStore.ClientNKey = "" },
|
||||
"seed as key": func(in *SystemInstanceInput) { in.SQSStore.ClientNKey = string(seed) },
|
||||
"account key": func(in *SystemInstanceInput) { in.SQSStore.ClientNKey = accountPub },
|
||||
"lb agent env": func(in *SystemInstanceInput) { in.LBAgentEnv = "LB_ID=x" },
|
||||
"lb ca cert": func(in *SystemInstanceInput) { in.CACert = "cert" },
|
||||
"lb scheme": func(in *SystemInstanceInput) { in.Scheme = "internal" },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
in, _ := validSQSStoreInput(t)
|
||||
mutate(in)
|
||||
err := in.ValidateSQSStore()
|
||||
require.ErrorIs(t, err, ErrInvalidSQSStoreInput)
|
||||
assert.NotContains(t, err.Error(), string(seed), "a seed must never be echoed")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSQSStore_VolumeIDMatchesTheGuestSerial(t *testing.T) {
|
||||
accepted := identifiers.GenerateResourceID("vol")
|
||||
in, _ := validSQSStoreInput(t)
|
||||
in.SQSStore.DataVolumeID = accepted
|
||||
require.NoError(t, in.ValidateSQSStore(), "a platform-generated volume ID is accepted")
|
||||
// The guest accepts only vol + 17 lowercase hex, the full 20-byte serial.
|
||||
assert.Len(t, strings.ReplaceAll(accepted, "-", ""), 20)
|
||||
|
||||
for _, id := range []string{
|
||||
// 16 hex
|
||||
"vol-0123456789abcdef",
|
||||
// 18 hex: serial over 20 bytes
|
||||
"vol-0123456789abcdef01",
|
||||
// 8 hex
|
||||
"vol-01234567",
|
||||
// upper case
|
||||
"vol-0123456789ABCDEF0",
|
||||
// no dash
|
||||
"vol0123456789abcdef0",
|
||||
// not a volume
|
||||
"snap-0123456789abcdef0",
|
||||
// trailing newline
|
||||
"vol-0123456789abcdef0\n",
|
||||
} {
|
||||
in, _ := validSQSStoreInput(t)
|
||||
in.SQSStore.DataVolumeID = id
|
||||
require.ErrorIs(t, in.ValidateSQSStore(), ErrInvalidSQSStoreInput, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefuseNonLoadBalancerKind(t *testing.T) {
|
||||
require.NoError(t, (&SystemInstanceInput{InstanceType: "sys.micro"}).RefuseNonLoadBalancerKind())
|
||||
in, _ := validSQSStoreInput(t)
|
||||
require.ErrorIs(t, in.RefuseNonLoadBalancerKind(), ErrInvalidSQSStoreInput)
|
||||
require.Error(t, (&SystemInstanceInput{SQSStore: &SQSStoreInput{}}).RefuseNonLoadBalancerKind())
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package volume
|
||||
|
||||
//test:in-package — the creating state is asserted on the metadata document,
|
||||
// and the metadata store is unexported.
|
||||
|
||||
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/providers/ebs"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func createPreparingVolume(t *testing.T, svc *VolumeServiceImpl) string {
|
||||
t.Helper()
|
||||
vol, err := svc.CreateVolumeForPreparation(context.Background(), &ec2.CreateVolumeInput{
|
||||
Size: aws.Int64(4), AvailabilityZone: aws.String("ap-southeast-2a"),
|
||||
}, testVolAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "creating", aws.StringValue(vol.State))
|
||||
return aws.StringValue(vol.VolumeId)
|
||||
}
|
||||
|
||||
func requireVolumeState(t *testing.T, svc *VolumeServiceImpl, volumeID, state string) {
|
||||
t.Helper()
|
||||
meta, err := svc.metadata.GetVolume(context.Background(), testVolAccountID, volumeID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, state, meta.State)
|
||||
assert.Empty(t, meta.AttachedInstance)
|
||||
assert.Equal(t, uint64(4), meta.CapacityGiB)
|
||||
}
|
||||
|
||||
func TestCreateVolumeForPreparation_IsBornCreatingWhileCreateVolumeStaysAvailable(t *testing.T) {
|
||||
svc := newTestVolumeService("ap-southeast-2a")
|
||||
id := createPreparingVolume(t, svc)
|
||||
requireVolumeState(t, svc, id, "creating")
|
||||
|
||||
described, err := svc.DescribeVolumes(context.Background(), &ec2.DescribeVolumesInput{VolumeIds: []*string{aws.String(id)}}, testVolAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, described.Volumes, 1)
|
||||
assert.Equal(t, "creating", aws.StringValue(described.Volumes[0].State))
|
||||
|
||||
normal, err := svc.CreateVolume(context.Background(), &ec2.CreateVolumeInput{
|
||||
Size: aws.Int64(4), AvailabilityZone: aws.String("ap-southeast-2a"),
|
||||
}, testVolAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "available", aws.StringValue(normal.State))
|
||||
requireVolumeState(t, svc, aws.StringValue(normal.VolumeId), "available")
|
||||
}
|
||||
|
||||
// Every public path that could take, change or capture the volume refuses it
|
||||
// and leaves it exactly as it was until preparation completes.
|
||||
func TestCreatingVolume_PublicMutationsAreRefused(t *testing.T) {
|
||||
svc := newTestVolumeService("ap-southeast-2a")
|
||||
svc.snapshotKV = setupTestVolumeKV(t)
|
||||
id := createPreparingVolume(t, svc)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.DeleteVolume(ctx, &ec2.DeleteVolumeInput{VolumeId: aws.String(id)}, testVolAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorVolumeInUse)
|
||||
requireVolumeState(t, svc, id, "creating")
|
||||
|
||||
_, err = svc.ModifyVolume(ctx, &ec2.ModifyVolumeInput{VolumeId: aws.String(id), Size: aws.Int64(8)}, testVolAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorIncorrectState)
|
||||
requireVolumeState(t, svc, id, "creating")
|
||||
|
||||
_, err = svc.ForceDetachVolume(ctx, &ec2.DetachVolumeInput{VolumeId: aws.String(id), Force: aws.Bool(true)}, testVolAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorIncorrectState)
|
||||
requireVolumeState(t, svc, id, "creating")
|
||||
}
|
||||
|
||||
func TestCompleteVolumePreparation_ReleasesTheVolume(t *testing.T) {
|
||||
svc := newTestVolumeService("ap-southeast-2a")
|
||||
svc.snapshotKV = setupTestVolumeKV(t)
|
||||
id := createPreparingVolume(t, svc)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, svc.CompleteVolumePreparation(ctx, testVolAccountID, id))
|
||||
requireVolumeState(t, svc, id, "available")
|
||||
|
||||
// Released means ordinary behavior again: modify and delete now proceed.
|
||||
_, err := svc.ModifyVolume(ctx, &ec2.ModifyVolumeInput{VolumeId: aws.String(id), Size: aws.Int64(8)}, testVolAccountID)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.DeleteVolume(ctx, &ec2.DeleteVolumeInput{VolumeId: aws.String(id)}, testVolAccountID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Completion only releases an unattached creating volume, so it can never
|
||||
// flip a volume some other path owns to available.
|
||||
func TestCompleteVolumePreparation_RefusesAnythingButAnUnattachedCreatingVolume(t *testing.T) {
|
||||
svc := newTestVolumeService("ap-southeast-2a")
|
||||
ctx := context.Background()
|
||||
|
||||
normal, err := svc.CreateVolume(ctx, &ec2.CreateVolumeInput{
|
||||
Size: aws.Int64(4), AvailabilityZone: aws.String("ap-southeast-2a"),
|
||||
}, testVolAccountID)
|
||||
require.NoError(t, err)
|
||||
err = svc.CompleteVolumePreparation(ctx, testVolAccountID, aws.StringValue(normal.VolumeId))
|
||||
require.ErrorContains(t, err, awserrors.ErrorIncorrectState)
|
||||
|
||||
require.NoError(t, svc.UpdateVolumeState(testVolAccountID, aws.StringValue(normal.VolumeId), "in-use", "i-0123456789abcdef0", "/dev/sdf"))
|
||||
err = svc.CompleteVolumePreparation(ctx, testVolAccountID, aws.StringValue(normal.VolumeId))
|
||||
require.ErrorContains(t, err, awserrors.ErrorIncorrectState)
|
||||
meta, err := svc.metadata.GetVolume(ctx, testVolAccountID, aws.StringValue(normal.VolumeId))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "in-use", meta.State)
|
||||
|
||||
id := createPreparingVolume(t, svc)
|
||||
err = svc.CompleteVolumePreparation(ctx, "000000000099", id)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidVolumeNotFound)
|
||||
requireVolumeState(t, svc, id, "creating")
|
||||
|
||||
err = svc.CompleteVolumePreparation(ctx, testVolAccountID, "vol-0000000000000dead")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidVolumeNotFound)
|
||||
}
|
||||
|
||||
func TestDiscardVolumePreparation_DeletesTheCreatingVolumeAndItsStorage(t *testing.T) {
|
||||
svc := newTestVolumeService("ap-southeast-2a")
|
||||
id := createPreparingVolume(t, svc)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, svc.DiscardVolumePreparation(ctx, testVolAccountID, id))
|
||||
_, err := svc.metadata.GetVolume(ctx, testVolAccountID, id)
|
||||
require.Error(t, err, "the record must be gone")
|
||||
_, err = svc.provider.GetVolume(ctx, ebsprovider.GetVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: id})
|
||||
require.ErrorIs(t, err, ebsprovider.ErrNotFound, "the provider volume must be gone")
|
||||
}
|
||||
|
||||
// Discard is only for a failed preparation, so an ordinary or attached volume,
|
||||
// or one in another account, is never deleted through it.
|
||||
func TestDiscardVolumePreparation_RefusesAnythingButAnUnattachedCreatingVolume(t *testing.T) {
|
||||
svc := newTestVolumeService("ap-southeast-2a")
|
||||
ctx := context.Background()
|
||||
|
||||
normal, err := svc.CreateVolume(ctx, &ec2.CreateVolumeInput{
|
||||
Size: aws.Int64(4), AvailabilityZone: aws.String("ap-southeast-2a"),
|
||||
}, testVolAccountID)
|
||||
require.NoError(t, err)
|
||||
normalID := aws.StringValue(normal.VolumeId)
|
||||
err = svc.DiscardVolumePreparation(ctx, testVolAccountID, normalID)
|
||||
require.ErrorContains(t, err, awserrors.ErrorIncorrectState)
|
||||
requireVolumeState(t, svc, normalID, "available")
|
||||
|
||||
require.NoError(t, svc.UpdateVolumeState(testVolAccountID, normalID, "in-use", "i-0123456789abcdef0", "/dev/sdf"))
|
||||
err = svc.DiscardVolumePreparation(ctx, testVolAccountID, normalID)
|
||||
require.ErrorContains(t, err, awserrors.ErrorIncorrectState)
|
||||
|
||||
id := createPreparingVolume(t, svc)
|
||||
err = svc.DiscardVolumePreparation(ctx, "000000000099", id)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidVolumeNotFound)
|
||||
requireVolumeState(t, svc, id, "creating")
|
||||
_, err = svc.provider.GetVolume(ctx, ebsprovider.GetVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: id})
|
||||
require.NoError(t, err, "a refused discard must leave the storage")
|
||||
}
|
||||
@@ -118,70 +118,6 @@ func NewVolumeServiceImplWithStore(cfg *config.Config, store objectstore.ObjectS
|
||||
// CreateVolume allocates a new EBS volume through the EBS provider and records
|
||||
// its control-plane metadata document.
|
||||
func (s *VolumeServiceImpl) CreateVolume(ctx context.Context, input *ec2.CreateVolumeInput, accountID string) (*ec2.Volume, error) {
|
||||
return s.createVolume(ctx, input, accountID, ebsprovider.VolumeStateAvailable)
|
||||
}
|
||||
|
||||
// CreateVolumeForPreparation is CreateVolume for an internal caller that must
|
||||
// write the volume's first contents before anyone else can use it. The record
|
||||
// is born "creating", which attach, delete, modify, force-detach and snapshot
|
||||
// all refuse, until CompleteVolumePreparation makes it available.
|
||||
func (s *VolumeServiceImpl) CreateVolumeForPreparation(ctx context.Context, input *ec2.CreateVolumeInput, accountID string) (*ec2.Volume, error) {
|
||||
return s.createVolume(ctx, input, accountID, ebsprovider.VolumeStateCreating)
|
||||
}
|
||||
|
||||
// CompleteVolumePreparation moves a volume made by CreateVolumeForPreparation
|
||||
// to available. Anything other than an unattached "creating" volume is
|
||||
// refused, so it cannot release a volume some other path is using.
|
||||
func (s *VolumeServiceImpl) CompleteVolumePreparation(ctx context.Context, accountID, volumeID string) error {
|
||||
meta, err := s.getVolumeMetadata(ctx, accountID, volumeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.TenantID != accountID {
|
||||
return errors.New(awserrors.ErrorInvalidVolumeNotFound)
|
||||
}
|
||||
if meta.State != string(ebsprovider.VolumeStateCreating) || meta.AttachedInstance != "" {
|
||||
return awserrors.Errorf(awserrors.ErrorIncorrectState, "volume %s is %q, not an unattached creating volume", volumeID, meta.State)
|
||||
}
|
||||
meta.State = string(ebsprovider.VolumeStateAvailable)
|
||||
if err := s.metadata.PutVolume(ctx, meta); err != nil {
|
||||
return awserrors.Errorf(awserrors.ErrorServerInternal, "write volume metadata %s: %v", volumeID, err)
|
||||
}
|
||||
slog.InfoContext(ctx, "Volume preparation completed", "volumeId", volumeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscardVolumePreparation deletes a volume whose preparation failed. Only an
|
||||
// unattached "creating" volume in the caller's account qualifies; the caller
|
||||
// must already know no export of it can still be live.
|
||||
func (s *VolumeServiceImpl) DiscardVolumePreparation(ctx context.Context, accountID, volumeID string) error {
|
||||
if err := s.requireProvider(ctx, "DiscardVolumePreparation"); err != nil {
|
||||
return err
|
||||
}
|
||||
meta, err := s.getVolumeMetadata(ctx, accountID, volumeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.TenantID != accountID {
|
||||
return errors.New(awserrors.ErrorInvalidVolumeNotFound)
|
||||
}
|
||||
if meta.State != string(ebsprovider.VolumeStateCreating) || meta.AttachedInstance != "" {
|
||||
return awserrors.Errorf(awserrors.ErrorIncorrectState, "volume %s is %q, not an unattached creating volume", volumeID, meta.State)
|
||||
}
|
||||
// Snapshots refuse a creating volume, so none can depend on this one.
|
||||
if err := s.provider.DeleteVolume(ctx, ebsprovider.DeleteVolumeRequest{
|
||||
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, Handle: meta.ProviderHandle,
|
||||
}); err != nil {
|
||||
return awserrors.Errorf(awserrors.ErrorServerInternal, "delete volume %s: %v", volumeID, err)
|
||||
}
|
||||
if err := s.metadata.DeleteVolume(ctx, accountID, volumeID); err != nil && !objectstore.IsNoSuchKeyError(err) {
|
||||
return awserrors.Errorf(awserrors.ErrorServerInternal, "delete volume metadata %s: %v", volumeID, err)
|
||||
}
|
||||
slog.InfoContext(ctx, "Volume preparation discarded", "volumeId", volumeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *VolumeServiceImpl) createVolume(ctx context.Context, input *ec2.CreateVolumeInput, accountID string, initialState ebsprovider.VolumeState) (*ec2.Volume, error) {
|
||||
if input == nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
@@ -305,7 +241,7 @@ func (s *VolumeServiceImpl) createVolume(ctx context.Context, input *ec2.CreateV
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if err := s.metadata.PutVolume(ctx, ebsmetadata.Volume{
|
||||
VolumeID: volumeID, TenantID: accountID, CapacityGiB: uint64(size), State: string(initialState),
|
||||
VolumeID: volumeID, TenantID: accountID, CapacityGiB: uint64(size), State: string(ebsprovider.VolumeStateAvailable),
|
||||
CreatedAt: now, AvailabilityZone: *input.AvailabilityZone, VolumeType: volumeType,
|
||||
IOPS: iops, Throughput: throughput, SnapshotID: snapshotID, Tags: tags, ProviderHandle: created.Handle,
|
||||
Encrypted: encrypted,
|
||||
@@ -318,7 +254,7 @@ func (s *VolumeServiceImpl) createVolume(ctx context.Context, input *ec2.CreateV
|
||||
slog.InfoContext(ctx, "CreateVolume completed", "volumeId", volumeID, "size", size, "type", volumeType)
|
||||
|
||||
return &ec2.Volume{VolumeId: aws.String(volumeID), Size: aws.Int64(size), VolumeType: aws.String(volumeType),
|
||||
State: aws.String(string(initialState)), AvailabilityZone: input.AvailabilityZone, CreateTime: aws.Time(now),
|
||||
State: aws.String("available"), AvailabilityZone: input.AvailabilityZone, CreateTime: aws.Time(now),
|
||||
Iops: aws.Int64(int64(iops)), Throughput: aws.Int64(int64(throughput)), Encrypted: aws.Bool(encrypted),
|
||||
SnapshotId: snapshotIDOrNil(snapshotID), Tags: awstags.MapToEC2(tags)}, nil
|
||||
}
|
||||
@@ -1011,7 +947,7 @@ func (s *VolumeServiceImpl) ModifyVolume(ctx context.Context, input *ec2.ModifyV
|
||||
if input.Size == nil || *input.Size <= originalSize {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if (meta.AttachedInstance != "" && meta.State == "in-use") || meta.State == string(ebsprovider.VolumeStateCreating) {
|
||||
if meta.AttachedInstance != "" && meta.State == "in-use" {
|
||||
return nil, errors.New(awserrors.ErrorIncorrectState)
|
||||
}
|
||||
originalType := meta.VolumeType
|
||||
@@ -1134,11 +1070,6 @@ func (s *VolumeServiceImpl) ForceDetachVolume(ctx context.Context, input *ec2.De
|
||||
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
|
||||
}
|
||||
|
||||
// Clearing would also make a volume still being prepared available.
|
||||
if meta.State == string(ebsprovider.VolumeStateCreating) {
|
||||
return nil, errors.New(awserrors.ErrorIncorrectState)
|
||||
}
|
||||
|
||||
previous := meta.AttachedInstance
|
||||
if err := s.UpdateVolumeState(accountID, volumeID, "available", "", ""); err != nil {
|
||||
slog.ErrorContext(ctx, "ForceDetachVolume: failed to clear attachment", "volumeId", volumeID, "err", err)
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// OIDCDiscoveryDocument is the subset of the OpenID Connect Discovery metadata
|
||||
// (RFC 8414) that AWS STS and the AWS SDKs consume to validate IRSA
|
||||
// ServiceAccount tokens. Served unauthenticated at the per-cluster issuer URL.
|
||||
type OIDCDiscoveryDocument struct {
|
||||
Issuer string `json:"issuer"`
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
}
|
||||
|
||||
// OIDCPublisher serves each EKS cluster's OIDC discovery document and JWKS from
|
||||
// the per-account bucket this package owns. The caller resolves the account and
|
||||
// cluster from its route and mounts the handlers unauthenticated.
|
||||
type OIDCPublisher struct {
|
||||
js jetstream.JetStream
|
||||
}
|
||||
|
||||
// NewOIDCPublisher constructs an OIDCPublisher over js.
|
||||
func NewOIDCPublisher(js jetstream.JetStream) *OIDCPublisher {
|
||||
return &OIDCPublisher{js: js}
|
||||
}
|
||||
|
||||
// accountKV opens the read-only per-account EKS bucket. It returns
|
||||
// jetstream.ErrBucketNotFound (unwrapped) when the account has no clusters,
|
||||
// which the handlers translate to 404.
|
||||
func (p *OIDCPublisher) accountKV(r *http.Request, accountID string) (jetstream.KeyValue, error) {
|
||||
if p == nil || p.js == nil {
|
||||
return nil, errors.New("EKS OIDC publisher JetStream not initialised")
|
||||
}
|
||||
return p.js.KeyValue(r.Context(), AccountBucketName(accountID))
|
||||
}
|
||||
|
||||
// ServeDiscoveryDocument serves the cluster's OpenID discovery document. The
|
||||
// `issuer` it returns is the cluster's stored IssuerURL — byte-identical to the
|
||||
// apiserver's `iss` claim — so an AWS SDK that fetches this document while
|
||||
// registering the provider sees a matching issuer.
|
||||
func (p *OIDCPublisher) ServeDiscoveryDocument(w http.ResponseWriter, r *http.Request, accountID, clusterName string) {
|
||||
kv, err := p.accountKV(r, accountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("OIDC discovery: open account bucket", "accountID", accountID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := GetClusterMeta(r.Context(), kv, clusterName)
|
||||
if err != nil || meta == nil || meta.OIDCIssuer == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
writeOIDCJSON(w, OIDCDiscoveryDocument{
|
||||
Issuer: meta.OIDCIssuer,
|
||||
JWKSURI: meta.OIDCIssuer + "/keys",
|
||||
ResponseTypesSupported: []string{"id_token"},
|
||||
SubjectTypesSupported: []string{"public"},
|
||||
IDTokenSigningAlgValuesSupported: []string{"ES256"},
|
||||
ClaimsSupported: []string{"sub", "iss", "aud", "exp", "iat"},
|
||||
})
|
||||
}
|
||||
|
||||
// ServeJWKS serves the cluster's JWKS document (the public verification key) as
|
||||
// referenced by the discovery document's jwks_uri. The stored bytes are written
|
||||
// as they are, without validating that they parse as a JWKS.
|
||||
func (p *OIDCPublisher) ServeJWKS(w http.ResponseWriter, r *http.Request, accountID, clusterName string) {
|
||||
kv, err := p.accountKV(r, accountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("OIDC JWKS: open account bucket", "accountID", accountID, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := kv.Get(r.Context(), OIDCJWKSKey(clusterName))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
if _, err := w.Write(entry.Value()); err != nil {
|
||||
slog.Debug("OIDC JWKS: write response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeOIDCJSON(w http.ResponseWriter, v any) {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
if _, err := w.Write(body); err != nil {
|
||||
slog.Debug("OIDC discovery: write response", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
oidcPubAccount = "000000000001"
|
||||
oidcPubCluster = "demo"
|
||||
)
|
||||
|
||||
func serveOIDC(t *testing.T, p *OIDCPublisher, jwks bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if jwks {
|
||||
p.ServeJWKS(rec, req, oidcPubAccount, oidcPubCluster)
|
||||
} else {
|
||||
p.ServeDiscoveryDocument(rec, req, oidcPubAccount, oidcPubCluster)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestOIDCPublisher_UnwiredAnswers500(t *testing.T) {
|
||||
for _, p := range []*OIDCPublisher{nil, NewOIDCPublisher(nil)} {
|
||||
assert.Equal(t, http.StatusInternalServerError, serveOIDC(t, p, false).Code)
|
||||
assert.Equal(t, http.StatusInternalServerError, serveOIDC(t, p, true).Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCPublisher_NoAccountBucketAnswers404(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
p := NewOIDCPublisher(testutil.NewJetStream(t, nc))
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, serveOIDC(t, p, false).Code)
|
||||
assert.Equal(t, http.StatusNotFound, serveOIDC(t, p, true).Code)
|
||||
}
|
||||
|
||||
// A stored JWKS is written back as it is, even when it is not valid JSON.
|
||||
func TestOIDCPublisher_ServesStoredJWKSVerbatim(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
kv, err := GetOrCreateAccountBucket(t.Context(), js, oidcPubAccount)
|
||||
require.NoError(t, err)
|
||||
_, err = kv.Put(t.Context(), OIDCJWKSKey(oidcPubCluster), []byte("not json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
rec := serveOIDC(t, NewOIDCPublisher(js), true)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "not json", rec.Body.String())
|
||||
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||
assert.Equal(t, "public, max-age=300", rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
@@ -42,7 +42,7 @@ const (
|
||||
sourceActionTargetGroups
|
||||
)
|
||||
|
||||
// Every action the ELBv2 registration serves, with the resources AWS evaluates it
|
||||
// Every action ELBv2_Request serves, with the resources AWS evaluates it
|
||||
// against. Exhaustive by contract: a completeness test compares this table with
|
||||
// the dispatch table in both directions, so an action cannot be added with a
|
||||
// silent account-wide grant.
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
elbv2domain "github.com/mulgadc/spinifex/spinifex/domains/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsxml "github.com/mulgadc/spinifex/spinifex/foundation/aws/xml"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ServiceName is the SigV4 credential-scope service ELBv2 clients sign for.
|
||||
const ServiceName = "elasticloadbalancing"
|
||||
|
||||
// InventoryName keys the operation inventory; it differs from the credential
|
||||
// scope because the inventory is named for the API generation.
|
||||
const InventoryName = "elasticloadbalancingv2"
|
||||
|
||||
// Quota is the account load balancer cap ELBv2 consults. It is declared here
|
||||
// because domains/admission/quota imports this package to count live load
|
||||
// balancers, so this package cannot import it back.
|
||||
type Quota interface {
|
||||
EnforceLoadBalancers(ctx context.Context, nc *nats.Conn, accountID string, want int) error
|
||||
Exempt(accountID string) bool
|
||||
// LoadBalancerLimit is the cap the account's DescribeAccountLimits reports.
|
||||
LoadBalancerLimit(ctx context.Context, accountID string) (int, error)
|
||||
}
|
||||
|
||||
// Deps are the composed capabilities the ELBv2 control plane dispatches to. A
|
||||
// nil NATS connection answers every action with ServerInternal and a nil Quota
|
||||
// enforces and reports the AWS default.
|
||||
type Deps struct {
|
||||
NATS *nats.Conn
|
||||
Quota Quota
|
||||
}
|
||||
|
||||
// action parses once so authorization and dispatch share one typed input.
|
||||
type action struct {
|
||||
parse func(map[string]string) (any, error)
|
||||
dispatch func(ctx context.Context, name string, parsed any, d Deps, accountID string) ([]byte, error)
|
||||
}
|
||||
|
||||
// handler creates a type-safe action. Parsing is separate from dispatch so the
|
||||
// resolver authorizes the exact input the handler executes. ELBv2 uses the
|
||||
// IAM-style XML envelope: <ActionResponse><ActionResult>...</ActionResult></ActionResponse>.
|
||||
func handler[In any](h func(context.Context, *In, Deps, string) (any, error)) action {
|
||||
return action{
|
||||
parse: func(q map[string]string) (any, error) {
|
||||
input := new(In)
|
||||
if err := query.QueryParamsToStruct(q, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return input, nil
|
||||
},
|
||||
dispatch: func(ctx context.Context, name string, parsed any, d Deps, accountID string) ([]byte, error) {
|
||||
input, ok := parsed.(*In)
|
||||
if !ok {
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
output, err := h(ctx, input, d, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := awsxml.QueryResponsePayload(name, output)
|
||||
xmlOutput, err := awsxml.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to marshal response to XML")
|
||||
}
|
||||
return xmlOutput, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var actions = map[string]action{
|
||||
"CreateLoadBalancer": handler(func(ctx context.Context, input *elbv2.CreateLoadBalancerInput, d Deps, accountID string) (any, error) {
|
||||
if d.Quota != nil {
|
||||
if err := d.Quota.EnforceLoadBalancers(ctx, d.NATS, accountID, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return CreateLoadBalancer(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DeleteLoadBalancer": handler(func(ctx context.Context, input *elbv2.DeleteLoadBalancerInput, d Deps, accountID string) (any, error) {
|
||||
return DeleteLoadBalancer(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DescribeLoadBalancers": handler(func(ctx context.Context, input *elbv2.DescribeLoadBalancersInput, d Deps, accountID string) (any, error) {
|
||||
return DescribeLoadBalancers(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"CreateTargetGroup": handler(func(ctx context.Context, input *elbv2.CreateTargetGroupInput, d Deps, accountID string) (any, error) {
|
||||
return CreateTargetGroup(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DeleteTargetGroup": handler(func(ctx context.Context, input *elbv2.DeleteTargetGroupInput, d Deps, accountID string) (any, error) {
|
||||
return DeleteTargetGroup(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DescribeTargetGroups": handler(func(ctx context.Context, input *elbv2.DescribeTargetGroupsInput, d Deps, accountID string) (any, error) {
|
||||
return DescribeTargetGroups(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"RegisterTargets": handler(func(ctx context.Context, input *elbv2.RegisterTargetsInput, d Deps, accountID string) (any, error) {
|
||||
return RegisterTargets(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DeregisterTargets": handler(func(ctx context.Context, input *elbv2.DeregisterTargetsInput, d Deps, accountID string) (any, error) {
|
||||
return DeregisterTargets(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DescribeTargetHealth": handler(func(ctx context.Context, input *elbv2.DescribeTargetHealthInput, d Deps, accountID string) (any, error) {
|
||||
return DescribeTargetHealth(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"CreateListener": handler(func(ctx context.Context, input *elbv2.CreateListenerInput, d Deps, accountID string) (any, error) {
|
||||
return CreateListener(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DeleteListener": handler(func(ctx context.Context, input *elbv2.DeleteListenerInput, d Deps, accountID string) (any, error) {
|
||||
return DeleteListener(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"ModifyListener": handler(func(ctx context.Context, input *elbv2.ModifyListenerInput, d Deps, accountID string) (any, error) {
|
||||
return ModifyListener(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DescribeListeners": handler(func(ctx context.Context, input *elbv2.DescribeListenersInput, d Deps, accountID string) (any, error) {
|
||||
return DescribeListeners(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"CreateRule": handler(func(ctx context.Context, input *elbv2.CreateRuleInput, d Deps, accountID string) (any, error) {
|
||||
return CreateRule(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"ModifyRule": handler(func(ctx context.Context, input *elbv2.ModifyRuleInput, d Deps, accountID string) (any, error) {
|
||||
return ModifyRule(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DeleteRule": handler(func(ctx context.Context, input *elbv2.DeleteRuleInput, d Deps, accountID string) (any, error) {
|
||||
return DeleteRule(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DescribeRules": handler(func(ctx context.Context, input *elbv2.DescribeRulesInput, d Deps, accountID string) (any, error) {
|
||||
return DescribeRules(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"SetRulePriorities": handler(func(ctx context.Context, input *elbv2.SetRulePrioritiesInput, d Deps, accountID string) (any, error) {
|
||||
return SetRulePriorities(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DescribeTags": handler(func(ctx context.Context, input *elbv2.DescribeTagsInput, d Deps, accountID string) (any, error) {
|
||||
return DescribeTags(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"AddTags": handler(func(ctx context.Context, input *elbv2.AddTagsInput, d Deps, accountID string) (any, error) {
|
||||
return AddTags(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"RemoveTags": handler(func(ctx context.Context, input *elbv2.RemoveTagsInput, d Deps, accountID string) (any, error) {
|
||||
return RemoveTags(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"LBAgentHeartbeat": handler(func(ctx context.Context, input *elbv2domain.LBAgentHeartbeatInput, d Deps, accountID string) (any, error) {
|
||||
return LBAgentHeartbeat(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"GetLBConfig": handler(func(ctx context.Context, input *elbv2domain.GetLBConfigInput, d Deps, accountID string) (any, error) {
|
||||
return GetLBConfig(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"ModifyTargetGroup": handler(func(ctx context.Context, input *elbv2.ModifyTargetGroupInput, d Deps, accountID string) (any, error) {
|
||||
return ModifyTargetGroup(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"ModifyTargetGroupAttributes": handler(func(ctx context.Context, input *elbv2.ModifyTargetGroupAttributesInput, d Deps, accountID string) (any, error) {
|
||||
return ModifyTargetGroupAttributes(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DescribeTargetGroupAttributes": handler(func(ctx context.Context, input *elbv2.DescribeTargetGroupAttributesInput, d Deps, accountID string) (any, error) {
|
||||
return DescribeTargetGroupAttributes(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"ModifyLoadBalancerAttributes": handler(func(ctx context.Context, input *elbv2.ModifyLoadBalancerAttributesInput, d Deps, accountID string) (any, error) {
|
||||
return ModifyLoadBalancerAttributes(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DescribeLoadBalancerAttributes": handler(func(ctx context.Context, input *elbv2.DescribeLoadBalancerAttributesInput, d Deps, accountID string) (any, error) {
|
||||
return DescribeLoadBalancerAttributes(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"SetSecurityGroups": handler(func(ctx context.Context, input *elbv2.SetSecurityGroupsInput, d Deps, accountID string) (any, error) {
|
||||
return SetSecurityGroups(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"SetIpAddressType": handler(func(ctx context.Context, input *elbv2.SetIpAddressTypeInput, d Deps, accountID string) (any, error) {
|
||||
return SetIpAddressType(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"SetSubnets": handler(func(ctx context.Context, input *elbv2.SetSubnetsInput, d Deps, accountID string) (any, error) {
|
||||
return SetSubnets(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"AddListenerCertificates": handler(func(ctx context.Context, input *elbv2.AddListenerCertificatesInput, d Deps, accountID string) (any, error) {
|
||||
return AddListenerCertificates(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"RemoveListenerCertificates": handler(func(ctx context.Context, input *elbv2.RemoveListenerCertificatesInput, d Deps, accountID string) (any, error) {
|
||||
return RemoveListenerCertificates(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DescribeListenerCertificates": handler(func(ctx context.Context, input *elbv2.DescribeListenerCertificatesInput, d Deps, accountID string) (any, error) {
|
||||
return DescribeListenerCertificates(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DescribeSSLPolicies": handler(func(ctx context.Context, input *elbv2.DescribeSSLPoliciesInput, d Deps, accountID string) (any, error) {
|
||||
return DescribeSSLPolicies(ctx, input, d.NATS, accountID)
|
||||
}),
|
||||
"DescribeAccountLimits": handler(func(ctx context.Context, input *elbv2.DescribeAccountLimitsInput, d Deps, accountID string) (any, error) {
|
||||
loadBalancers := DefaultLoadBalancerLimit
|
||||
if d.Quota != nil {
|
||||
limit, err := d.Quota.LoadBalancerLimit(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loadBalancers = limit
|
||||
}
|
||||
return DescribeAccountLimits(input, loadBalancers), nil
|
||||
}),
|
||||
"DescribeListenerAttributes": handler(func(ctx context.Context, input *DescribeListenerAttributesInput, d Deps, accountID string) (any, error) {
|
||||
return DescribeListenerAttributes(input, accountID)
|
||||
}),
|
||||
"ModifyListenerAttributes": handler(func(ctx context.Context, input *ModifyListenerAttributesInput, d Deps, accountID string) (any, error) {
|
||||
return ModifyListenerAttributes(input, accountID)
|
||||
}),
|
||||
}
|
||||
|
||||
// NewRegistration returns the ELBv2 AWS Query control-plane registration.
|
||||
func NewRegistration(deps Deps) dispatch.Registration {
|
||||
return dispatch.Registration{
|
||||
Service: ServiceName,
|
||||
InventoryName: InventoryName,
|
||||
ResolveAction: resolveAction,
|
||||
Dispatch: deps.dispatch,
|
||||
Errors: dispatch.ErrorEnvelopeXML,
|
||||
Inventory: OperationInventory(),
|
||||
}
|
||||
}
|
||||
|
||||
// ActionNames lists every action the registration dispatches, sorted.
|
||||
func ActionNames() []string {
|
||||
return slices.Sorted(maps.Keys(actions))
|
||||
}
|
||||
|
||||
// OperationInventory classifies the actions the registration serves. Every
|
||||
// ELBv2 action is implemented, so none is Stubbed or Unsupported.
|
||||
func OperationInventory() dispatch.Inventory {
|
||||
return dispatch.Inventory{Registered: ActionNames()}
|
||||
}
|
||||
|
||||
// resolveAction is the one Query Action parser telemetry and dispatch share.
|
||||
// It leaves the body readable for the dispatcher and answers "" on any failure.
|
||||
func resolveAction(r *http.Request) string {
|
||||
args, err := readQueryArgs(r)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return args["Action"]
|
||||
}
|
||||
|
||||
// readQueryArgs parses the Query body and rewinds it, so the same bytes can be
|
||||
// read again. Any read or parse failure is a malformed query string.
|
||||
func readQueryArgs(r *http.Request) (map[string]string, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, sigv4.MaxPayloadLen+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if int64(len(body)) > sigv4.MaxPayloadLen {
|
||||
return nil, errors.New(awserrors.ErrorRequestEntityTooLarge)
|
||||
}
|
||||
return query.ParseArgs(string(body))
|
||||
}
|
||||
|
||||
// dispatch parses, scopes and authorizes the request from one parse of its
|
||||
// body, then runs the action. Errors are awserrors codes the gateway renders.
|
||||
func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
r := inv.Request
|
||||
queryArgs, err := readQueryArgs(r)
|
||||
if err != nil {
|
||||
slog.DebugContext(r.Context(), "ELBv2: malformed query string", "err", err)
|
||||
return errors.New(awserrors.ErrorMalformedQueryString)
|
||||
}
|
||||
|
||||
name := queryArgs["Action"]
|
||||
if name == "" {
|
||||
return errors.New(awserrors.ErrorMissingAction)
|
||||
}
|
||||
h, ok := actions[name]
|
||||
if !ok {
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
// inv.Region is the gateway's own region, never the caller-supplied
|
||||
// credential-scope region, which would let a caller sign for another region
|
||||
// and slide out from under a Deny.
|
||||
accountID := inv.AccountID
|
||||
if accountID == "" {
|
||||
slog.ErrorContext(r.Context(), "ELBv2 dispatch: no account ID in auth context")
|
||||
// InternalError, not ServerInternal: the policy gate used to reach this
|
||||
// case first and that is the code the caller has always seen.
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
input, err := h.parse(queryArgs)
|
||||
if err != nil {
|
||||
if errors.Is(err, query.ErrSliceTooLarge) {
|
||||
return errors.New(awserrors.ErrorMalformedQueryString)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
resources, err := ResourceARNs(name, inv.Region, accountID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.Authorize(ServiceName, name, resources, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.NATS == nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
xmlOutput, err := h.dispatch(r.Context(), name, input, d, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(xmlOutput); err != nil {
|
||||
slog.ErrorContext(r.Context(), "Failed to write ELBv2 response", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
package awsapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/request"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
iampolicy "github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
elbv2awsapi "github.com/mulgadc/spinifex/spinifex/domains/elbv2/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// agentForms are the two actions the AWS SDK has no builder for, so their
|
||||
// request bodies are written by hand in the shape the LB agent sends.
|
||||
var agentForms = map[string]string{
|
||||
"LBAgentHeartbeat": "Action=LBAgentHeartbeat&LBID=lb-1" +
|
||||
"&Servers.member.1.Backend=be1&Servers.member.1.Server=srv1&Servers.member.1.Status=UP",
|
||||
"GetLBConfig": "Action=GetLBConfig&LBID=lb-1",
|
||||
}
|
||||
|
||||
// queryBody encodes input the way a real client does, using the SDK's own
|
||||
// Query builder, so the registration parses what clients actually send.
|
||||
func queryBody(t *testing.T, action string, input any) string {
|
||||
t.Helper()
|
||||
if form, ok := agentForms[action]; ok {
|
||||
return form
|
||||
}
|
||||
svc := elbv2.New(session.Must(session.NewSession(&aws.Config{
|
||||
Region: aws.String("us-east-1"),
|
||||
Credentials: credentials.NewStaticCredentials("AKIATEST", "secret", ""),
|
||||
})))
|
||||
method := reflect.ValueOf(svc).MethodByName(action + "Request")
|
||||
require.True(t, method.IsValid(), "SDK has no %sRequest", action)
|
||||
out := method.Call([]reflect.Value{reflect.ValueOf(input)})
|
||||
req, ok := reflect.TypeAssert[*request.Request](out[0])
|
||||
require.True(t, ok)
|
||||
require.NoError(t, req.Build())
|
||||
body, err := io.ReadAll(req.HTTPRequest.Body)
|
||||
require.NoError(t, err)
|
||||
return string(body)
|
||||
}
|
||||
|
||||
type authCall struct {
|
||||
service, action string
|
||||
resources []string
|
||||
}
|
||||
|
||||
func invoke(t *testing.T, deps elbv2awsapi.Deps, body string, deny error) (*httptest.ResponseRecorder, []authCall, error) {
|
||||
t.Helper()
|
||||
var calls []authCall
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
inv := dispatch.Invocation{
|
||||
Request: req,
|
||||
AccountID: testAccountID,
|
||||
Region: "us-east-1",
|
||||
Authorize: func(service, action string, resources []string, _ iampolicy.ConditionKeys) error {
|
||||
calls = append(calls, authCall{service, action, resources})
|
||||
return deny
|
||||
},
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
err := elbv2awsapi.NewRegistration(deps).Dispatch(w, inv)
|
||||
return w, calls, err
|
||||
}
|
||||
|
||||
// jsonStrings collects every string leaf of v, which is what the XML response
|
||||
// must carry for the reply to have survived the round trip.
|
||||
func jsonStrings(t *testing.T, v any) []string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
var tree any
|
||||
require.NoError(t, json.Unmarshal(raw, &tree))
|
||||
var out []string
|
||||
var walk func(any)
|
||||
walk = func(n any) {
|
||||
switch x := n.(type) {
|
||||
case string:
|
||||
out = append(out, x)
|
||||
case []any:
|
||||
for _, e := range x {
|
||||
walk(e)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, e := range x {
|
||||
walk(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(tree)
|
||||
return out
|
||||
}
|
||||
|
||||
func rootElement(t *testing.T, body []byte) string {
|
||||
t.Helper()
|
||||
dec := xml.NewDecoder(strings.NewReader(string(body)))
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
require.NoError(t, err)
|
||||
if se, ok := tok.(xml.StartElement); ok {
|
||||
return se.Name.Local
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every NATS-backed action: the request body reaches the right subject with the
|
||||
// caller's values, the account rides in the header, authorization sees the
|
||||
// action first, and the daemon's reply comes back as that action's XML.
|
||||
func TestRegistrationDispatch_EveryActionRoundTrips(t *testing.T) {
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
for _, tc := range dispatchCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
replyBody, err := json.Marshal(tc.reply)
|
||||
require.NoError(t, err)
|
||||
got := serveOnce(t, nc, tc.subject, replyBody)
|
||||
|
||||
w, auths, err := invoke(t, elbv2awsapi.Deps{NATS: nc}, queryBody(t, tc.name, tc.input), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, auths, 1)
|
||||
assert.Equal(t, elbv2awsapi.ServiceName, auths[0].service)
|
||||
assert.Equal(t, tc.name, auths[0].action)
|
||||
|
||||
var msg *nats.Msg
|
||||
select {
|
||||
case msg = <-got:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("no request observed on %s", tc.subject)
|
||||
}
|
||||
wantBody, err := json.Marshal(tc.input)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, string(wantBody), string(msg.Data), "the daemon must receive the request values")
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "text/xml", w.Header().Get("Content-Type"))
|
||||
assert.Equal(t, tc.name+"Response", rootElement(t, w.Body.Bytes()))
|
||||
for _, s := range jsonStrings(t, tc.want) {
|
||||
assert.Contains(t, w.Body.String(), xmlText(s), "reply value missing from XML")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A daemon error reply is surfaced as that awserrors code with nothing written.
|
||||
func TestRegistrationDispatch_EveryActionSurfacesDaemonError(t *testing.T) {
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
for _, tc := range dispatchCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
serveOnce(t, nc, tc.subject, awserrors.GenerateErrorPayload(awserrors.ErrorELBv2LoadBalancerNotFound))
|
||||
|
||||
w, _, err := invoke(t, elbv2awsapi.Deps{NATS: nc}, queryBody(t, tc.name, tc.input), nil)
|
||||
require.EqualError(t, err, awserrors.ErrorELBv2LoadBalancerNotFound)
|
||||
assert.Zero(t, w.Body.Len())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type refusingQuota struct{ limit int }
|
||||
|
||||
func (refusingQuota) EnforceLoadBalancers(context.Context, *nats.Conn, string, int) error {
|
||||
return errors.New(awserrors.ErrorELBv2TooManyLoadBalancers)
|
||||
}
|
||||
func (refusingQuota) Exempt(string) bool { return false }
|
||||
func (q refusingQuota) LoadBalancerLimit(context.Context, string) (int, error) {
|
||||
return q.limit, nil
|
||||
}
|
||||
|
||||
// The cap is checked before the daemon is asked to create anything.
|
||||
func TestRegistrationDispatch_CreateLoadBalancerQuotaRefusalSkipsDaemon(t *testing.T) {
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
got := serveOnce(t, nc, "elbv2.CreateLoadBalancer", []byte(`{}`))
|
||||
|
||||
in := &elbv2.CreateLoadBalancerInput{Name: aws.String("my-alb"), Subnets: []*string{aws.String("subnet-1")}}
|
||||
_, _, err := invoke(t, elbv2awsapi.Deps{NATS: nc, Quota: refusingQuota{}}, queryBody(t, "CreateLoadBalancer", in), nil)
|
||||
require.EqualError(t, err, awserrors.ErrorELBv2TooManyLoadBalancers)
|
||||
|
||||
select {
|
||||
case <-got:
|
||||
t.Fatal("the daemon was asked to create a load balancer past the cap")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// An authorization denial is returned as is and the daemon is never reached.
|
||||
func TestRegistrationDispatch_DenyBeforeDaemon(t *testing.T) {
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
got := serveOnce(t, nc, "elbv2.DeleteLoadBalancer", []byte(`{}`))
|
||||
|
||||
in := &elbv2.DeleteLoadBalancerInput{LoadBalancerArn: aws.String(testLBArn)}
|
||||
deny := errors.New(awserrors.ErrorUnauthorizedOperation)
|
||||
_, auths, err := invoke(t, elbv2awsapi.Deps{NATS: nc}, queryBody(t, "DeleteLoadBalancer", in), deny)
|
||||
|
||||
require.ErrorIs(t, err, deny)
|
||||
require.Len(t, auths, 1)
|
||||
assert.Equal(t, []string{testLBArn}, auths[0].resources)
|
||||
select {
|
||||
case <-got:
|
||||
t.Fatal("the daemon was reached after a denial")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationDispatch_RequestShapeErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
deps elbv2awsapi.Deps
|
||||
body string
|
||||
account string
|
||||
want string
|
||||
}{
|
||||
{"missing Action", elbv2awsapi.Deps{}, "Version=2015-12-01", testAccountID, awserrors.ErrorMissingAction},
|
||||
{"unknown Action", elbv2awsapi.Deps{}, "Action=NoSuchThing", testAccountID, awserrors.ErrorInvalidAction},
|
||||
{"bad percent-encoding", elbv2awsapi.Deps{}, "Action=%zz", testAccountID, awserrors.ErrorMalformedQueryString},
|
||||
{"no account in auth context", elbv2awsapi.Deps{}, "Action=DescribeLoadBalancers", "", awserrors.ErrorInternalError},
|
||||
{"nil NATS", elbv2awsapi.Deps{}, "Action=DescribeLoadBalancers", testAccountID, awserrors.ErrorServerInternal},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tc.body))
|
||||
inv := dispatch.Invocation{
|
||||
Request: req, AccountID: tc.account, Region: "us-east-1",
|
||||
Authorize: func(string, string, []string, iampolicy.ConditionKeys) error { return nil },
|
||||
}
|
||||
err := elbv2awsapi.NewRegistration(tc.deps).Dispatch(httptest.NewRecorder(), inv)
|
||||
require.EqualError(t, err, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The listener attribute actions are served in process, with no daemon.
|
||||
func TestRegistrationDispatch_ListenerAttributesNeedNoDaemon(t *testing.T) {
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
for _, action := range []string{"DescribeListenerAttributes", "ModifyListenerAttributes"} {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
form := url.Values{"Action": {action}, "ListenerArn": {testLsnrArn}}
|
||||
w, _, err := invoke(t, elbv2awsapi.Deps{NATS: nc}, form.Encode(), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, action+"Response", rootElement(t, w.Body.Bytes()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// xmlText is s as the XML encoder writes it, so a value with a newline or an
|
||||
// ampersand is looked up in its escaped form.
|
||||
func xmlText(s string) string {
|
||||
var b strings.Builder
|
||||
_ = xml.EscapeText(&b, []byte(s))
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewRegistration_RegistersUnderCredentialScopeAndInventoryName(t *testing.T) {
|
||||
b := dispatch.NewBuilder()
|
||||
require.NoError(t, b.Register(NewRegistration(Deps{})))
|
||||
reg := b.Build()
|
||||
|
||||
entry, ok := reg.Lookup("elasticloadbalancing")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, dispatch.ErrorEnvelopeXML, entry.ErrorEnvelope())
|
||||
|
||||
inv := reg.Inventory()["elasticloadbalancingv2"]
|
||||
assert.Equal(t, ActionNames(), inv.Registered)
|
||||
assert.Empty(t, inv.Stubbed)
|
||||
assert.Empty(t, inv.Unsupported)
|
||||
}
|
||||
|
||||
func TestResolveAction_ReadsActionAndRewindsBody(t *testing.T) {
|
||||
body := "Action=DescribeLoadBalancers&Version=2015-12-01"
|
||||
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
|
||||
assert.Equal(t, "DescribeLoadBalancers", resolveAction(r))
|
||||
|
||||
again, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, body, string(again), "the dispatcher must still see the whole body")
|
||||
}
|
||||
|
||||
func TestResolveAction_UnparseableBodyIsEmpty(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=%zz"))
|
||||
assert.Empty(t, resolveAction(r))
|
||||
}
|
||||
|
||||
// A registration with no quota reports the AWS default cap and enforces none.
|
||||
func TestDescribeAccountLimits_NoQuotaReportsDefault(t *testing.T) {
|
||||
out, err := actions["DescribeAccountLimits"].dispatch(t.Context(), "DescribeAccountLimits",
|
||||
&elbv2.DescribeAccountLimitsInput{}, Deps{}, "123456789012")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "50", limitMax(t, out, "application-load-balancers"))
|
||||
}
|
||||
|
||||
// limitMax reads one limit out of the response by name, because the members of
|
||||
// a limit are written in map order.
|
||||
func limitMax(t *testing.T, body []byte, name string) string {
|
||||
t.Helper()
|
||||
var resp struct {
|
||||
Limits []struct {
|
||||
Name string `xml:"Name"`
|
||||
Max string `xml:"Max"`
|
||||
} `xml:"DescribeAccountLimitsResult>Limits>member"`
|
||||
}
|
||||
require.NoError(t, xml.Unmarshal(body, &resp))
|
||||
for _, l := range resp.Limits {
|
||||
if l.Name == name {
|
||||
return l.Max
|
||||
}
|
||||
}
|
||||
t.Fatalf("limit %q not in response", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
type fixedQuota struct{ limit int }
|
||||
|
||||
func (fixedQuota) EnforceLoadBalancers(_ context.Context, _ *nats.Conn, _ string, _ int) error {
|
||||
return nil
|
||||
}
|
||||
func (fixedQuota) Exempt(string) bool { return false }
|
||||
func (q fixedQuota) LoadBalancerLimit(context.Context, string) (int, error) {
|
||||
return q.limit, nil
|
||||
}
|
||||
|
||||
func TestDescribeAccountLimits_ReportsTheQuotaLimit(t *testing.T) {
|
||||
out, err := actions["DescribeAccountLimits"].dispatch(t.Context(), "DescribeAccountLimits",
|
||||
&elbv2.DescribeAccountLimitsInput{}, Deps{Quota: fixedQuota{limit: 7}}, "123456789012")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "7", limitMax(t, out, "application-load-balancers"))
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Load balancer attribute keys that shape the X-Forwarded-* headers.
|
||||
const (
|
||||
AttrXFFHeaderProcessing = "routing.http.xff_header_processing.mode"
|
||||
AttrXFFClientPort = "routing.http.xff_client_port.enabled"
|
||||
)
|
||||
|
||||
// X-Forwarded-For processing modes.
|
||||
const (
|
||||
XFFModeAppend = "append"
|
||||
XFFModePreserve = "preserve"
|
||||
XFFModeRemove = "remove"
|
||||
)
|
||||
|
||||
// maxXFFLines bounds how many X-Forwarded-For header lines a request may carry
|
||||
// before later ones are left out of the appended value; HAProxy cannot loop.
|
||||
const maxXFFLines = 8
|
||||
|
||||
// forwardedHeaderRules returns the http-request directives for one HTTP or
|
||||
// HTTPS frontend. An unknown mode renders as the default, append.
|
||||
func forwardedHeaderRules(mode string, clientPort, secure bool, listenerPort int64) []string {
|
||||
scheme := "http"
|
||||
if secure {
|
||||
scheme = "https"
|
||||
}
|
||||
var rules []string
|
||||
switch mode {
|
||||
case XFFModePreserve:
|
||||
case XFFModeRemove:
|
||||
rules = append(rules, "http-request del-header X-Forwarded-For")
|
||||
default:
|
||||
rules = appendXFFRules(clientPort)
|
||||
}
|
||||
return append(rules,
|
||||
"http-request set-header X-Forwarded-Proto "+scheme,
|
||||
"http-request set-header X-Forwarded-Port "+strconv.FormatInt(listenerPort, 10),
|
||||
)
|
||||
}
|
||||
|
||||
// appendXFFRules joins every client X-Forwarded-For line into one value and
|
||||
// appends the client address, as AWS does. req.hdr keeps only the last
|
||||
// comma-separated item and req.fhdr only the last line, so each line is read
|
||||
// by index into a transaction variable.
|
||||
func appendXFFRules(clientPort bool) []string {
|
||||
rules := []string{
|
||||
`http-request set-var-fmt(txn.xff) "%[req.fhdr(X-Forwarded-For,1)]" if { req.fhdr_cnt(X-Forwarded-For) ge 1 }`,
|
||||
}
|
||||
for i := 2; i <= maxXFFLines; i++ {
|
||||
rules = append(rules, fmt.Sprintf(
|
||||
`http-request set-var-fmt(txn.xff) "%%[var(txn.xff)], %%[req.fhdr(X-Forwarded-For,%d)]" if { req.fhdr_cnt(X-Forwarded-For) ge %d }`, i, i))
|
||||
}
|
||||
|
||||
client := "%[src]"
|
||||
if clientPort {
|
||||
client = "%[var(txn.xffc)]"
|
||||
rules = append(rules,
|
||||
`http-request set-var-fmt(txn.xffc) "[%[src]]:%[src_port]" if { src -m reg : }`,
|
||||
`http-request set-var-fmt(txn.xffc) "%[src]:%[src_port]" unless { src -m reg : }`)
|
||||
}
|
||||
return append(rules,
|
||||
`http-request set-header X-Forwarded-For "%[var(txn.xff)], `+client+`" if { var(txn.xff) -m len gt 0 }`,
|
||||
`http-request set-header X-Forwarded-For "`+client+`" unless { var(txn.xff) -m len gt 0 }`,
|
||||
)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const fwdALBArn = "arn:aws:elasticloadbalancing:us-east-1:1:loadbalancer/x/alb-fwd/alb-fwd"
|
||||
|
||||
func putFwdALB(t *testing.T, svc *ELBv2ServiceImpl) {
|
||||
t.Helper()
|
||||
putModifyFixture(t, svc, ProtocolHTTP)
|
||||
putModifyLB(t, svc, "alb-fwd", LoadBalancerTypeApplication, ProtocolHTTP)
|
||||
}
|
||||
|
||||
func modifyLBAttr(t *testing.T, svc *ELBv2ServiceImpl, kv ...string) error {
|
||||
t.Helper()
|
||||
var attrs []*elbv2.LoadBalancerAttribute
|
||||
for i := 0; i < len(kv); i += 2 {
|
||||
attrs = append(attrs, &elbv2.LoadBalancerAttribute{Key: aws.String(kv[i]), Value: aws.String(kv[i+1])})
|
||||
}
|
||||
_, err := svc.ModifyLoadBalancerAttributes(t.Context(), &elbv2.ModifyLoadBalancerAttributesInput{
|
||||
LoadBalancerArn: aws.String(fwdALBArn),
|
||||
Attributes: attrs,
|
||||
}, testAccountID)
|
||||
return err
|
||||
}
|
||||
|
||||
func TestModifyLoadBalancerAttributes_AcceptsForwardingValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, mode := range []string{"append", "preserve", "remove"} {
|
||||
for _, port := range []string{"true", "false"} {
|
||||
t.Run(mode+"/"+port, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
putFwdALB(t, svc)
|
||||
require.NoError(t, modifyLBAttr(t, svc, AttrXFFHeaderProcessing, mode, AttrXFFClientPort, port))
|
||||
got := getLB(t, svc, "alb-fwd")
|
||||
assert.Equal(t, mode, got.Attributes[AttrXFFHeaderProcessing])
|
||||
assert.Equal(t, port, got.Attributes[AttrXFFClientPort])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModifyLoadBalancerAttributes_RejectsBadForwardingValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct{ name, key, value string }{
|
||||
{"mode unknown", AttrXFFHeaderProcessing, "prepend"},
|
||||
{"mode wrong case", AttrXFFHeaderProcessing, "Append"},
|
||||
{"mode empty", AttrXFFHeaderProcessing, ""},
|
||||
{"client port word", AttrXFFClientPort, "yes"},
|
||||
{"client port wrong case", AttrXFFClientPort, "True"},
|
||||
{"client port empty", AttrXFFClientPort, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
putFwdALB(t, svc)
|
||||
before := getLB(t, svc, "alb-fwd")
|
||||
|
||||
// A valid pair submitted alongside must not be stored either.
|
||||
err := modifyLBAttr(t, svc, "idle_timeout.timeout_seconds", "120", tt.key, tt.value)
|
||||
|
||||
require.Error(t, err)
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorValidationError, code)
|
||||
_, msg, _ := awserrors.ResolveErrorDetail(err)
|
||||
assert.Contains(t, msg, tt.key)
|
||||
assert.Equal(t, before, getLB(t, svc, "alb-fwd"), "a rejected call leaves the record untouched")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModifyLoadBalancerAttributes_ForwardingChangeReRendersConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
putFwdALB(t, svc)
|
||||
before := getLB(t, svc, "alb-fwd")
|
||||
require.Contains(t, before.ConfigText, `set-header X-Forwarded-For "%[src]"`)
|
||||
|
||||
require.NoError(t, modifyLBAttr(t, svc, AttrXFFClientPort, "true"))
|
||||
withPort := getLB(t, svc, "alb-fwd")
|
||||
assert.Contains(t, withPort.ConfigText, "src_port")
|
||||
assert.NotEqual(t, before.ConfigHash, withPort.ConfigHash)
|
||||
|
||||
require.NoError(t, modifyLBAttr(t, svc, AttrXFFHeaderProcessing, "remove"))
|
||||
removed := getLB(t, svc, "alb-fwd")
|
||||
assert.Contains(t, removed.ConfigText, "del-header X-Forwarded-For")
|
||||
assert.NotContains(t, removed.ConfigText, "set-header X-Forwarded-For")
|
||||
assert.NotEqual(t, withPort.ConfigHash, removed.ConfigHash)
|
||||
}
|
||||
|
||||
func TestModifyLoadBalancerAttributes_UnchangedOrUnrelatedKeysLeaveTheConfigAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
putFwdALB(t, svc)
|
||||
require.NoError(t, modifyLBAttr(t, svc, AttrXFFHeaderProcessing, "preserve"))
|
||||
before := getLB(t, svc, "alb-fwd")
|
||||
|
||||
require.NoError(t, modifyLBAttr(t, svc, AttrXFFHeaderProcessing, "preserve"))
|
||||
assert.Equal(t, before, getLB(t, svc, "alb-fwd"), "same value: nothing written")
|
||||
|
||||
require.NoError(t, modifyLBAttr(t, svc, "idle_timeout.timeout_seconds", "120"))
|
||||
after := getLB(t, svc, "alb-fwd")
|
||||
assert.Equal(t, before.ConfigHash, after.ConfigHash, "an attribute the config does not render keeps the hash")
|
||||
assert.Equal(t, "120", after.Attributes["idle_timeout.timeout_seconds"])
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func fixedResponseListener(protocol string, port int64) *ListenerRecord {
|
||||
return &ListenerRecord{
|
||||
ListenerArn: "arn:aws:elasticloadbalancing:us-east-1:123:listener/app/my-alb/lb-abc123/lst-fwd",
|
||||
ListenerID: "lst-fwd",
|
||||
Protocol: protocol,
|
||||
Port: port,
|
||||
DefaultActions: []ListenerAction{{Type: ActionTypeFixedResponse, FixedResponse: &FixedResponseAction{StatusCode: "200"}}},
|
||||
}
|
||||
}
|
||||
|
||||
func renderForwarding(t *testing.T, attrs map[string]string, l *ListenerRecord) string {
|
||||
t.Helper()
|
||||
lb := &LoadBalancerRecord{LoadBalancerID: "lb-abc123", Name: "my-alb", Attributes: attrs}
|
||||
certs := map[string]string{certTestArn: "CERTPEM\nKEYPEM\n"}
|
||||
cfg, _, err := GenerateHAProxyConfigWithCerts(lb, []*ListenerRecord{l}, nil, nil, "0.0.0.0", certs)
|
||||
require.NoError(t, err)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestForwardedHeaders_DefaultsAppendWithoutPort(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := renderForwarding(t, nil, fixedResponseListener(ProtocolHTTP, 80))
|
||||
|
||||
assert.Contains(t, cfg, `http-request set-header X-Forwarded-For "%[var(txn.xff)], %[src]" if { var(txn.xff) -m len gt 0 }`)
|
||||
assert.Contains(t, cfg, `http-request set-header X-Forwarded-For "%[src]" unless { var(txn.xff) -m len gt 0 }`)
|
||||
assert.Contains(t, cfg, "http-request set-header X-Forwarded-Proto http\n")
|
||||
assert.Contains(t, cfg, "http-request set-header X-Forwarded-Port 80\n")
|
||||
assert.NotContains(t, cfg, "src_port")
|
||||
assert.NotContains(t, cfg, "del-header")
|
||||
}
|
||||
|
||||
func TestForwardedHeaders_ModeAndClientPort(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
attrs map[string]string
|
||||
wantAll []string
|
||||
wantAbsent []string
|
||||
}{
|
||||
{
|
||||
name: "append with client port",
|
||||
attrs: map[string]string{AttrXFFHeaderProcessing: "append", AttrXFFClientPort: "true"},
|
||||
wantAll: []string{
|
||||
`"[%[src]]:%[src_port]" if { src -m reg : }`,
|
||||
`"%[src]:%[src_port]" unless { src -m reg : }`,
|
||||
`set-header X-Forwarded-For "%[var(txn.xff)], %[var(txn.xffc)]"`,
|
||||
`set-header X-Forwarded-For "%[var(txn.xffc)]" unless`,
|
||||
},
|
||||
wantAbsent: []string{"del-header"},
|
||||
},
|
||||
{
|
||||
name: "append without client port",
|
||||
attrs: map[string]string{AttrXFFHeaderProcessing: "append", AttrXFFClientPort: "false"},
|
||||
wantAll: []string{`set-header X-Forwarded-For "%[src]" unless`},
|
||||
wantAbsent: []string{"src_port", "txn.xffc"},
|
||||
},
|
||||
{
|
||||
name: "preserve leaves the header alone",
|
||||
attrs: map[string]string{AttrXFFHeaderProcessing: "preserve"},
|
||||
wantAll: []string{"set-header X-Forwarded-Proto", "set-header X-Forwarded-Port"},
|
||||
wantAbsent: []string{"set-header X-Forwarded-For", "del-header", "txn.xff"},
|
||||
},
|
||||
{
|
||||
name: "preserve ignores client port",
|
||||
attrs: map[string]string{AttrXFFHeaderProcessing: "preserve", AttrXFFClientPort: "true"},
|
||||
wantAbsent: []string{"set-header X-Forwarded-For", "src_port"},
|
||||
},
|
||||
{
|
||||
name: "remove deletes the header",
|
||||
attrs: map[string]string{AttrXFFHeaderProcessing: "remove"},
|
||||
wantAll: []string{"http-request del-header X-Forwarded-For\n", "set-header X-Forwarded-Proto"},
|
||||
wantAbsent: []string{"set-header X-Forwarded-For", "txn.xff"},
|
||||
},
|
||||
{
|
||||
name: "unknown mode renders the default",
|
||||
attrs: map[string]string{AttrXFFHeaderProcessing: "bogus"},
|
||||
wantAll: []string{`set-header X-Forwarded-For "%[src]" unless`},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := renderForwarding(t, tt.attrs, fixedResponseListener(ProtocolHTTP, 8080))
|
||||
for _, w := range tt.wantAll {
|
||||
assert.Contains(t, cfg, w)
|
||||
}
|
||||
for _, a := range tt.wantAbsent {
|
||||
assert.NotContains(t, cfg, a)
|
||||
}
|
||||
assert.Contains(t, cfg, "http-request set-header X-Forwarded-Port 8080\n")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardedHeaders_HTTPSListenerSetsProtoAndPort(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := renderForwarding(t, nil, httpsListener())
|
||||
|
||||
assert.Contains(t, cfg, "http-request set-header X-Forwarded-Proto https\n")
|
||||
assert.Contains(t, cfg, "http-request set-header X-Forwarded-Port 443\n")
|
||||
assert.NotContains(t, cfg, "X-Forwarded-Proto http\n")
|
||||
}
|
||||
|
||||
func TestForwardedHeaders_RulesPrecedeRouting(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := renderForwarding(t, nil, fixedResponseListener(ProtocolHTTP, 80))
|
||||
|
||||
bind := strings.Index(cfg, "bind *:80")
|
||||
rule := strings.Index(cfg, "http-request set-header X-Forwarded-For")
|
||||
def := strings.Index(cfg, "default_backend")
|
||||
require.True(t, bind >= 0 && rule > bind && def > rule, "bind < header rules < default_backend")
|
||||
}
|
||||
|
||||
func TestForwardedHeaders_AppendReadsEveryLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
rules := strings.Join(forwardedHeaderRules(XFFModeAppend, false, false, 80), "\n")
|
||||
|
||||
for i := 1; i <= maxXFFLines; i++ {
|
||||
assert.Contains(t, rules, fmt.Sprintf("req.fhdr(X-Forwarded-For,%d)", i))
|
||||
}
|
||||
assert.NotContains(t, rules, "req.hdr(", "req.hdr keeps only the last comma-separated item")
|
||||
}
|
||||
|
||||
const haproxyTestImage = "haproxy:3.2-alpine"
|
||||
|
||||
// startHAProxyEcho runs haproxy with the rendered forwarding rules in front of
|
||||
// a fixed response that echoes the header the target would receive, and
|
||||
// returns the published address. It skips when the image is not present.
|
||||
func startHAProxyEcho(t *testing.T, rules []string) string {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available")
|
||||
}
|
||||
if err := exec.Command("docker", "image", "inspect", haproxyTestImage).Run(); err != nil {
|
||||
t.Skipf("%s not present locally", haproxyTestImage)
|
||||
}
|
||||
|
||||
conf := "global\n log stdout format short daemon\n" +
|
||||
"defaults\n mode http\n timeout connect 5s\n timeout client 20s\n timeout server 20s\n" +
|
||||
"frontend f\n bind *:8080\n " + strings.Join(rules, "\n ") + "\n" +
|
||||
" http-request return status 200 content-type text/plain lf-string \"%[req.fhdr(X-Forwarded-For,-1)]|%[req.fhdr(X-Forwarded-Proto)]|%[req.fhdr(X-Forwarded-Port)]\"\n"
|
||||
path := filepath.Join(t.TempDir(), "haproxy.cfg")
|
||||
require.NoError(t, os.WriteFile(path, []byte(conf), 0o644))
|
||||
|
||||
out, err := exec.Command("docker", "run", "--rm", "-v", path+":/usr/local/etc/haproxy/haproxy.cfg:ro",
|
||||
haproxyTestImage, "haproxy", "-c", "-f", "/usr/local/etc/haproxy/haproxy.cfg").CombinedOutput()
|
||||
require.NoError(t, err, "haproxy -c: %s", out)
|
||||
|
||||
id, err := exec.Command("docker", "run", "-d", "--rm", "-p", "127.0.0.1::8080",
|
||||
"-v", path+":/usr/local/etc/haproxy/haproxy.cfg:ro", haproxyTestImage).Output()
|
||||
require.NoError(t, err)
|
||||
cid := strings.TrimSpace(string(id))
|
||||
t.Cleanup(func() { _ = exec.Command("docker", "rm", "-f", cid).Run() })
|
||||
|
||||
port, err := exec.Command("docker", "port", cid, "8080/tcp").Output()
|
||||
require.NoError(t, err)
|
||||
addr := strings.TrimSpace(strings.SplitN(string(port), "\n", 2)[0])
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := http.Get("http://" + addr + "/")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return true
|
||||
}, 10*time.Second, 200*time.Millisecond)
|
||||
return addr
|
||||
}
|
||||
|
||||
func echoXFF(t *testing.T, addr string, xff ...string) (string, string, string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, "http://"+addr+"/", nil)
|
||||
require.NoError(t, err)
|
||||
for _, v := range xff {
|
||||
req.Header.Add("X-Forwarded-For", v)
|
||||
}
|
||||
req.Header.Set("X-Forwarded-Proto", "spoofed")
|
||||
req.Header.Set("X-Forwarded-Port", "1")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
parts := strings.Split(string(body), "|")
|
||||
require.Len(t, parts, 3, string(body))
|
||||
return parts[0], parts[1], parts[2]
|
||||
}
|
||||
|
||||
func TestForwardedHeaders_HAProxyAppendKeepsEveryClientValue(t *testing.T) {
|
||||
addr := startHAProxyEcho(t, forwardedHeaderRules(XFFModeAppend, false, false, 80))
|
||||
client := regexp.MustCompile(`\d+\.\d+\.\d+\.\d+$`)
|
||||
|
||||
xff, proto, port := echoXFF(t, addr)
|
||||
assert.Regexp(t, `^\d+\.\d+\.\d+\.\d+$`, xff, "no client header: only the client address")
|
||||
assert.Equal(t, "http", proto, "client X-Forwarded-Proto is overwritten")
|
||||
assert.Equal(t, "80", port, "client X-Forwarded-Port is overwritten")
|
||||
|
||||
xff, _, _ = echoXFF(t, addr, "1.1.1.1, 2.2.2.2")
|
||||
assert.True(t, strings.HasPrefix(xff, "1.1.1.1, 2.2.2.2, "), xff)
|
||||
assert.Regexp(t, client, xff)
|
||||
|
||||
xff, _, _ = echoXFF(t, addr, "1.1.1.1", "2.2.2.2, 3.3.3.3")
|
||||
assert.True(t, strings.HasPrefix(xff, "1.1.1.1, 2.2.2.2, 3.3.3.3, "), "values on earlier lines survive: %s", xff)
|
||||
}
|
||||
|
||||
func TestForwardedHeaders_HAProxyClientPortAndRemove(t *testing.T) {
|
||||
addr := startHAProxyEcho(t, forwardedHeaderRules(XFFModeAppend, true, true, 443))
|
||||
xff, proto, port := echoXFF(t, addr, "9.9.9.9")
|
||||
assert.Regexp(t, `^9\.9\.9\.9, \d+\.\d+\.\d+\.\d+:\d+$`, xff)
|
||||
assert.Equal(t, "https", proto)
|
||||
assert.Equal(t, "443", port)
|
||||
|
||||
addr = startHAProxyEcho(t, forwardedHeaderRules(XFFModeRemove, false, false, 80))
|
||||
xff, _, _ = echoXFF(t, addr, "9.9.9.9")
|
||||
assert.Empty(t, xff, "remove drops the client header")
|
||||
|
||||
addr = startHAProxyEcho(t, forwardedHeaderRules(XFFModePreserve, false, false, 80))
|
||||
xff, _, _ = echoXFF(t, addr, "9.9.9.9")
|
||||
assert.Equal(t, "9.9.9.9", xff, "preserve passes the client header unchanged")
|
||||
}
|
||||
@@ -30,9 +30,6 @@ defaults
|
||||
{{range .Frontends}}
|
||||
frontend {{.Name}}
|
||||
bind *:{{.Port}}{{if .CertPaths}} ssl{{range .CertPaths}} crt {{.}}{{end}}{{end}}
|
||||
{{- range .RequestRules}}
|
||||
{{.}}
|
||||
{{- end}}
|
||||
{{- range .Rules}}
|
||||
{{- range .ACLs}}
|
||||
acl {{.Name}} {{.Expr}}
|
||||
@@ -90,9 +87,6 @@ type HAProxyFrontend struct {
|
||||
CertPaths []string
|
||||
DefaultBackend string
|
||||
Rules []HAProxyRule
|
||||
// RequestRules are the http-request directives rendered ahead of routing,
|
||||
// carrying the X-Forwarded-* headers.
|
||||
RequestRules []string
|
||||
}
|
||||
|
||||
// HAProxyRule represents one routing rule as ACL declarations + a use_backend
|
||||
@@ -290,12 +284,6 @@ func buildHAProxyConfig(lb *LoadBalancerRecord, listeners []*ListenerRecord, tgB
|
||||
Port: l.Port,
|
||||
Protocol: l.Protocol,
|
||||
DefaultBackend: defaultBackendName,
|
||||
RequestRules: forwardedHeaderRules(
|
||||
lb.Attributes[AttrXFFHeaderProcessing],
|
||||
lb.Attributes[AttrXFFClientPort] == "true",
|
||||
protocolRequiresCert(l.Protocol),
|
||||
l.Port,
|
||||
),
|
||||
}
|
||||
|
||||
// Resolve every attached cert and stage each for delivery to the agent.
|
||||
|
||||
@@ -34,10 +34,6 @@ type attrResource[R any] struct {
|
||||
defaults func(*R) map[string]string
|
||||
attrsOf func(*R) map[string]string
|
||||
setAttrs func(*R, map[string]string)
|
||||
// validate rejects a bad value for a known key before anything is stored.
|
||||
validate func(key, value string) error
|
||||
// afterChange runs once the changed keys are persisted; its error fails the call.
|
||||
afterChange func(rec *R, changedKeys []string) error
|
||||
}
|
||||
|
||||
// load fetches and authorises the record. An empty accountID is rejected
|
||||
@@ -70,22 +66,12 @@ func modifyResourceAttributes[R any](res attrResource[R], pairs []attrPair, rawC
|
||||
known := res.defaults(rec)
|
||||
attrs := res.attrsOf(rec)
|
||||
submitted := make([]attrPair, 0, len(pairs))
|
||||
var changed []string
|
||||
dirty := false
|
||||
for _, p := range pairs {
|
||||
if _, ok := known[p.Key]; !ok {
|
||||
slog.Warn(res.opName+": rejecting unknown attribute key", "arn", res.arn, "key", p.Key)
|
||||
return nil, errors.New(awserrors.ErrorValidationError)
|
||||
}
|
||||
if res.validate != nil {
|
||||
if err := res.validate(p.Key, p.Value); err != nil {
|
||||
slog.Warn(res.opName+": rejecting attribute value", "arn", res.arn, "key", p.Key)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
// The merge below mutates the record's own map, so it starts only once every
|
||||
// pair has passed validation.
|
||||
for _, p := range pairs {
|
||||
submitted = append(submitted, p)
|
||||
if existing, ok := attrs[p.Key]; ok && existing == p.Value {
|
||||
continue // value already matches stored — no mutation needed
|
||||
@@ -94,7 +80,7 @@ func modifyResourceAttributes[R any](res attrResource[R], pairs []attrPair, rawC
|
||||
attrs = make(map[string]string)
|
||||
}
|
||||
attrs[p.Key] = p.Value
|
||||
changed = append(changed, p.Key)
|
||||
dirty = true
|
||||
}
|
||||
|
||||
// Caller sent attributes but every one was dropped by nil filtering: surface
|
||||
@@ -108,17 +94,12 @@ func modifyResourceAttributes[R any](res attrResource[R], pairs []attrPair, rawC
|
||||
// Skip the NATS/KV write when nothing changed. Terraform re-applies the same
|
||||
// attribute set on every drift check, so this kills ~all Modify traffic
|
||||
// during steady state and narrows the read-modify-write race window.
|
||||
if len(changed) > 0 {
|
||||
if dirty {
|
||||
res.setAttrs(rec, attrs)
|
||||
if err := res.save(rec); err != nil {
|
||||
slog.Error(res.opName+": failed to persist", "arn", res.arn, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if res.afterChange != nil {
|
||||
if err := res.afterChange(rec, changed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return submitted, nil
|
||||
@@ -222,54 +203,9 @@ func (s *ELBv2ServiceImpl) lbAttrResource(ctx context.Context, arn, accountID, o
|
||||
defaults: func(r *LoadBalancerRecord) map[string]string { return DefaultLoadBalancerAttributes(r.Type) },
|
||||
attrsOf: func(r *LoadBalancerRecord) map[string]string { return r.Attributes },
|
||||
setAttrs: func(r *LoadBalancerRecord, m map[string]string) { r.Attributes = m },
|
||||
validate: validateLBAttribute,
|
||||
afterChange: func(rec *LoadBalancerRecord, changed []string) error {
|
||||
return s.reRenderForAttributes(ctx, rec.LoadBalancerID, changed)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// validateLBAttribute checks the values of the load balancer attributes that
|
||||
// shape the rendered configuration; other keys are stored as submitted.
|
||||
func validateLBAttribute(key, value string) error {
|
||||
switch key {
|
||||
case AttrXFFHeaderProcessing:
|
||||
switch value {
|
||||
case XFFModeAppend, XFFModePreserve, XFFModeRemove:
|
||||
return nil
|
||||
}
|
||||
return awserrors.Errorf(awserrors.ErrorValidationError,
|
||||
"Invalid value '%s' for attribute '%s': must be one of append, preserve, remove", value, key)
|
||||
case AttrXFFClientPort:
|
||||
if value == "true" || value == "false" {
|
||||
return nil
|
||||
}
|
||||
return awserrors.Errorf(awserrors.ErrorValidationError,
|
||||
"Invalid value '%s' for attribute '%s': must be true or false", value, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reRenderForAttributes regenerates the stored data-plane config when a changed
|
||||
// attribute is one the config renders, so the change reaches the load balancer.
|
||||
func (s *ELBv2ServiceImpl) reRenderForAttributes(ctx context.Context, lbID string, changed []string) error {
|
||||
if !slices.ContainsFunc(changed, func(k string) bool {
|
||||
return k == AttrXFFHeaderProcessing || k == AttrXFFClientPort
|
||||
}) {
|
||||
return nil
|
||||
}
|
||||
lb, err := s.store.GetLoadBalancer(ctx, lbID)
|
||||
if err != nil || lb == nil {
|
||||
slog.ErrorContext(ctx, "ModifyLoadBalancerAttributes: failed to re-read load balancer", "lbId", lbID, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if err := s.updateStoredConfig(ctx, lb); err != nil {
|
||||
slog.ErrorContext(ctx, "ModifyLoadBalancerAttributes: failed to update config", "lbId", lbID, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tgAttrKeyVal(a *elbv2.TargetGroupAttribute) (*string, *string) {
|
||||
if a == nil {
|
||||
return nil, nil
|
||||
|
||||
@@ -46,10 +46,6 @@ func (l *natsSystemInstanceLauncher) LaunchSystemInstance(input *SystemInstanceI
|
||||
if input.InstanceType == "" {
|
||||
return nil, fmt.Errorf("system instance input missing InstanceType")
|
||||
}
|
||||
// An older receiver would ignore the kind and boot a load balancer.
|
||||
if err := input.RefuseNonLoadBalancerKind(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/systeminstance"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -270,44 +269,3 @@ func TestNATSSystemInstanceLauncher_TerminatePropagatesRemoteError(t *testing.T)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestNATSSystemInstanceLauncher_RefusesNonLoadBalancerKindBeforeSending(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
defer nc.Close()
|
||||
|
||||
// A responder on the legacy subject stands in for an older daemon that
|
||||
// would ignore the kind and boot a load balancer.
|
||||
var mu sync.Mutex
|
||||
received := 0
|
||||
sub, err := nc.Subscribe("system.LaunchInstance.sys.micro", func(msg *nats.Msg) {
|
||||
mu.Lock()
|
||||
received++
|
||||
mu.Unlock()
|
||||
payload, _ := json.Marshal(struct {
|
||||
Output *SystemInstanceOutput `json:"output,omitempty"`
|
||||
}{Output: &SystemInstanceOutput{InstanceID: "i-0123456789abcdef0"}})
|
||||
_ = msg.Respond(payload)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
launcher := NewNATSSystemInstanceLauncher(nc, 2*time.Second)
|
||||
for name, input := range map[string]*SystemInstanceInput{
|
||||
"kind": {InstanceType: "sys.micro", Kind: systeminstance.KindSQSStore},
|
||||
"sqs store input": {InstanceType: "sys.micro", SQSStore: &systeminstance.SQSStoreInput{DataVolumeID: "vol-0123456789abcdef0"}},
|
||||
} {
|
||||
out, err := launcher.LaunchSystemInstance(input)
|
||||
require.Error(t, err, name)
|
||||
assert.Nil(t, out, name)
|
||||
}
|
||||
|
||||
// An ownerless load balancer launch still reaches the responder, so the
|
||||
// zero count above is the refusal, not a missing subscriber.
|
||||
out, err := launcher.LaunchSystemInstance(&SystemInstanceInput{InstanceType: "sys.micro"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
assert.Equal(t, 1, received, "only the load balancer launch may be sent")
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ServiceName is the SigV4 credential-scope service RDS clients sign for.
|
||||
const ServiceName = "rds"
|
||||
|
||||
// Quota is the account DB-instance cap a create consults.
|
||||
type Quota interface {
|
||||
EnforceRDSInstances(ctx context.Context, nc *nats.Conn, accountID string, want int) error
|
||||
}
|
||||
|
||||
// Deps are the composed capabilities the RDS control plane dispatches to. A
|
||||
// nil NATS connection answers every action with ServerInternal and a nil Quota
|
||||
// skips the DB-instance cap.
|
||||
type Deps struct {
|
||||
NATS *nats.Conn
|
||||
ExpectedNodes int
|
||||
Quota Quota
|
||||
}
|
||||
|
||||
// NewRegistration returns the RDS AWS Query control-plane registration.
|
||||
func NewRegistration(deps Deps) dispatch.Registration {
|
||||
return dispatch.Registration{
|
||||
Service: ServiceName,
|
||||
ResolveAction: resolveAction,
|
||||
Dispatch: deps.dispatch,
|
||||
Errors: dispatch.ErrorEnvelopeXML,
|
||||
Inventory: OperationInventory(),
|
||||
}
|
||||
}
|
||||
|
||||
// OperationInventory classifies the actions the registration serves. The
|
||||
// recognised-but-unavailable ones are Unsupported, so they are not implemented.
|
||||
func OperationInventory() dispatch.Inventory {
|
||||
return dispatch.Inventory{
|
||||
Registered: ActionNames(),
|
||||
Unsupported: UnsupportedActionNames(),
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAction is the one Query Action parser telemetry and dispatch share.
|
||||
// It leaves the body readable for the dispatcher and answers "" on any failure.
|
||||
func resolveAction(r *http.Request) string {
|
||||
args, err := readQueryArgs(r)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return args["Action"]
|
||||
}
|
||||
|
||||
// readQueryArgs parses the Query body and rewinds it, so the same bytes can be
|
||||
// read again. Any read or parse failure is a malformed query string.
|
||||
func readQueryArgs(r *http.Request) (map[string]string, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, sigv4.MaxPayloadLen+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if int64(len(body)) > sigv4.MaxPayloadLen {
|
||||
return nil, errors.New(awserrors.ErrorRequestEntityTooLarge)
|
||||
}
|
||||
return query.ParseArgs(string(body))
|
||||
}
|
||||
|
||||
// CallerFromActor projects the verified actor onto the RDS caller. The role name
|
||||
// comes from the underlying role ARN, never the session name, which the caller
|
||||
// picks at AssumeRole time and could name its way past the agent gate.
|
||||
func CallerFromActor(actor dispatch.Actor) Caller {
|
||||
name := actor.UserName
|
||||
if actor.PrincipalType == principalTypeAssumedRole {
|
||||
name = actor.SessionName
|
||||
}
|
||||
return Caller{
|
||||
AccountID: actor.AccountID,
|
||||
PrincipalType: actor.PrincipalType,
|
||||
SessionName: name,
|
||||
RoleName: actor.RoleName(),
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch parses, scopes and authorizes the request, then runs the action.
|
||||
// The principal-class gate runs before the policy check so no customer grant is
|
||||
// ever evaluated against an internal agent action. Errors are awserrors codes
|
||||
// the gateway renders.
|
||||
func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
r := inv.Request
|
||||
queryArgs, err := readQueryArgs(r)
|
||||
if err != nil {
|
||||
slog.DebugContext(r.Context(), "RDS: malformed query string", "err", err)
|
||||
return errors.New(awserrors.ErrorMalformedQueryString)
|
||||
}
|
||||
|
||||
action := queryArgs["Action"]
|
||||
if action == "" {
|
||||
return errors.New(awserrors.ErrorMissingAction)
|
||||
}
|
||||
// Resolved before the policy check, so an unrecognised action is rejected as
|
||||
// InvalidAction rather than evaluated as an rds:<garbage> permission.
|
||||
if !HasAction(action) {
|
||||
slog.DebugContext(r.Context(), "RDS: unknown action", "action", action)
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
if inv.AccountID == "" {
|
||||
slog.ErrorContext(r.Context(), "RDS dispatch: no account ID in auth context")
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
// An error means no caller ARN could be composed; the actor is then empty,
|
||||
// which no internal action admits and the policy check below refuses.
|
||||
actor, _ := inv.Actor()
|
||||
caller := CallerFromActor(actor)
|
||||
caller.AccountID = inv.AccountID
|
||||
|
||||
if err := AuthorizeCaller(r.Context(), action, caller); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// inv.Region is the gateway's own region, never the caller-supplied
|
||||
// credential-scope region, which would let a caller sign for another region
|
||||
// and slide out from under a Deny.
|
||||
resources, err := ResourceARN(action, inv.Region, caller.AccountID, queryArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.Authorize(ServiceName, action, resources, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.NATS == nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
env := Env{ExpectedNodes: d.ExpectedNodes}
|
||||
if d.Quota != nil {
|
||||
env.QuotaCheck = func(ctx context.Context, accountID string, want int) error {
|
||||
return d.Quota.EnforceRDSInstances(ctx, d.NATS, accountID, want)
|
||||
}
|
||||
}
|
||||
xmlOutput, err := Dispatch(r.Context(), action, queryArgs, d.NATS, caller, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(xmlOutput); err != nil {
|
||||
slog.ErrorContext(r.Context(), "Failed to write RDS response", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
rdsinstance "github.com/mulgadc/spinifex/spinifex/domains/rds/instance"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func invocation(body, accountID string, actor dispatch.Actor, actorErr error, authorize dispatch.AuthorizeFunc) dispatch.Invocation {
|
||||
if authorize == nil {
|
||||
authorize = func(string, string, []string, iampolicy.ConditionKeys) error { return nil }
|
||||
}
|
||||
return dispatch.Invocation{
|
||||
Request: httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)),
|
||||
AccountID: accountID,
|
||||
Region: "ap-southeast-2",
|
||||
Authorize: authorize,
|
||||
Actor: func() (dispatch.Actor, error) { return actor, actorErr },
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallerFromActor_NameFollowsPrincipalType(t *testing.T) {
|
||||
roleARN := "arn:aws:iam::" + awsidentifiers.GlobalAccountID + ":role/" + rdsinstance.InstanceRoleName
|
||||
session := dispatch.Actor{
|
||||
AccountID: awsidentifiers.GlobalAccountID, PrincipalType: principalTypeAssumedRole,
|
||||
PrincipalARN: roleARN, SessionName: "i-abc", UserName: "ignored",
|
||||
}
|
||||
got := CallerFromActor(session)
|
||||
assert.Equal(t, "i-abc", got.SessionName)
|
||||
assert.Equal(t, rdsinstance.InstanceRoleName, got.RoleName)
|
||||
|
||||
user := dispatch.Actor{AccountID: "000000000002", PrincipalType: "user", UserName: "alice", SessionName: "ignored"}
|
||||
got = CallerFromActor(user)
|
||||
assert.Equal(t, "alice", got.SessionName)
|
||||
assert.Empty(t, got.RoleName, "a user has no role name")
|
||||
}
|
||||
|
||||
func TestCallerFromActor_RoleNameNeverFromSessionName(t *testing.T) {
|
||||
// The caller picks its own session name at AssumeRole time; naming it after
|
||||
// the agent role must not make it the agent role.
|
||||
got := CallerFromActor(dispatch.Actor{
|
||||
AccountID: "000000000002", PrincipalType: principalTypeAssumedRole,
|
||||
PrincipalARN: "arn:aws:iam::000000000002:role/tenant", SessionName: rdsinstance.InstanceRoleName,
|
||||
})
|
||||
assert.Equal(t, "tenant", got.RoleName)
|
||||
assert.Error(t, requireAgentPrincipal(context.Background(), got))
|
||||
}
|
||||
|
||||
func TestDispatch_RequestShapeErrors(t *testing.T) {
|
||||
actor := dispatch.Actor{AccountID: "000000000002", PrincipalType: "user", UserName: "alice"}
|
||||
tests := []struct {
|
||||
name, body, account, code string
|
||||
}{
|
||||
{"malformed query", "Action=%zz", "000000000002", awserrors.ErrorMalformedQueryString},
|
||||
{"missing action", "", "000000000002", awserrors.ErrorMissingAction},
|
||||
{"unknown action", "Action=Nope", "000000000002", awserrors.ErrorInvalidAction},
|
||||
{"no account", "Action=DescribeDBInstances", "", awserrors.ErrorServerInternal},
|
||||
{"no NATS", "Action=DescribeDBInstances", "000000000002", awserrors.ErrorServerInternal},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Deps{}.dispatch(httptest.NewRecorder(), invocation(tt.body, tt.account, actor, nil, nil))
|
||||
code, _, _ := awserrors.ResolveErrorDetail(err)
|
||||
assert.Equal(t, tt.code, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_InternalActionRefusedBeforePolicy(t *testing.T) {
|
||||
consulted := false
|
||||
authorize := func(string, string, []string, iampolicy.ConditionKeys) error { consulted = true; return nil }
|
||||
actor := dispatch.Actor{AccountID: "000000000002", PrincipalType: "user", UserName: "alice"}
|
||||
err := Deps{}.dispatch(httptest.NewRecorder(),
|
||||
invocation("Action=GetDBBootstrapConfig", "000000000002", actor, nil, authorize))
|
||||
code, _, _ := awserrors.ResolveErrorDetail(err)
|
||||
assert.Equal(t, awserrors.ErrorAccessDenied, code)
|
||||
assert.False(t, consulted, "no customer grant is evaluated against an internal action")
|
||||
}
|
||||
|
||||
func TestDispatch_PolicyDenialStopsBeforeHandler(t *testing.T) {
|
||||
deny := func(string, string, []string, iampolicy.ConditionKeys) error {
|
||||
return errors.New(awserrors.ErrorAccessDenied)
|
||||
}
|
||||
// An actor error leaves the actor empty; the policy check still decides.
|
||||
err := Deps{}.dispatch(httptest.NewRecorder(),
|
||||
invocation("Action=DescribeDBInstances", "000000000002", dispatch.Actor{}, errors.New("no arn"), deny))
|
||||
code, _, _ := awserrors.ResolveErrorDetail(err)
|
||||
assert.Equal(t, awserrors.ErrorAccessDenied, code)
|
||||
}
|
||||
|
||||
func TestOperationInventory_SplitsUnsupported(t *testing.T) {
|
||||
inv := OperationInventory()
|
||||
assert.Contains(t, inv.Registered, "CreateDBInstance")
|
||||
assert.Contains(t, inv.Unsupported, "CreateDBCluster")
|
||||
assert.NotContains(t, inv.Unsupported, "CreateDBInstance")
|
||||
require.NoError(t, dispatch.NewBuilder().Register(NewRegistration(Deps{})))
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/private/protocol/xml/xmlutil"
|
||||
"github.com/aws/aws-sdk-go/service/route53"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsxml "github.com/mulgadc/spinifex/spinifex/foundation/aws/xml"
|
||||
)
|
||||
@@ -49,30 +48,9 @@ func bindInput(input any, path map[string]string, query url.Values, body []byte)
|
||||
if err := xmlutil.UnmarshalXML(input, xml.NewDecoder(bytes.NewReader(body)), ""); err != nil {
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidInput, "the request body is not valid XML: %v", err)
|
||||
}
|
||||
if in, ok := input.(*route53.ChangeTagsForResourceInput); ok {
|
||||
keepEmptyTagLists(in, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// keepEmptyTagLists restores an <AddTags/> or <RemoveTagKeys/> sent with no
|
||||
// entries, which the SDK unmarshaler drops, so the service can refuse it.
|
||||
func keepEmptyTagLists(in *route53.ChangeTagsForResourceInput, body []byte) {
|
||||
var sent struct {
|
||||
AddTags *struct{} `xml:"AddTags"`
|
||||
RemoveTagKeys *struct{} `xml:"RemoveTagKeys"`
|
||||
}
|
||||
if xml.Unmarshal(body, &sent) != nil {
|
||||
return
|
||||
}
|
||||
if sent.AddTags != nil && in.AddTags == nil {
|
||||
in.AddTags = []*route53.Tag{}
|
||||
}
|
||||
if sent.RemoveTagKeys != nil && in.RemoveTagKeys == nil {
|
||||
in.RemoveTagKeys = []*string{}
|
||||
}
|
||||
}
|
||||
|
||||
// writeXML renders output under a <ActionResponse> root in the Route 53
|
||||
// namespace, plus the Location header CreateHostedZone carries.
|
||||
func writeXML(w http.ResponseWriter, r *http.Request, action string, status int, output any) error {
|
||||
|
||||
@@ -322,17 +322,3 @@ func TestExtensionsRoundTripThroughTheSDK(t *testing.T) {
|
||||
&ext.GetHostedZoneActivationInput{Id: aws.String("ZNOPE")}, &ext.GetHostedZoneActivationOutput{})
|
||||
requireAWSCode(t, missing.Send(), "NoSuchHostedZone")
|
||||
}
|
||||
|
||||
func TestEmptyTagListsAreRefused(t *testing.T) {
|
||||
client, _ := newClient(t)
|
||||
zone, err := client.CreateHostedZone(&r53.CreateHostedZoneInput{Name: aws.String("example.com"), CallerReference: aws.String("tags")})
|
||||
require.NoError(t, err)
|
||||
for _, list := range []string{"AddTags", "RemoveTagKeys"} {
|
||||
req, _ := client.ChangeTagsForResourceRequest(&r53.ChangeTagsForResourceInput{ResourceType: aws.String("hostedzone"), ResourceId: zone.HostedZone.Id})
|
||||
// The SDK omits an empty list, so the body is written by hand.
|
||||
req.Handlers.Build.PushBack(func(r *request.Request) {
|
||||
r.SetStringBody(`<ChangeTagsForResourceRequest xmlns="` + awsapi.XMLNamespace + `"><` + list + `/></ChangeTagsForResourceRequest>`)
|
||||
})
|
||||
requireAWSCode(t, req.Send(), "InvalidInput")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package route53
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -65,9 +64,6 @@ func (s *Service) CreateHostedZone(ctx context.Context, in *route53.CreateHosted
|
||||
if ref == "" || len(ref) > maxCallerReferenceLength {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidInput, "CallerReference must be 1 to %d characters", maxCallerReferenceLength)
|
||||
}
|
||||
if err := cmp.Or(requireMember("name", in.Name), checkLength("name", in.Name, maxDNSNameLength)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.VPC != nil || (in.HostedZoneConfig != nil && aws.BoolValue(in.HostedZoneConfig.PrivateZone)) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidInput, "private hosted zones are not supported")
|
||||
}
|
||||
@@ -149,7 +145,7 @@ func (s *Service) CreateHostedZone(ctx context.Context, in *route53.CreateHosted
|
||||
}
|
||||
|
||||
func (s *Service) GetHostedZone(ctx context.Context, in *route53.GetHostedZoneInput, account string) (*route53.GetHostedZoneOutput, error) {
|
||||
zone, doc, _, err := s.zoneWithSets(ctx, account, "id", in.Id)
|
||||
zone, doc, _, err := s.zoneWithSets(ctx, account, aws.StringValue(in.Id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -169,9 +165,6 @@ func (s *Service) ListHostedZones(ctx context.Context, in *route53.ListHostedZon
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cmp.Or(checkEnum("hostedZoneType", in.HostedZoneType, route53.HostedZoneType_Values()), checkLength("marker", in.Marker, maxPageMarkerLength)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.DelegationSetId != nil {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidInput, "reusable delegation sets are not supported")
|
||||
}
|
||||
@@ -211,11 +204,8 @@ func (s *Service) ListHostedZonesByName(ctx context.Context, in *route53.ListHos
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startID := BareZoneID(aws.StringValue(in.HostedZoneId))
|
||||
if err := cmp.Or(checkLength("dnsName", in.DNSName, maxDNSNameLength), checkLength("hostedZoneId", &startID, maxResourceIDLength)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dnsName := normalizeName(aws.StringValue(in.DNSName))
|
||||
startID := BareZoneID(aws.StringValue(in.HostedZoneId))
|
||||
if startID != "" && dnsName == "" {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidInput, "HostedZoneId requires DNSName")
|
||||
}
|
||||
@@ -252,11 +242,8 @@ func (s *Service) UpdateHostedZoneComment(ctx context.Context, in *route53.Updat
|
||||
if len(comment) > maxCommentLength {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidInput, "Comment must be at most %d characters", maxCommentLength)
|
||||
}
|
||||
id, err := hostedZoneID("id", in.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = s.store.MutateZone(ctx, account, id, func(z *ZoneRecord) (bool, error) {
|
||||
id := BareZoneID(aws.StringValue(in.Id))
|
||||
err := s.store.MutateZone(ctx, account, id, func(z *ZoneRecord) (bool, error) {
|
||||
if z.Comment == comment {
|
||||
return false, nil
|
||||
}
|
||||
@@ -270,7 +257,7 @@ func (s *Service) UpdateHostedZoneComment(ctx context.Context, in *route53.Updat
|
||||
}
|
||||
|
||||
func (s *Service) updatedZone(ctx context.Context, account, id string) (*route53.UpdateHostedZoneCommentOutput, error) {
|
||||
zone, doc, _, err := s.zoneWithSets(ctx, account, "id", &id)
|
||||
zone, doc, _, err := s.zoneWithSets(ctx, account, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -278,7 +265,7 @@ func (s *Service) updatedZone(ctx context.Context, account, id string) (*route53
|
||||
}
|
||||
|
||||
func (s *Service) DeleteHostedZone(ctx context.Context, in *route53.DeleteHostedZoneInput, account string) (*route53.DeleteHostedZoneOutput, error) {
|
||||
zone, doc, rev, err := s.zoneWithSets(ctx, account, "id", in.Id)
|
||||
zone, doc, rev, err := s.zoneWithSets(ctx, account, aws.StringValue(in.Id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -309,11 +296,8 @@ func (s *Service) ChangeResourceRecordSets(ctx context.Context, in *route53.Chan
|
||||
if len(comment) > maxCommentLength {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidInput, "Comment must be at most %d characters", maxCommentLength)
|
||||
}
|
||||
if err := checkChangeBatchShape(in.ChangeBatch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for range commitAttempts {
|
||||
zone, doc, rev, err := s.zoneWithSets(ctx, account, "hostedZoneId", in.HostedZoneId)
|
||||
zone, doc, rev, err := s.zoneWithSets(ctx, account, aws.StringValue(in.HostedZoneId))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -345,15 +329,12 @@ func (s *Service) ListResourceRecordSets(ctx context.Context, in *route53.ListRe
|
||||
if in.StartRecordIdentifier != nil {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidInput, "StartRecordIdentifier is not supported: no record set has a SetIdentifier")
|
||||
}
|
||||
if err := cmp.Or(checkLength("startRecordName", in.StartRecordName, maxDNSNameLength), checkEnum("startRecordType", in.StartRecordType, rrTypes)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startType := aws.StringValue(in.StartRecordType)
|
||||
startType := strings.ToUpper(aws.StringValue(in.StartRecordType))
|
||||
startName := normalizeName(aws.StringValue(in.StartRecordName))
|
||||
if startType != "" && startName == "" {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidInput, "The input is not valid: StartRecordType requires StartRecordName")
|
||||
}
|
||||
_, doc, _, err := s.zoneWithSets(ctx, account, "hostedZoneId", in.HostedZoneId)
|
||||
_, doc, _, err := s.zoneWithSets(ctx, account, aws.StringValue(in.HostedZoneId))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -470,10 +451,10 @@ func (s *Service) ListTagsForResources(ctx context.Context, in *route53.ListTags
|
||||
}
|
||||
|
||||
// zoneWithSets loads an account's zone and its record-set document.
|
||||
func (s *Service) zoneWithSets(ctx context.Context, account, field string, rawID *string) (*ZoneRecord, *RRSetsDoc, uint64, error) {
|
||||
id, err := hostedZoneID(field, rawID)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
func (s *Service) zoneWithSets(ctx context.Context, account, rawID string) (*ZoneRecord, *RRSetsDoc, uint64, error) {
|
||||
id := BareZoneID(rawID)
|
||||
if id == "" {
|
||||
return nil, nil, 0, awserrors.Errorf(awserrors.ErrorInvalidInput, "a hosted zone ID is required")
|
||||
}
|
||||
zone, err := s.store.GetZone(ctx, account, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -253,6 +253,7 @@ func TestChangeBatchValidation(t *testing.T) {
|
||||
{"bad IPv6", change("CREATE", "v6.example.com", "AAAA", 60, "192.0.2.1"), awserrors.ErrorInvalidChangeBatch, "not a valid IPv6 address"},
|
||||
{"bad MX", change("CREATE", "example.com", "MX", 60, "mail.example.com"), awserrors.ErrorInvalidChangeBatch, "priority host"},
|
||||
{"two CNAME values", change("CREATE", "c.example.com", "CNAME", 60, "a.example.net", "b.example.net"), awserrors.ErrorInvalidChangeBatch, "more than one value"},
|
||||
{"bad action", change("REPLACE", "r.example.com", "A", 60, "192.0.2.1"), awserrors.ErrorInvalidChangeBatch, "Invalid action"},
|
||||
{"alias", alias, awserrors.ErrorInvalidInput, "alias"},
|
||||
{"weighted", weighted, awserrors.ErrorInvalidInput, "simple routing"},
|
||||
{"unsupported type", change("CREATE", "ds.example.com", "DS", 60, "1 2 3 abc"), awserrors.ErrorInvalidInput, "not supported"},
|
||||
@@ -551,7 +552,7 @@ func TestLookupsOfUnissuableIDsNameNothing(t *testing.T) {
|
||||
// connection every later request shares.
|
||||
_, err := svc.GetChange(ctx, &r53.GetChangeInput{Id: aws.String(strings.Repeat("C", 6500))}, acctA)
|
||||
requireCode(t, err, awserrors.ErrorNoSuchChange)
|
||||
for _, bad := range []string{"Z1.*", "Z1 >"} {
|
||||
for _, bad := range []string{strings.Repeat("Z", 65), "Z1.*", "Z1 >"} {
|
||||
_, err = svc.GetHostedZone(ctx, &r53.GetHostedZoneInput{Id: aws.String(bad)}, acctA)
|
||||
requireCode(t, err, awserrors.ErrorNoSuchHostedZone)
|
||||
}
|
||||
@@ -559,106 +560,3 @@ func TestLookupsOfUnissuableIDsNameNothing(t *testing.T) {
|
||||
_, err = svc.GetHostedZone(ctx, &r53.GetHostedZoneInput{Id: aws.String(id)}, acctA)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// AWS refuses a value outside the model's bounds with InvalidInput before it
|
||||
// looks anything up, whatever the operation's semantic errors would be.
|
||||
func TestModelBoundViolationsAreInvalidInput(t *testing.T) {
|
||||
svc := newService(t)
|
||||
id := createZone(t, svc, acctA, "example.com")
|
||||
longID := aws.String(strings.Repeat("Z", 33))
|
||||
longName := strings.Repeat("a", 1025)
|
||||
without := func(edit func(c *r53.Change)) *r53.Change {
|
||||
c := change("CREATE", "x.example.com", "TXT", 60, `"v"`)
|
||||
edit(c)
|
||||
return c
|
||||
}
|
||||
|
||||
cases := map[string]func() error{
|
||||
"zone type outside enum": func() error {
|
||||
_, err := svc.ListHostedZones(ctx, &r53.ListHostedZonesInput{HostedZoneType: aws.String("PublicHostedZone")}, acctA)
|
||||
return err
|
||||
},
|
||||
"marker over 64": func() error {
|
||||
_, err := svc.ListHostedZones(ctx, &r53.ListHostedZonesInput{Marker: aws.String(strings.Repeat("Z", 65))}, acctA)
|
||||
return err
|
||||
},
|
||||
"by-name DNSName over 1024": func() error {
|
||||
_, err := svc.ListHostedZonesByName(ctx, &r53.ListHostedZonesByNameInput{DNSName: aws.String(longName)}, acctA)
|
||||
return err
|
||||
},
|
||||
"by-name zone ID over 32": func() error {
|
||||
_, err := svc.ListHostedZonesByName(ctx, &r53.ListHostedZonesByNameInput{DNSName: aws.String("example.com"), HostedZoneId: longID}, acctA)
|
||||
return err
|
||||
},
|
||||
"start name over 1024": func() error {
|
||||
_, err := svc.ListResourceRecordSets(ctx, &r53.ListResourceRecordSetsInput{HostedZoneId: aws.String(id), StartRecordName: aws.String(longName)}, acctA)
|
||||
return err
|
||||
},
|
||||
"start type outside enum": func() error {
|
||||
_, err := svc.ListResourceRecordSets(ctx, &r53.ListResourceRecordSetsInput{HostedZoneId: aws.String(id), StartRecordName: aws.String("example.com"), StartRecordType: aws.String("BOGUS")}, acctA)
|
||||
return err
|
||||
},
|
||||
"GetHostedZone ID over 32": func() error {
|
||||
_, err := svc.GetHostedZone(ctx, &r53.GetHostedZoneInput{Id: longID}, acctA)
|
||||
return err
|
||||
},
|
||||
"DeleteHostedZone ID over 32": func() error {
|
||||
_, err := svc.DeleteHostedZone(ctx, &r53.DeleteHostedZoneInput{Id: longID}, acctA)
|
||||
return err
|
||||
},
|
||||
"UpdateHostedZoneComment ID over 32": func() error {
|
||||
_, err := svc.UpdateHostedZoneComment(ctx, &r53.UpdateHostedZoneCommentInput{Id: longID, Comment: aws.String("c")}, acctA)
|
||||
return err
|
||||
},
|
||||
"ListResourceRecordSets ID over 32": func() error {
|
||||
_, err := svc.ListResourceRecordSets(ctx, &r53.ListResourceRecordSetsInput{HostedZoneId: longID}, acctA)
|
||||
return err
|
||||
},
|
||||
"ChangeResourceRecordSets ID over 32": func() error {
|
||||
_, err := apply(svc, acctA, *longID, change("CREATE", "x.example.com", "A", 60, "192.0.2.1"))
|
||||
return err
|
||||
},
|
||||
"action outside enum": func() error {
|
||||
_, err := apply(svc, acctA, id, change("REPLACE", "x.example.com", "A", 60, "192.0.2.1"))
|
||||
return err
|
||||
},
|
||||
"action missing": func() error {
|
||||
_, err := apply(svc, acctA, id, without(func(c *r53.Change) { c.Action = nil }))
|
||||
return err
|
||||
},
|
||||
"record name over 1024": func() error {
|
||||
_, err := apply(svc, acctA, id, without(func(c *r53.Change) { c.ResourceRecordSet.Name = aws.String(longName) }))
|
||||
return err
|
||||
},
|
||||
"record name missing": func() error {
|
||||
_, err := apply(svc, acctA, id, without(func(c *r53.Change) { c.ResourceRecordSet.Name = nil }))
|
||||
return err
|
||||
},
|
||||
"value over 4000": func() error {
|
||||
_, err := apply(svc, acctA, id, without(func(c *r53.Change) {
|
||||
c.ResourceRecordSet.ResourceRecords[0].Value = aws.String(strings.Repeat("v", 4001))
|
||||
}))
|
||||
return err
|
||||
},
|
||||
"value missing": func() error {
|
||||
_, err := apply(svc, acctA, id, without(func(c *r53.Change) { c.ResourceRecordSet.ResourceRecords[0].Value = nil }))
|
||||
return err
|
||||
},
|
||||
"zone name over 1024": func() error {
|
||||
_, err := svc.CreateHostedZone(ctx, &r53.CreateHostedZoneInput{Name: aws.String(longName), CallerReference: aws.String("long")}, acctA)
|
||||
return err
|
||||
},
|
||||
"zone name missing": func() error {
|
||||
_, err := svc.CreateHostedZone(ctx, &r53.CreateHostedZoneInput{CallerReference: aws.String("none")}, acctA)
|
||||
return err
|
||||
},
|
||||
}
|
||||
for name, call := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert.Contains(t, requireCode(t, call(), awserrors.ErrorInvalidInput), "failed to satisfy constraint")
|
||||
})
|
||||
}
|
||||
|
||||
_, err := svc.ListResourceRecordSets(ctx, &r53.ListResourceRecordSetsInput{HostedZoneId: aws.String(id), StartRecordName: aws.String("example.com"), StartRecordType: aws.String("HTTPS")}, acctA)
|
||||
require.NoError(t, err, "the model's RRType includes types the v1 SDK predates")
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user