mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-30 16:06:22 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| caf6c438d2 |
@@ -223,27 +223,5 @@
|
||||
"runner": "ci-single",
|
||||
"extra": "nat-single",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 37,
|
||||
"install": "binary",
|
||||
"topo": "real-multi",
|
||||
"net": "dhcp",
|
||||
"host": "oracle-10",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-multi",
|
||||
"extra": "",
|
||||
"suite": ""
|
||||
},
|
||||
{
|
||||
"cell": 38,
|
||||
"install": "binary",
|
||||
"topo": "real-multi",
|
||||
"net": "nat",
|
||||
"host": "oracle-10",
|
||||
"arch": "x86_64",
|
||||
"runner": "ci-multi",
|
||||
"extra": "nat-multi",
|
||||
"suite": ""
|
||||
}
|
||||
]
|
||||
|
||||
@@ -102,9 +102,9 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot + SQS store and worker images)
|
||||
- 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 build-sqs-worker-microvm-image
|
||||
run: cd spinifex && make build-microvm-image
|
||||
|
||||
- name: Build amd64 Tarball
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cells:
|
||||
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 34-36 Oracle Linux single node (basic, reboot, routed NAT), 37-38 Oracle Linux on three nodes (multi-node subset with IPsec, routed NAT), 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes, 33 KV bucket quorum under node loss on three nodes"
|
||||
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 34-36 Oracle Linux single node (basic, reboot, routed NAT), 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes, 33 KV bucket quorum under node loss on three nodes"
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -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 and worker images)
|
||||
run: cd spinifex && make build-microvm-image build-sqs-store-microvm-image build-sqs-worker-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,12 +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/microvm/sqs-worker/vmlinuz
|
||||
build/microvm/sqs-worker/initramfs.cpio.gz
|
||||
build/microvm/sqs-worker/.stage.*/
|
||||
build/qboot/
|
||||
!spinifex/runtime/roles/spinifexui/frontend/dist/
|
||||
tmp/
|
||||
|
||||
@@ -140,38 +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
|
||||
|
||||
# The shared SQS worker microVM image: the store image's /init and kernel with
|
||||
# the worker binary instead of nats-server. Not installed or released yet.
|
||||
SQS_WORKER_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/sqs-worker
|
||||
SQS_WORKER_MICROVM_ARTIFACTS := $(SQS_WORKER_MICROVM_OUT_DIR)/vmlinuz $(SQS_WORKER_MICROVM_OUT_DIR)/initramfs.cpio.gz
|
||||
SQS_WORKER_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/sqs-store-microvm-publish.sh $(SQS_STORE_MICROVM_OUT_DIR)/init.sh $(SQS_WORKER_MICROVM_OUT_DIR)/inittab $(addprefix $(SQS_WORKER_MICROVM_OUT_DIR)/,sqs-worker-lib.sh sqs-worker-prepare sqs-worker-ready sqs-worker-stop) bin/sqs-worker
|
||||
|
||||
$(SQS_WORKER_MICROVM_ARTIFACTS) &: $(SQS_WORKER_MICROVM_INPUTS)
|
||||
SQS_MICROVM_PROFILE=worker ./scripts/build-sqs-store-microvm-image.sh
|
||||
|
||||
bin/sqs-worker: go.mod go.sum $(shell find cmd/sqs-worker spinifex/agents/sqs -name '*.go' ! -name '*_test.go')
|
||||
@mkdir -p bin
|
||||
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ ./cmd/sqs-worker
|
||||
|
||||
build-sqs-worker-microvm-image: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Build the shared SQS worker 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)
|
||||
@@ -183,18 +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-sqs-worker-microvm: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Install the SQS worker microVM image to /usr/share/spinifex/microvm/sqs-worker/
|
||||
sudo install -d /usr/share/spinifex/microvm/sqs-worker
|
||||
sudo rm -f /usr/share/spinifex/microvm/sqs-worker/initramfs.cpio.gz
|
||||
sudo install -m 0644 $(SQS_WORKER_MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/sqs-worker/vmlinuz
|
||||
sudo install -m 0644 $(SQS_WORKER_MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/sqs-worker/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
|
||||
@@ -202,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
|
||||
@@ -221,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...."
|
||||
@@ -229,14 +186,13 @@ test-build-scripts:
|
||||
echo "-- $$t"; \
|
||||
sh "$$t" || exit 1; \
|
||||
done
|
||||
@echo -e "\n....Running scripts/*_test.sh, scripts/lib/*_test.sh and scripts/appliance/*_test.sh...."
|
||||
@for t in $$( (find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib scripts/appliance -name '*_test.sh') | sort); do \
|
||||
@echo -e "\n....Running scripts/*_test.sh and scripts/lib/*_test.sh...."
|
||||
@for t in $$( (find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib -name '*_test.sh') | sort); do \
|
||||
echo "-- $$t"; \
|
||||
sh "$$t" || exit 1; \
|
||||
done
|
||||
@echo -e "\n....Running shellcheck over build/scripts/**/*.sh + the node scripts...."
|
||||
shellcheck -S warning $$(find build/scripts -name '*.sh' | sort) $(NODE_SCRIPTS)
|
||||
shellcheck -S warning -x scripts/build-oracle-appliance.sh $$(find scripts/appliance -name '*.sh' | sort)
|
||||
@echo " test-build-scripts ok"
|
||||
|
||||
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
|
||||
@@ -283,6 +239,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)...."
|
||||
@@ -495,8 +468,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
|
||||
./scripts/lib/stage-sqs-store-microvm.sh $(SQS_WORKER_MICROVM_OUT_DIR) dist/amd64 sqs-worker
|
||||
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
|
||||
|
||||
@@ -515,7 +486,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 build-sqs-worker-microvm-image install-microvm install-sqs-store-microvm install-sqs-worker-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,15 +29,8 @@ 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/ecs/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/eks/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/iam/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/sts/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 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 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"},
|
||||
|
||||
@@ -59,6 +52,9 @@ var allowlist = []allowance{
|
||||
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/cache", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
|
||||
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/vm", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
|
||||
|
||||
{From: "domains/ec2/awsapi/spotinstance", To: "domains/admission/quota", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
|
||||
{From: "domains/ec2/awsapi/spotinstance", To: "domains/iam", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
|
||||
|
||||
{From: "domains/ec2/eip", To: "domains/network/topology", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
|
||||
|
||||
{From: "domains/ec2/guestmetadata", To: "domains/dns", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
|
||||
@@ -95,18 +91,42 @@ var allowlist = []allowance{
|
||||
{From: "domains/ochre", To: "domains/ec2/systeminstance", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
|
||||
{From: "domains/ochre", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "secret encrypt/decrypt helpers; needs a shared crypto boundary"},
|
||||
{From: "domains/ochre", To: "domains/network/systemvpc", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
|
||||
{From: "domains/ochre", To: "ingress/aws/bodyscope", Debt: "domains/ochre (action adapters)", Reason: "Bedrock action adapters still live in the Ochre package; they move to domains/ochre/awsapi"},
|
||||
{From: "domains/ochre", To: "runtime/compute/gpu", Debt: "Domain → runtime", Reason: "GPU runtime state read directly; needs a domain-owned projection"},
|
||||
|
||||
{From: "domains/ochre/vector", To: "domains/iam", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
|
||||
|
||||
{From: "gateway", To: "domains/ec2/awsapi", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/account", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/capacityreservation", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/eigw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/eip", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/idem", Debt: "gateway/ec2.go (registration; ec2_idempotency.go)", Reason: "dispatch-time idempotency wrapper; moves with the EC2 dispatch map"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/igw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/image", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/instance", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/key", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/launchtemplate", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/natgw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/placementgroup", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/routetable", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/snapshot", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/spotinstance", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/tags", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/volume", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/vpc", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/zone", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
|
||||
{From: "domains/rds/awsapi", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "enginecatalog.go calls the instance adapter; needs an EC2 capability"},
|
||||
|
||||
{From: "domains/sts/awsapi", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "GetCallerIdentity takes IAMService; needs an IAM capability"},
|
||||
|
||||
{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"},
|
||||
@@ -139,4 +159,6 @@ var allowlist = []allowance{
|
||||
{From: "domains/rds/instance", To: "runtime/compute/vm", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
|
||||
{From: "domains/sts", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "STS reads IAM service, trust-policy and OIDC-provider state; needs an IAM capability"},
|
||||
|
||||
{From: "operator/cli", To: "gateway", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
}
|
||||
|
||||
@@ -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,216 +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=$$"
|
||||
# ADR-0011:S11 bounds the store's Go heap; the guest must be larger.
|
||||
GOMEMLIMIT=512MiB 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
|
||||
@@ -1,8 +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-worker/sqs-worker-prepare
|
||||
::respawn:/usr/local/lib/sqs-worker/sqs-worker-ready
|
||||
::ctrlaltdel:/sbin/reboot
|
||||
::shutdown:/usr/local/lib/sqs-worker/sqs-worker-stop
|
||||
@@ -1,115 +0,0 @@
|
||||
# sqs-worker-lib.sh — shared by the SQS worker guest's readiness and stop
|
||||
# steps. Sourced, never executed. Every path and command is overridable so the
|
||||
# host test can drive the same code against test-owned processes and stub tools.
|
||||
#
|
||||
# store-ready exists only while the running watcher's last check passed. A
|
||||
# check proves the store accepted the worker key, not the store server's
|
||||
# identity, and a store failure is noticed up to one interval plus call time late.
|
||||
|
||||
SQSW_RUN=${SQSW_RUN:-/run/sqs-worker}
|
||||
SQSW_DATA=${SQSW_DATA:-/data}
|
||||
SQSW_CONSOLE=${SQSW_CONSOLE:-/dev/console}
|
||||
SQSW_MOUNTS=${SQSW_MOUNTS:-/proc/mounts}
|
||||
SQSW_BIN=${SQSW_BIN:-/usr/local/bin/sqs-worker}
|
||||
SQSW_STOP_BUDGET_TENTHS=${SQSW_STOP_BUDGET_TENTHS:-100}
|
||||
SQSW_RESPAWN_GAP=${SQSW_RESPAWN_GAP:-5}
|
||||
SQSW_PARK_INTERVAL=${SQSW_PARK_INTERVAL:-30}
|
||||
UMOUNT=${UMOUNT:-umount}
|
||||
|
||||
sqsw_log() { echo "[sqs-worker] $*" >> "$SQSW_CONSOLE"; }
|
||||
|
||||
# sqsw_alive <pid>: running or stopped, not exited (a zombie counts as exited).
|
||||
sqsw_alive() { [ -r "/proc/$1/status" ] && ! grep -q '^State:[[:space:]]*Z' "/proc/$1/status" 2>/dev/null; }
|
||||
|
||||
# sqsw_starttime <pid>: kernel start time (stat field 22). It survives exec, and
|
||||
# pairing it with the pid reduces PID-reuse risk; it is not an absolute guarantee.
|
||||
sqsw_starttime() { sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f20; }
|
||||
|
||||
# sqsw_same <pid> <starttime>: the recorded process is still alive.
|
||||
sqsw_same() { [ -n "$2" ] && sqsw_alive "$1" && [ "$(sqsw_starttime "$1")" = "$2" ]; }
|
||||
|
||||
# sqsw_run: the respawned readiness body. A predecessor that was killed or
|
||||
# crashed could not remove its marker, so every start removes it first. Its
|
||||
# identity is recorded before the stopping check, as the store's run step does.
|
||||
sqsw_run() {
|
||||
mkdir -p "$SQSW_RUN"
|
||||
echo "$$ $(sqsw_starttime $$)" > "$SQSW_RUN/ready.pid"
|
||||
if [ -e "$SQSW_RUN/store-ready" ]; then
|
||||
rm -f "$SQSW_RUN/store-ready"
|
||||
sqsw_log "ready: removed a store-ready marker left by an earlier watcher"
|
||||
fi
|
||||
# Returning here would make init respawn and log at once, forever. Park
|
||||
# instead, logging once: shutdown stops this recorded process, and a key
|
||||
# that appears later lets the watcher start.
|
||||
if [ -e "$SQSW_RUN/stopping" ] || [ ! -s "$SQSW_RUN/public-key" ]; then
|
||||
if [ -e "$SQSW_RUN/stopping" ]; then
|
||||
sqsw_log "ready: shutdown in progress, not starting"
|
||||
else
|
||||
sqsw_log "ready: no worker key; readiness not started, waiting"
|
||||
fi
|
||||
while [ -e "$SQSW_RUN/stopping" ] || [ ! -s "$SQSW_RUN/public-key" ]; do
|
||||
sleep "$SQSW_PARK_INTERVAL"
|
||||
done
|
||||
fi
|
||||
# Init respawns at once; a watcher that keeps exiting restarts at most
|
||||
# once per gap instead of in a tight loop.
|
||||
now=$(date +%s)
|
||||
last=$(cat "$SQSW_RUN/ready.last" 2>/dev/null || echo 0)
|
||||
wait=$((last + SQSW_RESPAWN_GAP - now))
|
||||
if [ "$wait" -gt 0 ] && [ "$wait" -le "$SQSW_RESPAWN_GAP" ]; then sleep "$wait"; fi
|
||||
date +%s > "$SQSW_RUN/ready.last"
|
||||
sqsw_log "ready: starting watcher pid=$$"
|
||||
exec "$SQSW_BIN" ready >> "$SQSW_CONSOLE" 2>&1
|
||||
}
|
||||
|
||||
# sqsw_stop: block any respawn, drop the marker, SIGTERM the recorded watcher,
|
||||
# SIGKILL only after the budget, and unmount the worker disk only once the
|
||||
# watcher is confirmed gone. A survivor keeps the disk mounted.
|
||||
sqsw_stop() {
|
||||
mkdir -p "$SQSW_RUN"
|
||||
: > "$SQSW_RUN/stopping"
|
||||
rm -f "$SQSW_RUN/store-ready"
|
||||
rec=$(cat "$SQSW_RUN/ready.pid" 2>/dev/null || true)
|
||||
pid=${rec%% *} st=${rec#* }
|
||||
[ "$st" != "$rec" ] || st=""
|
||||
if [ -n "$pid" ] && sqsw_same "$pid" "$st"; then
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
sqsw_log "stop: SIGTERM sent to readiness watcher pid=$pid"
|
||||
i=0
|
||||
while sqsw_same "$pid" "$st" && [ "$i" -lt "$SQSW_STOP_BUDGET_TENTHS" ]; do sleep 0.1; i=$((i + 1)); done
|
||||
if sqsw_same "$pid" "$st"; then
|
||||
sqsw_log "stop: readiness watcher pid=$pid still running after $((SQSW_STOP_BUDGET_TENTHS / 10))s, SIGKILL"
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
j=0
|
||||
while sqsw_same "$pid" "$st" && [ "$j" -lt 30 ]; do sleep 0.1; j=$((j + 1)); done
|
||||
fi
|
||||
if sqsw_same "$pid" "$st"; then
|
||||
rm -f "$SQSW_RUN/store-ready"
|
||||
sqsw_log "stop: readiness watcher pid=$pid STILL ALIVE, leaving $SQSW_DATA mounted"
|
||||
return 1
|
||||
fi
|
||||
sqsw_log "stop: readiness watcher pid=$pid exited"
|
||||
else
|
||||
sqsw_log "stop: no running readiness watcher"
|
||||
fi
|
||||
rm -f "$SQSW_RUN/store-ready" "$SQSW_RUN/ready.pid"
|
||||
sync
|
||||
if grep -q " $SQSW_DATA " "$SQSW_MOUNTS" 2>/dev/null; then
|
||||
rc=0
|
||||
"$UMOUNT" "$SQSW_DATA" || rc=$?
|
||||
sqsw_log "stop: umount $SQSW_DATA rc=$rc"
|
||||
fi
|
||||
}
|
||||
|
||||
# sqsw_shutdown: the only shutdown action. The generic read-only remount runs
|
||||
# only after the watcher stopped; a survivor keeps every mount as it is.
|
||||
sqsw_shutdown() {
|
||||
if ! sqsw_stop; then
|
||||
sync
|
||||
sqsw_log "shutdown: readiness watcher survived, skipping umount -a -r"
|
||||
return 1
|
||||
fi
|
||||
rc=0
|
||||
"$UMOUNT" -a -r || rc=$?
|
||||
echo "[init] shutdown: umount -a -r rc=$rc" >> "$SQSW_CONSOLE"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/sh
|
||||
# sysinit: load the disk drivers, then select, check and mount the worker disk
|
||||
# and load or create the worker's store key on it. Any refusal is logged; the
|
||||
# key is never read or created on a disk that is not proven mounted.
|
||||
modprobe virtio_blk 2>/dev/null || true
|
||||
modprobe ext4 2>/dev/null || true
|
||||
/usr/local/bin/sqs-worker boot >> /dev/console 2>&1 || echo "[sqs-worker] boot refused" >> /dev/console
|
||||
exit 0
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Respawned by init. A watcher that must not run parks here instead of exiting,
|
||||
# so init does not respawn it in a tight loop.
|
||||
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
|
||||
sqsw_run
|
||||
exec sleep 2147483647
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/sh
|
||||
# The only shutdown action: stop the readiness watcher and unmount the worker
|
||||
# disk, then run the generic read-only remount only if the watcher is gone.
|
||||
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
|
||||
sqsw_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
|
||||
|
||||
@@ -4,19 +4,11 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
"github.com/mulgadc/predastore/s3api"
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
ecsawsapi "github.com/mulgadc/spinifex/spinifex/domains/ecs/awsapi"
|
||||
eksawsapi "github.com/mulgadc/spinifex/spinifex/domains/eks/awsapi"
|
||||
elbv2awsapi "github.com/mulgadc/spinifex/spinifex/domains/elbv2/awsapi"
|
||||
iamawsapi "github.com/mulgadc/spinifex/spinifex/domains/iam/awsapi"
|
||||
rdsawsapi "github.com/mulgadc/spinifex/spinifex/domains/rds/awsapi"
|
||||
route53awsapi "github.com/mulgadc/spinifex/spinifex/domains/route53/awsapi"
|
||||
stsawsapi "github.com/mulgadc/spinifex/spinifex/domains/sts/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
|
||||
@@ -57,23 +49,9 @@ func compareAll() ([]awsmodel.OperationCoverage, error) {
|
||||
// Registered services contribute their declared inventory, which needs no
|
||||
// live dependencies; nothing here serves a request.
|
||||
inventories := gateway.AWSOperationInventory(map[string]dispatch.Inventory{
|
||||
awsapi.ServiceName: awsapi.OperationInventory(),
|
||||
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
|
||||
route53awsapi.ServiceName: route53awsapi.OperationInventory(),
|
||||
elbv2awsapi.InventoryName: elbv2awsapi.OperationInventory(),
|
||||
rdsawsapi.ServiceName: rdsawsapi.OperationInventory(),
|
||||
ecsawsapi.ServiceName: ecsawsapi.OperationInventory(),
|
||||
eksawsapi.ServiceName: eksawsapi.OperationInventory(),
|
||||
iamawsapi.ServiceName: iamawsapi.OperationInventory(),
|
||||
stsawsapi.ServiceName: stsawsapi.OperationInventory(),
|
||||
awsapi.ServiceName: awsapi.OperationInventory(),
|
||||
acmawsapi.ServiceName: acmawsapi.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.
|
||||
for name := range inventories {
|
||||
if !slices.Contains(awsmodel.Services(), awsmodel.Service(name)) {
|
||||
return nil, fmt.Errorf("no embedded AWS model for served service %s", name)
|
||||
}
|
||||
}
|
||||
coverages := make([]awsmodel.OperationCoverage, 0, len(awsmodel.Services()))
|
||||
for _, service := range awsmodel.Services() {
|
||||
inventory, ok := inventories[string(service)]
|
||||
|
||||
@@ -29,7 +29,6 @@ func loadConfig() (tokenwebhook.Config, error) {
|
||||
CertPath: envOr("EKS_WEBHOOK_CERT", "/etc/spinifex-eks/token-webhook.crt"),
|
||||
KeyPath: envOr("EKS_WEBHOOK_KEY", "/etc/spinifex-eks/token-webhook.key"),
|
||||
Kubeconfig: envOr("EKS_WEBHOOK_KUBECONFIG", "/etc/spinifex-eks/token-webhook.kubeconfig"),
|
||||
ReadyAddr: os.Getenv("EKS_WEBHOOK_READY_ADDR"),
|
||||
}
|
||||
// Static SigV4 creds are optional: when absent, eksgw.New signs with the
|
||||
// AWS SDK chain (IMDS instance-role creds), the same path the CP VM's
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
// sqs-worker runs inside the shared SQS worker microVM.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// sqs-worker boot
|
||||
// sqs-worker ready
|
||||
//
|
||||
// boot selects and mounts the worker's data disk named by fw_cfg, asks the
|
||||
// gateway for its current first-store decision with its instance-role
|
||||
// credentials, then loads or creates the store key on that disk and
|
||||
// publishes only the public key. ready then keeps checking the bound store's
|
||||
// JetStream with that key until it is stopped, keeping a readiness marker only
|
||||
// while the check passes; it proves the store accepted the worker key, not
|
||||
// the store server's identity.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/gwsign"
|
||||
"github.com/mulgadc/spinifex/internal/sqsworkergw"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerboot"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerkey"
|
||||
)
|
||||
|
||||
// The cluster CA the daemon supplies; the only root the client trusts.
|
||||
const caPath = "/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/ca-cert/raw"
|
||||
|
||||
// newClaimer signs with the IMDS instance-role credentials only; there is
|
||||
// no static-key fallback.
|
||||
func newClaimer() workerboot.NewClaimer {
|
||||
return func(region, gatewayURL string) (workerkey.Claimer, error) {
|
||||
return newClient(region, gatewayURL)
|
||||
}
|
||||
}
|
||||
|
||||
func newClient(region, gatewayURL string) (*sqsworkergw.Client, error) {
|
||||
ca, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read cluster CA: %w", err)
|
||||
}
|
||||
return sqsworkergw.New(gatewayURL, region, ca, gwsign.NewInstanceRole())
|
||||
}
|
||||
|
||||
type describer struct{ c *sqsworkergw.Client }
|
||||
|
||||
func (d describer) DescribeStore(ctx context.Context) (workerboot.Store, error) {
|
||||
st, err := d.c.DescribeStore(ctx)
|
||||
return workerboot.Store(st), err
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 || (os.Args[1] != "boot" && os.Args[1] != "ready") {
|
||||
fmt.Fprintln(os.Stderr, "usage: sqs-worker boot|ready")
|
||||
os.Exit(2)
|
||||
}
|
||||
logf := func(msg string) { fmt.Fprintf(os.Stderr, "[sqs-worker] %s\n", msg) }
|
||||
run := boot
|
||||
if os.Args[1] == "ready" {
|
||||
run = ready
|
||||
}
|
||||
if err := run(logf); err != nil {
|
||||
logf("FATAL: " + err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func config(logf func(string)) workerboot.Config {
|
||||
return workerboot.Config{
|
||||
FwCfgDir: "/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/sqs-worker",
|
||||
SysBlock: "/sys/block",
|
||||
DevDir: "/dev",
|
||||
MountInfo: "/proc/self/mountinfo",
|
||||
DataDir: "/data",
|
||||
RunDir: "/run/sqs-worker",
|
||||
Disk: workerboot.SystemDisk{E2fsck: "/sbin/e2fsck"},
|
||||
IsBlockDevice: workerboot.IsBlockDevice,
|
||||
NewClaimer: newClaimer(),
|
||||
Attempts: 60,
|
||||
Wait: 5 * time.Second,
|
||||
Log: logf,
|
||||
}
|
||||
}
|
||||
|
||||
func boot(logf func(string)) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
_, err := workerboot.Boot(ctx, config(logf))
|
||||
return err
|
||||
}
|
||||
|
||||
// ready runs until init stops it; the marker is removed on the way out.
|
||||
func ready(logf func(string)) error {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||
defer cancel()
|
||||
err := workerboot.Watch(ctx, workerboot.ReadyConfig{
|
||||
Config: config(logf),
|
||||
NewDescriber: func(region, gatewayURL string) (workerboot.StoreDescriber, error) {
|
||||
c, err := newClient(region, gatewayURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return describer{c}, nil
|
||||
},
|
||||
Checker: workerboot.NATSChecker{},
|
||||
CallTimeout: 30 * time.Second,
|
||||
Refresh: 30 * time.Second,
|
||||
Refused: 5 * time.Minute,
|
||||
})
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package clusterv1
|
||||
|
||||
import "time"
|
||||
|
||||
// ClusterStateBucket is the KV bucket holding each daemon's heartbeat alongside
|
||||
// other cluster-wide state.
|
||||
const ClusterStateBucket = "spinifex-cluster-state"
|
||||
|
||||
// HeartbeatKey is the ClusterStateBucket key a node writes its heartbeat under.
|
||||
func HeartbeatKey(node string) string {
|
||||
return "heartbeat." + node
|
||||
}
|
||||
|
||||
// HeartbeatTimestamp is the read side of a heartbeat for node liveness. It
|
||||
// decodes only the timestamp, so a reader does not depend on the capacity
|
||||
// figures the daemon writes beside it.
|
||||
type HeartbeatTimestamp struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Time returns when the heartbeat was written. A timestamp that is missing or
|
||||
// unparseable vouches for nothing, so ok is false.
|
||||
func (h HeartbeatTimestamp) Time() (written time.Time, ok bool) {
|
||||
parsed, err := time.Parse(time.RFC3339, h.Timestamp)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed.UTC(), true
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package clusterv1_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The bucket and key are the deployed layout: a rolling upgrade has old daemons
|
||||
// writing and new readers reading, so these strings cannot change.
|
||||
func TestHeartbeatKey_IsTheDeployedLayout(t *testing.T) {
|
||||
assert.Equal(t, "spinifex-cluster-state", clusterv1.ClusterStateBucket)
|
||||
assert.Equal(t, "heartbeat.node-a", clusterv1.HeartbeatKey("node-a"))
|
||||
}
|
||||
|
||||
func TestHeartbeatTimestamp_ReadsAFullDaemonHeartbeat(t *testing.T) {
|
||||
full := `{"node":"node-a","epoch":3,"timestamp":"2026-10-30T01:02:03Z","vm_count":4,"allocated_vcpu":8}`
|
||||
var h clusterv1.HeartbeatTimestamp
|
||||
require.NoError(t, json.Unmarshal([]byte(full), &h))
|
||||
|
||||
written, ok := h.Time()
|
||||
require.True(t, ok)
|
||||
assert.True(t, written.Equal(time.Date(2026, 10, 30, 1, 2, 3, 0, time.UTC)))
|
||||
}
|
||||
|
||||
func TestHeartbeatTimestamp_UndatedBeatVouchesForNothing(t *testing.T) {
|
||||
for _, ts := range []string{"", "yesterday"} {
|
||||
_, ok := clusterv1.HeartbeatTimestamp{Timestamp: ts}.Time()
|
||||
assert.False(t, ok, "timestamp %q", ts)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package ec2v1
|
||||
|
||||
// VolumeHeldElsewhereText is the text of a start refused because another node
|
||||
// holds one of the instance's volumes. It crosses NATS as message text, so
|
||||
// readers match on it; the provider that refuses and every reader share this
|
||||
// string so they cannot drift.
|
||||
const VolumeHeldElsewhereText = "volume is leased by another owner"
|
||||
@@ -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
-16
@@ -136,7 +136,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin volumes orphans` | — | Lists volumes the storage provider holds that the control plane has no record of. Their blocks consume space but they have no API handle, so they can be neither described nor deleted through the EC2 API. **Reports only, never deletes**: the evidence that an orphan is still wanted is the record that went missing, so removal is an operator decision taken per volume. |
|
||||
| `spx admin volumes unsealed` | — | Lists volumes a node has open, or had open, without a confirmed seal to the backend — the node named holds writes predastore may not have. Starting such a volume elsewhere is refused while that node is alive (its daemon heartbeat is under 60 seconds old, or cannot be read) and is otherwise allowed, in which case it opens from the last checkpoint that did reach the backend. This is the list of volumes where that would cost something. |
|
||||
| `spx admin volumes unsealed` | — | Lists volumes a node has open, or had open, without a confirmed seal to the backend — the node named holds writes predastore may not have. Starting such a volume elsewhere is allowed, and is preferred over leaving the instance unable to run, but it opens from the last checkpoint that did reach the backend. This is the list of volumes where that would cost something. |
|
||||
| `spx admin storage status` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Sends the signed `GetStorageStatus` action to the AWS gateway with credentials from the standard AWS chain (e.g. `AWS_PROFILE=spinifex`) and prints what the console's Service Metrics page shows: predastore encoding, meta and blob node counts, cluster health, a meta node table (health, raft state, leader, term, commit and applied index) and the blob nodes. Cluster health is `Degraded` when any meta node is unreachable or the nodes reporting raft state do not agree on one leader. A meta node read only through its host's `/readyz` shows healthy with blank raft fields. |
|
||||
|
||||
### Upgrade Management
|
||||
@@ -192,21 +192,6 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|---------|-------|-------------|
|
||||
| `spx admin eks restore-snapshot` | `--cluster` (required), `--snapshot` (optional, defaults to the latest snapshot in predastore), `--account` (optional, defaults to the bootstrap account) | Single-CP total-loss DR path (fail-safe): validates the snapshot exists in predastore BEFORE any mutation (a typo'd/missing key hard-fails, never resets into an empty datastore) → launches a fresh control-plane VM as a cluster-init seed (replaying the persisted create-time launch template) → sets a required-snapshot `RecoveryDirective` (`cluster-reset`) so the boot-time recovery agent aborts rather than resets-into-empty if it cannot fetch the snapshot → persists the replacement in cluster meta BEFORE re-pointing the NLB (so an NLB failure is convergeable by the reconciler, returned as a provisional status, not a hard error) → re-points the cluster NLB's apiserver and konnectivity target groups from the old CP's ENI to the new one → fences the old CP with retries, failing loudly if it cannot be confirmed terminated (split-brain guard). Any failure before the meta commit unwinds the fresh CP (terminate + clear directive) so a re-run does not stack a second resetting control plane. The returned status is provisional — success means the sequence completed, not that etcd is restored and serving; verify cluster health. HA clusters (a spread with a potentially surviving quorum) are rejected — recover those via quorum reformation instead. |
|
||||
|
||||
### SQS System Instances
|
||||
|
||||
These commands start the first SQS system instances on one node. They do not serve the SQS API, and a bound VM is not a ready service.
|
||||
|
||||
Both send a signed `spinifex` admin action (`EnsureSQSWorker`, `EnsureSQSStore`) to the AWS gateway using the standard AWS credential chain, for example `AWS_PROFILE=spinifex`. The caller must be in the super-admin account (`000000000001`) and hold `spinifex:<Action>`. The VMs, volumes, private VPC and ENIs belong to the system account (`000000000000`). `--endpoint`, `--region` and `--ca-bundle` default to this node's gateway, region and `config/ca.pem`; off a node, `--endpoint` and `--region` are required. Each call waits up to 11 minutes.
|
||||
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin sqs ensure-worker` | `--node` (required), `--instance-type` (required), `--endpoint`, `--region`, `--ca-bundle` | Launches the Region's one SQS worker VM on `--node`, or reports the VM already bound there: node, region, instance, instance type and generation. The worker is pinned to its first node; another node is refused with `InvalidParameterValue`, and there is no takeover. It gets a private system VPC with one /24 subnet and no internet gateway, NAT or Elastic IP, and an encrypted 1 GiB system-account data volume on which the guest generates its own store key. The platform records only the public key. A different `--instance-type` is accepted only while no volume, network interface or launch step has been attempted; once any has, it is `IncorrectState`, whether or not the step completed. |
|
||||
| `spx admin sqs ensure-store` | `--node` (required, the worker's node), `--instance-type` (required, more than 512 MiB memory), `--volume-size-gib` (required, 1–16384), `--endpoint`, `--region`, `--ca-bundle` | Launches the Region's SQS store VM on the worker's node with an encrypted system-account data volume, or reports the VM already bound there: node, region, instance, instance type and address. Needs the worker bound with its public key recorded, else `IncorrectState`. The store accepts only that key. A different size, type or key than the first call is `IncorrectState`. |
|
||||
|
||||
`ServiceUnavailable` never proves that no SQS resources exist, because an earlier attempt may already have created some. Its message says what it does establish. When no node serves the subject, this request was not delivered. When a volume create was not sent, that particular create did not happen. When the node cannot boot the guest yet or its launcher lacks a capability, this request stopped before allocating or recording anything. When the gateway times out waiting for the node's reply, nothing is known: the launch may still be running, so never assume it created nothing. In every case, call the command again with the same arguments. A retry first checks the durable record of the attempt and continues only when that is safe; it can proceed to the first launch after a create that was proven unsent, and otherwise reports the recorded outcome as bound or `IncorrectState`, or returns `ServiceUnavailable` again. `IncorrectState` from a volume, network interface or launch step means that step was recorded as sent but its outcome was never observed. It is never resent, the message names the volume when its ID is known, and no tool reconciles it yet: recovery is manual.
|
||||
|
||||
`bound` says only that the VM was launched for the current record. The worker decides store readiness itself, by checking that the store is bound to its own public key and that a NATS connection with that key succeeds; nothing reports that readiness to the operator. The worker-to-store connection does not authenticate the store server. Real VPC routing, IMDS and gateway TLS from the guest, and worker-to-store traffic on a real interface have not been tested.
|
||||
|
||||
## Private Admin API
|
||||
|
||||
Not an AWS API: a Spinifex-internal surface on the AWS gateway, JSON in and JSON out, one method per path segment at `POST /admin/<Method>`. It exists so a self-service signup form, a provisioning system or a load-test harness can create and remove tenant accounts without an operator shell on a node.
|
||||
|
||||
-189
@@ -229,53 +229,6 @@ type EBSRequest struct {
|
||||
|
||||
Viperblock responds with an NBD URI that QEMU uses to access the block device.
|
||||
|
||||
#### Direct-boot microVM data volumes
|
||||
|
||||
A direct-boot microVM keeps the launcher's saved `vm.Config` as a clean base, and `startQEMU` adds data volumes only to a launch-only copy of it (`runtime/compute/vm/lifecycle.go`).
|
||||
At each start the daemon takes one snapshot of the instance's already-mounted data requests, probes each NBD or host block export from that snapshot, and builds the command from the same snapshot, so the launched endpoint is the one that was checked and is never written back into the saved base.
|
||||
Each data volume becomes a `virtio-blk-device` on the MMIO bus with the same id, node name, I/O thread, serial and `werror=stop,rerror=stop` policy as the PCI device, and takes no PCIe hot-plug port.
|
||||
|
||||
Boot and EFI requests are rejected on this path, and a direct-boot instance with no data requests launches its base unchanged.
|
||||
Disk-boot (q35) instances keep the existing PCI hot-plug path.
|
||||
|
||||
Hot-attaching a volume to a running direct-boot microVM is not supported, because the MMIO bus has no PCIe root port to plug into.
|
||||
`AttachVolume` refuses it before mounting the volume or changing any instance or volume state, and the EC2 API returns `UnsupportedOperation`.
|
||||
|
||||
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.
|
||||
|
||||
#### Unsealed writes and start elsewhere (interim)
|
||||
|
||||
A volume is unsealed while its node holds writes the backend has not confirmed; the node records this in a dirty marker naming itself as owner (`providers/ebs/viperblock/volume_dirty.go`).
|
||||
Opening such a volume on another node loses those writes, so two guards stop it while the previous owner may still hold them.
|
||||
This is an interim guard until the instance lifecycle work defines ownership, and it is not a fence.
|
||||
|
||||
When `ec2.start` is forwarded to the node that last ran a stopped instance and that node refuses for capacity, the forwarding node normally starts the instance locally.
|
||||
If the refusing node still holds unsealed writes for any of the instance's volumes, it sets the `X-Unsealed-Writes` NATS reply header and the forwarder relays `InsufficientInstanceCapacity` instead of starting elsewhere.
|
||||
The check asks the owner's viperblockd over the per-node `unsealed` verb with a 5 second bound, and a check that fails or times out counts as holding, so the refusal is kept.
|
||||
|
||||
Independently, a node that finds another node's dirty marker on a volume refuses to open it when that node's daemon heartbeat (`heartbeat.<node>` in the cluster-state bucket) is under 60 seconds old.
|
||||
It also refuses when the heartbeat cannot be read or no heartbeat source is wired, and the refusal maps to the held-elsewhere error that recovery retries.
|
||||
A previous owner that has stopped heartbeating is opened over with a warning, as before.
|
||||
The heartbeat age is compared across node clocks, so the 60 second window is also the clock-skew allowance.
|
||||
|
||||
EKS control-plane recovery treats both refusals, capacity and held-elsewhere, as retryable: it keeps retrying the stopped member and does not count them against its restart attempts.
|
||||
|
||||
### Predastore (S3)
|
||||
|
||||
Object storage used for:
|
||||
@@ -303,148 +256,6 @@ file. It does not connect to Predastore to do so, and no Predastore node
|
||||
exposes a status endpoint. This is an operator status interface, not a generic
|
||||
storage-provider contract.
|
||||
|
||||
### SQS message store (internal)
|
||||
|
||||
`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 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.
|
||||
|
||||
The adapter provisions a fixed set of shared message streams (`SQS_M_<shard>`, one subject per queue) and shared state buckets (`sqs_state_<shard>`), keeps historical ones, and applies a 14-day-plus-one-hour retention backstop to both.
|
||||
It supports one node (R1) and three nodes (R3) only and refuses any other declared cluster size before provisioning.
|
||||
|
||||
Reads that must observe a completed write or delete go to the stream leader: `GetState`, the state-key pass behind `Count` and `Namespaces`, and the body pass behind `Namespaces` and `Scan`.
|
||||
This avoids the follower-served direct gets and follower-placed consumers that returned purged data in local three-node fault-injection tests; it is not a general linearizability guarantee.
|
||||
Each leader read is one request per record, and the body pass transfers each full body internally even though `Scan` returns metadata only, which is a material cost for a large backlog.
|
||||
A state-key pass is bounded but is not a snapshot when a key is overwritten during it.
|
||||
|
||||
The feed reads through an in-memory, headers-only ordered consumer.
|
||||
If that consumer delivers nothing for about two seconds while the stream holds a change after the feed's cursor, the feed replaces it from the cursor, so a stored change does not wait on a stalled consumer; the cause of those stalls is not established.
|
||||
`Close` is terminal, waits for consumer opens and deletes in progress and retries failed deletes, but cleanup is best effort for a create whose outcome is unknown: a consumer that lands after its last delete is removed by the server's one-minute inactive threshold.
|
||||
|
||||
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.
|
||||
|
||||
### SQS worker guest (operator-launched)
|
||||
|
||||
The shared SQS worker guest holds the SQS store's client credential, so that no platform component does (ADR-0011:S2).
|
||||
Only an operator activates it, with the super-admin `EnsureSQSWorker` action described below; nothing starts it at daemon startup, and no worker service runs after boot.
|
||||
|
||||
`make build-sqs-worker-microvm-image` builds it with the store image's script and network `/init` under `SQS_MICROVM_PROFILE=worker`; it carries the static `sqs-worker` binary and the ext4 tools, no `nats-server`, and an empty `/data` mountpoint.
|
||||
Releases build it beside the store image and ship it as `microvm-sqs-worker`, which `setup.sh` installs to `/usr/share/spinifex/microvm/sqs-worker` with the store image's rules: a release without it removes an earlier one, and an incomplete one is refused.
|
||||
At boot `sqs-worker boot` reads three firmware config values under `opt/spinifex/sqs-worker/`: the worker disk's serial, its Region and the gateway URL; none is a first-store decision, because a static value survives a guest reset.
|
||||
A missing, empty or malformed value refuses before any disk is touched.
|
||||
|
||||
It selects exactly one disk with that serial, runs `e2fsck -p`, mounts it at `/data` and then confirms from `/proc/self/mountinfo` that this disk, as a whole ext4 filesystem, is what is mounted there; an empty mountpoint, a foreign mount or a failed check or mount refuses, and the guest never formats.
|
||||
Only then does it ask the gateway for the current decision with `ClaimFirstStore`, signed by `internal/sqsworkergw` with credentials from the instance metadata service only (`gwsign.NewInstanceRole`, no environment, profile or static-key fallback), over TLS 1.3 that trusts only the cluster CA from `opt/spinifex/ca-cert` and never follows a redirect.
|
||||
Under that decision `workerkey.Acquire` creates the key on `/data/sqs-worker-key`, owner-only and synced, records only its public key and then uses it, or loads the recorded key; a missing or different key once one is recorded refuses and is never replaced.
|
||||
The VM can start before its launch binds the instance, so its first calls are legitimately `AccessDenied`; that denial creates nothing and is asked again, as are a lost reply and a `ServiceUnavailable` or `Throttling` refusal, all within one bound (60 attempts 5 seconds apart), after which a worker that was never bound, or was replaced, refuses the boot.
|
||||
A signature or other refusal, or an answer that is neither "create" nor one valid public key, refuses the boot at once.
|
||||
|
||||
`domains/sqs/firststore` is the SQS domain's first-store decision: one record per Region in the `spinifex-sqs-first-store` KV bucket moves from `claimed` to `key-recorded` to `launch-intent` under compare-and-swap, bound to the worker's instance ID, claim ID and generation.
|
||||
A claim authorizes key creation only while no public key is recorded; once one is, every claim returns only that key, and a store launch intent needs it recorded first.
|
||||
An older generation, a different identity at the same generation or an unreadable record authorizes nothing, and the record holds no private key material.
|
||||
Reads are stream message gets answered by the stream leader, never direct gets a lagging replica can answer, and every authorizing answer is also confirmed by a compare-and-swap at the revision it read, so a stale or unavailable read authorizes nothing.
|
||||
The authenticated caller that will carry this decision must derive the worker's instance ID and generation from authoritative instance state; a generation supplied in a request cannot itself authorize a takeover.
|
||||
|
||||
`domains/sqs/workerident` is that authoritative instance state: one record per Region in the `spinifex-sqs-worker-identity` KV bucket holds a monotonic generation, claim ID, pinned node and encrypted data volume, and moves from `launching` to `bound` to `stopped`, or from `launching` to `failed`, under compare-and-swap.
|
||||
A launch intent is refused while the previous attempt is `launching` or `bound`, including one whose outcome is unknown, so only one caller ever holds an intent to start a VM on the pinned volume.
|
||||
A replacement takes the next generation only after `FailLaunch` or `RecordStopped` records that nothing holds the volume, and each request names the generation it supersedes, so a replayed request cannot take a generation it never observed; there is no automatic takeover.
|
||||
The launch path then raises the first-store fence to the successor's generation with `firststore.Fence` before `BeginLaunch`, which refuses a replacement until a leader read shows that fence; the fence only rises, and `firststore` refuses every call from a generation below it.
|
||||
Only the current claim ID is recorded, so claim IDs are minted fresh per attempt and a VM launch must be idempotent on generation and claim together.
|
||||
Binding writes the instance's index entry first and then binds the record to that instance.
|
||||
The index is only a lookup: `Authorize` accepts an instance only while the Region's record is `bound` to exactly that instance, generation and claim, confirmed by a compare-and-swap at the leader-read revision, so an old, orphaned, rewritten or unbound entry authorizes nothing.
|
||||
A same-instance reboot keeps its instance ID and identity; a replacement takes a new generation, and the first cut refuses another node or data volume and an unencrypted volume, although the encryption flag is the caller's claim and the launch wiring must verify it from the volume's metadata.
|
||||
Both buckets are opened through `OpenDurableKV`, which refuses an existing bucket whose effective stream configuration could expire, evict, lose, lag or refuse records: non-limits retention, an age, per-message or delete-marker TTL, memory storage, asynchronous flush, a size, count or message-size cap, a discard policy other than discard-new, unacknowledged writes, a sealed stream, a mirror or sources, or fewer replicas than the cluster needs.
|
||||
`workerkey.Acquire` drives that decision: it syncs a new key to disk, records its public key and only then returns it, so a crash before recording reuses the key already on disk.
|
||||
|
||||
`domains/sqs/awsapi/worker` carries the decision to the worker as `ClaimFirstStore` and `RecordWorkerPublicKey`, served under the internal `spinifex-sqs` signing service, which no AWS SDK signs for; the public AmazonSQS action inventory is untouched.
|
||||
The gateway admits only an assumed-role session of `sqsWorkerRole` in the system account, then the role's policy, and only then resolves the session name, the instance ID STS set, through `workerident.Authorize`; the request carries no identity, and any parameter beyond the action's own is refused.
|
||||
The first-store identity is the current record's instance, claim and generation, and a worker bound to another Region is refused.
|
||||
A request that passed `Authorize` just before a confirmed stop and replacement still carries the old generation; the fence was raised before the successor began, so `firststore` refuses it whether or not the successor has claimed yet.
|
||||
The adapter never logs a caller-chosen parameter name, because one could carry a secret.
|
||||
`RecordWorkerPublicKey` accepts only a valid public user key, so a seed is refused without being recorded, logged or echoed.
|
||||
The gateway binds both buckets per request and never creates them, so it provisions nothing and answers `ServiceUnavailable` until a worker launch has created them; the worker launch below ensures the role.
|
||||
The private key stays on the worker disk; the guest logs and publishes only the public key to `/run/sqs-worker/public-key`.
|
||||
|
||||
`domains/sqs/workerlaunch` realizes the worker on the node it runs on, its only node: a bound record is returned as is; otherwise it takes the intent, then on the first launch marks the intent's one volume create, prepares the volume and records its ID, reads the volume's own record and refuses it unless encrypted, ensures the role, finds or creates the network for the claim and reads the recorded store key, and marks the intent dispatched before launching.
|
||||
A dispatched launch is never sent again: a lost reply or error leaves the intent `launching` and dispatched, and every later call returns `ErrOutcomeUnknown` until the outcome is reconciled, since the launch path is not idempotent.
|
||||
A failure before dispatch leaves an undispatched intent that the next call resumes with the same claim; a confirmed stop or failure is replaced only after `firststore.Fence` excludes the old generation, reusing the pinned volume.
|
||||
The launch passes no store decision or key: the guest asks the gateway at boot.
|
||||
The intent exists before any volume, so concurrent first launches allocate nothing for the losers. A Prepare proven to have sent no create withdraws the mark and returns `ServiceUnavailable`, or `UnsupportedOperation` when the cluster storage key is not configured, and a retry may prepare the volume; a create that may have applied is never sent again, and every later call returns `IncorrectState` until it is reconciled. A volume the call knows of but cannot use is reported with its ID in a `VolumeError` and left in place, never deleted. An intent that has sent no create may be re-typed; one that has keeps its type until replaced. The worker role is created with EC2-only trust, and an existing role is checked for that trust before any policy is granted; the instance profile must carry exactly the worker role, and one holding any other role is refused unchanged.
|
||||
The daemon's `LaunchSQSWorkerInstance` boots the `sqs-worker` image through the same direct-boot path as the store, with the data volume attached at boot and kept on terminate, a required instance profile, and a required mgmt NIC; it re-reads the volume's own record and refuses one that is not available and encrypted, before anything is allocated.
|
||||
Its firmware config adds the gateway URL on this node's mgmt bridge and the cluster CA from the daemon's own trust store; the caller cannot supply either, and no value written is secret.
|
||||
A relaunch rebuilds that config from the VM record on the node that launched it and is refused anywhere else, and membership takeover already skips SQS-managed instances.
|
||||
`sqsWorkerRole` is ensured in the system account granting exactly `spinifex-sqs:ClaimFirstStore` and `spinifex-sqs:RecordWorkerPublicKey`, from the `domains/sqs/workerrole` contract the gateway gate also reads; an existing role whose trust admits more than the EC2 service is refused.
|
||||
|
||||
Evidence is local only: unit tests with a fake firmware config, sysfs and mount table, tests running the boot through the real client against the real `spinifex-sqs` registration and records, and QEMU boots of the built image against disposable scratch disks and a host-side test gateway and metadata service, showing the first boot creates the key, reboots reuse it, a key lost after recording is never replaced, and the seed reaches no log or request.
|
||||
The worker's network is a private SQS system VPC per Region, built by `systemvpc.EnsurePrivate` from the `[sqs]` supernet (default `10.240.0.0/14`): one VPC and one /24 subnet, with no internet gateway, NAT gateway, EIP, route table or daemon-to-store node port. Each launch claim gets one ENI in that subnet, in the VPC default security group, found again by its claim on a retry; two ENIs for one claim are refused. The launch intent is marked before the one create is sent, so a create whose ENI is not yet visible is never sent again: the launch fails closed with `IncorrectState` until the ENI appears or the outcome is reconciled. Worker-to-store TCP and the guest's route to the metadata service are not yet proven by any traffic test.
|
||||
The super-admin `EnsureSQSWorker` action on the internal `spinifex` operator service takes a `NodeId`, which must be a node of this cluster and a single subject token, and an `InstanceType`, and sends a request to `sqs.worker.Ensure.v1.<node>` with no queue group, so only that node answers and the worker stays pinned.
|
||||
That node's daemon re-checks the node the request names and the instance type, and refuses as `ServiceUnavailable` before allocating a volume or network or recording an intent unless the worker image's kernel and initramfs are non-empty files, its mgmt bridge exists and the cluster CA is readable; a call after the image is installed then launches.
|
||||
It then runs its one composed launcher for its own Region, one launch at a time, in the daemon's own context with a 30-minute bound, so a caller's timeout does not cancel it.
|
||||
A reply lost to that timeout is reported as `ServiceUnavailable`; calling again reports the bound worker, or `IncorrectState` for a dispatched launch whose outcome is unknown, which is never resent; a Region pinned to another node is refused as `InvalidParameterValue`.
|
||||
The reply names the bound VM's node, Region, instance ID and generation; bound is not ready, and it says nothing about the guest's boot, its key or the store.
|
||||
Stop/detach evidence for `RecordStopped` is not wired, the guest's metadata route and gateway TLS on a real ENI and worker-to-store TCP are unproven, and Viperblock's at-rest key is held by the platform.
|
||||
|
||||
The worker identity record holds the instance type of its generation; a request for another type is refused as `IncorrectState`, before dispatch and after binding, the reply names the recorded type, and only a replacement after a confirmed stop may change it.
|
||||
Operators call both actions with `spx admin sqs ensure-worker --node --instance-type` and `spx admin sqs ensure-store --node --instance-type --volume-size-gib`, signed for the `spinifex` service with the standard AWS credential chain like the other `spx admin` gateway commands.
|
||||
Each command refuses missing inputs before sending anything and reports only the bound VM; a bound VM is not a ready worker or store.
|
||||
The super-admin `EnsureSQSStore` action takes a `NodeId`, an `InstanceType` and a `VolumeSizeGiB` and sends a request to `sqs.store.Ensure.v1.<node>` with no queue group; only the node the Region's worker is bound to can launch the store.
|
||||
That node refuses an instance type whose memory does not exceed 512 MiB, the store's `GOMEMLIMIT` under ADR-0011:S11; this is a floor, not a validated size, and no type is defaulted.
|
||||
It refuses before recording or allocating anything unless the store image and the cluster storage key are present, the worker is bound on this node, and that worker's identity has recorded its public key in the first-store decision, which it then advances to a launch intent.
|
||||
The store's own launch record in `spinifex-sqs-store-launch` holds only that public key, the claim, the node and the operator's instance type and size, and marks each step before its uncertain create: the volume, the ENI in the worker's private subnet, and the launch.
|
||||
A later request for a different instance type or size is refused as `IncorrectState`, before dispatch and after binding, and the reply names the type the store was launched with.
|
||||
A volume preparation that failed before sending any create, such as a missing `mkfs.ext4` or a scratch or format failure, withdraws its mark and returns `ServiceUnavailable`, so a retry may try again once the host is fixed.
|
||||
Any other marked step whose result is not visible is reported as `IncorrectState` and never resent; a volume that is not encrypted by its own record is refused as `UnsupportedOperation` with its ID kept, and the daemon re-checks the volume's encryption and the guest's size at launch.
|
||||
The daemon never receives the worker's seed; the store's NATS server accepts only that public key, and the reply's bound VM and address are not readiness, which only the worker can check.
|
||||
|
||||
After boot, the worker's long-running `sqs-worker ready` step mounts and verifies its disk again and calls the internal `spinifex-sqs` `DescribeStore` action, which admits only the current worker and returns the bound store's address and recorded public key, or pending.
|
||||
It loads its key only if it matches that public key, then connects to the store's NATS on port 4222 with it and asks JetStream for account info, each pass bounded to 30 seconds.
|
||||
The step runs until shutdown with no overall deadline, so a store the operator launches at any time is picked up: it asks again every 5 seconds while the store is pending, not answering or the reply was lost, and re-checks a ready store every 30 seconds.
|
||||
A pass writes only the address and public key to `/run/sqs-worker/store-ready`; any failed pass removes that file, and so does the step's own exit.
|
||||
A start that finds shutdown in progress or no worker key logs once and waits in place rather than exiting, so init does not respawn it in a loop; shutdown stops it like a running watcher.
|
||||
Init respawns the step, at most once every 5 seconds, and every start removes a marker left by a watcher that was killed or crashed before the new watcher runs.
|
||||
A ready store that fails is noticed up to one re-check interval plus one bounded call later, so the marker can be up to about a minute stale.
|
||||
Shutdown blocks further respawns, removes the marker, and stops the watcher by its recorded PID and start time, escalating to SIGKILL after a budget; it unmounts the worker disk only once the watcher is confirmed gone, and a survivor leaves every mount in place.
|
||||
A mismatched key, a store that refuses the key, a key missing from the disk or an unclassified gateway refusal is asked again only every 5 minutes, so it never spins and still recovers if the operator fixes it; a missing key is never replaced.
|
||||
The NKey authenticates the worker to the store, but nothing authenticates the store to the worker: there is no TLS, so a pass means only that something at that address accepted the key.
|
||||
This is store readiness for the worker alone; there is no SQS API, and no production sizing or support is claimed.
|
||||
|
||||
## Configuration
|
||||
|
||||
Cluster configuration (`spinifex/bootstrap/config/config.go`):
|
||||
|
||||
@@ -351,22 +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}` moved onto the same registration in `mulga-3nbla.7`, recorded below. |
|
||||
| `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#1345` | `spinifex/gateway/admin*.go` (the `/admin/{method}` handlers, gates and body cap) and the `AccountQuotaRequest`/`AccountQuotaResponse`/`DeleteAccountResponse` wire types | `spinifex/operator/admin` (`rest.go`, `account_*.go`), served by the same `spinifex` registration | `/admin/{method}` stays a chi route in `gateway`, ahead of the service selector, but `Admin_Request` now only mints the request ID and header and hands the request to the registered entry through `dispatchRegistered`, the path every Query request takes. `dispatch.Invocation` gains `Service`, the credential-scope service, so the scope gate is the first identity gate in the dispatcher and a key signed for another service is denied exactly as before. The order is unchanged: verb and method, then the five fail-closed gates (scope, super-admin account, IAM user, identity, `spinifex:<Method>` policy), then the cluster check, then the 8 KiB body cap. The dispatcher returns errors and the route renders them as the JSON envelope with `requestId`, then logs once from the real result, so a denial or a 5xx is never logged as a completed request; the registration's `ResolveAction` labels the method, which removes the `auth.go` special case. The caller is read from `dispatch.Actor` (`UserName` for the gate and `updatedBy`). `BucketStore` and `SignupMaxAccounts` leave `GatewayConfig` for `operator/admin.Deps`, composed in `awsgw` beside `IAM`, `Quota`, `ExpectedNodes` and `NodeIDs`. The three wire types that could not enter `contracts` move to `operator/admin` with their handlers, `operator/cli` imports them from there, and the last `operator/cli` → `gateway` allowlist entry is deleted. |
|
||||
| `spinifex#1346` | `spinifex/gateway/iam.go` (`IAM_Request`, the IAM action table, `getUserForCaller`, the PassRole check) | `spinifex/domains/iam/awsapi` (`NewRegistration`, `Deps`, `ActionNames`), composed by `spinifex/runtime/roles/awsgw` | IAM registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, service and account checks, then one parse of the input, the `GetUser` default for a missing `UserName`, scope resolution, authorization, the `iam:PassRole` check for `AddRoleToInstanceProfile`, then the handler. The caller is read from `dispatch.Actor` (`PrincipalType`, `UserName`), with no ARN parsing. The dependencies are `Deps.IAM` and `Deps.CountInstanceProfileAssociations`, composed in `awsgw`, and the gateway keeps no IAM fallback. The `cmd/aws-model-coverage` named exception gains an `iam` row. |
|
||||
| `spinifex#1346` | `spinifex/gateway/sts.go` (`STS_Request`, the STS action table, the anonymous STS dispatch) | `spinifex/domains/sts/awsapi` (`NewRegistration`, `Deps`, `ActionNames`, `NewAnonymousAssumeRoleWithWebIdentity`), composed by `spinifex/runtime/roles/awsgw` | STS registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, service check, caller, the `AssumeRole` identity gate scoped to the stored role ARN, then the handler. The caller is read from `dispatch.Actor` (`CallerARN`, `UserName`, `SessionName`, `UserID`, `AccessKeyID`), with no ARN parsing. The gateway's authorization failure wraps a generic `dispatch.PolicyDenialError`, and STS matches it to answer with the caller, action and resource, so the gateway holds no STS-specific denial code. An unsigned `AssumeRoleWithWebIdentity` stays ahead of SigV4 in the gateway and reaches the handler through `GatewayConfig.AnonymousSTS`, a func composed in `awsgw`, never the STS service. The `cmd/aws-model-coverage` named exception gains an `sts` row. |
|
||||
| `spinifex#1348` | `spinifex/gateway/ecs.go` (`ECS_Request`) and `spinifex/gateway/eks.go` (`EKS_Request`, the EKS route table) | `spinifex/domains/ecs/awsapi` and `spinifex/domains/eks/awsapi` (`NewRegistration`, `Deps`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | ECS (AWS JSON 1.1) and EKS (REST JSON) register through the ADR-0002 seam with the JSON error envelopes they had, both declared as AWS JSON 1.1 (see the envelope divergence below). The check order and every response are kept, pinned by the pipeline characterisation tests committed first. EKS and ECS resolve roles through `foundation/aws/passrole` `Check` with `domains/iam` `RoleResolver`: a malformed or foreign role is refused first, a missing role is authorized as written before it is reported, and a resolver outage is returned unchanged. Role handling now follows the AWS capture of 2026-10-30. ECS RegisterTaskDefinition refuses a foreign role with `ClientException` "Role is not valid" before PassRole and accepts a nonexistent, invented-path or non-ARN role from a caller who can pass roles. ECS RunTask refuses a foreign `taskRoleArn` or `executionRoleArn` override the same way and answers a nonexistent or invented-path one with "ECS was unable to assume the role". EKS CreateCluster and CreateNodegroup answer a non-ARN or foreign role with `AccessDeniedException` "Cross-account pass role is not allowed.". EKS public routes now report every parameter fault as `InvalidParameterException`, the code the EKS model declares, while the internal control-plane routes keep `InvalidParameterValue` because the guest agents match on it. Two deliberate EKS divergences remain. First, CreateCluster still refuses a nonexistent or invented-path role with `InvalidParameterException` "role ... does not exist", where AWS accepts it and the cluster later ends FAILED. Our EKS only stores, validates and echoes the cluster role and never assumes it, so accepting would leave an ACTIVE cluster bound to a role that does not exist. Second, CreateNodegroup keeps its existing refusal of a nonexistent role because AWS was not captured for it. A third divergence is the error envelope. EKS is restJson1 but registers `ErrorEnvelopeJSON`, so errors carry `application/x-amz-json-1.1` and a `__type` body where AWS sends `application/json` and a `message` body. The `X-Amzn-Errortype` header is correct and SDKs read the code from it, so impact is low. Switching to `ErrorEnvelopeRESTJSON` also changes the throttle code and the default internal-error code (`ServiceException` where the EKS model declares `ServerException`), so it is left for its own change. Verified live on env14: the ECS and EKS role cases above, and a valid CreateCluster reaching ACTIVE with the control-plane agent publishing through the gateway. The EKS route table and its tests move with the registration, and the gateway keeps no ECS or EKS fallback. The `cmd/aws-model-coverage` named exception gains an `ecs` and an `eks` row, and the two `gateway` allowlist rows are removed. |
|
||||
| `spinifex#1350` | `spinifex/gateway` (`ecr.go`, `ecrauth_middleware.go`, the `/v2/token` exchange, `ecr_principal.go` with `ResolveECRPrincipal`, the per-operation `/v2` authorization, the registry host matcher, `ctxECRPrincipal`) | `spinifex/domains/ecr/registry` (`Bridge`, `NewBridge`, `BridgeConfig`, and its own `Principal`, `Rehydrator`, `Authorizer`, `Admission` types with `ErrInvalidCredential` and `ErrUnavailable`), `spinifex/ingress/http` (`HostMatcher`, `TargetAccount`, `RequestClientIP`; names no service), `spinifex/ingress/aws/credential` (`Rehydrator`, `Principal.CallerARN`), and under `spinifex/runtime/roles/awsgw`: `credverify` (`Rehydrate`, sharing the SigV4 key and session lookups), `policyauth`, `accountadmission`, `registryadapt` | ADR-0001 amendment 2026-10-29 and ADR-0005:S2: the OCI `/v2` bridge and its token auth move out of the gateway, and ECR resolves its principal through the credential capability instead of reading `IAMService` and `STSService` directly. ADR-0002:S5 keeps `/v2` outside the seam, so the gateway keeps only a `RegistryHandler` mount that answers 501 when unset. `domains/ecr/registry` imports no ingress package (`TestADR0001_Layering` unchanged): the principal, rehydrator and authorizer are registry-local types and `registryadapt` maps `credential.Failure` codes onto `ErrInvalidCredential` (401) and `ErrUnavailable` (503). No widening of `dispatch.Actor` or `dispatch.Invocation`. The SigV4 path and the bridge share one policy evaluator (`policyauth`) and one account-admission check. Deliberate change: the open-registry mode for a nil token verifier is deleted and `NewBridge` refuses a nil Verifier, Issuer, Rehydrator, Authorizer or Admission, so tests that want an unauthenticated registry mount `registry.Handler` directly. The pre-move pipeline characterisation (status codes, error bodies, the `WWW-Authenticate` challenge, referrers, token exchange, revoked and deleted principals) passes unchanged apart from setup, and the retired `ResolveECRPrincipal` parity rows live on as `credverify` `Rehydrate` rows and `Bridge` claim-check rows. Live on env14 (DHCP external IP): crane, skopeo and docker push and pull (`tests/e2e/ecr`), a cosign sign and verify in the legacy tag mode and in OCI 1.1 referrers mode with the signature returned by `GET /v2/{repo}/referrers/{digest}`, `/v2/token` refresh from fresh logins, and a deactivated access key answering 401 on `/v2/token`, manifest GET, crane pull and crane push, then 200 again once reactivated. The ECR signing-key deadlock bead (`mulga-6wqce`) is untouched. |
|
||||
| `spinifex#1351` | `spinifex/gateway/{bedrock,bedrockruntime,bedrockagent,bedrockagentruntime}.go` (`Bedrock_Request`, `BedrockRuntime_Request`, `BedrockAgent_Request`, `BedrockAgentRuntime_Request`, the four route tables), `spinifex/domains/ochre/authz.go`, and the 13 `Bedrock*` fields of `GatewayConfig` | `spinifex/domains/ochre/awsapi` (`NewRegistration`, `Deps`, `Quota`, `OperationInventory`, `ServiceName`), composed by `spinifex/runtime/roles/awsgw` | The four Bedrock services register through the ADR-0002 seam as one registration, per ADR-0002:S1 (one per signing service) and the ADR-0001 amendment of 2026-10-06: the service is `bedrock` and `bedrock-runtime`, `bedrock-agent` and `bedrock-agent-runtime` are aliases. A request signed with a sub-service scope is dispatched to that sub-service whatever its path, so a path owned by another sub-service answers `InvalidActionException` as before; only the shared `bedrock` scope is split by path. The pipeline characterisation tests committed first (status, error type and envelope per service, metering order, nil-capability answers) pass unchanged apart from setup. The 13 gateway fields become `awsapi.Deps`: `Credentials`, `Endpoints`, `Access`, `Recorder`, `Logging`, `Provisioned`, `Guardrails`, `Embedder`, `KB`, `DataSources`, `Vector` and `Quota`, with the documented nil fallbacks (no grants denies every model, a nil store errors rather than panics); `BedrockAccessAdmin` had no reader outside one test and is gone, and `Deps.NATS` is dropped because the nil-NATS answer is given by the gateway-wide gate before dispatch. `Logging`, `Provisioned`, `Guardrails`, `KB` and `DataSources` are the concrete exported store types, a temporary edge until Ochre exposes capability interfaces for them. The wire-typed action functions stay in the `domains/ochre` root and the SDK-typed adapters move; `authz.go` moves with them, which removes the `domains/ochre` to `ingress/aws/bodyscope` allowlist row, the only allowlist change. The legacy switch cases, the `supportedServices` and `jsonErrorService` entries and the path-based sub-service rewrite in `GetService` are removed, so `ec2` is the only service left on the legacy table; the rewrite's rows are pinned in `awsapi` as `resolveSubservice` and dispatch tests. The registration declares the AWS JSON 1.1 envelope it had and `cmd/aws-model-coverage` is not wired, so no coverage allowlist row is added. Recorded divergences, not changed by this move: Bedrock is restJson1 in AWS but answers with the JSON 1.1 envelope (`application/x-amz-json-1.1`, `__type` body), and CreateGuardrail answers 200 where AWS answers 202; both are tracked by `mulga-xug8b`. No change to `dispatch.Actor` or `dispatch.Invocation`. |
|
||||
| `spinifex#1354` | `spinifex/gateway/ec2.go` (the EC2 action table, `ec2Handler`, `ec2Actions`, the dispatch pipeline), `ec2_idempotency.go`, `ec2_validation.go`, and the EC2 token-bucket fields of `GatewayConfig` | `spinifex/domains/ec2/awsapi` (`Handler`, `NewHandler`, `Deps`, `Quota`, `Dispatch`, `ActionNames`, `InputHasField`), composed by `spinifex/gateway/ec2.go` | Slice 9a of the EC2 move: the capabilities first, with no behaviour change. The EC2 action table, idempotency and validation move to `domains/ec2/awsapi` behind `Deps{NATS, IAM, Quota, ActiveNodes, ExpectedNodes, NodeIDs, AZ, Endpoint, InstanceStatus}`; the gateway keeps the composition and `EC2_Request`, still on the legacy switch until slice 9b registers EC2 through the ADR-0002 seam. `Quota` is declared in `awsapi` because `domains/admission/quota` imports the EC2 action packages and cannot be imported back, and the gateway wraps `quota.Service` to supply the NATS instance-type resolver its retype gate takes. The instance and spot-instance adapters take the two-method `InstanceProfiles` interface (`ResolveInstanceProfile`, `CanonicalResourceARN`) instead of `iamdomain.IAMService`, which pays off the `spotinstance` to `domains/admission/quota` and `domains/iam` allowlist rows. Allowlist changes are 20 deletions and no additions: the 18 `gateway` to `domains/ec2/awsapi/*` rows and those 2. The pipeline characterisation tests committed first (error envelopes, check order, gateway-local actions, idempotent replay, PassRole, ImportKeyPair padding, launch-template expansion before authorization, RunInstances instance-profile refusal order) pass unchanged. Package-local tests drive every routed action through `Dispatch` against a stub daemon. No change to `dispatch.Actor` or `dispatch.Invocation`. Live on env14: DescribeRegions, DescribeAvailabilityZones and DescribeAccountAttributes, an idempotent CreateVolume replay and its parameter mismatch, the DryRun and MaxResults errors, a no-policy user refused with UnauthorizedOperation (including on the gateway-local DescribeRegions), an assumed-role session allowed and refused per its policy, and one RunInstances to running then terminated. |
|
||||
| `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
|
||||
@@ -401,9 +387,9 @@ the canonical IAM/STS principal before dispatch, because ECR must not depend on
|
||||
gateway identity context or ARN construction.
|
||||
|
||||
All implemented ECR control-plane actions now dispatch through explicit ECR
|
||||
capabilities. The OCI `/v2` bridge, its token exchange and its principal
|
||||
resolution now live in `domains/ecr/registry`; the gateway keeps only the
|
||||
`RegistryHandler` mount, and `awsgw` composes the bridge.
|
||||
capabilities. The remaining ECR-named gateway code is generic `/v2/*` route
|
||||
assembly, SigV4/registry authentication and policy enforcement; it remains in
|
||||
gateway rather than inverting those dependencies into the ECR domain.
|
||||
|
||||
### ACM relocation residuals
|
||||
|
||||
@@ -452,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`, `domains/ecs/awsapi`, `domains/eks/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` and `gateway` to read ECR's and ACM'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.
|
||||
@@ -460,7 +446,7 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
- `foundation/messaging/nats/nats_trace.go` still names its OpenTelemetry tracer `github.com/mulgadc/spinifex/spinifex/utils`. It is an emitted instrumentation-scope name that dashboards may filter on, so renaming it is a telemetry change, not a path fix.
|
||||
- `runtime/compute/cache.New`: two construction-time panics carry a lint exemption pending a constructor that returns an error.
|
||||
- `domains/ec2/systeminstance`: managed-service parent metadata is only the `ManagedBy` tag value.
|
||||
- `domains/ochre` (action adapters): cleared by the Bedrock registration in `spinifex#1351`. The SDK-typed adapters and `authz.go` moved to `domains/ochre/awsapi`, the `ingress/aws/bodyscope` allowlist row is deleted, and the adapters take the concrete exported Ochre stores through `awsapi.Deps` as a temporary edge until Ochre exposes capability interfaces for them.
|
||||
- `domains/ochre` (action adapters): since `spinifex#1266` the Bedrock action layer (handlers, action authorization, event-stream framing and the CRUD, catalogue and Converse/Invoke entry points) shares package `ochre` with the domain core, stores and inference engine, and imports `ingress/aws/bodyscope`. Moving it to `domains/ochre/awsapi` is not mechanical: the adapters reach package-private store internals (the guardrail and provisioned-throughput store handles and get/put/update methods), and adapter tests override the package-private model catalogue. It needs core capabilities first, then the move; the top-level `gateway/bedrock*.go` HTTP handlers additionally wait for the ingress registration seam.
|
||||
- `domains/network`: `reconcile/intent.go` builds desired network state by reading EC2's KV buckets and decoding EC2's private VPC, subnet, ENI, route-table, gateway, EIP, NAT and security-group records, and `reconcile/drift.go` watches those buckets. ADR-0001 names this pair explicitly: EC2 and network communicate through a capability/projection or versioned contract, never by one being the other's KV reader. Step 7 fix: an EC2-owned `domains/ec2/projection` exposing network intent with list and watch (it may stay KV-backed, so no data moves); a narrow intent interface declared by `reconcile`; wiring at the composition root; and the duplicated `PurposeEIP`/`PurposeIGWLRP` constants and mirrored `SGRule`/`SGEvent` folded into the projection or `contracts/ec2/v1`. The drift-watch coverage test and reconcile scenario tests are the evidence the projection must keep. `domains/network` also imports `runtime/compute/vm` and `runtime/host/command` (domain-to-runtime).
|
||||
- `domains/ec2/instance`: imports `runtime/compute/vm` and `runtime/compute/gpu` (domain-to-runtime) and `domains/dns` (cross-domain implementation import).
|
||||
- `domains/ec2/instance/state` (dependency): the vpcd IMDS local reader imports `runtime/compute/vm` to return `vm.VM`. Removal condition: that type moves into a domain-owned package that `runtime/compute/vm` consumes (runtime to domain is allowed).
|
||||
@@ -470,13 +456,14 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
- `operator/installer/firstboot` (security): `Config.InstallCallback` is rendered into `spinifex-firstboot.sh`, which is world-readable (0755). The field is currently unused (`fireInstallCallback` reads `SPINIFEX_INSTALL_CALLBACK` from the environment instead), but any future wiring must not place secrets or tokenised callback URLs in the generated script; sensitive callback credentials should reach first boot through a protected channel such as a 0600 file or a credential store.
|
||||
- `operator/cli` (known security risk): `spx admin join` contacts the formation server with `InsecureSkipVerify`, sends the join token as a bearer credential and receives the cluster CA certificate and key, master key, Viperblock key and shared credentials, so an on-path attacker during formation can capture the token and the cluster root secrets or substitute a CA. This predates the move; lint surfaced it when the tree left `cmd/`. It is accepted for now and carries a line-level gosec annotation stating it is not a false positive. Removal condition: authenticate formation TLS with a pinned key or certificate delivered with the join material, or an out-of-band CA, with tests proving a mismatched server certificate fails before credentials are sent.
|
||||
- `domains/eks/cluster` (potential security risk): `ClusterMeta.ControlPlaneTemplate` (`domains/eks/cluster/service_impl.go`) clears the template's credentials but keeps `OIDCPrivateKeyPEM` and the k3s `JoinToken`, so both are persisted in the `eks-account-{acct}` cluster record, which the gateway also reads. Whether that record is encrypted at rest is unconfirmed; if it is not, a reader of the bucket can forge service-account tokens for the cluster's OIDC issuer and join nodes to it. This predates the refactor and is not fixed in it. Removal condition: the key and token are held by a dedicated secret store (a future KMS or Secrets Manager capability is the likely owner) and the cluster record keeps only a reference, with a test proving the persisted record contains neither secret.
|
||||
- `operator/cli` (dependency): the command tree still imports domain and service implementations directly (`handlers/{iam,eks}`, `domains/admission/quota`, `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.
|
||||
- `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 action table, idempotency and validation moved to `domains/ec2/awsapi` behind `Handler` and `Deps` (slice 9a, `spinifex#1354`), but the gateway still composes the handler and serves it from the legacy switch, so `gateway` keeps importing `domains/ec2/awsapi` and `domains/ec2/awsapi/instance`. Registering EC2 through the ADR-0002 seam and emptying the legacy switch is slice 9b.
|
||||
- `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/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, 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` for the `InstanceProfile` type that `InstanceProfiles.ResolveInstanceProfile` returns (`RunInstances.go`, `IamInstanceProfileAssociation.go`), `runtime/compute/cache` (`NodeState`, `NodeUnknown`, `NodeLive`) and `runtime/compute/vm` (`VM`), both in `status_synthesis.go`. The IAM edge is now the two-method `InstanceProfiles` interface plus that type; the runtime edges are domain-to-runtime and need the instance record and node liveness exposed through an EC2-owned projection.
|
||||
- `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.
|
||||
- `domains/ec2/awsapi/spotinstance` (dependency): imports `domains/iam.IAMService` and `domains/admission/quota.Service` in `RequestSpotInstances.go`.
|
||||
- Instance adapter consumers outside EC2: `domains/rds/awsapi/enginecatalog.go`, `runtime/daemon/eks_cp_control.go`, `runtime/daemon/rds_deps.go`, `domains/admission/quota`'s `vcpu.go` and `gateway/iam.go` (`CountInstanceProfileAssociations`) call `domains/ec2/awsapi/instance` directly; each needs an EC2 capability rather than the AWS action adapter.
|
||||
- `runtime/daemon` (Q-28 ownership and deployment): the daemon moved whole, keeping its current ownership, deployment and `daemon → gateway → handlers` dependencies. Q-28's write-side consolidation and any daemon decomposition or split remain deferred by ADR-0001 and are not paid by the move.
|
||||
|
||||
@@ -686,11 +673,11 @@ series above; "needs decision" is open.
|
||||
| `handlers/elbv2` | — | Done. The implementation moved to `domains/elbv2` (package `elbv2`) with no API, NATS subject, storage or behaviour change. Q-30's data-plane boundary remains deferred debt; the amended ADR-0001 row makes it not a relocation prerequisite. Its cross-domain, IAM and `agents/elbv2` imports remain dependency-rule debt, with their allowlist entries now sourced from `domains/elbv2`. |
|
||||
| `handlers/iam/mock` | — | Done. Moved with the implementation to `domains/iam/mock`. |
|
||||
| `gateway` (root) | Generic HTTP route assembly, SigV4/registry authentication, the policy gate, request-audit middleware, request-context keys, and the legacy per-service dispatch switch for every service not yet registered. | Deferred: the ADR-0002 phases above (auth/IAM and request-context boundary design) gate moving the audit, context-key and legacy-dispatch mechanics. |
|
||||
| `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. The action adapters then moved to `domains/ochre/awsapi` behind one ADR-0002 registration in `spinifex#1351`. |
|
||||
| `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 and the `/admin/{method}` handlers that call them now live in `operator/admin` as one seam registration. |
|
||||
| `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. |
|
||||
@@ -709,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`. Done: the `spinifex` service and `/admin` dispatch registration, owned by `operator/admin` | 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 |
|
||||
@@ -157,7 +158,6 @@ Restarting the daemon is what re-arms it: the node rebuilds its peer list from t
|
||||
|
||||
> [!WARNING]
|
||||
> Do not flush the whole nftables ruleset to clear the policy. The VPC daemon writes SNAT and per-elastic-IP forward rules into the `ip nat` and `ip filter` tables and reinstalls them only when it starts, so `nft flush ruleset` breaks elastic IPs silently until the next `systemctl restart spinifex-vpcd`. Use `disable`, which only ever touches `inet spinifex_filter`.
|
||||
> On Oracle Linux with IPsec on, a flush also removes `inet spinifex_ipsec_guard`, so Geneve to a peer without an SA goes out in clear until the daemon puts the table back, within 60 seconds.
|
||||
|
||||
## The Files
|
||||
|
||||
|
||||
@@ -144,27 +144,6 @@ The guest MTU moves with the setting, but a running guest holds its DHCP lease f
|
||||
|
||||
**Expect launches to fail for a minute or two after re-enabling.** `RunInstances` returns `ServerInternal` with `vpc.add-nat` timing out, because the OVN flows-ready barrier cannot confirm while `ovn-controller` reprograms every tunnel for the new SAs. It clears once `ip xfrm state` settles. The error is reported as a capacity problem, which it is not.
|
||||
|
||||
### Containers and nested overlays inside a guest
|
||||
|
||||
A Docker bridge, Kubernetes pod network or other overlay inside a guest defaults to an MTU of 1500, which is larger than the guest's 1408 or 1442.
|
||||
Downloads from the internet still work for TCP on the veth and routed uplinks, because vpcd clamps the TCP MSS on the host hop (see [Performance Tuning → MTU](/docs/performance-tuning#4-mtu)).
|
||||
That was measured on a single node (env12) with a Docker container; it was not measured on a physical uplink or on a multi-node cluster.
|
||||
|
||||
On a physical uplink there is no host hop to clamp, and UDP is never clamped, so set the inner MTU to the guest MTU.
|
||||
For Docker, in `/etc/docker/daemon.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mtu": 1408,
|
||||
"default-network-opts": {
|
||||
"bridge": { "com.docker.network.driver.mtu": "1408" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use 1442 when `ipsec_enabled = false`, and restart the Docker daemon so the default bridge is recreated.
|
||||
The gateway MTU follows `ipsec_enabled` and converges on the next reconcile pass, so change the inner MTU together with the guest MTU.
|
||||
|
||||
## Private vs Public Subnets
|
||||
|
||||
A subnet's behavior depends on three things: whether the VPC has an Internet Gateway, whether the subnet's route table has a default route to that IGW, and whether the subnet has `MapPublicIpOnLaunch` enabled.
|
||||
|
||||
@@ -45,7 +45,6 @@ Each repository holds image manifests and their layers. Images are stored in obj
|
||||
|
||||
- Repository lifecycle — `CreateRepository`, `DeleteRepository`, `DescribeRepositories`.
|
||||
- OCI push and pull — blobs, manifests, and tags, including cross-repository blob mounts.
|
||||
- OCI 1.1 referrers — the referrers API, so cosign v3 signatures and other artifacts attached to an image can be listed (see [OCI referrers and image signing](#oci-referrers-and-image-signing)).
|
||||
- Token auth — `GetAuthorizationToken` plus bearer/basic auth on `/v2/`.
|
||||
- Repository policies — set, get, and delete a repository policy document.
|
||||
|
||||
@@ -177,71 +176,6 @@ Then authenticate Docker and push using the `repository_url` output, following s
|
||||
|
||||
:::
|
||||
|
||||
## EKS workers and multiple gateways
|
||||
|
||||
Each gateway puts its own address in the `repositoryUri` it returns, so repositories created through different gateways can carry different hosts.
|
||||
When an EKS worker launches, the daemon that launches it writes every cluster gateway into the worker's registry mirrors, TLS trust and credential-provider config.
|
||||
A pod can then pull from any `repositoryUri` host in the cluster.
|
||||
The worker tries the gateway named in the image reference first and falls back to the others in cluster order.
|
||||
|
||||
Limits:
|
||||
|
||||
- The gateway list comes from the cluster config the launching daemon loaded at start.
|
||||
A gateway added later is not trusted by existing workers; restart the launching daemon and replace the worker.
|
||||
- Peer gateways are assumed to listen on port 9999.
|
||||
A hand-edited `awsgw` bind on a peer is not covered.
|
||||
- A gateway address that does not answer costs about 30 seconds on each cold pull while the worker's connection attempt gives up.
|
||||
A stopped gateway on a host that is still up refuses the connection, and the worker moves to the next gateway at once.
|
||||
|
||||
Measured on a single-node environment: pulls by `repositoryUri` and by the account's ECR DNS name, a pull rejected with `x509: certificate signed by unknown authority` when the gateway host was not in the worker config, and a 31 second pull through an unreachable first endpoint.
|
||||
Measured on a 3-node environment: a pod pulling each of the three gateways' `repositoryUri` on a worker launched with this change (two of the three failed with `x509` on a worker launched without it), and a pull of the stopped gateway's own `repositoryUri` that succeeded through another gateway within one retry (the pull itself took 147 ms after the first endpoint refused).
|
||||
|
||||
## OCI referrers and image signing
|
||||
|
||||
A manifest can name another manifest as its `subject`, which is how tools such as cosign v3 attach signatures and SBOMs to an image.
|
||||
The registry records the subject when the manifest is pushed and answers with an `OCI-Subject` header on the `201`.
|
||||
The `subject` does not have to exist in the repository when the referrer is pushed.
|
||||
|
||||
`GET /v2/<name>/referrers/<digest>` lists the manifests that name `<digest>` as their subject.
|
||||
The request is authorised as `ecr:BatchGetImage` on the repository.
|
||||
The response is `200` with `Content-Type: application/vnd.oci.image.index.v1+json` and an index whose `manifests` hold each referrer's `mediaType`, `digest`, `size`, `artifactType` and annotations.
|
||||
A digest with no referrers, or one that is not stored, returns an empty index.
|
||||
A malformed digest returns `400` with `DIGEST_INVALID`.
|
||||
|
||||
Adding `?artifactType=<type>` returns only referrers of that type and sets `OCI-Filters-Applied: artifactType`.
|
||||
The header is also set when nothing matches.
|
||||
An image manifest without an `artifactType` is listed under its config `mediaType`; an index without one has none.
|
||||
|
||||
Deleting an image by digest (`DELETE /v2/<name>/manifests/<digest>`, `aws ecr batch-delete-image` with `imageDigest`) also deletes its referrers, and their referrers in turn.
|
||||
Deleting by tag removes only the tag and leaves the image and its referrers.
|
||||
|
||||
cosign v3 sign and verify work against the registry, including on a child manifest of an image index copied with `cosign copy`.
|
||||
cosign v2 keeps writing its `sha256-<digest>.sig` tags and is unaffected.
|
||||
`GET` on a manifest still returns `406` when the request's `Accept` header does not include the stored `mediaType`.
|
||||
|
||||
Limits:
|
||||
|
||||
- `cosign copy` does not copy v3 signatures to the destination repository.
|
||||
This is client behaviour.
|
||||
To keep the original signature, copy with `oras cp -r <source> <mirror>`, which copies the image and its referrers in one command, or `crane copy` the signature manifest by digest after the image.
|
||||
Otherwise, sign the copy again.
|
||||
- Referrers pushed before this version are not listed.
|
||||
Push them again to add them to the index.
|
||||
- A crash between deleting an image and deleting its referrers can leave referrers behind that nothing lists.
|
||||
- The referrers listing is not paginated.
|
||||
- `HEAD` on the referrers route is not served.
|
||||
|
||||
Measured on a 3-node environment with cosign v3.1.4 and v2.6.5:
|
||||
|
||||
- cosign v3.1.4 signed a fresh image index, with one `GET /referrers` answered `200`, no `sha256-<digest>` tag written and no `406`.
|
||||
- Verify passed.
|
||||
- A copied index's child manifest signed and verified, which returned `406` before this change.
|
||||
- The listing returned the signature with its `artifactType`, the filter returned one entry for the signature's type and none for another type.
|
||||
- `aws ecr batch-delete-image` of the signed image left an empty listing and `404` for both the signature and the image.
|
||||
- cosign v2.6.5 signed and verified a fresh image.
|
||||
|
||||
Not measured: `oras`, `notation`, a listing with many referrers, and a crash in the middle of a delete.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`docker login` fails with an authorization error.** The token is account-scoped and short-lived. Re-run `aws ecr get-login-password` to mint a fresh token, and confirm the registry host in `docker login` matches your account's endpoint (`aws ecr describe-repositories` returns it).
|
||||
|
||||
@@ -60,36 +60,6 @@ Authentication uses **EKS access entries** (the API authentication mode) — IAM
|
||||
- **The worker image is fixed.** Node groups always boot Spinifex's `eks-node` image. `ami_type` is recorded but does not select the image, and the image must be registered on the cluster before you create a node group.
|
||||
- **Default Kubernetes version is `1.32`.**
|
||||
|
||||
### Control plane readiness and kubectl authentication
|
||||
|
||||
Each control-plane VM authenticates `kubectl` tokens through a webhook that calls the AWS gateway on the host the VM runs on.
|
||||
When that gateway is stopped, the webhook cannot validate tokens and `kubectl` reports `Unauthorized` for the share of requests that the load balancer sends to that control plane.
|
||||
|
||||
The webhook serves `/readyz` on port 8444 of each control-plane VM.
|
||||
It returns 200 only when the host gateway answers a TLS handshake and the local API server accepts a TCP connection, and 503 otherwise.
|
||||
The API server target group health-checks `HTTP :8444/readyz` every 5 seconds with a healthy and unhealthy threshold of 2.
|
||||
The target group still forwards TCP on 6443.
|
||||
A control plane that cannot authenticate leaves rotation, and returns when its gateway is back.
|
||||
|
||||
Measured on a three-node cluster, one control plane per host, with `kubectl get ns` run continuously after stopping one gateway:
|
||||
|
||||
| Gateway stopped | Result |
|
||||
| --- | --- |
|
||||
| Host without the cluster load balancer VM, first run | 4 of 20 calls failed in the first round, 0 of 20 in the next round 54 s after the stop |
|
||||
| Host without the cluster load balancer VM, second run | 2 failures in 100 s, at 3 s and 8 s after the stop |
|
||||
| Host running the cluster load balancer VM | Not fixed: 20 of 60 calls failed over 100 s |
|
||||
|
||||
Before this change the same test failed 3, 4 and 3 calls of 10 per stopped gateway.
|
||||
|
||||
Limits:
|
||||
|
||||
- Only clusters created with the current `eks-node` image get the readiness listener, and only clusters created from now on get the health check on the target group.
|
||||
Older clusters keep the TCP check on 6443 and are not changed.
|
||||
- A cluster created with an older image gets the health check on its target group but no listener on 8444, so every control plane fails the check and the load balancer fails open to all of them.
|
||||
- A stopped gateway on the host that runs the cluster load balancer VM is not covered.
|
||||
The load balancer VM reports target health through its own host's gateway, so while that gateway is down the unhealthy control plane stays in rotation.
|
||||
- Not measured: more than one control plane per host, a physical-node failure instead of a stopped gateway, and the target health state as `DescribeTargetHealth` reports it, because the load balancer and control-plane VMs belong to the system account.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -153,7 +123,7 @@ aws eks describe-cluster --name demo --query 'cluster.status'
|
||||
aws eks create-nodegroup \
|
||||
--cluster-name demo \
|
||||
--nodegroup-name default \
|
||||
--node-role arn:aws:iam::000000000000:role/eks-node-role \
|
||||
--node-role-arn arn:aws:iam::000000000000:role/eks-node-role \
|
||||
--subnets subnet-aaaa subnet-bbbb \
|
||||
--scaling-config minSize=1,maxSize=2,desiredSize=1 \
|
||||
--instance-types t3.medium
|
||||
@@ -286,10 +256,6 @@ The workbooks form a ladder. **eks-quickstart** is a NodePort demo; **eks-https-
|
||||
|
||||
**`kubectl` cannot connect.** Re-run `aws eks update-kubeconfig --name <name>` to refresh the endpoint and CA, and confirm the cluster is `ACTIVE`. `aws sts get-caller-identity` verifies your credentials reach the Spinifex endpoint.
|
||||
|
||||
**`kubectl` returns `Unauthorized` for some requests and works for others.**
|
||||
One control plane cannot reach its host's AWS gateway, so check `systemctl status spinifex-awsgw` on each node.
|
||||
Control plane readiness and kubectl authentication above lists which clusters recover on their own.
|
||||
|
||||
**Workers stay `NotReady`.** Almost always an egress or IAM problem:
|
||||
- Confirm the workers can pull images — a default route to an Internet Gateway (public) or NAT Gateway (private).
|
||||
- Confirm the node role has `AmazonEKSWorkerNodePolicy`, `AmazonEKS_CNI_Policy`, and `AmazonEC2ContainerRegistryReadOnly`.
|
||||
|
||||
@@ -17,7 +17,7 @@ tags:
|
||||
|
||||
## Overview
|
||||
|
||||
The platform serves **405 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
|
||||
The platform serves **392 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
|
||||
|
||||
| Service | Operations |
|
||||
|---|---:|
|
||||
@@ -29,7 +29,6 @@ The platform serves **405 operations** across the AWS APIs below. Every page nam
|
||||
| [ELBv2](/coverage/elbv2) | 36 |
|
||||
| [IAM](/coverage/iam) | 81 |
|
||||
| [RDS](/coverage/rds) | 27 |
|
||||
| [Route 53](/coverage/route53) | 13 |
|
||||
| [S3](/coverage/s3) | 26 |
|
||||
| [STS](/coverage/sts) | 5 |
|
||||
| **Total** | **405** |
|
||||
| **Total** | **392** |
|
||||
|
||||
+2
-228
@@ -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",
|
||||
@@ -3535,180 +3483,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"service": "route53",
|
||||
"api_version": "2013-04-01",
|
||||
"opaque": false,
|
||||
"note": "",
|
||||
"operations": {
|
||||
"modelled": [
|
||||
"ActivateKeySigningKey",
|
||||
"AssociateVPCWithHostedZone",
|
||||
"ChangeCidrCollection",
|
||||
"ChangeResourceRecordSets",
|
||||
"ChangeTagsForResource",
|
||||
"CreateCidrCollection",
|
||||
"CreateHealthCheck",
|
||||
"CreateHostedZone",
|
||||
"CreateKeySigningKey",
|
||||
"CreateQueryLoggingConfig",
|
||||
"CreateReusableDelegationSet",
|
||||
"CreateTrafficPolicy",
|
||||
"CreateTrafficPolicyInstance",
|
||||
"CreateTrafficPolicyVersion",
|
||||
"CreateVPCAssociationAuthorization",
|
||||
"DeactivateKeySigningKey",
|
||||
"DeleteCidrCollection",
|
||||
"DeleteHealthCheck",
|
||||
"DeleteHostedZone",
|
||||
"DeleteKeySigningKey",
|
||||
"DeleteQueryLoggingConfig",
|
||||
"DeleteReusableDelegationSet",
|
||||
"DeleteTrafficPolicy",
|
||||
"DeleteTrafficPolicyInstance",
|
||||
"DeleteVPCAssociationAuthorization",
|
||||
"DisableHostedZoneDNSSEC",
|
||||
"DisassociateVPCFromHostedZone",
|
||||
"EnableHostedZoneDNSSEC",
|
||||
"GetAccountLimit",
|
||||
"GetChange",
|
||||
"GetCheckerIpRanges",
|
||||
"GetDNSSEC",
|
||||
"GetGeoLocation",
|
||||
"GetHealthCheck",
|
||||
"GetHealthCheckCount",
|
||||
"GetHealthCheckLastFailureReason",
|
||||
"GetHealthCheckStatus",
|
||||
"GetHostedZone",
|
||||
"GetHostedZoneCount",
|
||||
"GetHostedZoneLimit",
|
||||
"GetQueryLoggingConfig",
|
||||
"GetReusableDelegationSet",
|
||||
"GetReusableDelegationSetLimit",
|
||||
"GetTrafficPolicy",
|
||||
"GetTrafficPolicyInstance",
|
||||
"GetTrafficPolicyInstanceCount",
|
||||
"ListCidrBlocks",
|
||||
"ListCidrCollections",
|
||||
"ListCidrLocations",
|
||||
"ListGeoLocations",
|
||||
"ListHealthChecks",
|
||||
"ListHostedZones",
|
||||
"ListHostedZonesByName",
|
||||
"ListHostedZonesByVPC",
|
||||
"ListQueryLoggingConfigs",
|
||||
"ListResourceRecordSets",
|
||||
"ListReusableDelegationSets",
|
||||
"ListTagsForResource",
|
||||
"ListTagsForResources",
|
||||
"ListTrafficPolicies",
|
||||
"ListTrafficPolicyInstances",
|
||||
"ListTrafficPolicyInstancesByHostedZone",
|
||||
"ListTrafficPolicyInstancesByPolicy",
|
||||
"ListTrafficPolicyVersions",
|
||||
"ListVPCAssociationAuthorizations",
|
||||
"TestDNSAnswer",
|
||||
"UpdateHealthCheck",
|
||||
"UpdateHostedZoneComment",
|
||||
"UpdateHostedZoneFeatures",
|
||||
"UpdateTrafficPolicyComment",
|
||||
"UpdateTrafficPolicyInstance"
|
||||
],
|
||||
"registered": [
|
||||
"ChangeResourceRecordSets",
|
||||
"ChangeTagsForResource",
|
||||
"CreateHostedZone",
|
||||
"DeleteHostedZone",
|
||||
"GetChange",
|
||||
"GetHostedZone",
|
||||
"GetHostedZoneCount",
|
||||
"ListHostedZones",
|
||||
"ListHostedZonesByName",
|
||||
"ListResourceRecordSets",
|
||||
"ListTagsForResource",
|
||||
"ListTagsForResources",
|
||||
"UpdateHostedZoneComment"
|
||||
],
|
||||
"implemented": [
|
||||
"ChangeResourceRecordSets",
|
||||
"ChangeTagsForResource",
|
||||
"CreateHostedZone",
|
||||
"DeleteHostedZone",
|
||||
"GetChange",
|
||||
"GetHostedZone",
|
||||
"GetHostedZoneCount",
|
||||
"ListHostedZones",
|
||||
"ListHostedZonesByName",
|
||||
"ListResourceRecordSets",
|
||||
"ListTagsForResource",
|
||||
"ListTagsForResources",
|
||||
"UpdateHostedZoneComment"
|
||||
],
|
||||
"stubbed": [],
|
||||
"unsupported": [],
|
||||
"missing": [
|
||||
"ActivateKeySigningKey",
|
||||
"AssociateVPCWithHostedZone",
|
||||
"ChangeCidrCollection",
|
||||
"CreateCidrCollection",
|
||||
"CreateHealthCheck",
|
||||
"CreateKeySigningKey",
|
||||
"CreateQueryLoggingConfig",
|
||||
"CreateReusableDelegationSet",
|
||||
"CreateTrafficPolicy",
|
||||
"CreateTrafficPolicyInstance",
|
||||
"CreateTrafficPolicyVersion",
|
||||
"CreateVPCAssociationAuthorization",
|
||||
"DeactivateKeySigningKey",
|
||||
"DeleteCidrCollection",
|
||||
"DeleteHealthCheck",
|
||||
"DeleteKeySigningKey",
|
||||
"DeleteQueryLoggingConfig",
|
||||
"DeleteReusableDelegationSet",
|
||||
"DeleteTrafficPolicy",
|
||||
"DeleteTrafficPolicyInstance",
|
||||
"DeleteVPCAssociationAuthorization",
|
||||
"DisableHostedZoneDNSSEC",
|
||||
"DisassociateVPCFromHostedZone",
|
||||
"EnableHostedZoneDNSSEC",
|
||||
"GetAccountLimit",
|
||||
"GetCheckerIpRanges",
|
||||
"GetDNSSEC",
|
||||
"GetGeoLocation",
|
||||
"GetHealthCheck",
|
||||
"GetHealthCheckCount",
|
||||
"GetHealthCheckLastFailureReason",
|
||||
"GetHealthCheckStatus",
|
||||
"GetHostedZoneLimit",
|
||||
"GetQueryLoggingConfig",
|
||||
"GetReusableDelegationSet",
|
||||
"GetReusableDelegationSetLimit",
|
||||
"GetTrafficPolicy",
|
||||
"GetTrafficPolicyInstance",
|
||||
"GetTrafficPolicyInstanceCount",
|
||||
"ListCidrBlocks",
|
||||
"ListCidrCollections",
|
||||
"ListCidrLocations",
|
||||
"ListGeoLocations",
|
||||
"ListHealthChecks",
|
||||
"ListHostedZonesByVPC",
|
||||
"ListQueryLoggingConfigs",
|
||||
"ListReusableDelegationSets",
|
||||
"ListTrafficPolicies",
|
||||
"ListTrafficPolicyInstances",
|
||||
"ListTrafficPolicyInstancesByHostedZone",
|
||||
"ListTrafficPolicyInstancesByPolicy",
|
||||
"ListTrafficPolicyVersions",
|
||||
"ListVPCAssociationAuthorizations",
|
||||
"TestDNSAnswer",
|
||||
"UpdateHealthCheck",
|
||||
"UpdateHostedZoneFeatures",
|
||||
"UpdateTrafficPolicyComment",
|
||||
"UpdateTrafficPolicyInstance"
|
||||
],
|
||||
"extra": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"service": "s3",
|
||||
"api_version": "2006-03-01",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -24,74 +24,6 @@ Spinifex implements **36 operations** in the ELBv2 `2015-12-01` API model.
|
||||
|
||||
The data plane is a system-managed load balancer VM, launched automatically during `CreateLoadBalancer`. Application Load Balancers run HAProxy for the L7 surface — rules, fixed responses and redirects over HTTP and HTTPS. Network Load Balancers run nginx `stream` for the L4 surface — TCP, UDP, TLS and TCP_UDP — because HAProxy cannot load-balance UDP. The agent selects the engine from the configuration the control plane delivers, so the choice follows the load balancer type and is not separately configurable.
|
||||
|
||||
### Target health and routing
|
||||
|
||||
Traffic goes only to targets that are healthy or still `initial`.
|
||||
A target that has failed its health check is removed from the data plane on the next render and put back when it passes again.
|
||||
When every target in a group is unhealthy, the load balancer fails open and sends traffic to all of them rather than refusing it.
|
||||
A Network Load Balancer renders every registered server in that case, and an Application Load Balancer falls back to unchecked backup servers that HAProxy uses only when every checked server is down.
|
||||
`DescribeTargetHealth` keeps reporting the targets as unhealthy while this happens.
|
||||
Draining targets and targets without an address never receive traffic.
|
||||
|
||||
An `initial` target receives traffic before its first successful check.
|
||||
This differs from AWS, where a target takes no traffic until it passes.
|
||||
Health state is reset to `initial` for every target when the control plane restarts, so excluding `initial` targets would drop all traffic after a restart.
|
||||
|
||||
Changing the health check of a target group with `ModifyTargetGroup` takes effect on the next render of every load balancer that uses it.
|
||||
The time from a backend failing to traffic stopping is the health check interval times the unhealthy threshold, plus the agent poll of about 5 seconds and the heartbeat that reports the result.
|
||||
Recovery takes the interval times the healthy threshold, plus the same delays.
|
||||
|
||||
Measured on a three-node cluster with a 5 second interval and thresholds of 2:
|
||||
|
||||
| Event | Time |
|
||||
| --- | --- |
|
||||
| Backend starts failing to reported `unhealthy` | 21 s |
|
||||
| Backend recovers to reported `healthy` | 15 s, serving again 6 s later |
|
||||
| Every backend failing to reported `unhealthy` | 12 s, fail open immediate |
|
||||
|
||||
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 |
|
||||
|
||||
@@ -1,71 +1,3 @@
|
||||
### Two data planes
|
||||
|
||||
The data plane is a system-managed load balancer VM, launched automatically during `CreateLoadBalancer`. Application Load Balancers run HAProxy for the L7 surface — rules, fixed responses and redirects over HTTP and HTTPS. Network Load Balancers run nginx `stream` for the L4 surface — TCP, UDP, TLS and TCP_UDP — because HAProxy cannot load-balance UDP. The agent selects the engine from the configuration the control plane delivers, so the choice follows the load balancer type and is not separately configurable.
|
||||
|
||||
### Target health and routing
|
||||
|
||||
Traffic goes only to targets that are healthy or still `initial`.
|
||||
A target that has failed its health check is removed from the data plane on the next render and put back when it passes again.
|
||||
When every target in a group is unhealthy, the load balancer fails open and sends traffic to all of them rather than refusing it.
|
||||
A Network Load Balancer renders every registered server in that case, and an Application Load Balancer falls back to unchecked backup servers that HAProxy uses only when every checked server is down.
|
||||
`DescribeTargetHealth` keeps reporting the targets as unhealthy while this happens.
|
||||
Draining targets and targets without an address never receive traffic.
|
||||
|
||||
An `initial` target receives traffic before its first successful check.
|
||||
This differs from AWS, where a target takes no traffic until it passes.
|
||||
Health state is reset to `initial` for every target when the control plane restarts, so excluding `initial` targets would drop all traffic after a restart.
|
||||
|
||||
Changing the health check of a target group with `ModifyTargetGroup` takes effect on the next render of every load balancer that uses it.
|
||||
The time from a backend failing to traffic stopping is the health check interval times the unhealthy threshold, plus the agent poll of about 5 seconds and the heartbeat that reports the result.
|
||||
Recovery takes the interval times the healthy threshold, plus the same delays.
|
||||
|
||||
Measured on a three-node cluster with a 5 second interval and thresholds of 2:
|
||||
|
||||
| Event | Time |
|
||||
| --- | --- |
|
||||
| Backend starts failing to reported `unhealthy` | 21 s |
|
||||
| Backend recovers to reported `healthy` | 15 s, serving again 6 s later |
|
||||
| Every backend failing to reported `unhealthy` | 12 s, fail open immediate |
|
||||
|
||||
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`
|
||||
@@ -1269,71 +1243,6 @@ option-groups: Option groups configure engine add-ons for engines this platform
|
||||
- `DescribeOptionGroups`
|
||||
- `ModifyOptionGroup`
|
||||
|
||||
## route53
|
||||
|
||||
`2013-04-01` — 13 of 71 modelled operations implemented (18.3%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
|
||||
|
||||
### Not implemented
|
||||
|
||||
- `ActivateKeySigningKey`
|
||||
- `AssociateVPCWithHostedZone`
|
||||
- `ChangeCidrCollection`
|
||||
- `CreateCidrCollection`
|
||||
- `CreateHealthCheck`
|
||||
- `CreateKeySigningKey`
|
||||
- `CreateQueryLoggingConfig`
|
||||
- `CreateReusableDelegationSet`
|
||||
- `CreateTrafficPolicy`
|
||||
- `CreateTrafficPolicyInstance`
|
||||
- `CreateTrafficPolicyVersion`
|
||||
- `CreateVPCAssociationAuthorization`
|
||||
- `DeactivateKeySigningKey`
|
||||
- `DeleteCidrCollection`
|
||||
- `DeleteHealthCheck`
|
||||
- `DeleteKeySigningKey`
|
||||
- `DeleteQueryLoggingConfig`
|
||||
- `DeleteReusableDelegationSet`
|
||||
- `DeleteTrafficPolicy`
|
||||
- `DeleteTrafficPolicyInstance`
|
||||
- `DeleteVPCAssociationAuthorization`
|
||||
- `DisableHostedZoneDNSSEC`
|
||||
- `DisassociateVPCFromHostedZone`
|
||||
- `EnableHostedZoneDNSSEC`
|
||||
- `GetAccountLimit`
|
||||
- `GetCheckerIpRanges`
|
||||
- `GetDNSSEC`
|
||||
- `GetGeoLocation`
|
||||
- `GetHealthCheck`
|
||||
- `GetHealthCheckCount`
|
||||
- `GetHealthCheckLastFailureReason`
|
||||
- `GetHealthCheckStatus`
|
||||
- `GetHostedZoneLimit`
|
||||
- `GetQueryLoggingConfig`
|
||||
- `GetReusableDelegationSet`
|
||||
- `GetReusableDelegationSetLimit`
|
||||
- `GetTrafficPolicy`
|
||||
- `GetTrafficPolicyInstance`
|
||||
- `GetTrafficPolicyInstanceCount`
|
||||
- `ListCidrBlocks`
|
||||
- `ListCidrCollections`
|
||||
- `ListCidrLocations`
|
||||
- `ListGeoLocations`
|
||||
- `ListHealthChecks`
|
||||
- `ListHostedZonesByVPC`
|
||||
- `ListQueryLoggingConfigs`
|
||||
- `ListReusableDelegationSets`
|
||||
- `ListTrafficPolicies`
|
||||
- `ListTrafficPolicyInstances`
|
||||
- `ListTrafficPolicyInstancesByHostedZone`
|
||||
- `ListTrafficPolicyInstancesByPolicy`
|
||||
- `ListTrafficPolicyVersions`
|
||||
- `ListVPCAssociationAuthorizations`
|
||||
- `TestDNSAnswer`
|
||||
- `UpdateHealthCheck`
|
||||
- `UpdateHostedZoneFeatures`
|
||||
- `UpdateTrafficPolicyComment`
|
||||
- `UpdateTrafficPolicyInstance`
|
||||
|
||||
## s3
|
||||
|
||||
`2006-03-01` — 26 of 112 modelled operations implemented (23.2%), 0 stubbed, 6 not applicable, 0 registered outside the pinned model.
|
||||
|
||||
@@ -247,20 +247,6 @@
|
||||
"option-groups": "Option groups configure engine add-ons for engines this platform does not offer, such as Oracle and SQL Server."
|
||||
}
|
||||
},
|
||||
"route53": {
|
||||
"slug": "route53",
|
||||
"name": "Route 53",
|
||||
"title": "Route 53 API Coverage",
|
||||
"seoTitle": "Amazon Route 53 API Coverage on Spinifex — Spinifex Docs",
|
||||
"description": "The Amazon Route 53 API operations Spinifex implements, covering the hosted zones it serves, their resource record sets, change status and tags on each zone.",
|
||||
"tags": [
|
||||
"aws",
|
||||
"compatibility",
|
||||
"coverage",
|
||||
"route53",
|
||||
"dns"
|
||||
]
|
||||
},
|
||||
"s3": {
|
||||
"slug": "s3",
|
||||
"name": "S3",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
title: "Route 53 API Coverage"
|
||||
seoTitle: "Amazon Route 53 API Coverage on Spinifex — Spinifex Docs"
|
||||
description: "The Amazon Route 53 API operations Spinifex implements, covering the hosted zones it serves, their resource record sets, change status and tags on each zone."
|
||||
category: "Coverage"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- aws
|
||||
- compatibility
|
||||
- coverage
|
||||
- route53
|
||||
- dns
|
||||
---
|
||||
|
||||
# Route 53 API Coverage
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex implements **13 operations** in the Route 53 `2013-04-01` API model.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|---|
|
||||
| `ChangeResourceRecordSets` |
|
||||
| `ChangeTagsForResource` |
|
||||
| `CreateHostedZone` |
|
||||
| `DeleteHostedZone` |
|
||||
| `GetChange` |
|
||||
| `GetHostedZone` |
|
||||
| `GetHostedZoneCount` |
|
||||
| `ListHostedZones` |
|
||||
| `ListHostedZonesByName` |
|
||||
| `ListResourceRecordSets` |
|
||||
| `ListTagsForResource` |
|
||||
| `ListTagsForResources` |
|
||||
| `UpdateHostedZoneComment` |
|
||||
@@ -163,14 +163,11 @@ Open between all hosts:
|
||||
| Protocol and port | Used by |
|
||||
|---|---|
|
||||
| UDP 6081 | Geneve tunnels |
|
||||
| UDP 500, 4500 and IP protocol 50 (ESP) | IPsec for the Geneve tunnels, while IPsec is on |
|
||||
| TCP 4222, 4248 | NATS |
|
||||
| TCP 6641, 6642 | OVN northbound and southbound |
|
||||
| TCP 8443 | Predastore S3 gate |
|
||||
| UDP 6660, 7660 | Object shards, metadata consensus |
|
||||
|
||||
UDP 4500 carries the tunnel traffic only where IKE detects NAT between two nodes; otherwise it travels as ESP, on Oracle Linux as on Debian.
|
||||
|
||||
Open between the three OVN database servers (servers 1 to 3) only:
|
||||
|
||||
| Protocol and port | Used by |
|
||||
@@ -210,10 +207,7 @@ Spinifex's own **host** firewall is separate, and already carries this policy
|
||||
|
||||
Choose one method and apply it to **every** server in the cluster.
|
||||
|
||||
**Option A — existing OS.** Use this on servers already running Ubuntu 26.04, Debian 13 or Oracle Linux 10, and prepare an Oracle Linux server with [Oracle Linux Hosts](/docs/install-oracle-linux) first.
|
||||
Every server in a cluster must run the same OS: Oracle Linux never shares a cluster with Debian or Ubuntu, and `spx admin join` does not check this for you.
|
||||
|
||||
Run the installer on each server:
|
||||
**Option A — existing OS.** On a server already running Ubuntu 26.04 or Debian 13 (Oracle Linux runs a single node only for now, see [Oracle Linux Hosts](/docs/install-oracle-linux)):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.mulgadc.com | bash
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
---
|
||||
title: "Oracle Linux Hosts"
|
||||
seoTitle: "Install Spinifex on Oracle Linux 10 — Spinifex Docs"
|
||||
description: "Prepare Oracle Linux 10 hosts for Spinifex: UEK R8, SELinux, a NetworkManager br-wan bridge, the NFV SIG repository, IPsec on libreswan and host updates."
|
||||
description: "Prepare an Oracle Linux 10 host for Spinifex: UEK R8, SELinux, a NetworkManager br-wan bridge, the CentOS NFV SIG repository, firewalld handover and updates."
|
||||
category: "Install"
|
||||
tags:
|
||||
- install
|
||||
- oracle linux
|
||||
- single node
|
||||
- multi node
|
||||
resources:
|
||||
- title: "Oracle Linux 10 Documentation"
|
||||
url: "https://docs.oracle.com/en/operating-systems/oracle-linux/10/"
|
||||
@@ -36,12 +35,13 @@ Follow it alongside [Single-Node Install](/docs/install).
|
||||
|
||||
**Supported on Oracle Linux:**
|
||||
|
||||
- A single node, or a cluster in which every node runs Oracle Linux; servers beyond the third join as compute nodes, as on Debian.
|
||||
- A single node.
|
||||
- Guests, VPCs, routed NAT, Elastic IPs, and Application and Network Load Balancers.
|
||||
- Whole-GPU passthrough, on x86_64 GPU hosts (see [GPU Passthrough](/docs/gpu-passthrough)).
|
||||
|
||||
**Not supported on Oracle Linux yet:**
|
||||
|
||||
- Clusters of more than one node. They wait for IPsec on libreswan, so `spx admin init` turns IPsec off on Oracle Linux and refuses `--ipsec=true`.
|
||||
- Clusters that mix Oracle Linux with Debian or Ubuntu nodes, on any release.
|
||||
- SELinux enforcing, aarch64, Oracle Linux 9 or earlier, the Red Hat Compatible Kernel, a ZFS root, and NVIDIA MIG.
|
||||
|
||||
@@ -51,7 +51,7 @@ Spinifex installs `openvswitch3.5` and `ovn25.03`, the versions Debian 13 ships,
|
||||
QEMU comes from Oracle's KVM Utilities repository, because only that build has the `microvm` machine type Spinifex load balancers boot on.
|
||||
|
||||
An Oracle Linux node must be a dedicated Spinifex host.
|
||||
Spinifex claims every `tap*` device and owns libreswan's `ipsec.service`, which it keeps stopped on a single node and runs for its own tunnels on a cluster node, so VPNs, hand-run QEMU guests and libreswan tunnels of your own cannot share the host.
|
||||
Spinifex claims every `tap*` device and stops libreswan's `ipsec.service`, so VPNs, hand-run QEMU guests and libreswan tunnels of your own cannot share the host.
|
||||
Crypto policies other than `DEFAULT` and `FIPS` are untested, and so is Secure Boot.
|
||||
|
||||
## Prerequisites
|
||||
@@ -134,28 +134,8 @@ Run the installer as a user with `sudo`, exactly as on Debian:
|
||||
curl -fsSL https://install.mulgadc.com | bash
|
||||
```
|
||||
|
||||
For a single node, continue with [Single-Node Install](/docs/install) from Step 2: `setup-ovn.sh --management`, `spx admin init` and `systemctl start spinifex.target`.
|
||||
IPsec stays off on a single node, because there is no peer to encrypt traffic to.
|
||||
|
||||
For a cluster, prepare and install every node this way, then continue with [Multi-Node Install](/docs/install-multi-node) from Step 2.
|
||||
|
||||
### Clusters
|
||||
|
||||
Every node in an Oracle Linux cluster runs Oracle Linux.
|
||||
`install-node.sh` refuses a host list that mixes Oracle Linux with Debian or Ubuntu, but `spx admin join` does not check, so keep to one OS yourself when joining by hand.
|
||||
|
||||
IPsec encrypts the Geneve tunnels between nodes and is on by default, as on Debian.
|
||||
On Oracle Linux it runs on libreswan, the IKE daemon Oracle ships, rather than strongSwan.
|
||||
Once the node's IPsec certificate exists, the daemon unmasks and starts `ipsec.service` and `openvswitch-ipsec.service`.
|
||||
`ovs-monitor-ipsec` then loads the node certificate, its key and the cluster CA into libreswan's NSS database at `/var/lib/ipsec/nss`, under nicknames that start with `ovs_cert`.
|
||||
|
||||
The tunnels are plain ESP (IP protocol 50), as on Debian, negotiated over UDP 500.
|
||||
Open UDP 500, UDP 4500 and ESP between nodes: libreswan moves to UDP 4500 if it detects NAT between two nodes.
|
||||
The Spinifex host firewall opens all three to cluster peers; a firewall between the nodes must allow them too.
|
||||
While IKE to a peer fails, the node drops its Geneve traffic to that peer rather than send it in clear.
|
||||
The nft table `inet spinifex_ipsec_guard` drops outbound UDP 6081 from the encap address whose route carries no SA, which also covers the moments pluto flushes the xfrm policy table as it starts and stops.
|
||||
It is not persisted: `ipsec.service` puts it back at boot before ovn-controller builds the tunnels, and the daemon re-syncs it every 60 seconds.
|
||||
`spx admin init --ipsec=false` leaves the tunnels unencrypted, as on any OS.
|
||||
Then continue with [Single-Node Install](/docs/install) from Step 2: `setup-ovn.sh --management`, `spx admin init` and `systemctl start spinifex.target`.
|
||||
`spx admin init` prints that IPsec is off on this host; on a single node there is no peer to encrypt traffic to.
|
||||
|
||||
## Step 2. Know What the Installer Changed
|
||||
|
||||
@@ -202,9 +182,7 @@ Those advisories do not cover the NFV SIG packages, so follow Open vSwitch and O
|
||||
|
||||
## Step 5. Uninstall
|
||||
|
||||
`/usr/local/share/spinifex/uninstall-spx.sh` removes Spinifex as on Debian.
|
||||
On a cluster node it also stops `openvswitch-ipsec.service` and deletes the `ovs_cert*` entries from libreswan's NSS database, so the node can join a different cluster; other NSS identities and your connection files in `/etc/ipsec.d` stay.
|
||||
It lists what it leaves in place on Oracle Linux: the repository changes and the SIG key, `OVS_USER_ID` and `OVN_USER_ID`, the OVS and OVN logrotate edits, the chrony include, `/etc/modules-load.d/spinifex.conf`, the SELinux mode, a masked `ipsec.service`, firewalld disabled, and any GPU passthrough configuration.
|
||||
`/usr/local/share/spinifex/uninstall-spx.sh` removes Spinifex as on Debian, and lists what it leaves in place on Oracle Linux: the repository changes and the SIG key, `OVS_USER_ID` and `OVN_USER_ID`, the OVS and OVN logrotate edits, the chrony include, `/etc/modules-load.d/spinifex.conf`, the SELinux mode, a masked `ipsec.service`, firewalld disabled, and any GPU passthrough configuration.
|
||||
`--purge-deps` is refused on Oracle Linux, because dnf cannot tell the packages Spinifex installed from the host's own; remove them with `dnf remove` yourself.
|
||||
|
||||
## Troubleshooting
|
||||
@@ -246,20 +224,6 @@ If it prints `yes`, run `sudo nmcli connection modify br-wan bridge.stp no` and
|
||||
|
||||
Check that `/usr/bin/spx` links to `/usr/local/bin/spx`; run the installer again if it does not, or call `sudo /usr/local/bin/spx` meanwhile.
|
||||
|
||||
### Cluster nodes form no IPsec tunnels
|
||||
|
||||
Check that both IPsec services run, and that libreswan holds the node's certificate and the cluster CA:
|
||||
|
||||
```bash
|
||||
systemctl is-active ipsec.service openvswitch-ipsec.service
|
||||
sudo certutil -L -d sql:/var/lib/ipsec/nss # ovs_certkey_<node>, ovs_cert_cacert
|
||||
sudo ipsec trafficstatus # one line per peer tunnel
|
||||
```
|
||||
|
||||
If the services are inactive, check the daemon log with `journalctl -u spinifex-daemon` for the IPsec reconcile.
|
||||
If the certificates are present but no tunnel is listed, check that UDP 500 and ESP pass between the nodes in both directions, and look for `SEC_ERROR_UNKNOWN_ISSUER` in `journalctl -u ipsec`, which means a peer's certificate is from another CA.
|
||||
Until the tunnel forms, guest traffic between those two nodes is dropped, which is the guard in [Clusters](#clusters) working.
|
||||
|
||||
### CA certificate not trusted
|
||||
|
||||
`sudo spx admin init` installs the CA automatically. To install it again by hand on Oracle Linux:
|
||||
|
||||
@@ -39,7 +39,7 @@ This guide installs Spinifex on a single server using the binary installer. For
|
||||
|
||||
- Ubuntu 26.04
|
||||
- Debian 13
|
||||
- Oracle Linux 10.2 or a later 10.x, on UEK R8, x86_64 only, as a single node or, with [Multi-Node Install](/docs/install-multi-node), a cluster. Open vSwitch and OVN come from the CentOS NFV SIG, a community repository that Oracle does not support. Prepare the host with [Oracle Linux Hosts](/docs/install-oracle-linux) first.
|
||||
- Oracle Linux 10.2 or a later 10.x, on UEK R8, x86_64 only, as a single node. Open vSwitch and OVN come from the CentOS NFV SIG, a community repository that Oracle does not support. Prepare the host with [Oracle Linux Hosts](/docs/install-oracle-linux) first.
|
||||
|
||||
**What Gets Installed:**
|
||||
|
||||
|
||||
@@ -269,35 +269,6 @@ ip link set <dev> mtu 9000 # both ends
|
||||
ping -M do -s 8972 -c 3 <peer> # must succeed
|
||||
```
|
||||
|
||||
### The internet path
|
||||
|
||||
A guest MTU of 1408 or 1442 sits below the 1500 bytes the internet carries, so a server can send the guest segments it cannot receive.
|
||||
The VPC gateway port carries `gateway_mtu = min(guest MTU, 1500)` and answers an oversized packet with ICMP fragmentation-needed (IPv4) or Packet Too Big (IPv6).
|
||||
It follows `ipsec_enabled` and `network.underlay_mtu` and converges on the next reconcile pass, with no VPC recreate.
|
||||
|
||||
Path MTU discovery depends on that ICMP reaching the server, and an upstream NAT router or a CDN often drops it.
|
||||
vpcd therefore also clamps the TCP MSS on the host hop to the gateway MTU minus 40 (IPv4) or minus 60 (IPv6), and re-asserts the rule every 30 seconds so a firewall reload cannot remove it for long.
|
||||
|
||||
| Uplink | Clamp | Table |
|
||||
| --- | --- | --- |
|
||||
| veth (distributed NAT) | yes | `bridge spinifex_mss`, matching the `veth-wan-br` port |
|
||||
| routed NAT | yes | `inet spinifex_mss`, matching `spx-nat-host` |
|
||||
| physical | no host hop exists | path MTU discovery only |
|
||||
|
||||
On a physical uplink the only cover is path MTU discovery, which fails when the upstream drops the ICMP or the peer ignores it.
|
||||
|
||||
Measured on one node (env12), IPsec on, Ubuntu 26.04 guest, 16 MB HTTPS download inside a Docker container at the default bridge MTU of 1500:
|
||||
|
||||
| Uplink | Without the clamp | With the clamp |
|
||||
| --- | --- | --- |
|
||||
| veth, instance with a public IP | stalled 3/3 | completed 3/3 |
|
||||
| routed NAT | stalled 3/3, no ICMP seen on the host | completed 3/3 |
|
||||
|
||||
An instance behind a NAT gateway on the veth uplink also completed 3/3 with the clamp; its unclamped internet run was not measured.
|
||||
The guest's own `curl` completed in every case.
|
||||
Without the clamp, `gateway_mtu` alone fixed the download only against a server on the same L2 with no NAT in between (completed 3/3), and not against the internet through the lab's upstream NAT router.
|
||||
Not measured: a physical uplink, a multi-node cluster, IPv6, and a Debian guest.
|
||||
|
||||
## 5. Measurement notes
|
||||
|
||||
Throughput on this stack is **noisy** — repeated identical 20s runs varied between
|
||||
|
||||
@@ -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 |
|
||||
@@ -108,8 +111,8 @@ behavior in other words.
|
||||
| 6643 | OVN Northbound DB (RAFT) | OVSDB/TCP | Cluster | NB database RAFT replication between the 3 quorum nodes | Cluster network only; TLS planned |
|
||||
| 6644 | OVN Southbound DB (RAFT) | OVSDB/TCP | Cluster | SB database RAFT replication between the 3 quorum nodes | Cluster network only; TLS planned |
|
||||
| 6081 | OVN (Geneve) | UDP | Encap | Tenant traffic overlay between chassis. A kernel UDP-tunnel socket, so packets are delivered locally and traverse the host's netfilter input hook before OVS sees them — a host firewall must accept them explicitly. The socket is opened by the kernel tunnel driver and takes no bind address, so it binds the wildcard by design and reach must be restricted by firewall. | None — see 500/4500 |
|
||||
| 500, 4500 | strongSwan `charon`, or libreswan `pluto` on Oracle Linux | IKEv2 / UDP | Encap | IKE and NAT-T for the IPsec protecting Geneve, managed entirely by `ovs-monitor-ipsec`; 4500 carries the ESP payload only where IKE detects NAT between two nodes. `charon` binds the wildcard by design (the upstream strongSwan default, accepted rather than overridden); `pluto` binds each host address separately, the WAN address included, which is no narrower. Either way reach must be restricted by firewall. | Certificate-based, against the cluster CA |
|
||||
| — | ESP | IP proto 50 | Encap | The IPsec payload itself, once IKE has negotiated an SA, on Oracle Linux as on Debian. | Cluster CA |
|
||||
| 500, 4500 | strongSwan `charon` | IKEv2 / UDP | Encap | IKE and NAT-T for the IPsec protecting Geneve, managed entirely by `ovs-monitor-ipsec`. Binds the wildcard by design (the upstream strongSwan default, accepted rather than overridden), so reach must be restricted by firewall. | Certificate-based, against the cluster CA |
|
||||
| — | ESP | IP proto 50 | Encap | The IPsec payload itself, once IKE has negotiated an SA | Cluster CA |
|
||||
| 8222 | spinifex-nats (monitoring) | HTTP | Localhost | `varz`/`subsz` metrics consumed by the daemon | Loopback only |
|
||||
| 323 | chronyd | NTP | Localhost | Time sync client control socket | Loopback only |
|
||||
| 169.254.169.254:80 | spinifex-vpcd (IMDS) | HTTP | Guest | Instance metadata service. One socket per instance, bound to that instance's `ime-*` interface. Terminates on the host, so it traverses the netfilter input hook. | Instance identity by interface; IMDSv2 tokens |
|
||||
@@ -174,7 +177,7 @@ Control-plane and data-plane traffic between Spinifex nodes, for completeness an
|
||||
| OVN NB/SB (client) | 6641 / 6642 | Cluster network only (TLS planned) | Network control plane; vpcd and ovn-controller dial the quorum |
|
||||
| OVN NB/SB (RAFT) | 6643 / 6644 | Cluster network only (TLS planned) | NB/SB database replication across the 3 quorum nodes |
|
||||
| OVN tunnels (Geneve) | UDP 6081 | Encapsulated by IPsec when `network.ipsec_enabled` is true, which is the default on multi-node clusters | Tenant traffic overlay between chassis, on the `vpc` plane |
|
||||
| OVN IPsec (IKE / NAT-T / ESP) | UDP 500, UDP 4500, IP proto 50 | Certificate-based against the cluster CA, negotiated by `ovs-monitor-ipsec` | Protects the Geneve tunnels above. OVN-native only: no layer configures strongSwan or libreswan directly. On Oracle Linux the daemon also blocks outbound Geneve that no SA covers, so a peer failing IKE gets no plaintext. |
|
||||
| OVN IPsec (IKE / NAT-T / ESP) | UDP 500, UDP 4500, IP proto 50 | Certificate-based against the cluster CA, negotiated by `ovs-monitor-ipsec` | Protects the Geneve tunnels above. OVN-native only — no layer manages strongSwan directly. |
|
||||
| Instance DNS forwarding | UDP/TCP 5300 | None | Each node's per-instance DNS shim forwards guest queries to peer nodes' northstar `:5300` |
|
||||
|
||||
Nodes **must** sit on a network segment that is not routed to tenant/guest VMs or to the internet. The Predastore blob and meta transports and the OVN DBs are cluster-internal and must not be reachable from anywhere else.
|
||||
@@ -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
|
||||
@@ -236,7 +239,6 @@ Notes that make the difference between this working and locking you out:
|
||||
|
||||
- **Use a dedicated table and never flush the others.** `vpcd` writes MASQUERADE and per-EIP FORWARD rules into the `ip filter` and `ip nat` tables, and only reinstalls them when the service starts. `iptables -F`, `nft flush ruleset`, `ufw enable` and `firewalld` all destroy them silently, and instance networking stays broken until the next restart.
|
||||
- **Do not add a `forward` hook.** nftables evaluates every table registered on a hook and any `drop` is final, so a default-deny forward chain here would override `vpcd`'s accepts in the other table and break every routed-NAT instance and every EIP. Filtering forwarded guest traffic is the security group's job, not the host firewall's.
|
||||
- **`vpcd` owns a second table named `spinifex_mss`, separate from `inet spinifex_filter`.** It is a `bridge` table on the veth uplink and an `inet` table on the routed uplink, it has a `forward` hook with policy `accept`, and it only rewrites the MSS option of TCP SYN packets on the host hop to the gateway. It never drops or accepts traffic and does not replace the forward-hook advice above. `vpcd` re-installs it every 30 seconds, so a flush is repaired within that interval, and there is none on a physical uplink.
|
||||
- **`output` is deliberately untouched.** The metadata service's reply path egresses through per-instance policy routing; filtering `output` breaks it in ways that are hard to attribute.
|
||||
- **On a single-NIC node the cluster and encap sets are the node's public addresses**, because the planes collapse. The rules are still correct, but they are no longer a boundary — an upstream ACL is the only real control there.
|
||||
|
||||
@@ -248,7 +250,7 @@ Every listener and outbound destination is controlled by one of these files. Cha
|
||||
|
||||
| File | Keys | Controls |
|
||||
|------|------|----------|
|
||||
| `/etc/spinifex/spinifex.toml` | `nodes.<node>.{awsgw,nats,predastore,daemon}.host`, `nodes.<node>.vpcd.ovn_{nb,sb}_addr`, `nodes.<node>.daemon.dev_networking`, `network.ipsec_enabled` | Per-service bind addresses/ports; dev-mode QEMU port forwarding. `network.ipsec_enabled` (default `true`) decides whether OVN-native IPsec protects the Geneve tunnels, and therefore whether the IKE daemon (`charon`, or `pluto` on Oracle Linux) listens on 500/4500 — single-node clusters never enable it. |
|
||||
| `/etc/spinifex/spinifex.toml` | `nodes.<node>.{awsgw,nats,predastore,daemon}.host`, `nodes.<node>.vpcd.ovn_{nb,sb}_addr`, `nodes.<node>.daemon.dev_networking`, `network.ipsec_enabled` | Per-service bind addresses/ports; dev-mode QEMU port forwarding. `network.ipsec_enabled` (default `true`) decides whether OVN-native IPsec protects the Geneve tunnels, and therefore whether `charon` listens on 500/4500 — single-node clusters never enable it. |
|
||||
| `/etc/spinifex/nats.conf` | `listen`, `cluster.listen`, `cluster.routes`, `http`, `tls`, `cluster.authorization` | NATS client/cluster/monitoring listeners, peer routes, TLS, cluster token. |
|
||||
| `/etc/spinifex/predastore/predastore.toml` | `[[host]].bind_addr`, `[[host]].addr`, `[[host]].tls_cert`, `[[host]].tls_key`, `[[host.node]].role`, `[[host.node]].port` | Predastore host and node layout: `bind_addr` is the address the host's sockets bind, `addr` is the address peer hosts dial it on, and both carry no port — the nodes pinned to the host supply their own. The service's `--host`/`--port` override the bind address and the gate's S3 port. |
|
||||
| `/etc/spinifex/northstar/northstar.toml` | `listen`, `forwarders` | DNS listen addresses (`:53` on the advertise address, `:5300` wildcard) and upstream resolvers. |
|
||||
|
||||
@@ -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
|
||||
@@ -40,7 +40,7 @@ Runs **on** a node, shelling out to `ip`/`iptables`/`ovn-nbctl` locally. Skips u
|
||||
|
||||
Phases 7 and 8 are gated on a cluster by what the datapath can actually do: the transit segment is a localnet, so host-to-guest Tier 1 ingress only exists where the VPC's gateway router port and the guest are both on this host. The VPC ingress route itself is installed on every node, so phase 8 checks it unconditionally.
|
||||
|
||||
Four nightly cells run it: `nat-single` (cell 19) and `nat-multi` (cell 30), and their Oracle Linux twins (cells 36 and 38). Cell 20 is the bare-metal job and is not in the permutation matrix at all, which is why the multi-node NAT cell is numbered past the end rather than filling a gap.
|
||||
Three nightly cells run it: `nat-single` (cell 19), its Oracle Linux twin (cell 36) and `nat-multi` (cell 30). Cell 20 is the bare-metal job and is not in the permutation matrix at all, which is why the multi-node NAT cell is numbered past the end rather than filling a gap.
|
||||
|
||||
### `multinode` — behaviour that only exists on more than one node
|
||||
|
||||
@@ -50,17 +50,6 @@ Four nightly cells run it: `nat-single` (cell 19) and `nat-multi` (cell 30), and
|
||||
|
||||
**`TestMultinodeDNS` convergence budgets are env-tunable.** `SPINIFEX_MULTINODE_DNS_TOPOLOGY_TIMEOUT` (default `90s`) bounds every node agreeing on the NS topology, `SPINIFEX_MULTINODE_DNS_TIMEOUT` (default `2m`) bounds a record becoming answerable from a guest or a node, and `SPINIFEX_MULTINODE_DNS_POLL` (default `3s`) is the poll interval for both. Values are Go durations; a malformed or non-positive value panics rather than falling back to the default.
|
||||
|
||||
**`TestMultinodeOVNRaft` restarts each node's OVN central in turn before it kills the NB leader.**
|
||||
A `setup-ovn.sh` rerun restarts a node's NB and SB members together, on EL through the one `ovn-northd.service`, so the test does the same on every database node in turn, waits for both members to be back as cluster members under a known leader, and requires an NB write to land after each step and the full quorum with one leader at the end.
|
||||
|
||||
**`TestMultinodeIPSec` proves guest traffic is encrypted on the wire, not only that IPsec is configured.**
|
||||
It first requires every node to carry the OVS certificate, key and CA pointers, `NB_Global.ipsec=true` never asserted over an unconfigured chassis, and an AES-GCM SA in `ip xfrm state`.
|
||||
None of those fails when SAs exist but traffic bypasses them, so it then launches two instances on distinct nodes and sends 200 pings of 1000 bytes between them.
|
||||
For each direction of that pair it fails unless the outbound SAs to the peer's `ovn-encap-ip` carried at least the payload sent, an outbound-only capture on the interface routing to the peer saw ESP (raw or UDP/4500-encapsulated), and that capture saw no plaintext Geneve (UDP/6081).
|
||||
The capture is outbound-only because inbound ESP is decrypted and seen again as Geneve on the same interface, which would read as plaintext on a healthy node.
|
||||
It proves the node pair the guests landed on; the SA check covers every node.
|
||||
It skips when the node config sets `network.ipsec_enabled=false`, or when the scheduler places all four launches on one node.
|
||||
|
||||
### `kvquorum` — the cluster's own state survives losing a node (multi-node, needs three)
|
||||
|
||||
**The control plane keeps its state in JetStream KV buckets, and this is the suite that proves those buckets are actually replicated.** A bucket on one replica lives on one JetStream-chosen server recorded in no config, so losing that node makes it return `nats: no responders available for request` from *every* node at once — a cluster-wide outage caused by a single-node event. Leader leases are the worst case: acquiring one is a write, so a lease bucket without a quorum cannot be acquired by anyone, and every reconciler sharing it stops cluster-wide at exactly the moment a node has failed and there is repair work to do.
|
||||
@@ -115,12 +104,9 @@ CA installed and downloadable, fingerprint matches config, hostnames present in
|
||||
|
||||
Internal and internet-facing ALB and NLB, HTTPS listeners, listener rules, `ModifyListener`, and NLB UDP.
|
||||
|
||||
Unhealthy-target routing: an NLB with an HTTP health check on a TCP target group stops sending traffic to a target that fails it while still accepting TCP, sends traffic again once it recovers, and fails open when every target is unhealthy.
|
||||
An ALB whose targets all fail their health check keeps serving from them instead of answering 503.
|
||||
|
||||
### `eks`, `ecs`, `ecr` — container services
|
||||
|
||||
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod wired with a projected SA token. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
|
||||
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, and an IRSA pod wired with a projected SA token. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
|
||||
|
||||
### `rds` — managed databases
|
||||
|
||||
@@ -143,6 +129,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")
|
||||
|
||||
@@ -17,7 +17,6 @@ var modelFiles = map[Service]string{
|
||||
ElasticLoadBalancingV2: "models/elastic-load-balancing-v2-2015-12-01.json.gz",
|
||||
IAM: "models/iam-2010-05-08.json.gz",
|
||||
RDS: "models/rds-2014-10-31.json.gz",
|
||||
Route53: "models/route-53-2013-04-01.json.gz",
|
||||
S3: "models/s3-2006-03-01.json.gz",
|
||||
STS: "models/sts-2011-06-15.json.gz",
|
||||
}
|
||||
|
||||
+28
-245
@@ -1,10 +1,8 @@
|
||||
package awsmodel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
@@ -41,7 +39,7 @@ func (r *EncodedRequest) URL(endpoint string) string {
|
||||
}
|
||||
|
||||
// EncodeRequest serializes input, as produced by GenerateRequests, for the
|
||||
// service's protocol: query, ec2, json, rest-json or rest-xml.
|
||||
// service's protocol. S3's rest-xml is not supported.
|
||||
func EncodeRequest(service Service, operationName string, input map[string]any) (*EncodedRequest, error) {
|
||||
model, err := Load(service)
|
||||
if err != nil {
|
||||
@@ -59,8 +57,6 @@ func EncodeRequest(service Service, operationName string, input map[string]any)
|
||||
return encoder.json(operation, input)
|
||||
case "rest-json":
|
||||
return encoder.restJSON(operation, input)
|
||||
case "rest-xml":
|
||||
return encoder.restXML(operation, input)
|
||||
default:
|
||||
return nil, fmt.Errorf("awsmodel: encoding %s requests is not implemented", model.metadata.Protocol)
|
||||
}
|
||||
@@ -286,50 +282,18 @@ func (e requestEncoder) jsonValue(ref ShapeRef, value any) (any, error) {
|
||||
}
|
||||
|
||||
func (e requestEncoder) restJSON(operation *Operation, input map[string]any) (*EncodedRequest, error) {
|
||||
request, members, err := e.restBindings(operation, input)
|
||||
if err != nil || len(members) == 0 {
|
||||
return request, err
|
||||
}
|
||||
shape := e.model.shapes[operation.Input.Shape]
|
||||
var document any
|
||||
if payload, ok := members[shape.Payload]; ok && shape.Payload != "" {
|
||||
if document, err = e.jsonValue(shape.Members[shape.Payload], payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
body := make(map[string]any, len(members))
|
||||
for member, value := range members {
|
||||
ref := shape.Members[member]
|
||||
encoded, err := e.jsonValue(ref, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body[elementName(member, ref)] = encoded
|
||||
}
|
||||
document = body
|
||||
}
|
||||
if request.Body, err = json.Marshal(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// restBindings places the URI, query string and header members of input on a
|
||||
// new request, and returns the members left for the body.
|
||||
func (e requestEncoder) restBindings(operation *Operation, input map[string]any) (*EncodedRequest, map[string]any, error) {
|
||||
request := &EncodedRequest{Method: operation.HTTP.Method, Query: url.Values{}, Header: http.Header{}}
|
||||
path := operation.HTTP.RequestURI
|
||||
if operation.Input == nil {
|
||||
request.Path = path
|
||||
return request, nil, nil
|
||||
return request, nil
|
||||
}
|
||||
shape := e.model.shapes[operation.Input.Shape]
|
||||
body := map[string]any{}
|
||||
for _, member := range slices.Sorted(maps.Keys(input)) {
|
||||
ref, ok := shape.Members[member]
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("%s has no member %q", operation.Input.Shape, member)
|
||||
return nil, fmt.Errorf("%s has no member %q", operation.Input.Shape, member)
|
||||
}
|
||||
value := input[member]
|
||||
target := e.model.shapes[ref.Shape]
|
||||
@@ -337,18 +301,18 @@ func (e requestEncoder) restBindings(operation *Operation, input map[string]any)
|
||||
case "uri":
|
||||
text, err := scalarString(value, ref, target, "iso8601")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", member, err)
|
||||
return nil, fmt.Errorf("%s: %w", member, err)
|
||||
}
|
||||
path = strings.Replace(path, "{"+member+"+}", escapeGreedyLabel(text), 1)
|
||||
path = strings.Replace(path, "{"+member+"}", url.PathEscape(text), 1)
|
||||
case "querystring":
|
||||
if err := e.restQuery(request.Query, ref, target, value); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", member, err)
|
||||
return nil, fmt.Errorf("%s: %w", member, err)
|
||||
}
|
||||
case "header":
|
||||
text, err := e.headerValue(ref, target, value)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", member, err)
|
||||
return nil, fmt.Errorf("%s: %w", member, err)
|
||||
}
|
||||
request.Header.Set(ref.LocationName, text)
|
||||
case "headers":
|
||||
@@ -357,220 +321,39 @@ func (e requestEncoder) restBindings(operation *Operation, input map[string]any)
|
||||
request.Header.Set(ref.LocationName+key, fmt.Sprint(entry))
|
||||
}
|
||||
default:
|
||||
body[member] = value
|
||||
encoded, err := e.jsonValue(ref, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := member
|
||||
if ref.LocationName != "" {
|
||||
name = ref.LocationName
|
||||
}
|
||||
body[name] = encoded
|
||||
}
|
||||
}
|
||||
request.Path = path
|
||||
return request, body, nil
|
||||
}
|
||||
|
||||
// restXML encodes the body as AWS SDKs do: a root element named for the input
|
||||
// shape, or for the payload member, in the service's namespace. An input with
|
||||
// body members sends its root element even when none of them is set.
|
||||
func (e requestEncoder) restXML(operation *Operation, input map[string]any) (*EncodedRequest, error) {
|
||||
request, members, err := e.restBindings(operation, input)
|
||||
if err != nil || operation.Input == nil {
|
||||
return request, err
|
||||
}
|
||||
shape := e.model.shapes[operation.Input.Shape]
|
||||
var root *xmlElement
|
||||
switch {
|
||||
case shape.Payload != "":
|
||||
value, ok := members[shape.Payload]
|
||||
if !ok {
|
||||
return request, nil
|
||||
}
|
||||
ref := shape.Members[shape.Payload]
|
||||
target := e.model.shapes[ref.Shape]
|
||||
if target.Type != "structure" {
|
||||
switch value := value.(type) {
|
||||
case []byte:
|
||||
request.Body = value
|
||||
case string:
|
||||
request.Body = []byte(value)
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: want a blob or string payload, got %T", shape.Payload, value)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
name := ref.LocationName
|
||||
if name == "" {
|
||||
name = elementName(ref.Shape, ShapeRef{LocationName: target.LocationName})
|
||||
}
|
||||
if root, err = e.xmlRoot(name, ref, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case hasBodyMembers(shape):
|
||||
name := elementName(operation.Input.Shape, ShapeRef{LocationName: shape.LocationName})
|
||||
if root, err = e.xmlRoot(name, *operation.Input, members); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return request, nil
|
||||
}
|
||||
var body bytes.Buffer
|
||||
encoder := xml.NewEncoder(&body)
|
||||
if err := root.encode(encoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := encoder.Flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Body = body.Bytes()
|
||||
request.Header.Set("Content-Type", "application/xml")
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func hasBodyMembers(shape *Shape) bool {
|
||||
for _, ref := range shape.Members {
|
||||
if ref.Location == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// xmlRoot is the structure value encoded under name, in its shape's namespace
|
||||
// or else the service's.
|
||||
func (e requestEncoder) xmlRoot(name string, ref ShapeRef, value any) (*xmlElement, error) {
|
||||
holder := &xmlElement{}
|
||||
if err := e.xmlValue(holder, name, ref, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root := holder.children[0]
|
||||
if namespace := e.model.metadata.XMLNamespace; namespace.URI != "" && e.model.shapes[ref.Shape].XMLNamespace.URI == "" {
|
||||
root.attrs = append([]xml.Attr{namespaceAttr(namespace)}, root.attrs...)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (e requestEncoder) xmlStructure(parent *xmlElement, shapeName string, fields map[string]any) error {
|
||||
shape := e.model.shapes[shapeName]
|
||||
for _, member := range slices.Sorted(maps.Keys(fields)) {
|
||||
ref, ok := shape.Members[member]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s has no member %q", shapeName, member)
|
||||
}
|
||||
name := elementName(member, ref)
|
||||
if ref.XMLAttribute {
|
||||
text, err := scalarString(fields[member], ref, e.model.shapes[ref.Shape], "iso8601")
|
||||
if payload := shape.Payload; payload != "" {
|
||||
if document, ok := body[payload]; ok {
|
||||
body = nil
|
||||
encoded, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
return nil, err
|
||||
}
|
||||
parent.attrs = append(parent.attrs, xml.Attr{Name: xml.Name{Local: name}, Value: text})
|
||||
continue
|
||||
}
|
||||
if err := e.xmlValue(parent, name, ref, fields[member]); err != nil {
|
||||
return err
|
||||
request.Body = encoded
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// xmlValue appends the elements value is encoded as to parent. A list or map
|
||||
// is wrapped in an element named name unless it is flattened.
|
||||
func (e requestEncoder) xmlValue(parent *xmlElement, name string, ref ShapeRef, value any) error {
|
||||
shape := e.model.shapes[ref.Shape]
|
||||
switch shape.Type {
|
||||
case "structure":
|
||||
fields, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: want a structure, got %T", name, value)
|
||||
}
|
||||
element := parent.add(name)
|
||||
if shape.XMLNamespace.URI != "" {
|
||||
element.attrs = append(element.attrs, namespaceAttr(shape.XMLNamespace))
|
||||
}
|
||||
return e.xmlStructure(element, ref.Shape, fields)
|
||||
case "list":
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: want a list, got %T", name, value)
|
||||
}
|
||||
container, itemName := parent, name
|
||||
if !ref.Flattened {
|
||||
container, itemName = parent.add(name), elementName("member", *shape.Member)
|
||||
}
|
||||
for _, item := range items {
|
||||
if err := e.xmlValue(container, itemName, *shape.Member, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case "map":
|
||||
entries, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: want a map, got %T", name, value)
|
||||
}
|
||||
container, entryName := parent, name
|
||||
if !ref.Flattened {
|
||||
container, entryName = parent.add(name), "entry"
|
||||
}
|
||||
keyName, valueName := elementName("key", *shape.Key), elementName("value", *shape.Value)
|
||||
for _, key := range slices.Sorted(maps.Keys(entries)) {
|
||||
entry := container.add(entryName)
|
||||
entry.add(keyName).text = key
|
||||
if err := e.xmlValue(entry, valueName, *shape.Value, entries[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
text, err := scalarString(value, ref, shape, "iso8601")
|
||||
if len(body) > 0 {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
return nil, err
|
||||
}
|
||||
parent.add(name).text = text
|
||||
return nil
|
||||
request.Body = encoded
|
||||
}
|
||||
}
|
||||
|
||||
// elementName is a member's wire name: its xmlName or jsonName, else its own.
|
||||
func elementName(member string, ref ShapeRef) string {
|
||||
if ref.LocationName != "" {
|
||||
return ref.LocationName
|
||||
if request.Body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return member
|
||||
}
|
||||
|
||||
func namespaceAttr(namespace XMLNamespace) xml.Attr {
|
||||
name := "xmlns"
|
||||
if namespace.Prefix != "" {
|
||||
name += ":" + namespace.Prefix
|
||||
}
|
||||
return xml.Attr{Name: xml.Name{Local: name}, Value: namespace.URI}
|
||||
}
|
||||
|
||||
// xmlElement is one element of an encoded rest-xml body.
|
||||
type xmlElement struct {
|
||||
name string
|
||||
attrs []xml.Attr
|
||||
text string
|
||||
children []*xmlElement
|
||||
}
|
||||
|
||||
func (x *xmlElement) add(name string) *xmlElement {
|
||||
child := &xmlElement{name: name}
|
||||
x.children = append(x.children, child)
|
||||
return child
|
||||
}
|
||||
|
||||
func (x *xmlElement) encode(encoder *xml.Encoder) error {
|
||||
start := xml.StartElement{Name: xml.Name{Local: x.name}, Attr: x.attrs}
|
||||
if err := encoder.EncodeToken(start); err != nil {
|
||||
return err
|
||||
}
|
||||
if x.text != "" {
|
||||
if err := encoder.EncodeToken(xml.CharData(x.text)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, child := range x.children {
|
||||
if err := child.encode(encoder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return encoder.EncodeToken(start.End())
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (e requestEncoder) restQuery(query url.Values, ref ShapeRef, shape *Shape, value any) error {
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
package awsmodel_test
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/route53"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const sdkEndpoint = "https://aws.example"
|
||||
|
||||
var sdkSession = session.Must(session.NewSession(&aws.Config{
|
||||
Region: aws.String("us-east-1"),
|
||||
Endpoint: aws.String(sdkEndpoint),
|
||||
Credentials: credentials.AnonymousCredentials,
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
}))
|
||||
|
||||
// TestEncodeRequestRestXMLMatchesSDK checks the encoder against aws-sdk-go's
|
||||
// restxml marshaller for the same input, so the expected wire form is the
|
||||
// SDK's rather than one written by hand.
|
||||
func TestEncodeRequestRestXMLMatchesSDK(t *testing.T) {
|
||||
r53 := route53.New(sdkSession)
|
||||
tests := []struct {
|
||||
name string
|
||||
service awsmodel.Service
|
||||
operation string
|
||||
input map[string]any
|
||||
sdk *request.Request
|
||||
}{
|
||||
{
|
||||
name: "CreateHostedZone",
|
||||
service: awsmodel.Route53,
|
||||
operation: "CreateHostedZone",
|
||||
input: map[string]any{
|
||||
"Name": "example.com",
|
||||
"CallerReference": "ref-1",
|
||||
"HostedZoneConfig": map[string]any{"Comment": "a & b", "PrivateZone": true},
|
||||
"VPC": map[string]any{"VPCRegion": "us-east-1", "VPCId": "vpc-1"},
|
||||
},
|
||||
sdk: requestOf(r53.CreateHostedZoneRequest(&route53.CreateHostedZoneInput{
|
||||
Name: aws.String("example.com"),
|
||||
CallerReference: aws.String("ref-1"),
|
||||
HostedZoneConfig: &route53.HostedZoneConfig{Comment: aws.String("a & b"), PrivateZone: aws.Bool(true)},
|
||||
VPC: &route53.VPC{VPCRegion: aws.String("us-east-1"), VPCId: aws.String("vpc-1")},
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "ChangeResourceRecordSets",
|
||||
service: awsmodel.Route53,
|
||||
operation: "ChangeResourceRecordSets",
|
||||
input: map[string]any{
|
||||
"HostedZoneId": "Z0123456789",
|
||||
"ChangeBatch": map[string]any{
|
||||
"Comment": "two changes",
|
||||
"Changes": []any{
|
||||
map[string]any{"Action": "UPSERT", "ResourceRecordSet": map[string]any{
|
||||
"Name": "www.example.com", "Type": "A", "TTL": int64(300),
|
||||
"ResourceRecords": []any{map[string]any{"Value": "192.0.2.1"}, map[string]any{"Value": "192.0.2.2"}},
|
||||
}},
|
||||
map[string]any{"Action": "DELETE", "ResourceRecordSet": map[string]any{
|
||||
"Name": "old.example.com", "Type": "TXT", "TTL": int64(60),
|
||||
"ResourceRecords": []any{map[string]any{"Value": `"v=1"`}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
sdk: requestOf(r53.ChangeResourceRecordSetsRequest(&route53.ChangeResourceRecordSetsInput{
|
||||
HostedZoneId: aws.String("Z0123456789"),
|
||||
ChangeBatch: &route53.ChangeBatch{
|
||||
Comment: aws.String("two changes"),
|
||||
Changes: []*route53.Change{
|
||||
{Action: aws.String("UPSERT"), ResourceRecordSet: &route53.ResourceRecordSet{
|
||||
Name: aws.String("www.example.com"), Type: aws.String("A"), TTL: aws.Int64(300),
|
||||
ResourceRecords: []*route53.ResourceRecord{{Value: aws.String("192.0.2.1")}, {Value: aws.String("192.0.2.2")}},
|
||||
}},
|
||||
{Action: aws.String("DELETE"), ResourceRecordSet: &route53.ResourceRecordSet{
|
||||
Name: aws.String("old.example.com"), Type: aws.String("TXT"), TTL: aws.Int64(60),
|
||||
ResourceRecords: []*route53.ResourceRecord{{Value: aws.String(`"v=1"`)}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "ChangeTagsForResource",
|
||||
service: awsmodel.Route53,
|
||||
operation: "ChangeTagsForResource",
|
||||
input: map[string]any{
|
||||
"ResourceType": "hostedzone",
|
||||
"ResourceId": "Z0123456789",
|
||||
"AddTags": []any{map[string]any{"Key": "team", "Value": "dns"}, map[string]any{"Key": "env", "Value": "prod"}},
|
||||
"RemoveTagKeys": []any{"old", "stale"},
|
||||
},
|
||||
sdk: requestOf(r53.ChangeTagsForResourceRequest(&route53.ChangeTagsForResourceInput{
|
||||
ResourceType: aws.String("hostedzone"),
|
||||
ResourceId: aws.String("Z0123456789"),
|
||||
AddTags: []*route53.Tag{{Key: aws.String("team"), Value: aws.String("dns")}, {Key: aws.String("env"), Value: aws.String("prod")}},
|
||||
RemoveTagKeys: []*string{aws.String("old"), aws.String("stale")},
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "UpdateHostedZoneComment without a comment",
|
||||
service: awsmodel.Route53,
|
||||
operation: "UpdateHostedZoneComment",
|
||||
input: map[string]any{"Id": "Z0123456789"},
|
||||
sdk: requestOf(r53.UpdateHostedZoneCommentRequest(&route53.UpdateHostedZoneCommentInput{Id: aws.String("Z0123456789")})),
|
||||
},
|
||||
{
|
||||
name: "ListResourceRecordSets",
|
||||
service: awsmodel.Route53,
|
||||
operation: "ListResourceRecordSets",
|
||||
input: map[string]any{"HostedZoneId": "Z0123456789", "StartRecordName": "www.example.com", "StartRecordType": "A", "MaxItems": int64(5)},
|
||||
sdk: requestOf(r53.ListResourceRecordSetsRequest(&route53.ListResourceRecordSetsInput{
|
||||
HostedZoneId: aws.String("Z0123456789"), StartRecordName: aws.String("www.example.com"), StartRecordType: aws.String("A"), MaxItems: aws.String("5"),
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "DeleteHostedZone",
|
||||
service: awsmodel.Route53,
|
||||
operation: "DeleteHostedZone",
|
||||
input: map[string]any{"Id": "Z0123456789"},
|
||||
sdk: requestOf(r53.DeleteHostedZoneRequest(&route53.DeleteHostedZoneInput{Id: aws.String("Z0123456789")})),
|
||||
},
|
||||
{
|
||||
name: "S3 structure payload",
|
||||
service: awsmodel.S3,
|
||||
operation: "PutBucketTagging",
|
||||
input: map[string]any{
|
||||
"Bucket": "bucket",
|
||||
"Tagging": map[string]any{"TagSet": []any{map[string]any{"Key": "team", "Value": "storage"}}},
|
||||
},
|
||||
sdk: requestOf(s3.New(sdkSession).PutBucketTaggingRequest(&s3.PutBucketTaggingInput{
|
||||
Bucket: aws.String("bucket"),
|
||||
Tagging: &s3.Tagging{TagSet: []*s3.Tag{{Key: aws.String("team"), Value: aws.String("storage")}}},
|
||||
})),
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
require.NoError(t, test.sdk.Build())
|
||||
want := test.sdk.HTTPRequest
|
||||
wantBody, err := io.ReadAll(test.sdk.GetBody())
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := awsmodel.EncodeRequest(test.service, test.operation, test.input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want.Method, got.Method)
|
||||
gotURL, err := url.Parse(got.URL(sdkEndpoint))
|
||||
require.NoError(t, err)
|
||||
// The SDK's api-2.json ends ChangeResourceRecordSets' path in a
|
||||
// slash that the Smithy model drops; the gateway accepts both.
|
||||
require.Equal(t, strings.TrimSuffix(want.URL.Path, "/"), gotURL.Path)
|
||||
require.Equal(t, want.URL.Query(), gotURL.Query())
|
||||
if len(wantBody) == 0 {
|
||||
require.Empty(t, got.Body)
|
||||
require.Empty(t, got.Header.Get("Content-Type"))
|
||||
return
|
||||
}
|
||||
require.Equal(t, canonicalXML(t, wantBody), canonicalXML(t, got.Body))
|
||||
require.Equal(t, "application/xml", got.Header.Get("Content-Type"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func requestOf[O any](r *request.Request, _ O) *request.Request { return r }
|
||||
|
||||
// xmlTree is a parsed XML element. The SDK emits sibling elements in map
|
||||
// order, so canonicalXML sorts them by name, keeping list items in order.
|
||||
type xmlTree struct {
|
||||
XMLName xml.Name `xml:""`
|
||||
Attrs []xml.Attr `xml:",any,attr"`
|
||||
Text string `xml:",chardata"`
|
||||
Children []xmlTree `xml:",any"`
|
||||
}
|
||||
|
||||
func canonicalXML(t *testing.T, body []byte) xmlTree {
|
||||
t.Helper()
|
||||
var tree xmlTree
|
||||
require.NoError(t, xml.Unmarshal(body, &tree), string(body))
|
||||
var canonicalise func(*xmlTree)
|
||||
canonicalise = func(node *xmlTree) {
|
||||
node.Text = strings.TrimSpace(node.Text)
|
||||
slices.SortFunc(node.Attrs, func(a, b xml.Attr) int { return cmp.Compare(a.Name.Space+a.Name.Local, b.Name.Space+b.Name.Local) })
|
||||
slices.SortStableFunc(node.Children, func(a, b xmlTree) int { return cmp.Compare(a.XMLName.Local, b.XMLName.Local) })
|
||||
for i := range node.Children {
|
||||
canonicalise(&node.Children[i])
|
||||
}
|
||||
}
|
||||
canonicalise(&tree)
|
||||
return tree
|
||||
}
|
||||
|
||||
func TestEncodeRequestRestXMLPutsRootInServiceNamespace(t *testing.T) {
|
||||
request, err := awsmodel.EncodeRequest(awsmodel.Route53, "CreateHostedZone", map[string]any{"Name": "example.com", "CallerReference": "ref"})
|
||||
require.NoError(t, err)
|
||||
tree := canonicalXML(t, request.Body)
|
||||
require.Equal(t, xml.Name{Space: "https://route53.amazonaws.com/doc/2013-04-01/", Local: "CreateHostedZoneRequest"}, tree.XMLName)
|
||||
require.Equal(t, "/2013-04-01/hostedzone", request.Path)
|
||||
}
|
||||
@@ -117,3 +117,8 @@ func TestEncodeRequestRejectsUnmodelledMember(t *testing.T) {
|
||||
_, err := awsmodel.EncodeRequest(awsmodel.IAM, "GetRole", map[string]any{"RoleName": "app", "Bogus": "x"})
|
||||
require.ErrorContains(t, err, `no member "Bogus"`)
|
||||
}
|
||||
|
||||
func TestEncodeRequestS3IsNotSupported(t *testing.T) {
|
||||
_, err := awsmodel.EncodeRequest(awsmodel.S3, "ListBuckets", map[string]any{})
|
||||
require.ErrorContains(t, err, "not implemented")
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func (m *Model) decodeErrorCode(body []byte) (string, error) {
|
||||
return "", fmt.Errorf("JSON error envelope has no code")
|
||||
}
|
||||
return code, nil
|
||||
case "ec2", "query", "rest-xml":
|
||||
case "ec2", "query":
|
||||
root, err := parseXML(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -123,16 +123,3 @@ func TestValidateErrorResponseClassifiesCommonErrorsAsUnmodelled(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRequestResultReadsRestXMLErrorEnvelope(t *testing.T) {
|
||||
body := []byte(`<?xml version="1.0"?>
|
||||
<ErrorResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/"><Error><Type>Sender</Type><Code>NoSuchHostedZone</Code><Message>No hosted zone found with ID: Z1</Message></Error><RequestId>r-1</RequestId></ErrorResponse>`)
|
||||
result := DecodeRequestResult(Route53, 404, nil, body)
|
||||
if result.Code != "NoSuchHostedZone" || result.Message != "No hosted zone found with ID: Z1" {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
violations, modelled, err := ValidateErrorResponse(Route53, "GetHostedZone", body)
|
||||
if err != nil || !modelled || len(violations) != 0 {
|
||||
t.Fatalf("ValidateErrorResponse = %v, %v, %v; want a declared code", violations, modelled, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ const (
|
||||
ElasticLoadBalancingV2 Service = "elasticloadbalancingv2"
|
||||
IAM Service = "iam"
|
||||
RDS Service = "rds"
|
||||
Route53 Service = "route53"
|
||||
S3 Service = "s3"
|
||||
STS Service = "sts"
|
||||
)
|
||||
@@ -51,14 +50,6 @@ type Metadata struct {
|
||||
ServiceID string
|
||||
SigningName string
|
||||
TargetPrefix string
|
||||
XMLNamespace XMLNamespace
|
||||
}
|
||||
|
||||
// XMLNamespace is the namespace an XML element is serialized in. URI is empty
|
||||
// when none is declared.
|
||||
type XMLNamespace struct {
|
||||
URI string `json:"uri"`
|
||||
Prefix string `json:"prefix"`
|
||||
}
|
||||
|
||||
// Operation describes an AWS API operation and the shapes used by its input,
|
||||
@@ -93,7 +84,6 @@ type Shape struct {
|
||||
Pattern string
|
||||
LocationName string
|
||||
TimestampFormat string
|
||||
XMLNamespace XMLNamespace
|
||||
Payload string
|
||||
Flattened bool
|
||||
Sensitive bool
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestServices(t *testing.T) {
|
||||
want := []Service{ACM, EC2, ECR, ECS, EKS, ElasticLoadBalancingV2, IAM, RDS, Route53, S3, STS}
|
||||
want := []Service{ACM, EC2, ECR, ECS, EKS, ElasticLoadBalancingV2, IAM, RDS, S3, STS}
|
||||
if got := Services(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Services() = %v, want %v", got, want)
|
||||
}
|
||||
@@ -21,14 +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},
|
||||
Route53: {"2013-04-01", 71, 409},
|
||||
RDS: {"2014-10-31", 164, 791},
|
||||
S3: {"2006-03-01", 112, 710},
|
||||
STS: {"2011-06-15", 11, 101},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AWS models
|
||||
|
||||
The `*.json.gz` files are the Smithy JSON AST service models from [`aws/api-models-aws`](https://github.com/aws/api-models-aws), gzipped and embedded into the binary, so loading them needs no network or Go module cache. All eleven come from the one commit recorded in `../model_source.go`.
|
||||
The `*.json.gz` files are the Smithy JSON AST service models from [`aws/api-models-aws`](https://github.com/aws/api-models-aws), gzipped and embedded into the binary, so loading them needs no network or Go module cache. All ten come from the one commit recorded in `../model_source.go`.
|
||||
|
||||
Do not edit them by hand. To move to a newer commit, run `scripts/sync-aws-models.sh <commit-sha>`, then `make generate-aws-model-coverage` and review the regenerated `docs/coverage` and the `awsmodel` test expectations: a newer model can add operations, required members and enum values, and each resulting finding is a real gap to triage rather than a reason to stay on an older commit.
|
||||
|
||||
|
||||
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.
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,
|
||||
@@ -375,10 +374,6 @@ func (g *requestGenerator) structure(name string, path []pathStep, depth int) (m
|
||||
for _, member := range slices.Sorted(maps.Keys(shape.Members)) {
|
||||
ref := shape.Members[member]
|
||||
memberPath := append(slices.Clone(path), member)
|
||||
if !required[member] && slices.Contains(exclusiveMembers[g.model.service][name], member) {
|
||||
g.skip(memberPath, "AWS refuses it alongside the members a valid request carries")
|
||||
continue
|
||||
}
|
||||
memberValue, ok := g.value(ref, memberPath, depth)
|
||||
if !ok {
|
||||
if required[member] {
|
||||
|
||||
@@ -18,8 +18,7 @@ import (
|
||||
|
||||
// RequestOptions places generated ARNs in the account and region the
|
||||
// requests are sent as. AccessKeyID is the key the requests are signed with.
|
||||
// Fixtures maps a member, or an "Operation.Member" that overrides it for one
|
||||
// operation, to an existing resource it can name.
|
||||
// Fixtures maps a member to an existing resource it can name.
|
||||
type RequestOptions struct {
|
||||
AccountID string
|
||||
Region string
|
||||
@@ -74,12 +73,6 @@ var ec2IDMember = regexp.MustCompile(`^(.*?)Ids?$`)
|
||||
// with a code the judge cannot tell from a real finding.
|
||||
var conditionallyRequired = map[Service]map[string][]string{
|
||||
EC2: {"CreateCapacityReservation": {"AvailabilityZone"}},
|
||||
// A listing that starts at a zone ID needs the name it starts at, and
|
||||
// one that starts at a record type or identifier needs a name and type.
|
||||
Route53: {
|
||||
"ListHostedZonesByName": {"DNSName"},
|
||||
"ListResourceRecordSets": {"StartRecordName", "StartRecordType"},
|
||||
},
|
||||
ECS: {
|
||||
"CreateCapacityProvider": {"autoScalingGroupProvider"},
|
||||
"CreateService": {"taskDefinition"},
|
||||
@@ -130,47 +123,14 @@ var ec2TaggedTypes = map[string]string{
|
||||
"RunInstances": "instance",
|
||||
}
|
||||
|
||||
// exclusiveMembers are optional members a valid request leaves out of the
|
||||
// structure that holds them, because AWS refuses them in combination with
|
||||
// the rest. Generation skips them, so their constraints are not broken.
|
||||
var exclusiveMembers = map[Service]map[string][]string{
|
||||
// A record set takes one routing policy and either records or an alias.
|
||||
Route53: {"ResourceRecordSet": {
|
||||
"AliasTarget", "CidrRoutingConfig", "Failover", "GeoLocation", "GeoProximityLocation", "HealthCheckId",
|
||||
"MultiValueAnswer", "Region", "SetIdentifier", "TrafficPolicyInstanceId", "Weight",
|
||||
}},
|
||||
}
|
||||
|
||||
// fixture returns the fixture the member at path names, preferring one scoped
|
||||
// to the operation.
|
||||
func (g *requestGenerator) fixture(member string) (string, bool) {
|
||||
if fixture, ok := g.options.Fixtures[g.operation+"."+member]; ok {
|
||||
return fixture, true
|
||||
}
|
||||
fixture, ok := g.options.Fixtures[member]
|
||||
return fixture, ok
|
||||
}
|
||||
|
||||
// enumHint returns the enum value a valid request needs at path, where the
|
||||
// first value would not do.
|
||||
func (g *requestGenerator) enumHint(path []pathStep) (string, bool) {
|
||||
member := memberName(path)
|
||||
if fixture, ok := g.fixture(member); ok {
|
||||
return fixture, true
|
||||
if g.model.service != EC2 || len(path) == 0 || path[0] != "TagSpecifications" || memberName(path) != "ResourceType" {
|
||||
return "", false
|
||||
}
|
||||
switch g.model.service {
|
||||
case EC2:
|
||||
if len(path) > 0 && path[0] == "TagSpecifications" && member == "ResourceType" {
|
||||
value, ok := ec2TaggedTypes[g.operation]
|
||||
return value, ok
|
||||
}
|
||||
case Route53:
|
||||
// The first RRType, SOA, is one a zone already has and cannot gain.
|
||||
if member == "Type" {
|
||||
return "A", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
value, ok := ec2TaggedTypes[g.operation]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
// hint returns a value of the right form for the member at path, if one is
|
||||
@@ -181,7 +141,7 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
options := g.options
|
||||
if fixture, ok := g.fixture(member); ok {
|
||||
if fixture, ok := options.Fixtures[member]; ok {
|
||||
return fixture, true
|
||||
}
|
||||
suffix := fmt.Sprintf("%017x", g.saltNumber)
|
||||
@@ -270,17 +230,6 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) {
|
||||
if member == "type" && len(path) == 1 {
|
||||
return "STANDARD", true
|
||||
}
|
||||
case Route53:
|
||||
// A generated name is a top-level domain, which cannot be a zone.
|
||||
// Record names fall inside the fixture zone, example.com.
|
||||
switch member {
|
||||
case "Name", "DNSName", "StartRecordName":
|
||||
return name + ".example.com", true
|
||||
case "Value":
|
||||
if slices.Contains(path, pathStep("ResourceRecords")) {
|
||||
return "192.0.2.1", true
|
||||
}
|
||||
}
|
||||
case STS:
|
||||
// GetAccessKeyInfo answers only for a key that exists.
|
||||
if member == "AccessKeyId" && options.AccessKeyID != "" {
|
||||
|
||||
@@ -131,7 +131,6 @@ func TestGeneratedRequestsBreakOnlyTheirConstraint(t *testing.T) {
|
||||
for _, request := range plan.Cases {
|
||||
require.False(t, rejecting && request.Acceptance(), "%s acceptance %s follows a rejection", operationName, request.Path)
|
||||
rejecting = !request.Acceptance()
|
||||
// Predastore serves S3, so its requests are never sent.
|
||||
if service != S3 {
|
||||
_, err := EncodeRequest(service, operationName, request.Input)
|
||||
require.NoError(t, err, "%s %s %s encodes", operationName, request.Constraint, request.Path)
|
||||
@@ -242,17 +241,6 @@ func TestGenerateRequestsTargetsFixturesOutsideCreateAndDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRequestsPrefersOperationScopedFixtures(t *testing.T) {
|
||||
options := testRequestOptions
|
||||
options.Fixtures = map[string]string{"Id": "Z0FIXTURE", "GetChange.Id": "C0FIXTURE"}
|
||||
for operation, want := range map[string]string{"GetChange": "C0FIXTURE", "GetHostedZone": "Z0FIXTURE"} {
|
||||
plan, err := GenerateRequests(Route53, operation, options)
|
||||
require.NoError(t, err)
|
||||
require.True(t, plan.Cases[0].Acceptance())
|
||||
require.Equal(t, want, plan.Cases[0].Input["Id"], operation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRequestsAttributesRejections(t *testing.T) {
|
||||
plan, err := GenerateRequests(IAM, "CreateRole", testRequestOptions)
|
||||
require.NoError(t, err)
|
||||
@@ -337,20 +325,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
|
||||
|
||||
@@ -281,9 +281,6 @@ func (t *smithyTranslator) metadata() (Metadata, error) {
|
||||
if _, err := service.Traits.decode("aws.auth#sigv4", &sigv4); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
if _, err := service.Traits.decode("smithy.api#xmlNamespace", &metadata.XMLNamespace); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
metadata.EndpointPrefix = awsService.EndpointPrefix
|
||||
metadata.ServiceID = awsService.SDKID
|
||||
metadata.ServiceFullName = title
|
||||
@@ -446,9 +443,6 @@ func (t *smithyTranslator) applyShapeTraits(shape *Shape, traits smithyTraits) e
|
||||
if shape.LocationName, err = traits.string("smithy.api#xmlName"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := traits.decode("smithy.api#xmlNamespace", &shape.XMLNamespace); err != nil {
|
||||
return err
|
||||
}
|
||||
if shape.TimestampFormat, err = timestampFormat(traits); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds"
|
||||
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
|
||||
)
|
||||
|
||||
// Signer holds a credential provider and signs requests in place.
|
||||
@@ -44,16 +42,6 @@ func NewIMDS(ctx context.Context, region string) (*Signer, error) {
|
||||
return &Signer{provider: cfg.Credentials}, nil
|
||||
}
|
||||
|
||||
// NewInstanceRole returns a Signer whose credentials come only from the
|
||||
// instance metadata service: no environment, profile or static-key fallback.
|
||||
func NewInstanceRole() *Signer {
|
||||
return newInstanceRole(imds.New(imds.Options{}))
|
||||
}
|
||||
|
||||
func newInstanceRole(client *imds.Client) *Signer {
|
||||
return &Signer{provider: aws.NewCredentialsCache(ec2rolecreds.New(func(o *ec2rolecreds.Options) { o.Client = client }))}
|
||||
}
|
||||
|
||||
// EnsureCredentials retrieves once from the provider, surfacing the error so a
|
||||
// caller can warm the IMDS instance-role datapath before the first Sign. The
|
||||
// SDK's IMDS client fast-fails each probe (250ms dial), so a cold per-tap
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
)
|
||||
|
||||
@@ -124,41 +123,3 @@ func TestNewIMDS_BuildsProvider(t *testing.T) {
|
||||
t.Fatal("expected non-nil credentials provider")
|
||||
}
|
||||
}
|
||||
|
||||
// The instance-role signer ignores environment keys and signs only with the
|
||||
// credentials the metadata service issues.
|
||||
func TestNewInstanceRole_UsesOnlyIMDS(t *testing.T) {
|
||||
t.Setenv("AWS_ACCESS_KEY_ID", testAccessKey)
|
||||
t.Setenv("AWS_SECRET_ACCESS_KEY", testSecretKey)
|
||||
const imdsKey = "ASIAIMDSONLYEXAMPLE1"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/latest/api/token":
|
||||
_, _ = w.Write([]byte("token"))
|
||||
case "/latest/meta-data/iam/security-credentials/":
|
||||
_, _ = w.Write([]byte("sqsWorkerRole"))
|
||||
case "/latest/meta-data/iam/security-credentials/sqsWorkerRole":
|
||||
_, _ = w.Write([]byte(`{"Code":"Success","AccessKeyId":"` + imdsKey + `","SecretAccessKey":"s","Token":"t","Expiration":"2099-01-01T00:00:00Z"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
s := newInstanceRole(imds.New(imds.Options{Endpoint: srv.URL}))
|
||||
req := httptest.NewRequest(http.MethodPost, "https://gw.example/", nil)
|
||||
if err := s.Sign(req, emptySHA, testService, testRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auth := req.Header.Get("Authorization"); !strings.Contains(auth, "Credential="+imdsKey+"/") {
|
||||
t.Fatalf("Authorization = %q, want the IMDS credential", auth)
|
||||
}
|
||||
|
||||
srv.Close()
|
||||
cold := newInstanceRole(imds.New(imds.Options{Endpoint: srv.URL}))
|
||||
if err := cold.EnsureCredentials(context.Background()); err == nil {
|
||||
t.Fatal("environment keys were used when IMDS was unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
const emptySHA = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
// Package sqsworkergw is the shared SQS worker guest's SigV4 client for the
|
||||
// gateway's internal spinifex-sqs service. It sends only public values: the
|
||||
// worker's identity is the signing session, and the store seed never leaves
|
||||
// the guest.
|
||||
package sqsworkergw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/tlsconfig"
|
||||
"github.com/nats-io/nkeys"
|
||||
)
|
||||
|
||||
const (
|
||||
// The SigV4 credential scope the gateway routes the internal surface on.
|
||||
signingService = "spinifex-sqs"
|
||||
apiVersion = "2026-10-30"
|
||||
callTimeout = 30 * time.Second
|
||||
maxResponse = 64 << 10
|
||||
|
||||
actionClaim = "ClaimFirstStore"
|
||||
actionDescribe = "DescribeStore"
|
||||
actionRecord = "RecordWorkerPublicKey"
|
||||
)
|
||||
|
||||
// Signer signs a request in place for service and region.
|
||||
type Signer interface {
|
||||
Sign(r *http.Request, payloadHash, service, region string) error
|
||||
}
|
||||
|
||||
// Client calls the internal first-store actions for this worker.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
region string
|
||||
signer Signer
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New builds a TLS 1.3 client that trusts only caPEM, the cluster CA. An
|
||||
// https URL, a Region and a CA are all required: there is no fallback.
|
||||
func New(baseURL, region string, caPEM []byte, signer Signer) (*Client, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
return nil, errors.New("sqsworkergw: gateway URL must be an https URL")
|
||||
}
|
||||
if region == "" {
|
||||
return nil, errors.New("sqsworkergw: Region is required")
|
||||
}
|
||||
if signer == nil {
|
||||
return nil, errors.New("sqsworkergw: signer is required")
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return nil, errors.New("sqsworkergw: gateway CA has no usable certificates")
|
||||
}
|
||||
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS13, CurvePreferences: tlsconfig.Curves, RootCAs: pool}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
region: region,
|
||||
signer: signer,
|
||||
httpClient: &http.Client{
|
||||
Timeout: callTimeout,
|
||||
Transport: &http.Transport{TLSClientConfig: tlsCfg, MaxIdleConns: 1, IdleConnTimeout: 30 * time.Second},
|
||||
// A redirect would resend a signed request somewhere else.
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// APIError is a gateway refusal; Code is the AWS error code.
|
||||
type APIError struct {
|
||||
Action string
|
||||
StatusCode int
|
||||
Code string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("spinifex-sqs %s: %s (HTTP %d)", e.Action, e.Code, e.StatusCode)
|
||||
}
|
||||
|
||||
// Retryable reports a refusal that a later identical call may pass:
|
||||
// contention or a store that is not yet available. Every other refusal,
|
||||
// including AccessDenied, is final for this boot.
|
||||
func (e *APIError) Retryable() bool {
|
||||
return e.Code == "ServiceUnavailable" || e.Code == "Throttling" || e.StatusCode == http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
// Pending reports an AccessDenied, which a worker that has started but is
|
||||
// not yet bound to its identity legitimately receives. It authorizes
|
||||
// nothing; the caller may ask again within its own bound.
|
||||
func (e *APIError) Pending() bool { return e.Code == "AccessDenied" }
|
||||
|
||||
// TransientError is a call that may not have reached the gateway, or whose
|
||||
// reply was lost: signing, sending or reading failed. Both actions are safe
|
||||
// to repeat, so it is retryable.
|
||||
type TransientError struct{ Err error }
|
||||
|
||||
func (e *TransientError) Error() string { return e.Err.Error() }
|
||||
func (e *TransientError) Unwrap() error { return e.Err }
|
||||
func (e *TransientError) Retryable() bool { return true }
|
||||
|
||||
type claimResult struct {
|
||||
CreateKey bool `xml:"CreateKey"`
|
||||
PublicKey string `xml:"PublicKey"`
|
||||
}
|
||||
|
||||
// Claim asks for this worker's current first-store decision. A decision
|
||||
// that is neither "create" nor one valid public key is refused here.
|
||||
func (c *Client) Claim(ctx context.Context) (bool, string, error) {
|
||||
var res claimResult
|
||||
if err := c.call(ctx, actionClaim, nil, &res); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
switch {
|
||||
case res.CreateKey && res.PublicKey == "":
|
||||
return true, "", nil
|
||||
case !res.CreateKey && nkeys.IsValidPublicUserKey(res.PublicKey):
|
||||
return false, res.PublicKey, nil
|
||||
}
|
||||
return false, "", errors.New("spinifex-sqs ClaimFirstStore: response is not a valid decision")
|
||||
}
|
||||
|
||||
// Store is the Region's store launch as the gateway reports it. Bound means
|
||||
// a VM was launched for it, not that it is ready.
|
||||
type Store struct {
|
||||
Bound bool
|
||||
Address string
|
||||
PublicKey string
|
||||
}
|
||||
|
||||
type describeResult struct {
|
||||
State string `xml:"State"`
|
||||
Address string `xml:"Address"`
|
||||
PublicKey string `xml:"PublicKey"`
|
||||
}
|
||||
|
||||
// DescribeStore reports this worker's store. A bound reply must carry an
|
||||
// IPv4 address and a valid public key; a pending one must carry neither.
|
||||
func (c *Client) DescribeStore(ctx context.Context) (Store, error) {
|
||||
var res describeResult
|
||||
if err := c.call(ctx, actionDescribe, nil, &res); err != nil {
|
||||
return Store{}, err
|
||||
}
|
||||
switch {
|
||||
case res.State == "pending" && res.Address == "" && res.PublicKey == "":
|
||||
return Store{}, nil
|
||||
case res.State == "bound" && isIPv4(res.Address) && nkeys.IsValidPublicUserKey(res.PublicKey):
|
||||
return Store{Bound: true, Address: res.Address, PublicKey: res.PublicKey}, nil
|
||||
}
|
||||
return Store{}, errors.New("spinifex-sqs DescribeStore: response is not a valid store state")
|
||||
}
|
||||
|
||||
func isIPv4(s string) bool {
|
||||
ip := net.ParseIP(s)
|
||||
return ip != nil && ip.To4() != nil && !strings.Contains(s, ":")
|
||||
}
|
||||
|
||||
// RecordPublicKey records the worker's public key. Anything other than a
|
||||
// user public key is refused before it is sent.
|
||||
func (c *Client) RecordPublicKey(ctx context.Context, publicKey string) error {
|
||||
if !nkeys.IsValidPublicUserKey(publicKey) {
|
||||
return errors.New("spinifex-sqs RecordWorkerPublicKey: refusing to send a value that is not a user public key")
|
||||
}
|
||||
return c.call(ctx, actionRecord, url.Values{"PublicKey": {publicKey}}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) call(ctx context.Context, action string, params url.Values, out any) error {
|
||||
form := url.Values{"Action": {action}, "Version": {apiVersion}}
|
||||
maps.Copy(form, params)
|
||||
body := []byte(form.Encode())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("spinifex-sqs %s: build request: %w", action, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
||||
sum := sha256.Sum256(body)
|
||||
if err := c.signer.Sign(req, hex.EncodeToString(sum[:]), signingService, c.region); err != nil {
|
||||
return &TransientError{Err: fmt.Errorf("spinifex-sqs %s: sign: %w", action, err)}
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &TransientError{Err: fmt.Errorf("spinifex-sqs %s: send: %w", action, err)}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponse))
|
||||
if err != nil {
|
||||
return &TransientError{Err: fmt.Errorf("spinifex-sqs %s: read response: %w", action, err)}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return parseError(action, resp.StatusCode, respBody)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return decodeResult(action, respBody, out)
|
||||
}
|
||||
|
||||
func decodeResult(action string, body []byte, out any) error {
|
||||
want := action + "Result"
|
||||
dec := xml.NewDecoder(bytes.NewReader(body))
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("spinifex-sqs %s: response carries no <%s>", action, want)
|
||||
}
|
||||
if start, ok := tok.(xml.StartElement); ok && start.Name.Local == want {
|
||||
if err := dec.DecodeElement(out, &start); err != nil {
|
||||
return fmt.Errorf("spinifex-sqs %s: decode response: %w", action, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The error body is not echoed: only the parsed code is kept.
|
||||
func parseError(action string, status int, body []byte) error {
|
||||
var env struct {
|
||||
Error struct {
|
||||
Code string `xml:"Code"`
|
||||
} `xml:"Error"`
|
||||
}
|
||||
code := fmt.Sprintf("HTTP%d", status)
|
||||
if xml.Unmarshal(body, &env) == nil && env.Error.Code != "" {
|
||||
code = env.Error.Code
|
||||
}
|
||||
return &APIError{Action: action, StatusCode: status, Code: code}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
package sqsworkergw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nkeys"
|
||||
)
|
||||
|
||||
type nopSigner struct{ err error }
|
||||
|
||||
func (s nopSigner) Sign(r *http.Request, _, service, region string) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
r.Header.Set("Authorization", "test "+service+"/"+region)
|
||||
return nil
|
||||
}
|
||||
|
||||
func userKey(t *testing.T) (pub, seed string) {
|
||||
t.Helper()
|
||||
kp, err := nkeys.CreateUser()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ := kp.PublicKey()
|
||||
s, _ := kp.Seed()
|
||||
return p, string(s)
|
||||
}
|
||||
|
||||
// server answers with body and status, counting and keeping requests.
|
||||
func server(t *testing.T, status int, body string) (*Client, *atomic.Int32, *[]string) {
|
||||
t.Helper()
|
||||
var n atomic.Int32
|
||||
var seen []string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n.Add(1)
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
seen = append(seen, r.Header.Get("Authorization")+"|"+string(b))
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw})
|
||||
c, err := New(srv.URL, "ap-southeast-2", ca, nopSigner{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c, &n, &seen
|
||||
}
|
||||
|
||||
func TestNew_RefusesWithoutHTTPSRegionCAOrSigner(t *testing.T) {
|
||||
ca := []byte("not a certificate")
|
||||
cases := map[string]func() error{
|
||||
"http": func() error { _, err := New("http://10.0.0.1:9999", "ap-southeast-2", ca, nopSigner{}); return err },
|
||||
"no host": func() error { _, err := New("https://", "ap-southeast-2", ca, nopSigner{}); return err },
|
||||
"no region": func() error { _, err := New("https://10.0.0.1:9999", "", ca, nopSigner{}); return err },
|
||||
"no signer": func() error { _, err := New("https://10.0.0.1:9999", "ap-southeast-2", ca, nil); return err },
|
||||
"no CA": func() error { _, err := New("https://10.0.0.1:9999", "ap-southeast-2", ca, nopSigner{}); return err },
|
||||
}
|
||||
for name, fn := range cases {
|
||||
if fn() == nil {
|
||||
t.Errorf("%s: client built", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaim_SignsForTheInternalServiceAndRegion(t *testing.T) {
|
||||
c, _, seen := server(t, 200, `<ClaimFirstStoreResponse><ClaimFirstStoreResult><CreateKey>true</CreateKey></ClaimFirstStoreResult></ClaimFirstStoreResponse>`)
|
||||
create, pub, err := c.Claim(context.Background())
|
||||
if err != nil || !create || pub != "" {
|
||||
t.Fatalf("claim = %v %q %v", create, pub, err)
|
||||
}
|
||||
if got := (*seen)[0]; !strings.HasPrefix(got, "test spinifex-sqs/ap-southeast-2|") || !strings.Contains(got, "Action=ClaimFirstStore") {
|
||||
t.Fatalf("request = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Only "create" or exactly one valid public key is a decision.
|
||||
func TestClaim_RefusesAnythingButOneDecision(t *testing.T) {
|
||||
pub, seed := userKey(t)
|
||||
cases := map[string]string{
|
||||
"create with a key": `<CreateKey>true</CreateKey><PublicKey>` + pub + `</PublicKey>`,
|
||||
"neither": `<CreateKey>false</CreateKey>`,
|
||||
"seed as key": `<CreateKey>false</CreateKey><PublicKey>` + seed + `</PublicKey>`,
|
||||
"no result": ``,
|
||||
}
|
||||
for name, inner := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
body := `<ClaimFirstStoreResponse><ClaimFirstStoreResult>` + inner + `</ClaimFirstStoreResult></ClaimFirstStoreResponse>`
|
||||
if inner == "" {
|
||||
body = `<Other/>`
|
||||
}
|
||||
c, _, _ := server(t, 200, body)
|
||||
_, _, err := c.Claim(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("accepted a non-decision")
|
||||
}
|
||||
if strings.Contains(err.Error(), seed) {
|
||||
t.Fatal("the error echoes a seed")
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("existing", func(t *testing.T) {
|
||||
c, _, _ := server(t, 200, `<ClaimFirstStoreResponse><ClaimFirstStoreResult><CreateKey>false</CreateKey><PublicKey>`+pub+`</PublicKey></ClaimFirstStoreResult></ClaimFirstStoreResponse>`)
|
||||
create, got, err := c.Claim(context.Background())
|
||||
if err != nil || create || got != pub {
|
||||
t.Fatalf("claim = %v %q %v", create, got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecordPublicKey_SendsOnlyAPublicKey(t *testing.T) {
|
||||
pub, seed := userKey(t)
|
||||
c, n, seen := server(t, 200, `<RecordWorkerPublicKeyResponse/>`)
|
||||
if err := c.RecordPublicKey(context.Background(), seed); err == nil || strings.Contains(err.Error(), seed) {
|
||||
t.Fatalf("seed err = %v", err)
|
||||
}
|
||||
if n.Load() != 0 {
|
||||
t.Fatal("a seed was sent")
|
||||
}
|
||||
if err := c.RecordPublicKey(context.Background(), pub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains((*seen)[0], "PublicKey="+pub) {
|
||||
t.Fatalf("request = %q", (*seen)[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Refusals carry only the parsed code; ServiceUnavailable is the one
|
||||
// retryable refusal, and transport failures are retryable too.
|
||||
func TestErrors_ClassifiedWithoutEchoingTheBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
code string
|
||||
retryable bool
|
||||
}{
|
||||
{403, "AccessDenied", false},
|
||||
{400, "InvalidParameterValue", false},
|
||||
{400, "InvalidState", false},
|
||||
{403, "SignatureDoesNotMatch", false},
|
||||
{503, "ServiceUnavailable", true},
|
||||
{500, "InternalFailure", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
c, _, _ := server(t, tc.status, `<ErrorResponse><Error><Code>`+tc.code+`</Code><Message>secret-ish detail</Message></Error></ErrorResponse>`)
|
||||
_, _, err := c.Claim(context.Background())
|
||||
api, ok := errors.AsType[*APIError](err)
|
||||
if !ok || api.Code != tc.code || api.Retryable() != tc.retryable || api.Pending() != (tc.code == "AccessDenied") {
|
||||
t.Fatalf("%s: err = %v", tc.code, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-ish") {
|
||||
t.Fatal("the error echoes the response body")
|
||||
}
|
||||
}
|
||||
|
||||
c, _, _ := server(t, 200, "")
|
||||
c.baseURL = "https://127.0.0.1:1"
|
||||
_, _, err := c.Claim(context.Background())
|
||||
tr, ok := errors.AsType[*TransientError](err)
|
||||
if !ok || !tr.Retryable() {
|
||||
t.Fatalf("unreachable err = %v, want TransientError", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A redirect would resend a signed request elsewhere; it is not followed.
|
||||
func TestCall_DoesNotFollowRedirects(t *testing.T) {
|
||||
var followed atomic.Bool
|
||||
target := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { followed.Store(true) }))
|
||||
t.Cleanup(target.Close)
|
||||
c, _, _ := server(t, 0, "")
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c.baseURL = srv.URL
|
||||
if _, _, err := c.Claim(context.Background()); err == nil {
|
||||
t.Fatal("a redirect was accepted as a decision")
|
||||
}
|
||||
if followed.Load() {
|
||||
t.Fatal("the signed request followed a redirect")
|
||||
}
|
||||
}
|
||||
|
||||
// Only "pending" with nothing else, or "bound" with an IPv4 address and one
|
||||
// valid public key, is a store state.
|
||||
func TestDescribeStore_AcceptsOnlyAValidState(t *testing.T) {
|
||||
pub, seed := userKey(t)
|
||||
wrap := func(inner string) string {
|
||||
return `<DescribeStoreResponse><DescribeStoreResult>` + inner + `</DescribeStoreResult></DescribeStoreResponse>`
|
||||
}
|
||||
t.Run("pending", func(t *testing.T) {
|
||||
c, _, seen := server(t, 200, wrap(`<State>pending</State>`))
|
||||
st, err := c.DescribeStore(context.Background())
|
||||
if err != nil || st.Bound {
|
||||
t.Fatalf("store = %+v %v", st, err)
|
||||
}
|
||||
if !strings.Contains((*seen)[0], "Action=DescribeStore") {
|
||||
t.Fatalf("request = %q", (*seen)[0])
|
||||
}
|
||||
})
|
||||
t.Run("bound", func(t *testing.T) {
|
||||
c, _, _ := server(t, 200, wrap(`<State>bound</State><Address>10.240.0.9</Address><PublicKey>`+pub+`</PublicKey>`))
|
||||
st, err := c.DescribeStore(context.Background())
|
||||
if err != nil || !st.Bound || st.Address != "10.240.0.9" || st.PublicKey != pub {
|
||||
t.Fatalf("store = %+v %v", st, err)
|
||||
}
|
||||
})
|
||||
for name, inner := range map[string]string{
|
||||
"pending with address": `<State>pending</State><Address>10.240.0.9</Address>`,
|
||||
"bound without key": `<State>bound</State><Address>10.240.0.9</Address>`,
|
||||
"bound with seed": `<State>bound</State><Address>10.240.0.9</Address><PublicKey>` + seed + `</PublicKey>`,
|
||||
"bound with hostname": `<State>bound</State><Address>store.example</Address><PublicKey>` + pub + `</PublicKey>`,
|
||||
"bound with IPv6": `<State>bound</State><Address>::ffff:10.240.0.9</Address><PublicKey>` + pub + `</PublicKey>`,
|
||||
"unknown state": `<State>ready</State>`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
c, _, _ := server(t, 200, wrap(inner))
|
||||
_, err := c.DescribeStore(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("accepted an invalid store state")
|
||||
}
|
||||
if strings.Contains(err.Error(), seed) {
|
||||
t.Fatal("the error echoes a seed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -7,10 +7,9 @@ Node installation, dev-environment lifecycle, guest image builds, and verificati
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `setup.sh` | The production binary installer, served from `install.mulgadc.com`. Installs deps, users, sudoers, the host firewall policy, files, systemd units, logrotate and udev rules, and loads `openvswitch`, `geneve`, `vhost_net` and `tun`, listing them in `/etc/modules-load.d/spinifex.conf` for later boots. It masks firewalld wherever it is installed, since the Spinifex nftables policy is the only supported node firewall. An unset `--firewall` keeps the mode recorded in `/etc/spinifex/firewall/mode`, and otherwise arms the policy when firewalld was active. Where systemd-resolved is installed but not running, resolvconf is absent and NetworkManager runs, it hands host DNS to resolved: it saves `/etc/resolv.conf` once to `/var/lib/spinifex-reset/resolv.conf.orig`, links `/etc/resolv.conf` to the resolved stub, and stops unless resolved has upstream servers and the package repository host still resolves. It links `/usr/bin/spx` to `/usr/local/bin/spx` where sudo's PATH leaves out `/usr/local/bin`, and takes the nbdkit plugin directory from `nbdkit --dump-config`, stopping when nbdkit cannot say. `SETUP_STAGES` runs a subset; `ISO_BUILD=1` runs it inside the ISO builder's chroot. |
|
||||
| `setup-ovn.sh` | Bootstraps a compute node for OVN VPC networking: installs OVN/OVS, creates `br-int`, configures the WAN bridge (bridged, DHCP, or routed-NAT uplink), sets the chassis identity, and applies overlay sysctls. 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. |
|
||||
| `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`. 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 stops and masks `openvswitch-ipsec.service` and `ipsec.service`, because OVN IPsec does not run on libreswan yet; 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. |
|
||||
| `clone-deps.sh` | Clones or updates the viperblock and predastore checkouts alongside spinifex for cross-repo development. |
|
||||
|
||||
### `lib/distro.sh`: host-OS capability probes
|
||||
@@ -59,7 +58,7 @@ Both ship to `/usr/local/share/spinifex/` on every node, so they are available o
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `node-reset.sh` | Clears **state** and leaves the node installed: stops services, destroys guests, wipes `/etc/spinifex`, `/var/lib/spinifex`, `/var/log/spinifex` and the OVN databases, and removes the OVS bridges. On a libreswan host it deletes the `ovs_cert_*` and `ovs_certkey_*` entries `ovs-monitor-ipsec` imported into libreswan's NSS database, leaving other identities and `/etc/ipsec.d/*.conf` alone. It stops OVS and OVN central by the unit names the host's packages use. It leaves the NetworkManager unmanaged-device config, which the next `setup-ovn.sh` run rewrites, so NetworkManager cannot take a NIC back in between. The node is then ready for a fresh `spx admin init`. `--keep-data` preserves `/var/lib/spinifex`; `SPX_RESET_CONFIRM=reset` runs it unattended. |
|
||||
| `node-reset.sh` | Clears **state** and leaves the node installed: stops services, destroys guests, wipes `/etc/spinifex`, `/var/lib/spinifex`, `/var/log/spinifex` and the OVN databases, and removes the OVS bridges. It stops OVS and OVN central by the unit names the host's packages use. It leaves the NetworkManager unmanaged-device config, which the next `setup-ovn.sh` run rewrites, so NetworkManager cannot take a NIC back in between. The node is then ready for a fresh `spx admin init`. `--keep-data` preserves `/var/lib/spinifex`; `SPX_RESET_CONFIRM=reset` runs it unattended. |
|
||||
| `uninstall-spx.sh` | Removes the **software** — the inverse of `setup.sh`. Delegates the state teardown above to `node-reset.sh` rather than reimplementing it, then removes the binary, the nbdkit plugin, the systemd units and slices, the service users, the sudoers grant, the sysctl/logrotate/chrony/udev drop-ins, the `systemd-networkd` units, the host firewall policy and its loaded nft table, the Spinifex systemd-resolved drop-in, and the Spinifex CA from the host trust store. It unmasks firewalld and leaves it disabled. Where `setup.sh` handed DNS to systemd-resolved, it restores the saved `/etc/resolv.conf` and turns resolved off. Verifies afterwards and exits non-zero naming anything that survived, then lists the host settings `setup.sh` changed that it leaves in place, such as the dnf repositories, `OVS_USER_ID`, masked IPsec units and the SELinux mode. |
|
||||
|
||||
`node-reset.sh` finds guests by process name, `qemu-system-*` or `qemu-kvm`, owned by the daemon's service user, and waits for them to exit before it deletes any state.
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Spinifex appliance first boot, for Oracle Linux units cloned from the gold
|
||||
# image that build-oracle-appliance.sh seals. The OL counterpart of the ISO's
|
||||
# spinifex-firstboot.sh: it also does the per-unit work the ISO installer does
|
||||
# before first boot (hostname, br-wan on the NIC that is present), then sets
|
||||
# up a single node. On success it writes $DONE_MARKER and the unit's
|
||||
# ConditionPathExists=! stops it running again. A failed run leaves no marker,
|
||||
# so the next boot retries from the top; every step below is safe to repeat.
|
||||
set -euo pipefail
|
||||
|
||||
DONE_MARKER=/var/lib/spinifex/.appliance-firstboot-done
|
||||
SHARE=/usr/local/share/spinifex
|
||||
|
||||
# shellcheck source=lib.sh
|
||||
. "$SHARE/appliance/lib.sh"
|
||||
# shellcheck source=../lib/distro.sh
|
||||
. "$SHARE/lib/distro.sh"
|
||||
|
||||
log() { echo "[appliance-firstboot] $*"; }
|
||||
|
||||
if [ -f "$DONE_MARKER" ]; then
|
||||
log "already complete, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Primary NIC ─────────────────────────────────────────────────────────────
|
||||
# Links start down when NetworkManager has no profile for them, so bring every
|
||||
# physical NIC up to read its carrier, and give a cable up to 60s to come up.
|
||||
nics=$(appliance_physical_nics)
|
||||
if [ -z "$nics" ]; then
|
||||
log "no physical Ethernet device found"
|
||||
exit 1
|
||||
fi
|
||||
for nic in $nics; do
|
||||
ip link set "$nic" up || true
|
||||
done
|
||||
for _i in $(seq 1 60); do
|
||||
for nic in $nics; do
|
||||
[ "$(cat "/sys/class/net/$nic/carrier" 2>/dev/null)" = 1 ] && break 2
|
||||
done
|
||||
sleep 1
|
||||
done
|
||||
NIC=$(appliance_pick_nic)
|
||||
MAC=$(cat "/sys/class/net/$NIC/address")
|
||||
log "primary NIC $NIC ($MAC), carrier $(cat "/sys/class/net/$NIC/carrier" 2>/dev/null || echo 0)"
|
||||
|
||||
HOSTNAME_NEW=$(appliance_hostname "$MAC")
|
||||
hostnamectl set-hostname "$HOSTNAME_NEW"
|
||||
log "hostname $HOSTNAME_NEW"
|
||||
|
||||
# ── br-wan on the primary NIC, DHCP ─────────────────────────────────────────
|
||||
# The bridge takes the NIC's MAC so a DHCP reservation made for the unit's
|
||||
# port keeps working. bridge.stp no: with STP on, the port sits in its
|
||||
# forwarding delay long enough for DHCP on the bridge to time out.
|
||||
for con in $(nmcli -g UUID,DEVICE connection show | awk -F: -v d="$NIC" '$2 == d {print $1}'); do
|
||||
nmcli connection delete "$con" || true
|
||||
done
|
||||
nmcli connection delete br-wan br-wan-port >/dev/null 2>&1 || true
|
||||
nmcli connection add type bridge ifname br-wan con-name br-wan \
|
||||
bridge.stp no bridge.mac-address "$MAC" \
|
||||
ipv4.method auto ipv6.method disabled connection.autoconnect yes
|
||||
nmcli connection add type bridge-slave ifname "$NIC" master br-wan con-name br-wan-port \
|
||||
connection.autoconnect yes
|
||||
nmcli connection up br-wan || true
|
||||
|
||||
log "waiting for a DHCP address on br-wan"
|
||||
WAN_IP=""
|
||||
for _i in $(seq 1 120); do
|
||||
WAN_IP=$(ip -4 -o addr show br-wan scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
|
||||
if [ -n "$WAN_IP" ] && ip -4 route show default dev br-wan | grep -q .; then
|
||||
break
|
||||
fi
|
||||
WAN_IP=""
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$WAN_IP" ]; then
|
||||
log "br-wan got no DHCP address and default route in 120s; check the cable on $NIC and the site's DHCP"
|
||||
exit 1
|
||||
fi
|
||||
log "br-wan $WAN_IP"
|
||||
|
||||
# ── Clock ───────────────────────────────────────────────────────────────────
|
||||
# Credentials and TLS certificates are minted below, and a certificate issued
|
||||
# against a wrong RTC carries a NotBefore that outlives the correction.
|
||||
# Bounded and non-fatal, as on the ISO.
|
||||
chronyc -a makestep >/dev/null 2>&1 || true
|
||||
if chronyc waitsync 12 0.1 0 5 >/dev/null 2>&1; then
|
||||
log "clock synced: $(date -uIs)"
|
||||
else
|
||||
log "WARNING: clock not synced, continuing with $(date -uIs)"
|
||||
fi
|
||||
|
||||
# ── OVS and OVN ─────────────────────────────────────────────────────────────
|
||||
# Start them before setup-ovn.sh so their databases exist when it runs: on
|
||||
# physical hardware first-boot DB initialisation outlasts its own timeout.
|
||||
OVS_UNIT=$(distro_ovs_unit)
|
||||
OVN_LAYOUT=$(distro_ovn_central_layout)
|
||||
case $OVN_LAYOUT in
|
||||
split) OVN_CENTRAL_UNIT=ovn-central.service ;;
|
||||
lone-northd) OVN_CENTRAL_UNIT=ovn-northd.service ;;
|
||||
esac
|
||||
systemctl start "$OVS_UNIT" "$OVN_CENTRAL_UNIT"
|
||||
for _i in $(seq 1 120); do
|
||||
if ovn-nbctl --timeout=2 get-connection >/dev/null 2>&1; then
|
||||
log "OVN NB DB ready (${_i}s)"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
"$SHARE/setup-ovn.sh" --management
|
||||
|
||||
# ── Single-node cluster ─────────────────────────────────────────────────────
|
||||
# Same arguments as the ISO's single-NIC DHCP path. spx writes credentials to
|
||||
# disk and never prints them.
|
||||
spx admin init --node "$HOSTNAME_NEW" --nodes 1 2>&1
|
||||
|
||||
# spx ran as root, so the files it wrote are root:root. setup.sh's fixown
|
||||
# stage knows each file's owner and mode.
|
||||
SETUP_STAGES=fixown "$SHARE/setup.sh"
|
||||
|
||||
# ── spinifex login ──────────────────────────────────────────────────────────
|
||||
if [ ! -f /home/spinifex/.ssh/id_ed25519 ]; then
|
||||
install -d -m 700 -o spinifex -g spinifex /home/spinifex/.ssh
|
||||
ssh-keygen -q -t ed25519 -f /home/spinifex/.ssh/id_ed25519 -N "" -C "spinifex@$HOSTNAME_NEW"
|
||||
chown spinifex:spinifex /home/spinifex/.ssh/id_ed25519 /home/spinifex/.ssh/id_ed25519.pub
|
||||
fi
|
||||
# init wrote the AWS profile under HOME=/root; the operator logs in as spinifex.
|
||||
if [ -f /root/.aws/credentials ]; then
|
||||
install -d -m 700 -o spinifex -g spinifex /home/spinifex/.aws
|
||||
install -m 600 -o spinifex -g spinifex /root/.aws/credentials /home/spinifex/.aws/credentials
|
||||
[ -f /root/.aws/config ] && install -m 600 -o spinifex -g spinifex /root/.aws/config /home/spinifex/.aws/config
|
||||
fi
|
||||
|
||||
# ── Services ────────────────────────────────────────────────────────────────
|
||||
# Enabled here, not in the image, so a clone runs nothing of Spinifex before
|
||||
# init has written its config.
|
||||
systemctl enable spinifex.target spinifex-banner.service spinifex-firewall.service
|
||||
systemctl enable --now spinifex-nats-watchdog.timer
|
||||
|
||||
mkdir -p "$(dirname "$DONE_MARKER")"
|
||||
touch "$DONE_MARKER"
|
||||
|
||||
systemctl start spinifex.target
|
||||
/usr/local/bin/spx admin banner --boot-check
|
||||
log "complete: https://$WAN_IP:3000, login spinifex"
|
||||
@@ -1,4 +0,0 @@
|
||||
# Holds the console login prompt until first boot and the banner have written
|
||||
# /etc/issue, so the unit's IP shows before anyone logs in.
|
||||
[Unit]
|
||||
After=spinifex-appliance-firstboot.service spinifex-banner.service
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Helpers shared by the appliance first-boot script and its test. Sourced, not run.
|
||||
|
||||
# appliance_physical_nics — physical Ethernet devices, one per line, in PCI
|
||||
# order. Skips anything without a backing device (bridges, veths, OVS ports,
|
||||
# loopback), non-Ethernet link types and wireless. SYSFS overrides /sys.
|
||||
appliance_physical_nics() {
|
||||
local sys="${SYSFS:-/sys}/class/net" dev
|
||||
for dev in "$sys"/*; do
|
||||
[ -e "$dev/device" ] || continue
|
||||
[ "$(cat "$dev/type" 2>/dev/null)" = 1 ] || continue
|
||||
[ -d "$dev/wireless" ] && continue
|
||||
printf '%s %s\n' "$(readlink -f "$dev/device")" "$(basename "$dev")"
|
||||
done | sort | awk '{print $2}'
|
||||
}
|
||||
|
||||
# appliance_pick_nic — the first physical NIC with carrier, else the first
|
||||
# physical NIC. Prints nothing when the host has none.
|
||||
appliance_pick_nic() {
|
||||
local sys="${SYSFS:-/sys}/class/net" nic first=""
|
||||
for nic in $(appliance_physical_nics); do
|
||||
[ -n "$first" ] || first=$nic
|
||||
if [ "$(cat "$sys/$nic/carrier" 2>/dev/null)" = 1 ]; then
|
||||
echo "$nic"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
[ -n "$first" ] && echo "$first"
|
||||
return 0
|
||||
}
|
||||
|
||||
# appliance_hostname <mac> — spinifex-<last 6 hex digits of the MAC>.
|
||||
appliance_hostname() {
|
||||
local hex
|
||||
hex=$(echo "$1" | tr -d ':' | tr '[:upper:]' '[:lower:]')
|
||||
echo "spinifex-${hex: -6}"
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/bin/sh
|
||||
# lib_test.sh — tests for the appliance first-boot NIC and hostname helpers,
|
||||
# run against a fixture sysfs tree (SYSFS) so the host's own NICs never count.
|
||||
set -eu
|
||||
|
||||
DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
|
||||
echo "-- appliance/lib.sh"
|
||||
|
||||
# nic <name> <pci-slot> <carrier> [type] — a physical NIC behind a PCI device.
|
||||
nic() {
|
||||
mkdir -p "$SYS/devices/pci0000:00/$2" "$SYS/class/net/$1"
|
||||
ln -s "../../../devices/pci0000:00/$2" "$SYS/class/net/$1/device"
|
||||
echo "${4:-1}" > "$SYS/class/net/$1/type"
|
||||
echo "$3" > "$SYS/class/net/$1/carrier"
|
||||
}
|
||||
# virt <name> — a link with no backing device (bridge, veth, OVS port).
|
||||
virt() {
|
||||
mkdir -p "$SYS/class/net/$1"
|
||||
echo 1 > "$SYS/class/net/$1/type"
|
||||
echo 1 > "$SYS/class/net/$1/carrier"
|
||||
}
|
||||
fixture() { SYS="$TMP/$1"; mkdir -p "$SYS/class/net"; }
|
||||
run() { SYSFS="$SYS" bash -c ". '$DIR/lib.sh'; $1"; }
|
||||
|
||||
check() {
|
||||
if [ "$2" = "$3" ]; then pass "$1"; else fail "$1: want '$2', got '$3'"; fi
|
||||
}
|
||||
|
||||
fixture order
|
||||
nic enp3s0 0000:00:03.0 0
|
||||
nic enp1s0 0000:00:01.0 0
|
||||
virt br-wan
|
||||
virt lo
|
||||
check "physical NICs in PCI order, virtual links skipped" "enp1s0 enp3s0" "$(run appliance_physical_nics | tr '\n' ' ' | sed 's/ $//')"
|
||||
check "no carrier anywhere picks the first by PCI order" enp1s0 "$(run appliance_pick_nic)"
|
||||
|
||||
fixture carrier
|
||||
nic eno1 0000:00:01.0 0
|
||||
nic eno2 0000:00:02.0 1
|
||||
nic eno3 0000:00:03.0 1
|
||||
check "the first NIC with carrier wins over PCI order" eno2 "$(run appliance_pick_nic)"
|
||||
|
||||
fixture wireless
|
||||
nic wlp1s0 0000:00:01.0 1
|
||||
mkdir -p "$SYS/class/net/wlp1s0/wireless"
|
||||
nic ib0 0000:00:02.0 1 32
|
||||
nic enp4s0 0000:00:04.0 0
|
||||
check "wireless and non-Ethernet links are skipped" enp4s0 "$(run appliance_pick_nic)"
|
||||
|
||||
fixture none
|
||||
virt br-wan
|
||||
check "no physical NIC prints nothing" "" "$(run appliance_pick_nic)"
|
||||
|
||||
check "hostname from the MAC's last 6 hex digits" spinifex-c0ffee "$(run 'appliance_hostname 52:54:00:C0:FF:EE')"
|
||||
|
||||
[ "$fails" -eq 0 ] || { echo " $fails failed"; exit 1; }
|
||||
@@ -1,191 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Runs as root on the Oracle Linux build system, one stage per call, driven by
|
||||
# build-oracle-appliance.sh from the payload directory it copied over.
|
||||
# prepare.sh check refuse anything but OL 10.2+ on UEK R8, x86_64
|
||||
# prepare.sh update dnf update, SELinux permissive; prints PREPARE_REBOOT=1 if
|
||||
# the newest kernel is not the running one
|
||||
# prepare.sh install install Spinifex, uninitialised and not enabled
|
||||
# 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.
|
||||
set -euo pipefail
|
||||
|
||||
# Shared by every unit built from this image; first login forces a change.
|
||||
DEFAULT_PASSWORD=spinifex
|
||||
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
SHARE=/usr/local/share/spinifex
|
||||
|
||||
log() { echo "[prepare] $*"; }
|
||||
die() { echo "[prepare] ERROR: $*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" = 0 ] || die "run as root"
|
||||
|
||||
stage_check() {
|
||||
# shellcheck source=/dev/null
|
||||
. /etc/os-release
|
||||
[ "${ID:-}" = ol ] || die "not Oracle Linux (ID=${ID:-unset})"
|
||||
local major=${VERSION_ID%%.*} minor=${VERSION_ID#*.}
|
||||
[ "$minor" = "$VERSION_ID" ] && minor=0
|
||||
if [ "$major" -lt 10 ] || { [ "$major" -eq 10 ] && [ "$minor" -lt 2 ]; }; then
|
||||
# A 10.0 or 10.1 system reaches 10.2 through the update stage, which
|
||||
# calls this again with "strict" once it has run.
|
||||
if [ "$major" -ne 10 ] || [ "${1:-}" = strict ]; then
|
||||
die "Oracle Linux $VERSION_ID; 10.2 or later is required"
|
||||
fi
|
||||
log "Oracle Linux $VERSION_ID; the update stage brings it to the current minor"
|
||||
fi
|
||||
[ "$(uname -m)" = x86_64 ] || die "$(uname -m); only x86_64 is supported"
|
||||
case $(uname -r) in
|
||||
6.12.0-*uek*) ;;
|
||||
*) die "running kernel $(uname -r) is not UEK R8; install kernel-uek and boot it first" ;;
|
||||
esac
|
||||
command -v nmcli >/dev/null || die "NetworkManager (nmcli) is required"
|
||||
log "OK: $PRETTY_NAME, kernel $(uname -r)"
|
||||
}
|
||||
|
||||
stage_update() {
|
||||
setenforce 0 2>/dev/null || true
|
||||
sed -i 's/^SELINUX=.*/SELINUX=permissive/' /etc/selinux/config
|
||||
[ "$(getenforce)" != Enforcing ] || die "SELinux is still enforcing"
|
||||
dnf update -y
|
||||
stage_check strict >/dev/null
|
||||
local newest
|
||||
newest=$(grubby --default-kernel | sed 's|^/boot/vmlinuz-||')
|
||||
case $newest in
|
||||
6.12.0-*uek*) ;;
|
||||
*) die "the default boot kernel is $newest, not UEK R8; set a kernel-uek default with grubby" ;;
|
||||
esac
|
||||
if [ "$newest" != "$(uname -r)" ]; then
|
||||
echo PREPARE_REBOOT=1
|
||||
fi
|
||||
}
|
||||
|
||||
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)
|
||||
if [ -n "${INSTALL_SPINIFEX_TARBALL:-}" ]; then
|
||||
tarball=$INSTALL_SPINIFEX_TARBALL
|
||||
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"
|
||||
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.
|
||||
systemctl disable --now spinifex.target spinifex-nats-watchdog.timer
|
||||
log "Spinifex $(spx version 2>/dev/null | head -1) installed, not initialised"
|
||||
}
|
||||
|
||||
stage_appliance() {
|
||||
# As on the ISO: the existing spinifex system group as primary, so the
|
||||
# login can reach the sockets setup.sh group-owns, and passwordless sudo.
|
||||
if ! id spinifex >/dev/null 2>&1; then
|
||||
useradd --create-home --shell /bin/bash --gid spinifex --groups wheel spinifex
|
||||
fi
|
||||
echo "spinifex:$DEFAULT_PASSWORD" | chpasswd
|
||||
chage -d 0 spinifex
|
||||
echo 'spinifex ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/spinifex-admin
|
||||
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
|
||||
|
||||
# No DHCP on every port: first boot puts the primary NIC on br-wan and
|
||||
# leaves the rest alone, so no second port competes for the default route.
|
||||
printf '[main]\nno-auto-default=*\n' > /etc/NetworkManager/conf.d/90-spinifex-appliance.conf
|
||||
|
||||
install -D -m 0644 "$HERE/lib.sh" "$SHARE/appliance/lib.sh"
|
||||
install -D -m 0755 "$HERE/firstboot.sh" "$SHARE/appliance/firstboot.sh"
|
||||
install -m 0644 "$HERE/spinifex-appliance-firstboot.service" "$HERE/spinifex-banner.service" /etc/systemd/system/
|
||||
local getty
|
||||
for getty in getty@tty1 serial-getty@ttyS0; do
|
||||
install -D -m 0644 "$HERE/getty-wait.conf" "/etc/systemd/system/$getty.service.d/spinifex-wait.conf"
|
||||
done
|
||||
systemctl daemon-reload
|
||||
systemctl enable spinifex-appliance-firstboot.service
|
||||
|
||||
# Cloud-init would re-run on each unit with no datasource and rewrite the
|
||||
# network and users first boot owns.
|
||||
if [ -d /etc/cloud ]; then
|
||||
touch /etc/cloud/cloud-init.disabled
|
||||
fi
|
||||
log "spinifex login (default password '$DEFAULT_PASSWORD', change forced) and first-boot service installed"
|
||||
}
|
||||
|
||||
stage_seal() {
|
||||
dnf clean all
|
||||
|
||||
systemctl stop "$(. "$SHARE/lib/distro.sh" && distro_ovs_unit)" ovn-northd.service ovn-controller.service 2>/dev/null || true
|
||||
# OVS writes the chassis UUID on first start; a shared one makes every unit
|
||||
# the same chassis in OVN.
|
||||
rm -f /etc/openvswitch/system-id.conf /etc/openvswitch/conf.db /etc/openvswitch/.conf.db.~lock~
|
||||
rm -f /var/lib/ovn/*.db /etc/ovn/*.db
|
||||
rm -f /etc/spinifex/node.conf
|
||||
|
||||
# Profiles for the build system's NICs, its DHCP leases and NM's
|
||||
# per-machine secret. First boot writes br-wan for the unit's own NIC.
|
||||
rm -f /etc/NetworkManager/system-connections/*
|
||||
rm -f /var/lib/NetworkManager/*.lease /var/lib/NetworkManager/secret_key \
|
||||
/var/lib/NetworkManager/timestamps /var/lib/NetworkManager/seen-bssids
|
||||
if command -v cloud-init >/dev/null; then
|
||||
cloud-init clean --logs || true
|
||||
fi
|
||||
|
||||
# sshd-keygen regenerates host keys before sshd on the next boot; systemd
|
||||
# generates a machine-id when the file is empty.
|
||||
rm -f /etc/ssh/ssh_host_*
|
||||
truncate -s 0 /etc/machine-id
|
||||
rm -f /var/lib/dbus/machine-id /var/lib/systemd/random-seed
|
||||
echo localhost > /etc/hostname
|
||||
|
||||
# The build user's access goes; the spinifex login is the way in.
|
||||
if [ -n "${BUILD_USER:-}" ] && [ "$BUILD_USER" != spinifex ] && [ "$BUILD_USER" != root ]; then
|
||||
local home
|
||||
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
|
||||
fi
|
||||
rm -f /root/.ssh/authorized_keys /root/.bash_history /home/spinifex/.bash_history
|
||||
|
||||
printf '\nSpinifex first boot is in progress. This screen updates when it finishes.\n\n' > /etc/issue
|
||||
sed -i '/^# --- Spinifex ---$/,$d' /etc/motd 2>/dev/null || true
|
||||
|
||||
find /var/log -type f -exec truncate -s 0 {} + 2>/dev/null || true
|
||||
rm -rf /var/log/journal/* /tmp/* /var/tmp/*
|
||||
sync
|
||||
log "sealed; powering off"
|
||||
systemctl poweroff --no-block
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
check) stage_check ;;
|
||||
update) stage_update ;;
|
||||
install) stage_install ;;
|
||||
appliance) stage_appliance ;;
|
||||
seal) stage_seal ;;
|
||||
*) die "usage: $0 check|update|install|appliance|seal" ;;
|
||||
esac
|
||||
@@ -1,22 +0,0 @@
|
||||
[Unit]
|
||||
Description=Spinifex appliance first-boot provisioning
|
||||
After=NetworkManager.service network-online.target chronyd.service
|
||||
Wants=NetworkManager.service network-online.target
|
||||
ConditionPathExists=!/var/lib/spinifex/.appliance-firstboot-done
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=HOME=/root
|
||||
ExecStart=/usr/local/share/spinifex/appliance/firstboot.sh
|
||||
RemainAfterExit=yes
|
||||
# The banner is never enabled when first boot fails, so without this the
|
||||
# console would keep the "first boot in progress" placeholder.
|
||||
ExecStopPost=/bin/sh -c 'if [ "$EXIT_STATUS" != "0" ]; then printf "\n*** Spinifex first boot FAILED ***\nLog in as spinifex, then run:\n sudo journalctl -u spinifex-appliance-firstboot.service\nCheck the network cable and DHCP, then reboot to retry.\n\n" > /etc/issue; fi'
|
||||
# setup-ovn.sh and spx admin init must not wedge multi-user.target and keep
|
||||
# getty from ever showing a login prompt.
|
||||
TimeoutStartSec=600s
|
||||
StandardOutput=journal+console
|
||||
StandardError=journal+console
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,15 +0,0 @@
|
||||
[Unit]
|
||||
Description=Spinifex console banner and boot health check
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/spx admin banner --boot-check
|
||||
RemainAfterExit=yes
|
||||
# Cap it so a stuck boot-check cannot hold getty behind the console drop-in.
|
||||
TimeoutStartSec=30s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/bin/bash
|
||||
# build-oracle-appliance.sh — turn an Oracle Linux 10.2 system reachable over
|
||||
# SSH with sudo into a sealed Spinifex gold image: installed, not initialised,
|
||||
# with a first-boot service that sets each cloned unit up as a single node.
|
||||
# The system powers off at the end; capture its disk as the image.
|
||||
#
|
||||
# Usage: build-oracle-appliance.sh [options] <user>@<host>
|
||||
# --channel latest|dev install channel (default: latest)
|
||||
# --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.
|
||||
set -euo pipefail
|
||||
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
PAYLOAD_SRC="$HERE/appliance"
|
||||
REMOTE_DIR=/tmp/spinifex-appliance
|
||||
|
||||
CHANNEL="" VERSION="" TARBALL="" TARGET=""
|
||||
|
||||
usage() { sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
|
||||
log() { echo "==> $*"; }
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--channel) CHANNEL="${2:-}"; shift ;;
|
||||
--version) VERSION="${2:-}"; shift ;;
|
||||
--tarball) TARBALL="${2:-}"; shift ;;
|
||||
-h | --help) usage ;;
|
||||
-*) die "unknown option: $1" ;;
|
||||
*) [ -z "$TARGET" ] || die "one target only"; TARGET=$1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ -n "$TARGET" ] || usage 1
|
||||
case $TARGET in *@*) ;; *) die "target must be <user>@<host>" ;; esac
|
||||
BUILD_USER=${TARGET%@*}
|
||||
[ -z "$TARBALL" ] || [ -f "$TARBALL" ] || die "no such tarball: $TARBALL"
|
||||
|
||||
# One multiplexed connection; host key checking is relaxed because the build
|
||||
# system is usually a fresh install whose key nobody has recorded yet.
|
||||
CTL_DIR=$(mktemp -d)
|
||||
trap 'ssh -o ControlPath="$CTL_DIR/c" -O exit "$TARGET" >/dev/null 2>&1 || true; rm -rf "$CTL_DIR"' EXIT
|
||||
SSH_OPTS=(-o ControlMaster=auto -o ControlPath="$CTL_DIR/c" -o ControlPersist=60
|
||||
-o StrictHostKeyChecking=accept-new -o ServerAliveInterval=15 -o ConnectTimeout=10)
|
||||
|
||||
rsh() { ssh "${SSH_OPTS[@]}" "$TARGET" "$@"; }
|
||||
|
||||
# prepare <stage> — run one prepare.sh stage as root on the build system.
|
||||
prepare() {
|
||||
rsh "sudo env INSTALL_SPINIFEX_CHANNEL='$CHANNEL' INSTALL_SPINIFEX_VERSION='$VERSION' \
|
||||
INSTALL_SPINIFEX_TARBALL='${TARBALL:+$REMOTE_DIR/$(basename "$TARBALL")}' \
|
||||
BUILD_USER='$BUILD_USER' bash $REMOTE_DIR/prepare.sh $1"
|
||||
}
|
||||
|
||||
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/"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_ssh() {
|
||||
local _i
|
||||
ssh "${SSH_OPTS[@]}" -O exit "$TARGET" >/dev/null 2>&1 || true
|
||||
sleep 10
|
||||
for _i in $(seq 1 60); do
|
||||
if rsh true 2>/dev/null; then return 0; fi
|
||||
sleep 5
|
||||
done
|
||||
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"
|
||||
|
||||
log "Copying the payload to $TARGET"
|
||||
push_payload
|
||||
prepare check
|
||||
|
||||
log "Updating Oracle Linux"
|
||||
prepare update | tee "$CTL_DIR/update.log"
|
||||
if grep -qx 'PREPARE_REBOOT=1' "$CTL_DIR/update.log"; then
|
||||
log "Rebooting into the updated kernel"
|
||||
rsh "sudo systemctl reboot" || true
|
||||
wait_for_ssh
|
||||
# /tmp is cleared on boot.
|
||||
push_payload
|
||||
prepare check
|
||||
fi
|
||||
|
||||
log "Installing Spinifex (not initialising it)"
|
||||
prepare install
|
||||
|
||||
log "Installing the spinifex login and the first-boot service"
|
||||
prepare appliance
|
||||
|
||||
log "Sealing; the system powers off when done"
|
||||
prepare seal || true
|
||||
|
||||
log "Done. Capture $TARGET's disk once it has powered off; each unit set up from it initialises itself on first boot."
|
||||
@@ -1,518 +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.
|
||||
#
|
||||
# SQS_MICROVM_PROFILE=worker builds the shared SQS worker guest instead: the
|
||||
# same network /init and kernel, build/microvm/sqs-worker's steps and
|
||||
# $REPO_ROOT/bin/sqs-worker, with no nats-server.
|
||||
#
|
||||
# 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"
|
||||
|
||||
PROFILE="${SQS_MICROVM_PROFILE:-store}"
|
||||
case "$PROFILE" in
|
||||
store) ;;
|
||||
worker)
|
||||
BUILD_DIR="$REPO_ROOT/build/microvm/sqs-worker"
|
||||
INITTAB="$BUILD_DIR/inittab"
|
||||
SQS_WORKER_BIN="$REPO_ROOT/bin/sqs-worker"
|
||||
;;
|
||||
*) echo "ERROR: SQS_MICROVM_PROFILE must be store or worker, not $PROFILE" >&2; exit 1 ;;
|
||||
esac
|
||||
TAG="[build-sqs-$PROFILE-microvm-image]"
|
||||
|
||||
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 "$TAG repo root: $REPO_ROOT"
|
||||
echo "$TAG 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
|
||||
|
||||
if [ "$PROFILE" = worker ]; then
|
||||
if [ ! -x "$SQS_WORKER_BIN" ]; then
|
||||
echo "ERROR: $SQS_WORKER_BIN not found — run 'make bin/sqs-worker'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! file -b "$SQS_WORKER_BIN" | grep -q "statically linked"; then
|
||||
echo "ERROR: $SQS_WORKER_BIN is not statically linked — it cannot run on the musl guest" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# --- 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 "$TAG nats-server: $nats_server_version (matches go.mod)"
|
||||
fi
|
||||
|
||||
# --- 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 "$TAG cleaning up chroot: $CHROOT_DIR"
|
||||
rm -rf "$CHROOT_DIR"
|
||||
rm -rf "${STAGE_DIR:?}"
|
||||
if [ -n "$CONTAINER_CID" ] && [ -n "$CONTAINER_TOOL" ]; then
|
||||
echo "$TAG 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 "$TAG 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 "$TAG 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 "$TAG 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 "$TAG 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 "$TAG installing inittab..."
|
||||
install -m 0644 "$INITTAB" "$CHROOT_DIR/etc/inittab"
|
||||
|
||||
if [ "$PROFILE" = worker ]; then
|
||||
echo "$TAG installing worker guest steps..."
|
||||
for step in sqs-worker-lib.sh sqs-worker-prepare sqs-worker-ready sqs-worker-stop; do
|
||||
if [ ! -f "$BUILD_DIR/$step" ]; then
|
||||
echo "ERROR: $BUILD_DIR/$step not found — cannot build initramfs" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
mkdir -p "$CHROOT_DIR/usr/local/lib/sqs-worker" "$CHROOT_DIR/usr/local/bin"
|
||||
install -m 0755 "$BUILD_DIR/sqs-worker-lib.sh" "$BUILD_DIR/sqs-worker-prepare" "$BUILD_DIR/sqs-worker-ready" "$BUILD_DIR/sqs-worker-stop" \
|
||||
"$CHROOT_DIR/usr/local/lib/sqs-worker/"
|
||||
install -m 0755 "$SQS_WORKER_BIN" "$CHROOT_DIR/usr/local/bin/sqs-worker"
|
||||
# The mountpoint ships empty; the worker proves its disk is mounted here.
|
||||
mkdir -p "$CHROOT_DIR/data"
|
||||
else
|
||||
# --- Place the store's guest steps ---
|
||||
echo "$TAG 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 "$TAG copying nats-server binary..."
|
||||
mkdir -p "$CHROOT_DIR/usr/local/bin"
|
||||
install -m 0755 "$NATS_SERVER_BIN" "$CHROOT_DIR/usr/local/bin/nats-server"
|
||||
fi
|
||||
|
||||
# --- 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 "$TAG 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 "$TAG fw_cfg: built-in (CONFIG_FW_CFG_SYSFS=y)"
|
||||
else
|
||||
echo "$TAG 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 "$TAG 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 "$TAG 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 "$TAG extra module closure: $(echo "$extra_names" | tr '\n' ' ')"
|
||||
echo "$TAG 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 "$TAG modprobe $name: $(awk '{ n = split($2, a, "/"); printf "%s ", a[n] }' <<< "$plan")"
|
||||
done
|
||||
done
|
||||
|
||||
# --- Strip package-manager artifacts (not needed at runtime) ---
|
||||
echo "$TAG 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 "$TAG 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 "$TAG 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 "$TAG 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" "$PROFILE"
|
||||
echo ""
|
||||
echo "$TAG done."
|
||||
@@ -25,7 +25,7 @@ error_log="/dev/console"
|
||||
if [ -f /etc/spinifex-eks/first-boot.env ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/spinifex-eks/first-boot.env
|
||||
export EKS_GATEWAY_URL EKS_GATEWAY_CA EKS_ACCESS_KEY EKS_SECRET_KEY EKS_REGION EKS_ACCOUNT_ID EKS_CLUSTER_NAME EKS_WEBHOOK_READY_ADDR
|
||||
export EKS_GATEWAY_URL EKS_GATEWAY_CA EKS_ACCESS_KEY EKS_SECRET_KEY EKS_REGION EKS_ACCOUNT_ID EKS_CLUSTER_NAME
|
||||
fi
|
||||
|
||||
depend() {
|
||||
|
||||
+21
-2
@@ -298,6 +298,7 @@ log "preflight on $N host(s)"
|
||||
|
||||
versions=""
|
||||
families=""
|
||||
libreswan_hosts=""
|
||||
for i in $(seq 0 $((N - 1))); do
|
||||
host="${HOSTS[$i]}"
|
||||
|
||||
@@ -321,6 +322,12 @@ for i in $(seq 0 $((N - 1))); do
|
||||
fail "$host: cannot tell its OS from /etc/os-release"
|
||||
families+="${host}: ${family}"$'\n'
|
||||
|
||||
# libreswan's pluto: Spinifex cannot run OVN IPsec on it yet, and spx admin
|
||||
# init refuses --ipsec=true there, which would stop formation part way.
|
||||
if [ "$(out "$host" "test -x /usr/libexec/ipsec/pluto && echo yes")" = "yes" ]; then
|
||||
libreswan_hosts+=" $host"
|
||||
fi
|
||||
|
||||
# A node that is already in a multi-node cluster is one whose keys and
|
||||
# logical network state are load-bearing. Refusing here is deliberate:
|
||||
# forming a cluster must never be the command that destroys a live one.
|
||||
@@ -379,6 +386,12 @@ $(sed '/^$/d; s/^/ /' <<<"$families")
|
||||
A cluster must run one OS on every host."
|
||||
fi
|
||||
|
||||
if $IPSEC_ENABLED && [ -n "$libreswan_hosts" ]; then
|
||||
fail "--ipsec on, but these hosts use libreswan for IPsec:$libreswan_hosts
|
||||
Spinifex cannot run OVN IPsec on libreswan yet. Re-run with --ipsec off
|
||||
only if the nodes share a trusted private link."
|
||||
fi
|
||||
|
||||
# --- Confirm ---------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
@@ -447,7 +460,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
|
||||
@@ -609,9 +622,15 @@ else
|
||||
fi
|
||||
|
||||
# Compute nodes: no database of their own, pointed at every database node.
|
||||
#
|
||||
# --demote because these hosts may already carry a management role marker:
|
||||
# provision-iso-cluster.sh promotes every host during --reset-to-iso, since that
|
||||
# is what firstboot leaves behind, and formation is what decides which of them
|
||||
# hold the database. Without it setup-ovn.sh refuses the fourth host and the run
|
||||
# stops with every node left a standalone single-node cluster.
|
||||
if [ "$N" -gt "$DB_NODES" ]; then
|
||||
for i in $(seq "$DB_NODES" $((N - 1))); do
|
||||
on "${HOSTS[$i]}" "sudo $SETUP_OVN \
|
||||
on "${HOSTS[$i]}" "sudo $SETUP_OVN --demote \
|
||||
--node-name=${NODE_NAMES[$i]} \
|
||||
--ovn-remote=$OVN_REMOTE $OVN_UPLINK_ARGS \
|
||||
--encap-ip=${VPC_IPS[$i]}" || fail "${HOSTS[$i]}: setup-ovn.sh failed"
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
#!/bin/sh
|
||||
# ipsec_state_helper_test.sh — tests for the plaintext Geneve guard in the
|
||||
# IPsec state helper that setup.sh installs.
|
||||
#
|
||||
# The helper is cut out of setup.sh's heredoc and run against stub nft,
|
||||
# ovs-vsctl and systemctl commands, with pluto's path pointed at a fixture.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
|
||||
SETUP="$SCRIPT_DIR/setup.sh"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
|
||||
echo "-- IPsec state helper: plaintext Geneve guard"
|
||||
|
||||
HELPER="$TMP/helper"
|
||||
awk '/tee "\$\{IPSEC_STATE_HELPER\}"/ { on = 1; next } on && /^HELPER$/ { exit } on { print }' "$SETUP" |
|
||||
sed "s#/usr/libexec/ipsec/pluto#$TMP/pluto#g" > "$HELPER"
|
||||
chmod +x "$HELPER"
|
||||
grep -q '^ guard)' "$HELPER" || { echo "could not extract the helper from setup.sh" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$TMP/bin"
|
||||
cat > "$TMP/bin/nft" <<EOF
|
||||
#!/bin/sh
|
||||
[ "\$*" = "-f -" ] || { echo "nft \$*" >> "$TMP/nft.log"; exit 0; }
|
||||
cat >> "$TMP/nft.log"
|
||||
echo "--" >> "$TMP/nft.log"
|
||||
EOF
|
||||
cat > "$TMP/bin/ovs-vsctl" <<EOF
|
||||
#!/bin/sh
|
||||
[ -e "$TMP/ovs-down" ] && { echo "ovs-vsctl: database connection failed" >&2; exit 1; }
|
||||
case "\$*" in
|
||||
*" find Interface type=geneve") cat "$TMP/ifaces" ;;
|
||||
*" get Interface "*)
|
||||
for last; do :; done
|
||||
f="$TMP/opt.\$5.\${last#options:}"
|
||||
if [ -e "\$f" ]; then cat "\$f"; else echo; fi
|
||||
;;
|
||||
*" get Open_vSwitch . external_ids:ovn-encap-ip")
|
||||
if [ -e "$TMP/encap" ]; then cat "$TMP/encap"; else echo; fi
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
EOF
|
||||
printf '#!/bin/sh\nexit 0\n' > "$TMP/bin/systemctl"
|
||||
chmod +x "$TMP/bin/nft" "$TMP/bin/ovs-vsctl" "$TMP/bin/systemctl"
|
||||
|
||||
# reset — no tunnels, OVS up, no pluto.
|
||||
reset() {
|
||||
rm -f "$TMP"/opt.* "$TMP/ovs-down" "$TMP/nft.log" "$TMP/pluto" "$TMP/encap"
|
||||
: > "$TMP/ifaces"
|
||||
touch "$TMP/nft.log"
|
||||
}
|
||||
# tunnel <name> <local_ip|-> [remote_name] — a geneve interface as ovn-controller writes it.
|
||||
tunnel() {
|
||||
echo "$1" >> "$TMP/ifaces"
|
||||
[ "$2" = - ] || printf '"%s"\n' "$2" > "$TMP/opt.$1.local_ip"
|
||||
[ -z "${3:-}" ] || echo "$3" > "$TMP/opt.$1.remote_name"
|
||||
}
|
||||
run() {
|
||||
RC=0
|
||||
OUT=$(PATH="$TMP/bin:$PATH" "$HELPER" "$1" 2>&1) || RC=$?
|
||||
NFTLOG=$(cat "$TMP/nft.log")
|
||||
}
|
||||
LIFTED="table inet spinifex_ipsec_guard
|
||||
delete table inet spinifex_ipsec_guard
|
||||
--"
|
||||
# expect_guarded <desc> <sources> — one transaction that swaps in a table
|
||||
# dropping Geneve without an SA from exactly those sources.
|
||||
expect_guarded() {
|
||||
if [ "$RC" = 0 ] &&
|
||||
[ "$(grep -c '^--$' "$TMP/nft.log")" = 1 ] &&
|
||||
[ "$(sed -n 2p "$TMP/nft.log")" = "delete table inet spinifex_ipsec_guard" ] &&
|
||||
grep -q "^ elements = { $2 }$" "$TMP/nft.log" &&
|
||||
grep -q '^ ip saddr @srcs udp dport 6081 rt ipsec missing drop$' "$TMP/nft.log"; then
|
||||
pass "$1"
|
||||
else
|
||||
fail "$1 (rc=$RC): got [$NFTLOG] want sources [$2] $OUT"
|
||||
fi
|
||||
}
|
||||
expect_lifted() {
|
||||
if [ "$RC" = 0 ] && [ "$NFTLOG" = "$LIFTED" ]; then pass "$1"; else fail "$1 (rc=$RC): got [$NFTLOG] $OUT"; fi
|
||||
}
|
||||
|
||||
reset
|
||||
tunnel ovn-node2-0 10.26.7.1 node2
|
||||
tunnel ovn-node3-0 10.26.7.1 node3
|
||||
run guard
|
||||
expect_guarded "guards the encap address once OVN wants the tunnels encrypted" "10.26.7.1/32"
|
||||
|
||||
reset
|
||||
tunnel ovn-node2-0 10.26.7.1
|
||||
tunnel ovn-node3-0 10.26.7.1
|
||||
run guard
|
||||
expect_lifted "lifts the guard while OVN runs the tunnels in plaintext"
|
||||
|
||||
reset
|
||||
tunnel ovn-node2-0 10.26.7.1 node2
|
||||
tunnel ovn-node3-0 10.26.8.1
|
||||
run guard
|
||||
expect_guarded "guards only the addresses of tunnels OVN encrypts" "10.26.7.1/32"
|
||||
|
||||
reset
|
||||
tunnel ovn-node2-0 - node2
|
||||
run guard
|
||||
expect_guarded "guards a tunnel without a local_ip from every source" "0.0.0.0/0"
|
||||
|
||||
reset
|
||||
echo '"10.26.7.1"' > "$TMP/encap"
|
||||
run guard
|
||||
expect_guarded "guards the encap address before ovn-controller has built the tunnels" "10.26.7.1/32"
|
||||
|
||||
reset
|
||||
echo '"10.26.7.1,10.26.8.1"' > "$TMP/encap"
|
||||
run guard
|
||||
expect_guarded "guards each encap address of a multi-address host before the tunnels exist" "10.26.7.1/32,10.26.8.1/32"
|
||||
|
||||
reset
|
||||
run guard
|
||||
expect_guarded "guards every source before the tunnels exist when no encap address is set" "0.0.0.0/0"
|
||||
|
||||
reset
|
||||
tunnel ovn-node2-0 10.26.7.1 node2
|
||||
touch "$TMP/ovs-down"
|
||||
run guard
|
||||
if [ "$RC" != 0 ] && [ -z "$NFTLOG" ]; then
|
||||
pass "keeps the guard and fails when OVS cannot be read"
|
||||
else
|
||||
fail "with OVS down: rc=$RC nft calls [$NFTLOG]"
|
||||
fi
|
||||
|
||||
reset
|
||||
touch "$TMP/pluto"
|
||||
chmod +x "$TMP/pluto"
|
||||
run off
|
||||
expect_lifted "off lifts the guard on a libreswan host"
|
||||
|
||||
reset
|
||||
run off
|
||||
if [ "$RC" = 0 ] && [ -z "$NFTLOG" ]; then pass "off leaves nft alone without libreswan"; else fail "off without pluto: rc=$RC [$NFTLOG]"; fi
|
||||
|
||||
reset
|
||||
run unguard
|
||||
expect_lifted "unguard lifts the guard"
|
||||
|
||||
reset
|
||||
run bogus
|
||||
if [ "$RC" = 2 ] && [ -z "$NFTLOG" ]; then pass "refuses an unknown verb"; else fail "unknown verb: rc=$RC [$NFTLOG]"; fi
|
||||
|
||||
if [ "$fails" -gt 0 ]; then
|
||||
echo "$fails failure(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "all passed"
|
||||
+2
-11
@@ -30,19 +30,10 @@ distro_pkg_manager() {
|
||||
}
|
||||
|
||||
# The OVS umbrella unit: openvswitch-switch.service on Debian, openvswitch.service on EL.
|
||||
# Ubuntu's openvswitch-switch declares Alias=openvswitch.service; the symlink that
|
||||
# enabling it leaves points back at openvswitch-switch.service and is not the EL unit.
|
||||
distro_ovs_unit() {
|
||||
local deb=false el=false dir
|
||||
local deb=false el=false
|
||||
distro_unit_exists openvswitch-switch.service && deb=true
|
||||
for dir in /etc/systemd/system /run/systemd/system /usr/lib/systemd/system /lib/systemd/system; do
|
||||
if [ -e "${DISTRO_ROOT:-}$dir/openvswitch.service" ] || [ -L "${DISTRO_ROOT:-}$dir/openvswitch.service" ]; then
|
||||
case $(readlink "${DISTRO_ROOT:-}$dir/openvswitch.service" || true) in
|
||||
openvswitch-switch.service | */openvswitch-switch.service) ;;
|
||||
*) el=true ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
distro_unit_exists openvswitch.service && el=true
|
||||
if $deb && $el; then
|
||||
echo "distro.sh: OVS unit: found both openvswitch-switch.service and openvswitch.service; cannot tell which one owns OVS" >&2
|
||||
return 1
|
||||
|
||||
@@ -26,8 +26,6 @@ fixture() {
|
||||
unit() { mkdir -p "$ROOT/usr/lib/systemd/system" && : > "$ROOT/usr/lib/systemd/system/$1"; }
|
||||
file() { mkdir -p "$ROOT$(dirname "$1")" && : > "$ROOT$1"; }
|
||||
cmd() { printf '#!/bin/sh\nexit 0\n' > "$BIN/$1" && chmod +x "$BIN/$1"; }
|
||||
# realcmd <name> — the host's real command, for probes that read the fixture with it.
|
||||
realcmd() { ln -s "$(command -v "$1")" "$BIN/$1"; }
|
||||
|
||||
# probe <fn> [arg...] — runs one probe in bash against the current fixture,
|
||||
# with only the stub dir on PATH. Sets OUT, ERR and RC.
|
||||
@@ -75,22 +73,13 @@ expect_ok "OVS unit: Debian name" openvswitch-switch.service
|
||||
fixture ovs-el; unit openvswitch.service; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: EL name" openvswitch.service
|
||||
fixture ovs-masked; mkdir -p "$ROOT/etc/systemd/system"
|
||||
ln -s /dev/null "$ROOT/etc/systemd/system/openvswitch.service"; realcmd readlink; probe distro_ovs_unit
|
||||
ln -s /dev/null "$ROOT/etc/systemd/system/openvswitch.service"; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: a masked unit in /etc counts" openvswitch.service
|
||||
fixture ovs-lib; mkdir -p "$ROOT/lib/systemd/system"
|
||||
: > "$ROOT/lib/systemd/system/openvswitch-switch.service"; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: found under /lib on an unmerged-/usr host" openvswitch-switch.service
|
||||
fixture ovs-both; unit openvswitch-switch.service; unit openvswitch.service; probe distro_ovs_unit
|
||||
expect_fail "OVS unit: both fails naming both" openvswitch-switch.service openvswitch.service
|
||||
fixture ovs-ubuntu-alias; unit openvswitch-switch.service; mkdir -p "$ROOT/etc/systemd/system"
|
||||
ln -s /usr/lib/systemd/system/openvswitch-switch.service "$ROOT/etc/systemd/system/openvswitch.service"; realcmd readlink; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: Ubuntu's openvswitch.service alias is the Debian unit" openvswitch-switch.service
|
||||
fixture ovs-relative-alias; unit openvswitch-switch.service; mkdir -p "$ROOT/etc/systemd/system"
|
||||
ln -s openvswitch-switch.service "$ROOT/etc/systemd/system/openvswitch.service"; realcmd readlink; probe distro_ovs_unit
|
||||
expect_ok "OVS unit: a relative alias symlink is the Debian unit" openvswitch-switch.service
|
||||
fixture ovs-alias-and-el; unit openvswitch-switch.service; unit openvswitch.service; mkdir -p "$ROOT/etc/systemd/system"
|
||||
ln -s /usr/lib/systemd/system/openvswitch-switch.service "$ROOT/etc/systemd/system/openvswitch.service"; realcmd readlink; probe distro_ovs_unit
|
||||
expect_fail "OVS unit: an alias beside a real EL unit still fails naming both" openvswitch-switch.service openvswitch.service
|
||||
fixture ovs-none; probe distro_ovs_unit
|
||||
expect_fail "OVS unit: neither fails naming both" openvswitch-switch.service openvswitch.service
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user