mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-31 00:17:47 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bd7028329 | |||
| 237fcdff94 | |||
| 5dd95b1ecd | |||
| 63cf1beaa7 |
@@ -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 + SQS store image)
|
||||
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 build-sqs-store-microvm-image
|
||||
|
||||
- name: Build amd64 Tarball
|
||||
if: steps.version.outputs.exists != 'true'
|
||||
|
||||
@@ -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 + SQS store image)
|
||||
run: cd spinifex && make build-microvm-image build-sqs-store-microvm-image
|
||||
|
||||
- name: Build amd64 Tarball
|
||||
env:
|
||||
|
||||
@@ -24,9 +24,6 @@ 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/
|
||||
|
||||
@@ -157,21 +157,6 @@ bin/nats-server: go.mod go.sum
|
||||
|
||||
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)
|
||||
@@ -189,12 +174,6 @@ install-sqs-store-microvm: $(SQS_STORE_MICROVM_ARTIFACTS) ## Install the SQS sto
|
||||
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
|
||||
@@ -496,7 +475,6 @@ distro-amd64:
|
||||
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 +493,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 build-sqs-store-microvm-image install-microvm install-sqs-store-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck nilaway \
|
||||
|
||||
@@ -34,10 +34,6 @@ var allowlist = []allowance{
|
||||
{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: "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 +55,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 +94,40 @@ 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/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: "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"},
|
||||
|
||||
@@ -161,8 +161,7 @@ sqs_run() {
|
||||
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"
|
||||
exec "$NATS_BIN" -c "$SQS_RUN/nats.conf"
|
||||
}
|
||||
|
||||
# sqs_stop: first shutdown action. Block any respawn, SIGTERM the recorded run
|
||||
|
||||
@@ -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
|
||||
@@ -10,13 +10,9 @@ import (
|
||||
"github.com/mulgadc/spinifex/internal/awsmodel"
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
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"
|
||||
|
||||
@@ -62,10 +58,6 @@ func compareAll() ([]awsmodel.OperationCoverage, error) {
|
||||
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(),
|
||||
})
|
||||
// The loop below walks the embedded models, so a served service with no
|
||||
// model would otherwise drop out of the report without a word.
|
||||
|
||||
@@ -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"
|
||||
+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.
|
||||
|
||||
-102
@@ -259,23 +259,6 @@ 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:
|
||||
@@ -360,91 +343,6 @@ Evidence is local only: unit tests, real QEMU boots against disposable scratch d
|
||||
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`):
|
||||
|
||||
@@ -360,12 +360,6 @@ while the branch is in flight; they do not describe the umbrella repository's
|
||||
| `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
|
||||
@@ -401,9 +395,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 +446,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`, `gateway` and the `awsapi` package of each registered service (`domains/ecr/awsapi`, `domains/acm/awsapi`, `domains/route53/awsapi`, `domains/elbv2/awsapi`, `domains/rds/awsapi`) to read their declared inventories. It is a non-serving tool and the only named exception to the ADR-0002 composition invariant; it must not construct a registration.
|
||||
- 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 +454,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).
|
||||
@@ -472,11 +466,12 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
- `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.
|
||||
- `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/ec2.go` (registration): the EC2 dispatch map stays a `GatewayConfig` method in `gateway` and imports every `domains/ec2/awsapi/*` package; `gateway/ec2_idempotency.go` also imports `domains/ec2/awsapi/idem` (`KVBucket`, `TTL`, `TokenAndParams`) for the dispatch-time idempotency wrapper. ADR-0001 makes the domain own its action registration; moving the map waits on the generic ingress registration seam and must not be done as a package move.
|
||||
- `gateway` (service registration): the root `gateway` dispatch for IAM, STS, EKS, RDS and ECS imports `domains/{iam,sts,eks,rds,ecs}/awsapi`, which only AWSGW may wire. It clears when each service registers through the ADR-0002 ingress registration seam; as with EC2, moving the dispatch must not be done as a package move.
|
||||
- `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,7 +681,7 @@ 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. |
|
||||
|
||||
@@ -120,7 +120,7 @@ An ALB whose targets all fail their health check keeps serving from them instead
|
||||
|
||||
### `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
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ 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. It writes a needrestart drop-in so an apt upgrade never restarts the Spinifex, OVS or OVN units; restarting them after an upgrade is left to the operator. 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.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. |
|
||||
| `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. |
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
# 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}
|
||||
@@ -30,18 +26,6 @@ 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.
|
||||
@@ -52,8 +36,8 @@ ALPINE_PACKAGES="busybox linux-virt iproute2 ca-certificates e2fsprogs kmod"
|
||||
# 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"
|
||||
echo "[build-sqs-store-microvm-image] repo root: $REPO_ROOT"
|
||||
echo "[build-sqs-store-microvm-image] build dir: $BUILD_DIR"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
# --- Verify non-negotiable tools ---
|
||||
@@ -68,16 +52,6 @@ for tool in cpio gzip find fakeroot; do
|
||||
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")
|
||||
@@ -98,8 +72,7 @@ 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
|
||||
echo "[build-sqs-store-microvm-image] nats-server: $nats_server_version (matches go.mod)"
|
||||
|
||||
# --- Create chroot ---
|
||||
CHROOT_DIR=$(mktemp -d)
|
||||
@@ -115,11 +88,11 @@ STAGE_DIR=$(mktemp -d "$BUILD_DIR/.stage.XXXXXX")
|
||||
CONTAINER_TOOL=""
|
||||
CONTAINER_CID=""
|
||||
cleanup() {
|
||||
echo "$TAG cleaning up chroot: $CHROOT_DIR"
|
||||
echo "[build-sqs-store-microvm-image] cleaning up chroot: $CHROOT_DIR"
|
||||
rm -rf "$CHROOT_DIR"
|
||||
rm -rf "${STAGE_DIR:?}"
|
||||
if [ -n "$CONTAINER_CID" ] && [ -n "$CONTAINER_TOOL" ]; then
|
||||
echo "$TAG cleaning up container: $CONTAINER_CID"
|
||||
echo "[build-sqs-store-microvm-image] cleaning up container: $CONTAINER_CID"
|
||||
$CONTAINER_TOOL rm -f "$CONTAINER_CID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
@@ -128,7 +101,7 @@ 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"
|
||||
echo "[build-sqs-store-microvm-image] rootfs: native apk"
|
||||
mkdir -p \
|
||||
"$CHROOT_DIR/etc/apk" \
|
||||
"$CHROOT_DIR/lib/apk/db" \
|
||||
@@ -152,7 +125,7 @@ EOF
|
||||
# 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})"
|
||||
echo "[build-sqs-store-microvm-image] rootfs: docker buildx --output type=local (alpine:${ALPINE_VERSION})"
|
||||
BUILD_CTX=$(mktemp -d)
|
||||
cat > "$BUILD_CTX/Dockerfile" <<DOCKERFILE
|
||||
FROM alpine:${ALPINE_VERSION}
|
||||
@@ -167,7 +140,7 @@ DOCKERFILE
|
||||
# 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})"
|
||||
echo "[build-sqs-store-microvm-image] rootfs: podman export (alpine:${ALPINE_VERSION})"
|
||||
CONTAINER_TOOL=podman
|
||||
CONTAINER_CID=$(podman run -d \
|
||||
"alpine:${ALPINE_VERSION}" \
|
||||
@@ -197,7 +170,7 @@ rm -rf "${CHROOT_DIR:?}/dev"
|
||||
mkdir "$CHROOT_DIR/dev"
|
||||
|
||||
# --- Place init script ---
|
||||
echo "$TAG installing init.sh as /init..."
|
||||
echo "[build-sqs-store-microvm-image] installing init.sh as /init..."
|
||||
if [ ! -f "$INIT_SH" ]; then
|
||||
echo "ERROR: $INIT_SH not found — cannot build initramfs" >&2
|
||||
exit 1
|
||||
@@ -210,26 +183,11 @@ if [ ! -f "$INITTAB" ]; then
|
||||
echo "ERROR: $INITTAB not found — cannot build initramfs" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$TAG installing inittab..."
|
||||
echo "[build-sqs-store-microvm-image] 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..."
|
||||
echo "[build-sqs-store-microvm-image] installing store guest steps..."
|
||||
mkdir -p "$CHROOT_DIR/usr/local/lib/sqs-store"
|
||||
for step in sqs-store-lib.sh sqs-store-prepare sqs-store-run sqs-store-stop; do
|
||||
if [ ! -f "$BUILD_DIR/$step" ]; then
|
||||
@@ -242,10 +200,9 @@ install -m 0755 "$BUILD_DIR/sqs-store-prepare" "$BUILD_DIR/sqs-store-run" "$BUIL
|
||||
"$CHROOT_DIR/usr/local/lib/sqs-store/"
|
||||
|
||||
# --- Copy nats-server binary ---
|
||||
echo "$TAG copying nats-server binary..."
|
||||
echo "[build-sqs-store-microvm-image] copying nats-server binary..."
|
||||
mkdir -p "$CHROOT_DIR/usr/local/bin"
|
||||
install -m 0755 "$NATS_SERVER_BIN" "$CHROOT_DIR/usr/local/bin/nats-server"
|
||||
fi
|
||||
|
||||
# --- Create required directories for init ---
|
||||
mkdir -p \
|
||||
@@ -260,7 +217,7 @@ if [ -z "$KERNEL_IMG" ]; then
|
||||
echo "ERROR: no vmlinuz found in $CHROOT_DIR/boot" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$TAG kernel image: $KERNEL_IMG"
|
||||
echo "[build-sqs-store-microvm-image] kernel image: $KERNEL_IMG"
|
||||
|
||||
KERNEL_VER=$(basename "$KERNEL_IMG" | sed 's/vmlinuz-//')
|
||||
KERNEL_CONFIG="$CHROOT_DIR/boot/config-${KERNEL_VER}"
|
||||
@@ -274,9 +231,9 @@ if ! find "$CHROOT_DIR/lib/modules" \
|
||||
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)"
|
||||
echo "[build-sqs-store-microvm-image] fw_cfg: built-in (CONFIG_FW_CFG_SYSFS=y)"
|
||||
else
|
||||
echo "$TAG fw_cfg: module found in lib/modules"
|
||||
echo "[build-sqs-store-microvm-image] fw_cfg: module found in lib/modules"
|
||||
fi
|
||||
|
||||
# --- Assert ext4 userspace tools (data volume) ---
|
||||
@@ -286,7 +243,7 @@ for fs_tool in mkfs.ext4 e2fsck; do
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "$TAG e2fsprogs: mkfs.ext4 + e2fsck present"
|
||||
echo "[build-sqs-store-microvm-image] e2fsprogs: mkfs.ext4 + e2fsck present"
|
||||
|
||||
# --- Strip kernel modules (Phase 2 keep-list approach) ---
|
||||
# Only modules needed by a virtio-mmio microvm guest are kept:
|
||||
@@ -325,7 +282,7 @@ image_kmod() {
|
||||
--argv0 "$1" "$CHROOT_DIR/bin/kmod" "${@:2}"
|
||||
}
|
||||
|
||||
echo "$TAG stripping kernel modules to virtio+fw_cfg+${MICROVM_EXTRA_MODULES} only..."
|
||||
echo "[build-sqs-store-microvm-image] stripping kernel modules to virtio+fw_cfg+${MICROVM_EXTRA_MODULES} only..."
|
||||
for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
|
||||
kver="$(basename "$kver_dir")"
|
||||
kernel_dir="$kver_dir/kernel"
|
||||
@@ -424,8 +381,8 @@ for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "$TAG extra module closure: $(echo "$extra_names" | tr '\n' ' ')"
|
||||
echo "$TAG modules kept: $(echo "$kept_names" | tr '\n' ' ')"
|
||||
echo "[build-sqs-store-microvm-image] extra module closure: $(echo "$extra_names" | tr '\n' ' ')"
|
||||
echo "[build-sqs-store-microvm-image] modules kept: $(echo "$kept_names" | tr '\n' ' ')"
|
||||
|
||||
# Rebuild module metadata with the image's own kmod, the binary the guest's
|
||||
# modprobe runs, so no host depmod is needed. Stock indexes still name .ko.gz
|
||||
@@ -458,12 +415,12 @@ for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
|
||||
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")"
|
||||
echo "[build-sqs-store-microvm-image] modprobe $name: $(awk '{ n = split($2, a, "/"); printf "%s ", a[n] }' <<< "$plan")"
|
||||
done
|
||||
done
|
||||
|
||||
# --- Strip package-manager artifacts (not needed at runtime) ---
|
||||
echo "$TAG stripping package manager artifacts..."
|
||||
echo "[build-sqs-store-microvm-image] stripping package manager artifacts..."
|
||||
rm -rf \
|
||||
"$CHROOT_DIR/lib/apk" \
|
||||
"$CHROOT_DIR/var/cache/apk" \
|
||||
@@ -477,7 +434,7 @@ rm -rf \
|
||||
# --- 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..."
|
||||
echo "[build-sqs-store-microvm-image] stripping debug symbols from binaries..."
|
||||
find "$CHROOT_DIR/usr/sbin" "$CHROOT_DIR/usr/bin" \
|
||||
"$CHROOT_DIR/sbin" "$CHROOT_DIR/bin" \
|
||||
-type f 2>/dev/null | while read -r bin; do
|
||||
@@ -486,7 +443,7 @@ done
|
||||
|
||||
# --- Copy vmlinuz out before stripping /boot from chroot ---
|
||||
VMLINUZ_OUT="$STAGE_DIR/vmlinuz"
|
||||
echo "$TAG copying kernel: $VMLINUZ_OUT"
|
||||
echo "[build-sqs-store-microvm-image] copying kernel: $VMLINUZ_OUT"
|
||||
cp "$KERNEL_IMG" "$VMLINUZ_OUT"
|
||||
|
||||
# Drop /boot from the chroot — vmlinuz, Alpine's stock initramfs-virt, System.map
|
||||
@@ -499,7 +456,7 @@ rm -rf "${CHROOT_DIR:?}/boot"
|
||||
|
||||
# --- Build initramfs ---
|
||||
INITRAMFS_OUT="$STAGE_DIR/initramfs.cpio.gz"
|
||||
echo "$TAG building initramfs: $INITRAMFS_OUT"
|
||||
echo "[build-sqs-store-microvm-image] building initramfs: $INITRAMFS_OUT"
|
||||
# Create the static device nodes and pack the archive inside a single fakeroot
|
||||
# session: mknod/cpio see faked device entries and write real char-device
|
||||
# records into the cpio, while the host filesystem never gets an actual node.
|
||||
@@ -513,6 +470,6 @@ fakeroot sh -c '
|
||||
# --- 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"
|
||||
"$REPO_ROOT/scripts/lib/sqs-store-microvm-publish.sh" "$STAGE_DIR" "$BUILD_DIR"
|
||||
echo ""
|
||||
echo "$TAG done."
|
||||
echo "[build-sqs-store-microvm-image] done."
|
||||
|
||||
@@ -56,7 +56,6 @@ EOF
|
||||
cat > "$TMP/bin/nats-server" <<'EOF'
|
||||
#!/bin/sh
|
||||
echo "nats-server $*" >> "$T/calls.log"
|
||||
[ "$1" = -t ] || echo "GOMEMLIMIT=${GOMEMLIMIT:-unset}" >> "$T/env.log"
|
||||
[ "$1" = -t ] && exit "$(cat "$T/configtest.rc")"
|
||||
exit 0
|
||||
EOF
|
||||
@@ -189,8 +188,6 @@ run_step() {
|
||||
}
|
||||
fresh run-ready; prepare; : > "$T/calls.log"
|
||||
check "a prepared store starts the server" 'run_step && grep -qx "nats-server -c $T/run/nats.conf" "$T/calls.log"'
|
||||
fresh run-memlimit; prepare; : > "$T/env.log"
|
||||
check "the server runs under the ADR-0011:S11 heap limit" 'run_step && grep -qx "GOMEMLIMIT=512MiB" "$T/env.log"'
|
||||
fresh run-unready
|
||||
check "an unprepared store never starts the server" '! run_step && ! grep -q "^nats-server" "$T/calls.log"'
|
||||
fresh run-stopping; prepare; : > "$T/calls.log"; : > "$T/run/stopping"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
# sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR [PROFILE] — gate a staged SQS
|
||||
# store (default) or worker microVM kernel + initramfs and move them into
|
||||
# OUT_DIR only if every gate passes.
|
||||
# sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR — gate a staged SQS store
|
||||
# microVM kernel + initramfs and move them into OUT_DIR only if every gate passes.
|
||||
#
|
||||
# A failed gate leaves OUT_DIR as it was: a previous valid pair stays, and an
|
||||
# older pair stays older than the build inputs, so make rebuilds next time.
|
||||
@@ -11,15 +10,7 @@ STAGE_DIR="${1:?usage: sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR}"
|
||||
OUT_DIR="${2:?usage: sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR}"
|
||||
VMLINUZ="$STAGE_DIR/vmlinuz"
|
||||
INITRAMFS="$STAGE_DIR/initramfs.cpio.gz"
|
||||
PROFILE="${3:-store}"
|
||||
case "$PROFILE" in
|
||||
store)
|
||||
payload="usr/local/bin/nats-server usr/local/lib/sqs-store/sqs-store-lib.sh usr/local/lib/sqs-store/sqs-store-prepare usr/local/lib/sqs-store/sqs-store-run usr/local/lib/sqs-store/sqs-store-stop" ;;
|
||||
worker)
|
||||
payload="usr/local/bin/sqs-worker usr/local/lib/sqs-worker/sqs-worker-lib.sh usr/local/lib/sqs-worker/sqs-worker-prepare usr/local/lib/sqs-worker/sqs-worker-ready usr/local/lib/sqs-worker/sqs-worker-stop data" ;;
|
||||
*) echo "ERROR: profile must be store or worker, not $PROFILE" >&2; exit 1 ;;
|
||||
esac
|
||||
tag="[build-sqs-$PROFILE-microvm-image]"
|
||||
tag="[build-sqs-store-microvm-image]"
|
||||
|
||||
for f in "$VMLINUZ" "$INITRAMFS"; do
|
||||
if [ ! -s "$f" ]; then
|
||||
@@ -33,8 +24,9 @@ if ! archive_list=$(zcat "$INITRAMFS" | cpio --quiet -t); then
|
||||
echo "ERROR: $INITRAMFS is not a readable gzip cpio archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC2086 # payload is a word list on purpose
|
||||
for want in init etc/inittab dev/console dev/null $payload; do
|
||||
for want in init etc/inittab usr/local/bin/nats-server dev/console dev/null \
|
||||
usr/local/lib/sqs-store/sqs-store-lib.sh usr/local/lib/sqs-store/sqs-store-prepare \
|
||||
usr/local/lib/sqs-store/sqs-store-run usr/local/lib/sqs-store/sqs-store-stop; do
|
||||
if ! grep -qx "$want" <<< "$archive_list"; then
|
||||
echo "ERROR: $want missing from $INITRAMFS" >&2
|
||||
exit 1
|
||||
@@ -59,11 +51,6 @@ if grep -qE '(^|/)(lb-agent|haproxy|nginx)$' <<< "$archive_list"; then
|
||||
echo "ERROR: LB data-plane binaries found in the SQS store image" >&2
|
||||
exit 1
|
||||
fi
|
||||
# The worker holds the store client key; it must never carry the store server.
|
||||
if [ "$PROFILE" = worker ] && grep -qE '(^|/)nats-server$' <<< "$archive_list"; then
|
||||
echo "ERROR: nats-server found in the SQS worker image" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$tag content gate: PASS"
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
#!/bin/sh
|
||||
# sqs-worker-guest_test.sh — the SQS worker guest's readiness watcher is
|
||||
# supervised by init, never leaves a store-ready marker behind a dead watcher,
|
||||
# and shutdown unmounts the worker disk only once the watcher is gone.
|
||||
#
|
||||
# Drives build/microvm/sqs-worker/sqs-worker-lib.sh with test-owned processes
|
||||
# and a stub umount. Every signal goes only to this test's own children.
|
||||
# The case settings below are read by the sourced library, not by this file.
|
||||
# shellcheck disable=SC2034
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)
|
||||
LIB=${SQSW_GUEST_LIB:-"$ROOT/build/microvm/sqs-worker/sqs-worker-lib.sh"}
|
||||
INITTAB="$ROOT/build/microvm/sqs-worker/inittab"
|
||||
TMP=$(mktemp -d)
|
||||
CHILDREN=""
|
||||
cleanup() {
|
||||
for p in $CHILDREN; do kill -KILL "$p" 2>/dev/null || true; done
|
||||
rm -rf "$TMP"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
check() { if eval "$2"; then pass "$1"; else fail "$1"; fi; }
|
||||
|
||||
echo "-- build/microvm/sqs-worker/sqs-worker-lib.sh"
|
||||
|
||||
mkdir -p "$TMP/bin"
|
||||
cat > "$TMP/bin/umount" <<'EOF'
|
||||
#!/bin/sh
|
||||
echo "umount $*" >> "$T/calls.log"
|
||||
EOF
|
||||
# The fake watcher notes whether a marker was already present when it
|
||||
# started, publishes readiness as the real one does, then stays running.
|
||||
cat > "$TMP/bin/watcher" <<'EOF'
|
||||
#!/bin/sh
|
||||
[ ! -e "$T/run/store-ready" ] || echo stale >> "$T/saw-stale"
|
||||
echo "$$" >> "$T/watchers"
|
||||
echo "10.0.0.5 UPUB" > "$T/run/store-ready"
|
||||
exec sleep 60
|
||||
EOF
|
||||
chmod +x "$TMP/bin"/*
|
||||
|
||||
fresh() {
|
||||
T="$TMP/$1"
|
||||
export T
|
||||
mkdir -p "$T/run" "$T/data"
|
||||
echo UPUBLICKEY > "$T/run/public-key"
|
||||
for f in calls.log console mounts; do : > "$T/$f"; done
|
||||
}
|
||||
gone() { [ ! -r "/proc/$1/status" ] || grep -q '^State:[[:space:]]*Z' "/proc/$1/status" 2>/dev/null; }
|
||||
waitfor() { i=0; while [ "$i" -lt 100 ] && ! eval "$1"; do i=$((i + 1)); sleep 0.05; done; eval "$1"; }
|
||||
# start_run: one init respawn of the readiness step, in the background.
|
||||
start_run() {
|
||||
SQSW_RUN="$T/run" SQSW_CONSOLE="$T/console" SQSW_BIN="$TMP/bin/watcher" SQSW_RESPAWN_GAP="${GAP:-0}" SQSW_PARK_INTERVAL="${PARK:-30}" \
|
||||
sh -c '. "$1"; sqsw_run' sh "$LIB" & pid=$!
|
||||
CHILDREN="$CHILDREN $pid"
|
||||
}
|
||||
# record <pid>: write the identity sqs_run would record for that process.
|
||||
# shellcheck source=../../build/microvm/sqs-worker/sqs-worker-lib.sh
|
||||
record() { echo "$1 $( . "$LIB"; sqsw_starttime "$1" )" > "$T/run/ready.pid"; }
|
||||
# shutdown_step [survivor-pid]: with a pid, that process is reported alive
|
||||
# whatever happens to it, as a watcher stuck past SIGKILL would be.
|
||||
shutdown_step() {
|
||||
(
|
||||
SQSW_RUN="$T/run" SQSW_DATA="$T/data" SQSW_CONSOLE="$T/console" SQSW_MOUNTS="$T/mounts"
|
||||
UMOUNT="$TMP/bin/umount" SQSW_STOP_BUDGET_TENTHS=5
|
||||
# shellcheck source=../../build/microvm/sqs-worker/sqs-worker-lib.sh
|
||||
. "$LIB"
|
||||
if [ -n "${1:-}" ]; then sqsw_same() { [ "$1" = "$survivor" ]; }; survivor=$1; fi
|
||||
sqsw_shutdown
|
||||
) > /dev/null 2>&1
|
||||
}
|
||||
|
||||
check "init respawns the readiness step rather than running it once" \
|
||||
'grep -qx "::respawn:/usr/local/lib/sqs-worker/sqs-worker-ready" "$INITTAB" && ! grep -q "::once:" "$INITTAB"'
|
||||
|
||||
# --- A watcher killed after readiness: its marker must not outlive it ---
|
||||
fresh killed-after-ready
|
||||
start_run; first=$pid
|
||||
check "the watcher publishes store-ready" 'waitfor "[ -s \"\$T/run/store-ready\" ]"'
|
||||
kill -KILL "$first"
|
||||
check "SIGKILL leaves the marker behind, as a crash would" 'waitfor "gone $first" && [ -e "$T/run/store-ready" ]'
|
||||
start_run; second=$pid
|
||||
check "the respawned step starts a new watcher" 'waitfor "[ \"\$(wc -l < \"\$T/watchers\")\" -eq 2 ]"'
|
||||
check "the respawn removed the dead watcher's marker before the new watcher ran" \
|
||||
'[ ! -e "$T/saw-stale" ] && grep -q "removed a store-ready marker left by an earlier watcher" "$T/console"'
|
||||
check "the respawn records the new watcher's identity" 'grep -q "^$second " "$T/run/ready.pid"'
|
||||
kill -KILL "$second" 2>/dev/null || true
|
||||
|
||||
# --- A watcher that keeps exiting is respawned at most once per gap ---
|
||||
fresh respawn-gap
|
||||
date +%s > "$T/run/ready.last"
|
||||
GAP=2 start_run; t0=$(date +%s)
|
||||
waitfor "[ -s \"\$T/watchers\" ]" || true
|
||||
t1=$(date +%s)
|
||||
check "a start right after the previous one waits for the gap" '[ $((t1 - t0)) -ge 1 ]'
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
|
||||
# --- Runs that must not start ---
|
||||
# A run that cannot start parks rather than exiting, so init does not respawn
|
||||
# it in a loop; a second is many park intervals, and it must log only once.
|
||||
fresh run-stopping; : > "$T/run/stopping"; echo stale > "$T/run/store-ready"
|
||||
PARK=0.05 start_run; waitfor "grep -q 'shutdown in progress' \"\$T/console\"" || true
|
||||
sleep 1
|
||||
check "a start during shutdown parks instead of exiting and respawning" '! gone "$pid"'
|
||||
check "a parked start during shutdown starts no watcher and removes the marker" \
|
||||
'[ ! -e "$T/watchers" ] && [ ! -e "$T/run/store-ready" ]'
|
||||
check "a parked start during shutdown logs once" '[ "$(grep -c "shutdown in progress" "$T/console")" -eq 1 ]'
|
||||
shutdown_step
|
||||
check "shutdown stops the parked start" 'waitfor "gone $pid"'
|
||||
|
||||
fresh run-no-key; rm -f "$T/run/public-key"
|
||||
PARK=0.05 start_run; waitfor "grep -q 'no worker key' \"\$T/console\"" || true
|
||||
sleep 1
|
||||
check "a guest without a worker key parks instead of exiting and respawning" '! gone "$pid" && [ ! -e "$T/watchers" ]'
|
||||
check "a parked guest without a worker key logs once" '[ "$(grep -c "no worker key" "$T/console")" -eq 1 ]'
|
||||
echo UPUBLICKEY > "$T/run/public-key"
|
||||
check "a key that appears later starts the watcher" 'waitfor "[ -s \"\$T/watchers\" ]"'
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
|
||||
fresh run-no-key-stop; rm -f "$T/run/public-key"
|
||||
PARK=0.05 start_run; waitfor "grep -q 'no worker key' \"\$T/console\"" || true
|
||||
shutdown_step
|
||||
check "shutdown stops a start parked for a missing key" 'waitfor "gone $pid" && [ ! -e "$T/watchers" ]'
|
||||
|
||||
# --- Shutdown: unmount only once the watcher is confirmed gone ---
|
||||
fresh shutdown-orderly; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
|
||||
start_run; waitfor "[ -s \"\$T/run/store-ready\" ]"
|
||||
check "orderly shutdown succeeds" 'shutdown_step'
|
||||
check "orderly shutdown confirms the watcher exited, removes the marker, then unmounts" \
|
||||
'gone "$pid" && grep -q "readiness watcher pid=$pid exited" "$T/console" && [ ! -e "$T/run/store-ready" ] &&
|
||||
[ "$(grep "^umount" "$T/calls.log" | tr "\n" "|")" = "umount $T/data|umount -a -r|" ]'
|
||||
check "shutdown blocks any later respawn" '[ -e "$T/run/stopping" ]'
|
||||
|
||||
fresh shutdown-hung; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
|
||||
(trap '' TERM; exec sleep 60) & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
|
||||
echo "10.0.0.5 UPUB" > "$T/run/store-ready"
|
||||
shutdown_step
|
||||
check "a watcher ignoring SIGTERM is killed after the budget, then the disk is unmounted" \
|
||||
'gone "$pid" && grep -q SIGKILL "$T/console" && grep -qx "umount $T/data" "$T/calls.log"'
|
||||
|
||||
fresh shutdown-survivor; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
|
||||
(trap '' TERM; exec sleep 60) & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
|
||||
echo "10.0.0.5 UPUB" > "$T/run/store-ready"
|
||||
check "shutdown with a surviving watcher reports failure" '! shutdown_step "$pid"'
|
||||
check "a surviving watcher leaves every mount alone and is never reported stopped" \
|
||||
'! grep -q "^umount" "$T/calls.log" && grep -q "STILL ALIVE" "$T/console" && grep -q "skipping umount -a -r" "$T/console" && ! grep -q "exited" "$T/console"'
|
||||
check "a surviving watcher still leaves no readiness marker" '[ ! -e "$T/run/store-ready" ]'
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
|
||||
fresh shutdown-reused; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
|
||||
(exec sleep 60) & pid=$!; CHILDREN="$CHILDREN $pid"; echo "$pid 1" > "$T/run/ready.pid"
|
||||
shutdown_step
|
||||
check "a recorded PID with another start time is never signalled" '! gone "$pid" && grep -q "no running readiness watcher" "$T/console"'
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
|
||||
if [ "$fails" -ne 0 ]; then
|
||||
echo " $fails failure(s)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,24 +1,18 @@
|
||||
#!/bin/sh
|
||||
# stage-sqs-store-microvm.sh <image-dir> <dist-root> [sqs-store|sqs-worker]:
|
||||
# copy a built SQS microVM image into a release tree as <dist-root>/microvm-<name>.
|
||||
# Without a complete image it stages nothing and says so, as the LB image does.
|
||||
# stage-sqs-store-microvm.sh <image-dir> <dist-root>: copy a built SQS store
|
||||
# microVM image into a release tree as <dist-root>/microvm-sqs-store. Without a
|
||||
# complete image it stages nothing and says so, as the load balancer image does.
|
||||
set -eu
|
||||
src=$1 name=${3:-sqs-store}
|
||||
case "$name" in
|
||||
sqs-store) label="SQS store" ;;
|
||||
sqs-worker) label="SQS worker" ;;
|
||||
*) echo "[distro] unknown SQS microVM image '$name'" >&2; exit 2 ;;
|
||||
esac
|
||||
dest=$2/microvm-$name
|
||||
src=$1 dest=$2/microvm-sqs-store
|
||||
# An image staged by an earlier build must not ship with this one. Only the
|
||||
# two image files are removed; anything else in the directory is left alone.
|
||||
rm -f "$dest/vmlinuz" "$dest/initramfs.cpio.gz"
|
||||
if [ -s "$src/vmlinuz" ] && [ -s "$src/initramfs.cpio.gz" ]; then
|
||||
echo "[distro] staging the $label microVM image into the tarball"
|
||||
echo "[distro] staging the SQS store microVM image into the tarball"
|
||||
mkdir -p "$dest"
|
||||
cp "$src/vmlinuz" "$src/initramfs.cpio.gz" "$dest/"
|
||||
else
|
||||
[ ! -d "$dest" ] || rmdir "$dest" 2> /dev/null || true
|
||||
echo "[distro] WARNING: $label microVM image missing; the tarball will not include it"
|
||||
echo "[distro] run 'make build-$name-microvm-image' first"
|
||||
echo "[distro] WARNING: SQS store microVM image missing; the tarball will not include it"
|
||||
echo "[distro] run 'make build-sqs-store-microvm-image' first"
|
||||
fi
|
||||
|
||||
+13
-42
@@ -16,8 +16,8 @@
|
||||
# VERBOSE Set to 1 to echo "[setup] <stage>" before each top-level step.
|
||||
# SETUP_STAGES Comma-separated subset of stages to run:
|
||||
# deps, aws, dns, users, sudoers, firewall, timesync, files,
|
||||
# directories, env, systemd, sysctl, needrestart, modules,
|
||||
# logrotate, udev, fixown, migrations
|
||||
# directories, env, systemd, sysctl, modules, logrotate,
|
||||
# udev, fixown, migrations
|
||||
# Unset = run every stage appropriate for the current mode.
|
||||
|
||||
set -e
|
||||
@@ -1543,30 +1543,24 @@ install_files() {
|
||||
info " /usr/share/spinifex/microvm/*"
|
||||
fi
|
||||
install_sqs_store_image
|
||||
install_sqs_worker_image
|
||||
}
|
||||
|
||||
# The SQS images ship beside the load balancer's, not inside it: an older
|
||||
# The SQS store image ships beside the load balancer's, not inside it: an older
|
||||
# installer copies microvm/* without -r and would stop at a subdirectory.
|
||||
install_sqs_store_image() { install_sqs_microvm_image sqs-store "SQS store"; }
|
||||
install_sqs_worker_image() { install_sqs_microvm_image sqs-worker "SQS worker"; }
|
||||
|
||||
# install_sqs_microvm_image <name> <label>
|
||||
install_sqs_microvm_image() {
|
||||
local name=$1 label=$2 f
|
||||
local src="$EXTRACT_DIR/microvm-$name"
|
||||
local dest="${SPINIFEX_MICROVM_DIR:-/usr/share/spinifex/microvm}/$name"
|
||||
install_sqs_store_image() {
|
||||
local src="$EXTRACT_DIR/microvm-sqs-store" f
|
||||
local dest="${SPINIFEX_MICROVM_DIR:-/usr/share/spinifex/microvm}/sqs-store"
|
||||
# A previously installed image belongs to an older release, and this
|
||||
# release's daemon must not boot it. Only the two image files are removed.
|
||||
if [ ! -d "$src" ]; then
|
||||
remove_sqs_microvm_image "$dest" "$label"
|
||||
warn "This release has no $label microVM image; $label launches will be refused"
|
||||
remove_sqs_store_image "$dest"
|
||||
warn "This release has no SQS store microVM image; SQS store launches will be refused"
|
||||
return 0
|
||||
fi
|
||||
for f in vmlinuz initramfs.cpio.gz; do
|
||||
if [ ! -s "$src/$f" ]; then
|
||||
remove_sqs_microvm_image "$dest" "$label"
|
||||
fatal "The $label microVM image in this release is incomplete: $f is missing"
|
||||
remove_sqs_store_image "$dest"
|
||||
fatal "The SQS store microVM image in this release is incomplete: $f is missing"
|
||||
fi
|
||||
done
|
||||
# The initramfs goes last, so an interrupted install leaves no pair the
|
||||
@@ -1578,11 +1572,11 @@ install_sqs_microvm_image() {
|
||||
info " $dest/vmlinuz, $dest/initramfs.cpio.gz"
|
||||
}
|
||||
|
||||
# Running VMs keep their already-loaded kernel; the files are read at boot.
|
||||
remove_sqs_microvm_image() {
|
||||
# Running store VMs keep their already-loaded kernel; the files are read at boot.
|
||||
remove_sqs_store_image() {
|
||||
[ -e "$1/initramfs.cpio.gz" ] || [ -e "$1/vmlinuz" ] || return 0
|
||||
$SUDO rm -f "$1/initramfs.cpio.gz" "$1/vmlinuz"
|
||||
warn "Removed the $2 microVM image left by an earlier install from $1"
|
||||
warn "Removed the SQS store microVM image left by an earlier install from $1"
|
||||
}
|
||||
|
||||
# --- Create directories ---
|
||||
@@ -1936,28 +1930,6 @@ SYSCTL
|
||||
info "Kernel tunables installed (net.core.rmem_max/wmem_max = 16MB)"
|
||||
}
|
||||
|
||||
# --- Keep needrestart off the Spinifex, OVS and OVN units ---
|
||||
# Guest QEMU runs in spinifex-daemon's cgroup, so needrestart charges QEMU's
|
||||
# replaced libraries to the daemon and restarts it after every dpkg run, which
|
||||
# never clears them and ends at the unit's start limit. Restarting the stack
|
||||
# after an upgrade is the operator's call; needrestart still lists the units.
|
||||
install_needrestart_conf() {
|
||||
stage "installing needrestart override"
|
||||
local dir="${DISTRO_ROOT:-}/etc/needrestart/conf.d"
|
||||
$SUDO install -d "$dir"
|
||||
$SUDO tee "$dir/90-spinifex.conf" > /dev/null << 'NEEDRESTART'
|
||||
# Managed by spinifex setup.sh — edits are overwritten.
|
||||
# Never restart these automatically; restart them by hand when it suits.
|
||||
$nrconf{override_rc}{qr(^spinifex-)} = 0;
|
||||
$nrconf{override_rc}{qr(^ovs-)} = 0;
|
||||
$nrconf{override_rc}{qr(^ovsdb-)} = 0;
|
||||
$nrconf{override_rc}{qr(^ovn-)} = 0;
|
||||
$nrconf{override_rc}{qr(^openvswitch-)} = 0;
|
||||
NEEDRESTART
|
||||
$SUDO chmod 0644 "$dir/90-spinifex.conf"
|
||||
info "needrestart will not restart the spinifex, OVS or OVN units"
|
||||
}
|
||||
|
||||
# --- Install logrotate ---
|
||||
install_logrotate() {
|
||||
stage "installing logrotate config"
|
||||
@@ -2152,7 +2124,6 @@ main() {
|
||||
stage_enabled fixown && fix_file_ownership
|
||||
stage_enabled systemd && install_systemd
|
||||
stage_enabled sysctl && install_sysctl
|
||||
stage_enabled needrestart && install_needrestart_conf
|
||||
stage_enabled modules && install_kernel_modules
|
||||
stage_enabled logrotate && install_logrotate
|
||||
stage_enabled udev && install_udev
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# setup_host_test.sh — tests for setup.sh's DNS handover to systemd-resolved,
|
||||
# its chrony and needrestart drop-ins and its nbdkit plugin directory.
|
||||
# its chrony drop-in and its nbdkit plugin directory.
|
||||
#
|
||||
# setup.sh is sourced with INSTALL_SPINIFEX_LIB_ONLY=1 so main never runs. PATH
|
||||
# holds stub commands and links to a few real tools only, so no host resolver,
|
||||
@@ -17,10 +17,10 @@ fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
|
||||
echo "-- setup.sh host DNS, chrony, needrestart and nbdkit"
|
||||
echo "-- setup.sh host DNS, chrony and nbdkit"
|
||||
|
||||
mkdir -p "$TMP/sys"
|
||||
for tool in bash awk sed grep readlink basename dirname cp ln mkdir cat head tail wc tee rm chmod install; do
|
||||
for tool in bash awk sed grep readlink basename dirname cp ln mkdir cat head tail wc tee rm chmod; do
|
||||
ln -s "$(command -v "$tool")" "$TMP/sys/$tool"
|
||||
done
|
||||
|
||||
@@ -249,50 +249,6 @@ case "$RC:$OUT" in
|
||||
*) fail "no chrony: rc=$RC: $OUT" ;;
|
||||
esac
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# needrestart override.
|
||||
# --------------------------------------------------------------------------
|
||||
# restarts <conf> <unit> — how needrestart would treat <unit> under <conf>:
|
||||
# "no" when an override_rc pattern deselects it, "yes" otherwise.
|
||||
restarts() {
|
||||
perl -e 'our %nrconf; do $ARGV[0]; die $@ if $@;
|
||||
for my $re (keys %{$nrconf{override_rc}}) {
|
||||
if ($ARGV[1] =~ $re) { print $nrconf{override_rc}{$re} ? "yes" : "no"; exit }
|
||||
}
|
||||
print "yes"' "$1" "$2"
|
||||
}
|
||||
|
||||
host
|
||||
run install_needrestart_conf
|
||||
conf="$TMP/root/etc/needrestart/conf.d/90-spinifex.conf"
|
||||
if [ "$RC" != 0 ] || [ ! -f "$conf" ]; then
|
||||
fail "needrestart: rc=$RC: $OUT"
|
||||
elif ! command -v perl > /dev/null; then
|
||||
fail "needrestart: perl is not installed, so the override cannot be checked"
|
||||
else
|
||||
wrong=""
|
||||
for unit in spinifex-daemon.service spinifex-viperblock.service ovs-vswitchd.service \
|
||||
ovsdb-server.service ovn-controller.service openvswitch-switch.service; do
|
||||
[ "$(restarts "$conf" "$unit")" = no ] || wrong="$wrong $unit"
|
||||
done
|
||||
for unit in ssh.service rsyslog.service polkit.service; do
|
||||
[ "$(restarts "$conf" "$unit")" = yes ] || wrong="$wrong $unit"
|
||||
done
|
||||
if [ -n "$wrong" ]; then
|
||||
fail "needrestart override treats these wrongly:$wrong"
|
||||
else
|
||||
pass "needrestart keeps off the stack's units and still restarts everything else"
|
||||
fi
|
||||
fi
|
||||
|
||||
before=$(cat "$conf" 2>/dev/null)
|
||||
run install_needrestart_conf
|
||||
if [ "$RC" = 0 ] && [ "$(cat "$conf" 2>/dev/null)" = "$before" ]; then
|
||||
pass "needrestart override is unchanged by a second install"
|
||||
else
|
||||
fail "needrestart override changed on re-run: rc=$RC: $OUT"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# nbdkit plugin directory.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# setup_sqs_store_image_test.sh — tests for shipping the SQS store and worker
|
||||
# microVM images: staging them into a release tree and installing with setup.sh.
|
||||
# setup_sqs_store_image_test.sh — tests for shipping the SQS store microVM
|
||||
# image: staging it into a release tree and installing it with setup.sh.
|
||||
#
|
||||
# setup.sh is sourced with INSTALL_SPINIFEX_LIB_ONLY=1 so main never runs, and
|
||||
# installs into a temporary SPINIFEX_MICROVM_DIR with SUDO cleared.
|
||||
@@ -16,7 +16,7 @@ fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
|
||||
echo "-- SQS store and worker microVM image release and install"
|
||||
echo "-- SQS store microVM image release and install"
|
||||
|
||||
# image <dir> <tag>: a fake built image whose bytes name its tag.
|
||||
image() {
|
||||
@@ -186,59 +186,5 @@ else
|
||||
fail "interrupted install: rc=$RC initramfs=$([ -e "$TMP/mv/sqs-store/initramfs.cpio.gz" ] && echo present || echo missing)"
|
||||
fi
|
||||
|
||||
# --- SQS worker image: same staging and install path, its own directory ---
|
||||
install_worker() {
|
||||
RC=0
|
||||
OUT=$(EXTRACT_DIR="$1" SPINIFEX_MICROVM_DIR="$2" INSTALL_SPINIFEX_LIB_ONLY=1 \
|
||||
bash -c '. "$1"; SUDO=""; install_sqs_worker_image' _ "$SETUP" 2>&1) || RC=$?
|
||||
}
|
||||
image "$TMP/wbuilt" w1
|
||||
release "$TMP/wrel"
|
||||
sh "$STAGE" "$TMP/built" "$TMP/wrel" > /dev/null
|
||||
sh "$STAGE" "$TMP/wbuilt" "$TMP/wrel" sqs-worker > /dev/null
|
||||
if same "$TMP/wbuilt/vmlinuz" "$TMP/wrel/microvm-sqs-worker/vmlinuz" \
|
||||
&& same "$TMP/wbuilt/initramfs.cpio.gz" "$TMP/wrel/microvm-sqs-worker/initramfs.cpio.gz" \
|
||||
&& same "$TMP/built/vmlinuz" "$TMP/wrel/microvm-sqs-store/vmlinuz"; then
|
||||
pass "the worker image stages beside the store image"
|
||||
else
|
||||
fail "worker staging: $(ls -R "$TMP/wrel")"
|
||||
fi
|
||||
install_worker "$TMP/wrel" "$TMP/wmv"
|
||||
if [ "$RC" = 0 ] && same "$TMP/wbuilt/initramfs.cpio.gz" "$TMP/wmv/sqs-worker/initramfs.cpio.gz" \
|
||||
&& [ ! -e "$TMP/wmv/sqs-store" ]; then
|
||||
pass "the worker image installs into its own directory only"
|
||||
else
|
||||
fail "worker install: rc=$RC $OUT"
|
||||
fi
|
||||
release "$TMP/wrel-none"
|
||||
OUT=$(sh "$STAGE" "$TMP/no-such-build" "$TMP/wrel-none" sqs-worker)
|
||||
if [ ! -e "$TMP/wrel-none/microvm-sqs-worker" ] && echo "$OUT" | grep -q 'SQS worker microVM image missing'; then
|
||||
pass "a missing worker build stages nothing and says so"
|
||||
else
|
||||
fail "missing worker build: $OUT"
|
||||
fi
|
||||
install_worker "$TMP/wrel-none" "$TMP/wmv"
|
||||
if [ "$RC" = 0 ] && [ ! -e "$TMP/wmv/sqs-worker/vmlinuz" ] && [ ! -e "$TMP/wmv/sqs-worker/initramfs.cpio.gz" ] \
|
||||
&& echo "$OUT" | grep -q 'no SQS worker microVM image; SQS worker launches will be refused'; then
|
||||
pass "a release without the worker image removes the earlier one and warns"
|
||||
else
|
||||
fail "release without worker image: rc=$RC $OUT"
|
||||
fi
|
||||
cp -r "$TMP/wrel" "$TMP/wrel-bad"
|
||||
rm "$TMP/wrel-bad/microvm-sqs-worker/vmlinuz"
|
||||
install_worker "$TMP/wrel" "$TMP/wmv"
|
||||
install_worker "$TMP/wrel-bad" "$TMP/wmv"
|
||||
if [ "$RC" != 0 ] && echo "$OUT" | grep -q 'SQS worker microVM image in this release is incomplete: vmlinuz is missing' \
|
||||
&& [ ! -e "$TMP/wmv/sqs-worker/initramfs.cpio.gz" ]; then
|
||||
pass "an incomplete worker image is refused and the earlier pair is removed"
|
||||
else
|
||||
fail "incomplete worker image: rc=$RC $OUT"
|
||||
fi
|
||||
if OUT=$(sh "$STAGE" "$TMP/wbuilt" "$TMP/wrel" lb 2>&1); then
|
||||
fail "an unknown image name was staged"
|
||||
else
|
||||
pass "an unknown image name is refused"
|
||||
fi
|
||||
|
||||
[ "$fails" = 0 ] || { echo "FAIL: $fails"; exit 1; }
|
||||
echo " setup_sqs_store_image_test ok"
|
||||
|
||||
@@ -29,9 +29,8 @@
|
||||
#
|
||||
# /usr/local/bin/spx, the nbdkit plugin, /usr/local/{share,lib}/spinifex,
|
||||
# /usr/share/spinifex, every spinifex systemd unit and slice, the eight
|
||||
# spinifex-* service users, the sudoers grant, the sysctl, logrotate, chrony,
|
||||
# needrestart and udev drop-ins, the host firewall policy and its loaded nft
|
||||
# table, the OVS bridges
|
||||
# spinifex-* service users, the sudoers grant, the sysctl/logrotate/chrony/udev
|
||||
# drop-ins, the host firewall policy and its loaded nft table, the OVS bridges
|
||||
# and OVN databases, the Spinifex resolved drop-in, and the Spinifex CA from the
|
||||
# host trust store. Where setup.sh turned on systemd-resolved, it restores
|
||||
# /etc/resolv.conf and turns resolved off.
|
||||
@@ -458,7 +457,6 @@ rm_path /etc/sysctl.d/*spinifex* \
|
||||
/etc/sudoers.d/spinifex-network \
|
||||
/etc/chrony/conf.d/spinifex.conf \
|
||||
/etc/chrony.d/spinifex.conf \
|
||||
/etc/needrestart/conf.d/90-spinifex.conf \
|
||||
/etc/logrotate.d/spinifex \
|
||||
/etc/tmpfiles.d/spinifex.conf \
|
||||
/etc/udev/rules.d/*spinifex* \
|
||||
@@ -617,7 +615,6 @@ if ! $DRY_RUN; then
|
||||
/usr/share/spinifex "$ETC_DIR" "$LOG_DIR" \
|
||||
/etc/sudoers.d/spinifex-network /etc/logrotate.d/spinifex /etc/tmpfiles.d/spinifex.conf \
|
||||
/etc/chrony/conf.d/spinifex.conf /etc/chrony.d/spinifex.conf "$RESOLV_CONF_SAVED" \
|
||||
/etc/needrestart/conf.d/90-spinifex.conf \
|
||||
"$RESOLVED_DROPIN"; do
|
||||
exists "$p" && LEFT+=("$p")
|
||||
done
|
||||
|
||||
@@ -1,676 +0,0 @@
|
||||
package workerboot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/spinifex/internal/gwsign"
|
||||
"github.com/mulgadc/spinifex/internal/sqsworkergw"
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerkey"
|
||||
sqsworkerapi "github.com/mulgadc/spinifex/spinifex/domains/sqs/awsapi/worker"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/sqs/firststore"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/sqs/storelaunch"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/sqs/workerident"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/sqs/workerrole"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/clustersize"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/nats-io/nkeys"
|
||||
)
|
||||
|
||||
const (
|
||||
gwInstance = "i-0abc12345def67890"
|
||||
gwSuccessor = "i-0fedcba9876543210"
|
||||
gwVolume = "vol-0a1b2c3d4e5f60718"
|
||||
)
|
||||
|
||||
// session is one instance-role credential: its secret and the instance ID
|
||||
// STS names the session after.
|
||||
type session struct{ secret, instance string }
|
||||
|
||||
// localGateway stands for the gateway's ingress in front of the real
|
||||
// spinifex-sqs registration: it verifies each SigV4 signature against the
|
||||
// issued credential, names the caller from that session alone, and grants
|
||||
// only the worker role's actions. Every request body is kept for inspection.
|
||||
type localGateway struct {
|
||||
t *testing.T
|
||||
srv *httptest.Server
|
||||
reg dispatch.Registration
|
||||
mu sync.Mutex
|
||||
sessions map[string]session
|
||||
bodies []string
|
||||
// dropReply, while set, applies a request and then drops its reply.
|
||||
dropReply func(action string) bool
|
||||
// replied, when set, receives each reply's error code, "" on success.
|
||||
replied chan string
|
||||
}
|
||||
|
||||
func newLocalGateway(t *testing.T, deps sqsworkerapi.Deps) *localGateway {
|
||||
t.Helper()
|
||||
g := &localGateway{t: t, reg: sqsworkerapi.NewRegistration(deps), sessions: map[string]session{}}
|
||||
g.srv = httptest.NewTLSServer(http.HandlerFunc(g.serve))
|
||||
t.Cleanup(g.srv.Close)
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *localGateway) issue(accessKey, secret, instance string) *gwsign.Signer {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.sessions[accessKey] = session{secret: secret, instance: instance}
|
||||
return gwsign.NewStatic(accessKey, secret)
|
||||
}
|
||||
|
||||
func (g *localGateway) caPEM() []byte {
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: g.srv.Certificate().Raw})
|
||||
}
|
||||
|
||||
func (g *localGateway) serve(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
g.fail(w, http.StatusBadRequest, "MalformedRequest")
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.bodies = append(g.bodies, string(body))
|
||||
g.mu.Unlock()
|
||||
|
||||
sess, service, region, ok := g.verify(r, body)
|
||||
if !ok {
|
||||
g.fail(w, http.StatusForbidden, "SignatureDoesNotMatch")
|
||||
return
|
||||
}
|
||||
if service != sqsworkerapi.ServiceName {
|
||||
g.fail(w, http.StatusBadRequest, "InvalidAction")
|
||||
return
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
action := ""
|
||||
if q, err := parseForm(body); err == nil {
|
||||
action = q
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
err = g.reg.Dispatch(rec, dispatch.Invocation{
|
||||
Request: r,
|
||||
AccountID: awsidentifiers.GlobalAccountID,
|
||||
Service: service,
|
||||
Region: region,
|
||||
Authorize: func(svc, act string, _ []string, _ iampolicy.ConditionKeys) error {
|
||||
if svc != sqsworkerapi.ServiceName || !slices.Contains(workerrole.Actions(), act) {
|
||||
return &dispatch.PolicyDenialError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Actor: func() (dispatch.Actor, error) {
|
||||
return dispatch.Actor{
|
||||
AccountID: awsidentifiers.GlobalAccountID,
|
||||
PrincipalType: "assumed-role",
|
||||
PrincipalARN: "arn:aws:iam::" + awsidentifiers.GlobalAccountID + ":role/" + workerrole.RoleName,
|
||||
SessionName: sess.instance,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
code := err.Error()
|
||||
status := http.StatusBadRequest
|
||||
switch code {
|
||||
case "AccessDenied":
|
||||
status = http.StatusForbidden
|
||||
case "ServiceUnavailable":
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
g.fail(w, status, code)
|
||||
return
|
||||
}
|
||||
if g.dropReply != nil && g.dropReply(action) {
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
g.t.Error("cannot drop a reply")
|
||||
return
|
||||
}
|
||||
conn, _, err := hj.Hijack()
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
g.reply("")
|
||||
maps.Copy(w.Header(), rec.Header())
|
||||
w.WriteHeader(rec.Code)
|
||||
_, _ = w.Write(rec.Body.Bytes())
|
||||
}
|
||||
|
||||
func parseForm(body []byte) (string, error) {
|
||||
r, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.PostForm.Get("Action"), nil
|
||||
}
|
||||
|
||||
// verify recomputes the signature with the issued secret. It returns the
|
||||
// session, and the service and Region the credential scope names.
|
||||
func (g *localGateway) verify(r *http.Request, body []byte) (session, string, string, bool) {
|
||||
authz := r.Header.Get("Authorization")
|
||||
const prefix = "AWS4-HMAC-SHA256 Credential="
|
||||
if !strings.HasPrefix(authz, prefix) {
|
||||
return session{}, "", "", false
|
||||
}
|
||||
scope, _, _ := strings.Cut(strings.TrimPrefix(authz, prefix), ",")
|
||||
parts := strings.Split(scope, "/")
|
||||
if len(parts) != 5 || parts[4] != "aws4_request" {
|
||||
return session{}, "", "", false
|
||||
}
|
||||
g.mu.Lock()
|
||||
sess, ok := g.sessions[parts[0]]
|
||||
g.mu.Unlock()
|
||||
if !ok {
|
||||
return session{}, "", "", false
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
if r.Header.Get("X-Amz-Content-Sha256") != hash {
|
||||
return session{}, "", "", false
|
||||
}
|
||||
at, err := time.Parse("20060102T150405Z", r.Header.Get("X-Amz-Date"))
|
||||
if err != nil {
|
||||
return session{}, "", "", false
|
||||
}
|
||||
check, err := http.NewRequest(r.Method, "https://"+r.Host+r.URL.RequestURI(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return session{}, "", "", false
|
||||
}
|
||||
check.Header.Set("Content-Type", r.Header.Get("Content-Type"))
|
||||
check.Header.Set("X-Amz-Content-Sha256", hash)
|
||||
creds := aws.Credentials{AccessKeyID: parts[0], SecretAccessKey: sess.secret}
|
||||
if err := v4.NewSigner().SignHTTP(context.Background(), creds, check, hash, parts[3], parts[2], at); err != nil {
|
||||
return session{}, "", "", false
|
||||
}
|
||||
return sess, parts[3], parts[2], check.Header.Get("Authorization") == authz
|
||||
}
|
||||
|
||||
func (g *localGateway) fail(w http.ResponseWriter, status int, code string) {
|
||||
g.reply(code)
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.WriteHeader(status)
|
||||
_, _ = fmt.Fprintf(w, "<ErrorResponse><Error><Code>%s</Code></Error></ErrorResponse>", code)
|
||||
}
|
||||
|
||||
func (g *localGateway) reply(code string) {
|
||||
if g.replied != nil {
|
||||
g.replied <- code
|
||||
}
|
||||
}
|
||||
|
||||
func (g *localGateway) sentBodies() []string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return slices.Clone(g.bodies)
|
||||
}
|
||||
|
||||
// gwCluster is the domain's real worker-identity and first-store records.
|
||||
type gwCluster struct {
|
||||
ids workerident.Store
|
||||
fence firststore.Store
|
||||
stores storelaunch.Records
|
||||
gw *localGateway
|
||||
}
|
||||
|
||||
func newGWCluster(t *testing.T) gwCluster {
|
||||
t.Helper()
|
||||
clustersize.DeclareForTest(t, 1)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
ctx := context.Background()
|
||||
ids, err := workerident.Open(ctx, js)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fence, err := firststore.Open(ctx, js)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stores, err := storelaunch.Open(ctx, js)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return gwCluster{ids: ids, fence: fence, stores: stores, gw: newLocalGateway(t, sqsworkerapi.JetStreamDeps(js))}
|
||||
}
|
||||
|
||||
func (c gwCluster) launch(t *testing.T, claim string, supersedes uint64, inst string) workerident.Intent {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
in, err := c.ids.BeginLaunch(ctx, testRegion, workerident.LaunchRequest{InstanceType: "t3.small",
|
||||
ClaimID: claim, Supersedes: supersedes, NodeID: "node-a", DataVolumeID: gwVolume, DataVolumeEncrypted: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.ids.BindInstance(ctx, in, inst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// guestFor points the fake guest's boot at the local gateway, signing with
|
||||
// the instance's own credential through the real client.
|
||||
func (c gwCluster) guestFor(t *testing.T, g *guest, accessKey, inst string) {
|
||||
t.Helper()
|
||||
signer := c.gw.issue(accessKey, "secret-"+accessKey, inst)
|
||||
g.fw("data-serial", testSerial)
|
||||
g.fw("region", testRegion)
|
||||
g.fw("gateway-url", c.gw.srv.URL)
|
||||
g.cfg.Wait = 10 * time.Millisecond
|
||||
g.cfg.NewClaimer = func(region, url string) (workerkey.Claimer, error) {
|
||||
return sqsworkergw.New(url, region, c.gw.caPEM(), signer)
|
||||
}
|
||||
}
|
||||
|
||||
func (c gwCluster) assertNoSeedSent(t *testing.T, seed string) {
|
||||
t.Helper()
|
||||
seed = strings.TrimSpace(seed)
|
||||
for _, b := range c.gw.sentBodies() {
|
||||
if strings.Contains(b, seed) {
|
||||
t.Fatal("a request carried seed material")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A bound worker boots: the gateway authorizes creation, the guest creates
|
||||
// its key on the mounted disk and records only the public key; reboots get
|
||||
// that same key back from the domain.
|
||||
func TestGateway_FirstBootCreatesKeyAndRebootsReuseIt(t *testing.T) {
|
||||
c := newGWCluster(t)
|
||||
c.launch(t, "claim-0001", 0, gwInstance)
|
||||
g := newGuest(t)
|
||||
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
|
||||
|
||||
pub, err := g.boot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed := g.seed()
|
||||
for boot := 2; boot <= 3; boot++ {
|
||||
g.resetMounts()
|
||||
got, err := g.boot()
|
||||
if err != nil || got != pub || g.seed() != seed {
|
||||
t.Fatalf("reboot %d = %q, %v", boot, got, err)
|
||||
}
|
||||
}
|
||||
records := 0
|
||||
for _, b := range c.gw.sentBodies() {
|
||||
if strings.Contains(b, "Action=RecordWorkerPublicKey") {
|
||||
records++
|
||||
if !strings.Contains(b, "PublicKey="+pub) {
|
||||
t.Fatal("a record carried a different key")
|
||||
}
|
||||
}
|
||||
}
|
||||
if records != 1 {
|
||||
t.Fatalf("records = %d, want 1", records)
|
||||
}
|
||||
c.assertNoSeedSent(t, seed)
|
||||
g.assertNoSeedLeak(nil, seed)
|
||||
}
|
||||
|
||||
// The record's reply is lost after it landed: the boot retries, the domain
|
||||
// answers with the recorded key, and the same key is used.
|
||||
func TestGateway_LostRecordReplyKeepsTheKey(t *testing.T) {
|
||||
c := newGWCluster(t)
|
||||
c.launch(t, "claim-0001", 0, gwInstance)
|
||||
g := newGuest(t)
|
||||
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
|
||||
var dropped bool
|
||||
c.gw.dropReply = func(action string) bool {
|
||||
if action == "RecordWorkerPublicKey" && !dropped {
|
||||
dropped = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
pub, err := g.boot()
|
||||
if err != nil || !dropped {
|
||||
t.Fatalf("boot = %q, %v (dropped=%v)", pub, err, dropped)
|
||||
}
|
||||
g.resetMounts()
|
||||
if again, err := g.boot(); err != nil || again != pub {
|
||||
t.Fatalf("reboot = %q, %v", again, err)
|
||||
}
|
||||
c.assertNoSeedSent(t, g.seed())
|
||||
}
|
||||
|
||||
// After the key is recorded, a lost guest copy fails closed: the domain
|
||||
// answers with the recorded key and no replacement is created or sent.
|
||||
func TestGateway_KeyLostAfterRecordingFailsClosed(t *testing.T) {
|
||||
c := newGWCluster(t)
|
||||
c.launch(t, "claim-0001", 0, gwInstance)
|
||||
g := newGuest(t)
|
||||
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
|
||||
if _, err := g.boot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(g.keyFile()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := len(c.gw.sentBodies())
|
||||
g.resetMounts()
|
||||
if _, err := g.boot(); !errors.Is(err, workerkey.ErrKeyMissing) {
|
||||
t.Fatalf("err = %v, want ErrKeyMissing", err)
|
||||
}
|
||||
if _, err := os.Lstat(g.keyFile()); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("a replacement key was created")
|
||||
}
|
||||
for _, b := range c.gw.sentBodies()[before:] {
|
||||
if strings.Contains(b, "RecordWorkerPublicKey") {
|
||||
t.Fatal("a replacement key was offered")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An instance the domain never bound, or one already replaced, is refused
|
||||
// at the first call and creates no key; AccessDenied is not retried.
|
||||
func TestGateway_UnboundOrReplacedInstanceIsRefused(t *testing.T) {
|
||||
t.Run("unbound", func(t *testing.T) {
|
||||
c := newGWCluster(t)
|
||||
c.launch(t, "claim-0001", 0, gwInstance)
|
||||
g := newGuest(t)
|
||||
c.guestFor(t, g, "ASIAOTHER", gwSuccessor)
|
||||
_, err := g.boot()
|
||||
api, ok := errors.AsType[*sqsworkergw.APIError](err)
|
||||
if !ok || api.Code != "AccessDenied" {
|
||||
t.Fatalf("err = %v, want AccessDenied", err)
|
||||
}
|
||||
if n := len(c.gw.sentBodies()); n != g.cfg.Attempts {
|
||||
t.Fatalf("calls = %d, want the %d attempts and no more", n, g.cfg.Attempts)
|
||||
}
|
||||
g.assertNoKey()
|
||||
})
|
||||
t.Run("replaced", func(t *testing.T) {
|
||||
c := newGWCluster(t)
|
||||
first := c.launch(t, "claim-0001", 0, gwInstance)
|
||||
ctx := context.Background()
|
||||
if err := c.ids.RecordStopped(ctx, first, gwInstance); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.fence.Fence(ctx, testRegion, first.Generation+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.launch(t, "claim-0002", first.Generation, gwSuccessor)
|
||||
g := newGuest(t)
|
||||
c.guestFor(t, g, "ASIAOLD", gwInstance)
|
||||
if _, err := g.boot(); err == nil {
|
||||
t.Fatal("the replaced instance booted")
|
||||
}
|
||||
g.assertNoKey()
|
||||
})
|
||||
}
|
||||
|
||||
// A bad signature is refused before any decision, and creates no key.
|
||||
func TestGateway_BadSignatureCreatesNoKey(t *testing.T) {
|
||||
c := newGWCluster(t)
|
||||
c.launch(t, "claim-0001", 0, gwInstance)
|
||||
g := newGuest(t)
|
||||
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
|
||||
forged := gwsign.NewStatic("ASIAWORKER1", "not-the-secret")
|
||||
g.cfg.NewClaimer = func(region, url string) (workerkey.Claimer, error) {
|
||||
return sqsworkergw.New(url, region, c.gw.caPEM(), forged)
|
||||
}
|
||||
if _, err := g.boot(); err == nil {
|
||||
t.Fatal("a forged signature booted")
|
||||
}
|
||||
g.assertNoKey()
|
||||
}
|
||||
|
||||
// The client trusts only the cluster CA it was given: a gateway whose
|
||||
// certificate another CA signed is never sent a request.
|
||||
func TestGateway_UntrustedServerIsNotContacted(t *testing.T) {
|
||||
c := newGWCluster(t)
|
||||
c.launch(t, "claim-0001", 0, gwInstance)
|
||||
g := newGuest(t)
|
||||
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
|
||||
g.cfg.Attempts = 1
|
||||
signer := c.gw.issue("ASIAWORKER1", "secret-ASIAWORKER1", gwInstance)
|
||||
g.cfg.NewClaimer = func(region, url string) (workerkey.Claimer, error) {
|
||||
return sqsworkergw.New(url, region, otherCA(t), signer)
|
||||
}
|
||||
if _, err := g.boot(); err == nil || !strings.Contains(err.Error(), "certificate") {
|
||||
t.Fatalf("err = %v, want a TLS refusal", err)
|
||||
}
|
||||
if len(c.gw.sentBodies()) != 0 {
|
||||
t.Fatal("a request reached a server outside the trusted CA")
|
||||
}
|
||||
g.assertNoKey()
|
||||
}
|
||||
|
||||
// otherCA is a fresh self-signed CA unrelated to the test server's.
|
||||
func otherCA(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "other"},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
}
|
||||
|
||||
// The worker VM starts before its launch binds the instance, so its first
|
||||
// calls are denied. It creates nothing while denied, and boots once bound.
|
||||
func TestGateway_BootBeforeBindSucceedsAfterBinding(t *testing.T) {
|
||||
c := newGWCluster(t)
|
||||
ctx := context.Background()
|
||||
intent, err := c.ids.BeginLaunch(ctx, testRegion, workerident.LaunchRequest{InstanceType: "t3.small",
|
||||
ClaimID: "claim-0001", NodeID: "node-a", DataVolumeID: gwVolume, DataVolumeEncrypted: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := newGuest(t)
|
||||
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
|
||||
g.cfg.Attempts = 1000
|
||||
c.gw.replied = make(chan string, 2000)
|
||||
|
||||
type result struct {
|
||||
pub string
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
pub, err := g.boot()
|
||||
done <- result{pub, err}
|
||||
}()
|
||||
if code := <-c.gw.replied; code != "AccessDenied" {
|
||||
t.Fatalf("first reply = %q, want AccessDenied before binding", code)
|
||||
}
|
||||
g.assertNoKey()
|
||||
if err := c.ids.BindInstance(ctx, intent, gwInstance); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := <-done
|
||||
if r.err != nil || r.pub == "" {
|
||||
t.Fatalf("boot after binding = %q, %v", r.pub, r.err)
|
||||
}
|
||||
if g.seed() == "" {
|
||||
t.Fatal("booted without a key on the worker disk")
|
||||
}
|
||||
}
|
||||
|
||||
// A replaced worker is never bound again: it keeps being denied, creates
|
||||
// nothing and gives up within its attempts.
|
||||
func TestGateway_StaleWorkerGivesUpWithinTheBound(t *testing.T) {
|
||||
c := newGWCluster(t)
|
||||
ctx := context.Background()
|
||||
first := c.launch(t, "claim-0001", 0, gwInstance)
|
||||
if err := c.ids.RecordStopped(ctx, first, gwInstance); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.fence.Fence(ctx, testRegion, first.Generation+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.launch(t, "claim-0002", first.Generation, gwSuccessor)
|
||||
g := newGuest(t)
|
||||
c.guestFor(t, g, "ASIAOLD", gwInstance)
|
||||
g.cfg.Attempts = 5
|
||||
_, err := g.boot()
|
||||
if api, ok := errors.AsType[*sqsworkergw.APIError](err); !ok || !api.Pending() {
|
||||
t.Fatalf("err = %v, want the denial", err)
|
||||
}
|
||||
if n := len(c.gw.sentBodies()); n != 5 {
|
||||
t.Fatalf("calls = %d, want exactly the 5 attempts", n)
|
||||
}
|
||||
g.assertNoKey()
|
||||
}
|
||||
|
||||
// bindStore runs the store launch record through to bound for pub at address,
|
||||
// as the operator's EnsureSQSStore would.
|
||||
func (c gwCluster) bindStore(t *testing.T, pub, address string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
const claim = "store-claim-0001"
|
||||
steps := []func() error{
|
||||
func() error { return c.stores.Begin(ctx, testRegion, claim, "node-a", pub, "t3.medium", 8) },
|
||||
func() error { return c.stores.MarkVolumeAttempted(ctx, testRegion, claim) },
|
||||
func() error { return c.stores.RecordVolume(ctx, testRegion, claim, "vol-0b1c2d3e4f5a60718") },
|
||||
func() error { return c.stores.MarkNetworkAttempted(ctx, testRegion, claim) },
|
||||
func() error { return c.stores.MarkDispatched(ctx, testRegion, claim) },
|
||||
func() error { return c.stores.Bind(ctx, testRegion, claim, "i-0123456789abcdef0", address) },
|
||||
}
|
||||
for i, step := range steps {
|
||||
if err := step(); err != nil {
|
||||
t.Fatalf("store launch step %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readyFor points the guest's readiness at the local gateway and a store on port.
|
||||
func (c gwCluster) readyFor(g *guest, accessKey, inst string, port int) ReadyConfig {
|
||||
signer := c.gw.issue(accessKey, "secret-"+accessKey, inst)
|
||||
return ReadyConfig{
|
||||
Config: g.cfg,
|
||||
NewDescriber: func(region, url string) (StoreDescriber, error) {
|
||||
cl, err := sqsworkergw.New(url, region, c.gw.caPEM(), signer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gwDescriber{cl}, nil
|
||||
},
|
||||
Checker: NATSChecker{Port: port, Dial: time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type gwDescriber struct{ c *sqsworkergw.Client }
|
||||
|
||||
func (d gwDescriber) DescribeStore(ctx context.Context) (Store, error) {
|
||||
st, err := d.c.DescribeStore(ctx)
|
||||
return Store(st), err
|
||||
}
|
||||
|
||||
// The whole path with real components: the worker boots and records its key,
|
||||
// waits while the operator has not launched the store, then reaches the real
|
||||
// store's JetStream with its own key once the store is bound; after a reboot
|
||||
// it is ready again with the same key. No request carries the seed.
|
||||
func TestGateway_ReadyAfterADelayedStoreLaunch(t *testing.T) {
|
||||
c := newGWCluster(t)
|
||||
c.launch(t, "claim-0001", 0, gwInstance)
|
||||
g := newGuest(t)
|
||||
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
|
||||
pub, err := g.boot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := storeServer(t, pub)
|
||||
err = g.watch(c.readyFor(g, "ASIAWORKER1", gwInstance, port), func(call int, ready bool) bool {
|
||||
if ready {
|
||||
if call < 4 {
|
||||
t.Fatalf("ready at call %d, before the store launch", call)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if call == 3 {
|
||||
c.bindStore(t, pub, "127.0.0.1")
|
||||
}
|
||||
return false
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("watch = %v", err)
|
||||
}
|
||||
for boot := 1; boot <= 2; boot++ {
|
||||
g.resetMounts()
|
||||
if again, err := g.boot(); err != nil || again != pub {
|
||||
t.Fatalf("reboot %d = %q %v", boot, again, err)
|
||||
}
|
||||
if err := g.watch(c.readyFor(g, "ASIAWORKER1", gwInstance, port), func(_ int, ready bool) bool { return ready }); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("ready after reboot %d: %v", boot, err)
|
||||
}
|
||||
}
|
||||
c.assertNoSeedSent(t, g.seed())
|
||||
g.assertNoSeedLeak(nil, g.seed())
|
||||
}
|
||||
|
||||
// A store bound to a key other than this worker's is never checked, and a
|
||||
// replaced worker cannot read the store at all; neither is ever ready.
|
||||
func TestGateway_ReadyRefusesWrongKeyAndReplacedWorker(t *testing.T) {
|
||||
c := newGWCluster(t)
|
||||
first := c.launch(t, "claim-0001", 0, gwInstance)
|
||||
g := newGuest(t)
|
||||
c.guestFor(t, g, "ASIAWORKER1", gwInstance)
|
||||
if _, err := g.boot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, _ := nkeys.CreateUser()
|
||||
otherPub, _ := other.PublicKey()
|
||||
c.bindStore(t, otherPub, "127.0.0.1")
|
||||
port := storeServer(t, otherPub)
|
||||
never := func(call int, ready bool) bool {
|
||||
if ready {
|
||||
t.Fatal("ready against a store for another key")
|
||||
}
|
||||
return call == 3
|
||||
}
|
||||
_ = g.watch(c.readyFor(g, "ASIAWORKER1", gwInstance, port), never)
|
||||
if !slices.ContainsFunc(g.logs, func(l string) bool { return strings.Contains(l, ErrStoreKeyMismatch.Error()) }) {
|
||||
t.Fatalf("no mismatch reported: %v", g.logs)
|
||||
}
|
||||
|
||||
if err := c.ids.RecordStopped(context.Background(), first, gwInstance); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = g.watch(c.readyFor(g, "ASIAWORKER1", gwInstance, port), never)
|
||||
if !strings.Contains(g.logs[len(g.logs)-1], "AccessDenied") {
|
||||
t.Fatalf("stopped worker: %v", g.logs[len(g.logs)-1])
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package workerboot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/nats-io/nkeys"
|
||||
)
|
||||
|
||||
// StorePort is the port the store guest's NATS listens on.
|
||||
const StorePort = 4222
|
||||
|
||||
// NATSChecker connects with the worker key and asks JetStream for its account
|
||||
// info. It authenticates the client only: there is no TLS, so a pass says
|
||||
// something answering at the address accepted the key.
|
||||
type NATSChecker struct {
|
||||
// Port overrides StorePort, for tests against a local server.
|
||||
Port int
|
||||
// Dial bounds each connection attempt; zero is five seconds.
|
||||
Dial time.Duration
|
||||
}
|
||||
|
||||
var _ Checker = NATSChecker{}
|
||||
|
||||
// StoreUnreachableError is a store that did not answer yet; it is retried.
|
||||
type StoreUnreachableError struct{ Err error }
|
||||
|
||||
func (e *StoreUnreachableError) Error() string { return "store not answering: " + e.Err.Error() }
|
||||
func (e *StoreUnreachableError) Unwrap() error { return e.Err }
|
||||
func (e *StoreUnreachableError) Retryable() bool { return true }
|
||||
|
||||
// ErrStoreRefusedKey is a store that answered but refused the worker key.
|
||||
var ErrStoreRefusedKey = errors.New("the store refused the worker key")
|
||||
|
||||
// Check passes once JetStream answers for the worker key.
|
||||
func (c NATSChecker) Check(ctx context.Context, address string, kp nkeys.KeyPair) error {
|
||||
if ip := net.ParseIP(address); ip == nil || ip.To4() == nil {
|
||||
return errors.New("store address is not an IPv4 address")
|
||||
}
|
||||
pub, err := kp.PublicKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive worker public key: %w", err)
|
||||
}
|
||||
port := c.Port
|
||||
if port == 0 {
|
||||
port = StorePort
|
||||
}
|
||||
dial := c.Dial
|
||||
if dial == 0 {
|
||||
dial = 5 * time.Second
|
||||
}
|
||||
nc, err := nats.Connect("nats://"+net.JoinHostPort(address, strconv.Itoa(port)),
|
||||
nats.Nkey(pub, kp.Sign),
|
||||
nats.Timeout(dial),
|
||||
nats.NoReconnect(),
|
||||
nats.Name("spinifex-sqs-worker-readiness"),
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrAuthorization) || errors.Is(err, nats.ErrAuthExpired) {
|
||||
return fmt.Errorf("%w: %w", ErrStoreRefusedKey, err)
|
||||
}
|
||||
return &StoreUnreachableError{Err: err}
|
||||
}
|
||||
defer nc.Close()
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
return &StoreUnreachableError{Err: err}
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, dial)
|
||||
defer cancel()
|
||||
if _, err := js.AccountInfo(callCtx); err != nil {
|
||||
return &StoreUnreachableError{Err: fmt.Errorf("jetstream account info: %w", err)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package workerboot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerkey"
|
||||
"github.com/nats-io/nkeys"
|
||||
)
|
||||
|
||||
// Store is the Region's store launch as the gateway reports it. Bound means a
|
||||
// VM was launched for the store, not that it serves.
|
||||
type Store struct {
|
||||
Bound bool
|
||||
Address string
|
||||
PublicKey string
|
||||
}
|
||||
|
||||
// StoreDescriber asks the gateway for this worker's store.
|
||||
type StoreDescriber interface {
|
||||
DescribeStore(ctx context.Context) (Store, error)
|
||||
}
|
||||
|
||||
// NewDescriber builds the gateway client the store's address comes from.
|
||||
type NewDescriber func(region, gatewayURL string) (StoreDescriber, error)
|
||||
|
||||
// Checker proves the store's JetStream answers this worker's key at address.
|
||||
// Only an error with Retryable() true is asked again.
|
||||
type Checker interface {
|
||||
Check(ctx context.Context, address string, kp nkeys.KeyPair) error
|
||||
}
|
||||
|
||||
// ReadyConfig is Config plus the readiness capabilities and cadence.
|
||||
type ReadyConfig struct {
|
||||
Config
|
||||
|
||||
NewDescriber NewDescriber
|
||||
Checker Checker
|
||||
// CallTimeout bounds one describe-and-check pass; zero is 30 seconds.
|
||||
CallTimeout time.Duration
|
||||
// Refresh is the wait after a pass before checking again; zero is 30s.
|
||||
Refresh time.Duration
|
||||
// Refused is the wait after a failure that cannot clear by itself, such
|
||||
// as a refused or mismatched key; zero is five minutes.
|
||||
Refused time.Duration
|
||||
}
|
||||
|
||||
// ErrStoreKeyMismatch is a bound store launched for a key other than the one
|
||||
// on this worker's disk; it is asked again only at the Refused cadence.
|
||||
var ErrStoreKeyMismatch = errors.New("the store was launched for a different worker key")
|
||||
|
||||
const readyFile = "store-ready"
|
||||
|
||||
// Watch keeps the readiness marker true until ctx ends: it re-proves the disk,
|
||||
// then checks the store with the worker's existing key every pass, writing the
|
||||
// marker on a pass and removing it on any failure and on return. It never
|
||||
// creates a key. NKey authenticates this client only, not the store server.
|
||||
func Watch(ctx context.Context, cfg ReadyConfig) error {
|
||||
marker := filepath.Join(cfg.RunDir, readyFile)
|
||||
defer func() { _ = os.Remove(marker) }()
|
||||
if err := os.Remove(marker); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("clear stale store readiness: %w", err)
|
||||
}
|
||||
c, err := readContract(cfg.FwCfgDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mountWorkerDisk(cfg.Config, c.serial); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.NewDescriber == nil || cfg.Checker == nil {
|
||||
return errors.New("no gateway client or store checker is configured")
|
||||
}
|
||||
d, err := cfg.NewDescriber(c.region, c.gatewayURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build gateway client: %w", err)
|
||||
}
|
||||
keys := workerkey.Store{Dir: filepath.Join(cfg.DataDir, keyDirName)}
|
||||
callTimeout := orDefault(cfg.CallTimeout, 30*time.Second)
|
||||
refresh := orDefault(cfg.Refresh, 30*time.Second)
|
||||
refused := orDefault(cfg.Refused, 5*time.Minute)
|
||||
|
||||
last := ""
|
||||
for {
|
||||
callCtx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
st, err := readyOnce(callCtx, cfg, d, keys)
|
||||
cancel()
|
||||
delay, state := refresh, ""
|
||||
if err == nil {
|
||||
err = writeReady(cfg.RunDir, st)
|
||||
}
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
return ctx.Err()
|
||||
case err == nil:
|
||||
state = fmt.Sprintf("store at %s answered the worker key (client-authenticated only; the store server is not authenticated)", st.Address)
|
||||
default:
|
||||
_ = os.Remove(marker)
|
||||
delay = cfg.Wait
|
||||
if !pending(err) && !retryable(err) && !errors.Is(err, errStorePending) {
|
||||
delay = refused
|
||||
}
|
||||
state = fmt.Sprintf("store not ready: %v", err)
|
||||
}
|
||||
// Only a change is logged, so a long wait does not flood the console.
|
||||
if state != last {
|
||||
cfg.Log(state)
|
||||
last = state
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(max(delay, time.Millisecond)):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func orDefault(d, def time.Duration) time.Duration {
|
||||
if d <= 0 {
|
||||
return def
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
var errStorePending = errors.New("the store is not yet bound")
|
||||
|
||||
func readyOnce(ctx context.Context, cfg ReadyConfig, d StoreDescriber, keys workerkey.Store) (Store, error) {
|
||||
st, err := d.DescribeStore(ctx)
|
||||
if err != nil {
|
||||
return Store{}, err
|
||||
}
|
||||
if !st.Bound {
|
||||
return Store{}, errStorePending
|
||||
}
|
||||
kp, err := keys.ForExistingStore(st.PublicKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, workerkey.ErrKeyMismatch) {
|
||||
return Store{}, fmt.Errorf("%w: %w", ErrStoreKeyMismatch, err)
|
||||
}
|
||||
return Store{}, err
|
||||
}
|
||||
defer kp.Wipe()
|
||||
if err := cfg.Checker.Check(ctx, st.Address, kp); err != nil {
|
||||
return Store{}, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// writeReady publishes only the address and public key, never the seed.
|
||||
func writeReady(runDir string, st Store) error {
|
||||
if err := os.MkdirAll(runDir, 0o750); err != nil {
|
||||
return fmt.Errorf("create %s: %w", runDir, err)
|
||||
}
|
||||
body := "address=" + st.Address + "\npublic=" + st.PublicKey + "\n"
|
||||
if err := os.WriteFile(filepath.Join(runDir, readyFile), []byte(body), 0o600); err != nil {
|
||||
return fmt.Errorf("publish store readiness: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
package workerboot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nkeys"
|
||||
)
|
||||
|
||||
// describer is the gateway's DescribeStore, answering from a script.
|
||||
type describer struct {
|
||||
answers []describeAnswer
|
||||
calls int
|
||||
}
|
||||
|
||||
type describeAnswer struct {
|
||||
st Store
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *describer) DescribeStore(context.Context) (Store, error) {
|
||||
d.calls++
|
||||
a := d.answers[min(d.calls, len(d.answers))-1]
|
||||
return a.st, a.err
|
||||
}
|
||||
|
||||
type checker struct {
|
||||
errs []error
|
||||
calls int
|
||||
seen []string
|
||||
}
|
||||
|
||||
func (c *checker) Check(_ context.Context, address string, kp nkeys.KeyPair) error {
|
||||
c.calls++
|
||||
pub, _ := kp.PublicKey()
|
||||
c.seen = append(c.seen, address+"|"+pub)
|
||||
if len(c.errs) > 0 {
|
||||
err := c.errs[0]
|
||||
c.errs = c.errs[1:]
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bootedGuest has run the boot step, so its disk holds a recorded key.
|
||||
func bootedGuest(t *testing.T) (*guest, string) {
|
||||
t.Helper()
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
pub, err := g.boot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g, pub
|
||||
}
|
||||
|
||||
func (g *guest) readyCfg(d StoreDescriber, c Checker) ReadyConfig {
|
||||
return ReadyConfig{
|
||||
Config: g.cfg,
|
||||
NewDescriber: func(string, string) (StoreDescriber, error) { return d, nil },
|
||||
Checker: c,
|
||||
}
|
||||
}
|
||||
|
||||
// hooked asks stop before each describe, after the previous pass has written
|
||||
// or removed the marker, and ends the watch when stop says so.
|
||||
type hooked struct {
|
||||
d StoreDescriber
|
||||
marker string
|
||||
stop func(call int, ready bool) bool
|
||||
cancel context.CancelFunc
|
||||
calls int
|
||||
}
|
||||
|
||||
func (h *hooked) DescribeStore(ctx context.Context) (Store, error) {
|
||||
h.calls++
|
||||
_, err := os.Stat(h.marker)
|
||||
if h.stop(h.calls, err == nil) {
|
||||
h.cancel()
|
||||
return Store{}, context.Canceled
|
||||
}
|
||||
return h.d.DescribeStore(ctx)
|
||||
}
|
||||
|
||||
// watch runs Watch with millisecond cadences until stop ends it, then checks
|
||||
// the marker was removed on the way out.
|
||||
func (g *guest) watch(cfg ReadyConfig, stop func(call int, ready bool) bool) error {
|
||||
g.t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
inner := cfg.NewDescriber
|
||||
cfg.NewDescriber = func(region, url string) (StoreDescriber, error) {
|
||||
d, err := inner(region, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &hooked{d: d, marker: filepath.Join(cfg.RunDir, readyFile), stop: stop, cancel: cancel}, nil
|
||||
}
|
||||
for _, p := range []*time.Duration{&cfg.Wait, &cfg.Refresh, &cfg.Refused} {
|
||||
if *p == 0 {
|
||||
*p = time.Millisecond
|
||||
}
|
||||
}
|
||||
err := Watch(ctx, cfg)
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
g.t.Fatal("the watch never reached its stop condition")
|
||||
}
|
||||
g.assertNotReady()
|
||||
return err
|
||||
}
|
||||
|
||||
func bound(pub string) describeAnswer {
|
||||
return describeAnswer{st: Store{Bound: true, Address: "10.240.0.9", PublicKey: pub}}
|
||||
}
|
||||
|
||||
func (g *guest) marker() string {
|
||||
g.t.Helper()
|
||||
b, _ := os.ReadFile(filepath.Join(g.cfg.RunDir, readyFile))
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// A store launched long after the worker booted, far past the old 15-minute
|
||||
// bound of 180 attempts, still becomes ready; nothing is published before.
|
||||
func TestWatch_StoreLaunchedLateBecomesReady(t *testing.T) {
|
||||
g, pub := bootedGuest(t)
|
||||
answers := make([]describeAnswer, 201)
|
||||
answers[200] = bound(pub)
|
||||
d := &describer{answers: answers}
|
||||
c := &checker{errs: []error{&StoreUnreachableError{Err: errors.New("connection refused")}}}
|
||||
published := ""
|
||||
err := g.watch(g.readyCfg(d, c), func(call int, ready bool) bool {
|
||||
if ready {
|
||||
published = g.marker()
|
||||
return true
|
||||
}
|
||||
if call > 202 {
|
||||
t.Fatalf("not ready by call %d", call)
|
||||
}
|
||||
return false
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("watch = %v", err)
|
||||
}
|
||||
if d.calls != 202 || c.calls != 2 || c.seen[0] != "10.240.0.9|"+pub {
|
||||
t.Fatalf("describe %d, check %d, seen %v", d.calls, c.calls, c.seen)
|
||||
}
|
||||
if published != "address=10.240.0.9\npublic="+pub+"\n" {
|
||||
t.Fatalf("published %q", published)
|
||||
}
|
||||
notReady := 0
|
||||
for _, l := range g.logs {
|
||||
if strings.HasPrefix(l, "store not ready") {
|
||||
notReady++
|
||||
}
|
||||
}
|
||||
if notReady > 2 {
|
||||
t.Fatalf("%d not-ready lines for two distinct states", notReady)
|
||||
}
|
||||
}
|
||||
|
||||
// After a pass, the marker goes as soon as a later check fails, and returns
|
||||
// once the store answers again.
|
||||
func TestWatch_LossAfterReadyRemovesTheMarker(t *testing.T) {
|
||||
cases := map[string]func(pub string) (*describer, *checker){
|
||||
"store stops answering": func(pub string) (*describer, *checker) {
|
||||
return &describer{answers: []describeAnswer{bound(pub)}},
|
||||
&checker{errs: []error{nil, &StoreUnreachableError{Err: errors.New("connection refused")}, nil}}
|
||||
},
|
||||
"gateway reply lost": func(pub string) (*describer, *checker) {
|
||||
return &describer{answers: []describeAnswer{bound(pub), {err: transient{}}, bound(pub)}}, &checker{}
|
||||
},
|
||||
"store no longer bound": func(pub string) (*describer, *checker) {
|
||||
return &describer{answers: []describeAnswer{bound(pub), {}, bound(pub)}}, &checker{}
|
||||
},
|
||||
"store now refuses the key": func(pub string) (*describer, *checker) {
|
||||
return &describer{answers: []describeAnswer{bound(pub)}}, &checker{errs: []error{nil, ErrStoreRefusedKey, nil}}
|
||||
},
|
||||
}
|
||||
for name, mk := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
g, pub := bootedGuest(t)
|
||||
d, c := mk(pub)
|
||||
var seen []bool
|
||||
if err := g.watch(g.readyCfg(d, c), func(call int, ready bool) bool {
|
||||
seen = append(seen, ready)
|
||||
return call == 4
|
||||
}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("watch = %v", err)
|
||||
}
|
||||
if want := []bool{false, true, false, true}; !slices.Equal(seen, want) {
|
||||
t.Fatalf("marker before each pass = %v, want %v", seen, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A marker left by an earlier run is cleared before the first pass.
|
||||
func TestWatch_ClearsAStaleMarker(t *testing.T) {
|
||||
g, pub := bootedGuest(t)
|
||||
mustWrite(t, filepath.Join(g.cfg.RunDir, readyFile), "address=10.0.0.1\n")
|
||||
d := &describer{answers: []describeAnswer{{}}}
|
||||
_ = g.watch(g.readyCfg(d, &checker{}), func(call int, ready bool) bool {
|
||||
if call == 1 && ready {
|
||||
t.Fatal("a stale marker survived into the first pass")
|
||||
}
|
||||
return call == 2
|
||||
})
|
||||
_ = pub
|
||||
}
|
||||
|
||||
// Failures that cannot clear by themselves wait the Refused cadence: in the
|
||||
// window, each is tried once and never published.
|
||||
func TestWatch_FinalFailuresDoNotSpin(t *testing.T) {
|
||||
other, _ := nkeys.CreateUser()
|
||||
otherPub, _ := other.PublicKey()
|
||||
cases := map[string]struct {
|
||||
d func(pub string) *describer
|
||||
c *checker
|
||||
checks int
|
||||
}{
|
||||
"store refuses the key": {func(pub string) *describer { return &describer{answers: []describeAnswer{bound(pub)}} },
|
||||
&checker{errs: []error{ErrStoreRefusedKey}}, 1},
|
||||
"store launched for another key": {func(string) *describer { return &describer{answers: []describeAnswer{bound(otherPub)}} },
|
||||
&checker{}, 0},
|
||||
"unclassified gateway refusal": {func(string) *describer {
|
||||
return &describer{answers: []describeAnswer{{err: errors.New("InvalidParameterValue")}}}
|
||||
}, &checker{}, 0},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
g, pub := bootedGuest(t)
|
||||
g.cfg.Wait = time.Millisecond
|
||||
d := tc.d(pub)
|
||||
cfg := g.readyCfg(d, tc.c)
|
||||
cfg.Refresh, cfg.Refused = time.Millisecond, time.Hour
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := Watch(ctx, cfg); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("watch = %v", err)
|
||||
}
|
||||
if d.calls != 1 || tc.c.calls != tc.checks {
|
||||
t.Fatalf("describe %d, check %d in the window", d.calls, tc.c.calls)
|
||||
}
|
||||
g.assertNotReady()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A mismatched store is asked again at the Refused cadence, so a store the
|
||||
// operator relaunches for this worker's key is picked up.
|
||||
func TestWatch_RecoversWhenTheStoreIsRelaunchedForThisKey(t *testing.T) {
|
||||
g, pub := bootedGuest(t)
|
||||
other, _ := nkeys.CreateUser()
|
||||
otherPub, _ := other.PublicKey()
|
||||
d := &describer{answers: []describeAnswer{bound(otherPub), bound(pub)}}
|
||||
c := &checker{}
|
||||
if err := g.watch(g.readyCfg(d, c), func(_ int, ready bool) bool { return ready }); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("watch = %v", err)
|
||||
}
|
||||
if d.calls != 2 || c.calls != 1 {
|
||||
t.Fatalf("describe %d, check %d", d.calls, c.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A key missing from the disk is never replaced, however often it is asked.
|
||||
func TestWatch_LostKeyIsNeverReplaced(t *testing.T) {
|
||||
g, pub := bootedGuest(t)
|
||||
dir := filepath.Join(g.cfg.DataDir, keyDirName)
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if err := os.Remove(filepath.Join(dir, e.Name())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
d := &describer{answers: []describeAnswer{bound(pub)}}
|
||||
c := &checker{}
|
||||
_ = g.watch(g.readyCfg(d, c), func(call int, ready bool) bool {
|
||||
if ready {
|
||||
t.Fatal("ready without a key")
|
||||
}
|
||||
return call == 5
|
||||
})
|
||||
if c.calls != 0 {
|
||||
t.Fatalf("checked %d times without a key", c.calls)
|
||||
}
|
||||
if after, _ := os.ReadDir(dir); len(after) != 0 {
|
||||
t.Fatal("a replacement key was written")
|
||||
}
|
||||
}
|
||||
|
||||
// Readiness never runs on a disk that is not proven mounted.
|
||||
func TestWatch_RefusesWithoutTheMountedWorkerDisk(t *testing.T) {
|
||||
g, pub := bootedGuest(t)
|
||||
g.resetMounts()
|
||||
g.disk.noopMount = true
|
||||
d := &describer{answers: []describeAnswer{bound(pub)}}
|
||||
if err := Watch(context.Background(), g.readyCfg(d, &checker{})); err == nil || d.calls != 0 {
|
||||
t.Fatalf("err = %v, describe %d", err, d.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// After a restart the same key reaches readiness again with no new key.
|
||||
func TestWatch_SurvivesARestart(t *testing.T) {
|
||||
g, pub := bootedGuest(t)
|
||||
for range 2 {
|
||||
d := &describer{answers: []describeAnswer{bound(pub)}}
|
||||
if err := g.watch(g.readyCfg(d, &checker{}), func(_ int, ready bool) bool { return ready }); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.resetMounts()
|
||||
again, err := g.boot()
|
||||
if err != nil || again != pub {
|
||||
t.Fatalf("reboot = %q %v", again, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *guest) assertNotReady() {
|
||||
g.t.Helper()
|
||||
if _, err := os.Stat(filepath.Join(g.cfg.RunDir, readyFile)); !os.IsNotExist(err) {
|
||||
g.t.Fatalf("readiness was published: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func startStore(t *testing.T, pub string, port int) *server.Server {
|
||||
t.Helper()
|
||||
opts := &server.Options{Host: "127.0.0.1", Port: port, JetStream: true, StoreDir: t.TempDir(), NoLog: true,
|
||||
NoSigs: true, Nkeys: []*server.NkeyUser{{Nkey: pub}}}
|
||||
ns, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go ns.Start()
|
||||
if !ns.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("store server did not start")
|
||||
}
|
||||
t.Cleanup(ns.Shutdown)
|
||||
return ns
|
||||
}
|
||||
|
||||
// End to end on the guest with a real store: not ready before it serves,
|
||||
// ready once it does, not ready again once it stops, and the marker never
|
||||
// holds the seed.
|
||||
func TestWatch_RealStoreStartsLateThenStops(t *testing.T) {
|
||||
g, pub := bootedGuest(t)
|
||||
seedFile, _ := os.ReadDir(filepath.Join(g.cfg.DataDir, keyDirName))
|
||||
key, _ := os.ReadFile(filepath.Join(g.cfg.DataDir, keyDirName, seedFile[0].Name()))
|
||||
l, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
_ = l.Close()
|
||||
d := &describer{answers: []describeAnswer{{st: Store{Bound: true, Address: "127.0.0.1", PublicKey: pub}}}}
|
||||
cfg := g.readyCfg(d, NATSChecker{Port: port, Dial: time.Second})
|
||||
var ns *server.Server
|
||||
phase := 0
|
||||
err := g.watch(cfg, func(call int, ready bool) bool {
|
||||
switch {
|
||||
case phase == 0 && call == 3:
|
||||
if ready {
|
||||
t.Fatal("ready before the store served")
|
||||
}
|
||||
ns, phase = startStore(t, pub, port), 1
|
||||
case phase == 1 && ready:
|
||||
if strings.Contains(g.marker(), strings.TrimSpace(string(key))) {
|
||||
t.Fatal("the seed was published")
|
||||
}
|
||||
ns.Shutdown()
|
||||
ns.WaitForShutdown()
|
||||
phase = 2
|
||||
case phase == 2:
|
||||
if ready {
|
||||
t.Fatal("still ready after the store stopped")
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) || phase != 2 {
|
||||
t.Fatalf("watch = %v, phase %d", err, phase)
|
||||
}
|
||||
}
|
||||
|
||||
// storeServer is a real NATS server with JetStream that admits only pub.
|
||||
func storeServer(t *testing.T, pub string) int {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1", Port: -1, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true,
|
||||
Nkeys: []*server.NkeyUser{{Nkey: pub}},
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go ns.Start()
|
||||
if !ns.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("store server did not start")
|
||||
}
|
||||
t.Cleanup(ns.Shutdown)
|
||||
return ns.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// The real checker passes only with the admitted key; a wrong key is refused
|
||||
// as final, and nothing listening is retryable.
|
||||
func TestNATSChecker_AgainstARealStore(t *testing.T) {
|
||||
kp, _ := nkeys.CreateUser()
|
||||
pub, _ := kp.PublicKey()
|
||||
port := storeServer(t, pub)
|
||||
c := NATSChecker{Port: port, Dial: 2 * time.Second}
|
||||
if err := c.Check(context.Background(), "127.0.0.1", kp); err != nil {
|
||||
t.Fatalf("admitted key: %v", err)
|
||||
}
|
||||
other, _ := nkeys.CreateUser()
|
||||
err := c.Check(context.Background(), "127.0.0.1", other)
|
||||
if !errors.Is(err, ErrStoreRefusedKey) || retryable(err) {
|
||||
t.Fatalf("wrong key: %v", err)
|
||||
}
|
||||
seed, _ := kp.Seed()
|
||||
if strings.Contains(err.Error(), string(seed)) {
|
||||
t.Fatal("the error echoes a seed")
|
||||
}
|
||||
l, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
closed := l.Addr().(*net.TCPAddr).Port
|
||||
_ = l.Close()
|
||||
err = NATSChecker{Port: closed, Dial: time.Second}.Check(context.Background(), "127.0.0.1", kp)
|
||||
if !retryable(err) {
|
||||
t.Fatalf("nothing listening: %v is not retryable", err)
|
||||
}
|
||||
if err := c.Check(context.Background(), "store.example", kp); err == nil || retryable(err) {
|
||||
t.Fatalf("hostname accepted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package workerboot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// SystemDisk checks with e2fsck and mounts with the mount syscall.
|
||||
type SystemDisk struct {
|
||||
E2fsck string
|
||||
}
|
||||
|
||||
var _ Disk = SystemDisk{}
|
||||
|
||||
// Check runs a preen check: 0 is clean and 1 corrected; anything higher,
|
||||
// including a disk that is not ext4, refuses. Nothing formats.
|
||||
func (d SystemDisk) Check(dev string) error {
|
||||
out, err := exec.Command(d.E2fsck, "-p", dev).CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("e2fsck refused: %w: %s", err, strings.Join(strings.Fields(string(out)), " "))
|
||||
}
|
||||
|
||||
// Mount mounts the disk ext4 with no device nodes, setuid or execution.
|
||||
func (SystemDisk) Mount(dev, dir string) error {
|
||||
return syscall.Mount(dev, dir, "ext4", syscall.MS_NODEV|syscall.MS_NOSUID|syscall.MS_NOEXEC, "")
|
||||
}
|
||||
|
||||
// IsBlockDevice reports whether path is a block (not character) device node.
|
||||
func IsBlockDevice(path string) bool {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
m := fi.Mode()
|
||||
return m&os.ModeDevice != 0 && m&os.ModeCharDevice == 0
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
// Package workerboot prepares the shared SQS worker guest: it selects exactly
|
||||
// the expected data disk by serial, checks and mounts it, proves that disk is
|
||||
// what is mounted, and only then loads or creates the worker's store key on it
|
||||
// under the current first-store decision the gateway returns.
|
||||
package workerboot
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerkey"
|
||||
"github.com/nats-io/nkeys"
|
||||
)
|
||||
|
||||
// Guest contract, read from fw_cfg under opt/spinifex/sqs-worker/. None of it
|
||||
// is a decision: whether a store key may be created is asked of the gateway.
|
||||
//
|
||||
// data-serial virtio-blk serial of the worker's own ext4 data disk
|
||||
// region the Region the worker signs its gateway calls for
|
||||
// gateway-url https URL of the gateway on this node's mgmt bridge
|
||||
const (
|
||||
keyDirName = "sqs-worker-key"
|
||||
maxFwCfg = 128
|
||||
)
|
||||
|
||||
var regionPattern = regexp.MustCompile(`^[a-z]{2}(-[a-z]+)+-[0-9]+$`)
|
||||
|
||||
// NewClaimer builds the gateway client the first-store decision comes from.
|
||||
type NewClaimer func(region, gatewayURL string) (workerkey.Claimer, error)
|
||||
|
||||
// Disk checks and mounts the worker data disk. The guest never formats.
|
||||
type Disk interface {
|
||||
Check(dev string) error
|
||||
Mount(dev, dir string) error
|
||||
}
|
||||
|
||||
// Config holds every path the boot step reads or writes, so tests can run it
|
||||
// against a fake fw_cfg, sysfs and mount table.
|
||||
type Config struct {
|
||||
FwCfgDir string
|
||||
SysBlock string
|
||||
DevDir string
|
||||
MountInfo string
|
||||
DataDir string
|
||||
RunDir string
|
||||
Disk Disk
|
||||
// IsBlockDevice reports whether a /dev node is a block device.
|
||||
IsBlockDevice func(path string) bool
|
||||
NewClaimer NewClaimer
|
||||
// Attempts bounds the decision calls, Wait apart. Only a lost reply, a
|
||||
// retryable refusal or a pending authorization is asked again.
|
||||
Attempts int
|
||||
Wait time.Duration
|
||||
Log func(msg string)
|
||||
}
|
||||
|
||||
type contract struct {
|
||||
serial, region, gatewayURL string
|
||||
}
|
||||
|
||||
// Boot runs the boot step and returns the worker's public key. The key is
|
||||
// loaded or created only under the gateway's current decision; any missing,
|
||||
// malformed or ambiguous input or answer refuses.
|
||||
func Boot(ctx context.Context, cfg Config) (string, error) {
|
||||
c, err := readContract(cfg.FwCfgDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := mountWorkerDisk(cfg, c.serial); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if cfg.NewClaimer == nil {
|
||||
return "", errors.New("no gateway client is configured")
|
||||
}
|
||||
claimer, err := cfg.NewClaimer(c.region, c.gatewayURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build gateway client: %w", err)
|
||||
}
|
||||
kp, err := acquire(ctx, cfg, workerkey.Store{Dir: filepath.Join(cfg.DataDir, keyDirName)}, claimer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer kp.Wipe()
|
||||
pub, err := kp.PublicKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("derive worker public key: %w", err)
|
||||
}
|
||||
if err := writePublicKey(cfg.RunDir, pub); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfg.Log("store key ready: public=" + pub)
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
// mountWorkerDisk selects the disk with serial, checks and mounts it at
|
||||
// DataDir unless it is already there, and proves from the mount table that
|
||||
// exactly that disk is what is mounted.
|
||||
func mountWorkerDisk(cfg Config, serial string) error {
|
||||
name, err := findDisk(cfg, serial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dev := filepath.Join(cfg.DevDir, name)
|
||||
majMin, err := readTrimmed(filepath.Join(cfg.SysBlock, name, "dev"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read device number of %s: %w", name, err)
|
||||
}
|
||||
|
||||
mounted, err := mountedDevice(cfg.MountInfo, cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch mounted {
|
||||
case majMin:
|
||||
cfg.Log(fmt.Sprintf("%s already mounted at %s", dev, cfg.DataDir))
|
||||
case "":
|
||||
if err := cfg.Disk.Check(dev); err != nil {
|
||||
return fmt.Errorf("check %s: %w", dev, err)
|
||||
}
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o750); err != nil {
|
||||
return fmt.Errorf("create mountpoint %s: %w", cfg.DataDir, err)
|
||||
}
|
||||
if err := cfg.Disk.Mount(dev, cfg.DataDir); err != nil {
|
||||
return fmt.Errorf("mount %s at %s: %w", dev, cfg.DataDir, err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%s holds device %s, not the worker disk %s", cfg.DataDir, mounted, majMin)
|
||||
}
|
||||
|
||||
// Proven from the mount table, not inferred from the mount call: an empty
|
||||
// mountpoint directory must never receive a key.
|
||||
mounted, err = mountedDevice(cfg.MountInfo, cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mounted != majMin {
|
||||
return fmt.Errorf("worker disk %s (%s) is not mounted at %s", dev, majMin, cfg.DataDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readContract(dir string) (contract, error) {
|
||||
var c contract
|
||||
var err error
|
||||
if c.serial, err = readFwCfg(dir, "data-serial"); err != nil {
|
||||
return c, err
|
||||
}
|
||||
if !validSerial(c.serial) {
|
||||
return c, errors.New("data-serial is not a volume serial")
|
||||
}
|
||||
if c.region, err = readFwCfg(dir, "region"); err != nil {
|
||||
return c, err
|
||||
}
|
||||
if !regionPattern.MatchString(c.region) {
|
||||
return c, errors.New("region is not a Region name")
|
||||
}
|
||||
if c.gatewayURL, err = readFwCfg(dir, "gateway-url"); err != nil {
|
||||
return c, err
|
||||
}
|
||||
if u, err := url.Parse(c.gatewayURL); err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
return c, errors.New("gateway-url is not an https URL")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// acquire runs the decision and key step, repeating it only on an error that
|
||||
// says it is safe to repeat, including a denial a not-yet-bound worker gets.
|
||||
// A denial creates nothing, and each repeat re-asks the gateway.
|
||||
func acquire(ctx context.Context, cfg Config, store workerkey.Store, c workerkey.Claimer) (kp nkeys.KeyPair, err error) {
|
||||
attempts := max(cfg.Attempts, 1)
|
||||
for i := range attempts {
|
||||
kp, err = store.Acquire(ctx, c)
|
||||
if err == nil || i == attempts-1 {
|
||||
return kp, err
|
||||
}
|
||||
switch {
|
||||
case pending(err):
|
||||
cfg.Log(fmt.Sprintf("worker not yet authorized, asking again: %v", err))
|
||||
case retryable(err):
|
||||
cfg.Log(fmt.Sprintf("first-store decision not settled, retrying: %v", err))
|
||||
default:
|
||||
return kp, err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(cfg.Wait):
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func pending(err error) bool {
|
||||
p, ok := errors.AsType[interface {
|
||||
error
|
||||
Pending() bool
|
||||
}](err)
|
||||
return ok && p.Pending()
|
||||
}
|
||||
|
||||
func retryable(err error) bool {
|
||||
r, ok := errors.AsType[interface {
|
||||
error
|
||||
Retryable() bool
|
||||
}](err)
|
||||
return ok && r.Retryable()
|
||||
}
|
||||
|
||||
// readFwCfg returns one fw_cfg value: a single token with at most one trailing
|
||||
// newline. The value is never echoed in an error.
|
||||
func readFwCfg(dir, name string) (string, error) {
|
||||
path := filepath.Join(dir, name, "raw")
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", fmt.Errorf("fw_cfg %s not supplied: %w", name, fs.ErrNotExist)
|
||||
}
|
||||
return "", fmt.Errorf("fw_cfg %s unreadable", name)
|
||||
}
|
||||
if fi.Size() > maxFwCfg {
|
||||
return "", fmt.Errorf("fw_cfg %s is oversized", name)
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fw_cfg %s unreadable", name)
|
||||
}
|
||||
v := strings.TrimSuffix(string(b), "\n")
|
||||
if v == "" || strings.ContainsAny(v, " \t\r\n\v\f") {
|
||||
return "", fmt.Errorf("fw_cfg %s must be exactly one value", name)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// A volume serial is the volume ID with its dash removed: vol + 17 hex.
|
||||
func validSerial(s string) bool {
|
||||
if len(s) != 20 || !strings.HasPrefix(s, "vol") {
|
||||
return false
|
||||
}
|
||||
for _, r := range s[3:] {
|
||||
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// findDisk returns the block device name whose serial matches exactly once.
|
||||
func findDisk(cfg Config, serial string) (string, error) {
|
||||
entries, err := os.ReadDir(cfg.SysBlock)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list block devices: %w", err)
|
||||
}
|
||||
var found []string
|
||||
for _, e := range entries {
|
||||
s, err := readTrimmed(filepath.Join(cfg.SysBlock, e.Name(), "serial"))
|
||||
if err == nil && s == serial {
|
||||
found = append(found, e.Name())
|
||||
}
|
||||
}
|
||||
if len(found) != 1 {
|
||||
return "", fmt.Errorf("expected one disk with serial %s, found %d", serial, len(found))
|
||||
}
|
||||
if !cfg.IsBlockDevice(filepath.Join(cfg.DevDir, found[0])) {
|
||||
return "", fmt.Errorf("disk %s has no block device node", found[0])
|
||||
}
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
// mountedDevice returns the major:minor of the topmost ext4 mount of a whole
|
||||
// filesystem at dir, or "" when nothing is mounted there.
|
||||
func mountedDevice(mountInfo, dir string) (string, error) {
|
||||
f, err := os.Open(mountInfo)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read mount table: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
dir = filepath.Clean(dir)
|
||||
var majMin string
|
||||
found := false
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
sep := -1
|
||||
for i, fld := range fields {
|
||||
if fld == "-" {
|
||||
sep = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if sep < 6 || sep+1 >= len(fields) {
|
||||
continue
|
||||
}
|
||||
if unescapeMountPath(fields[4]) != dir {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
majMin = ""
|
||||
if fields[3] == "/" && fields[sep+1] == "ext4" {
|
||||
majMin = fields[2]
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return "", fmt.Errorf("read mount table: %w", err)
|
||||
}
|
||||
if found && majMin == "" {
|
||||
return "", fmt.Errorf("%s is mounted, but not as a whole ext4 filesystem", dir)
|
||||
}
|
||||
return majMin, nil
|
||||
}
|
||||
|
||||
// unescapeMountPath decodes the octal escapes mountinfo uses for space, tab,
|
||||
// newline and backslash.
|
||||
func unescapeMountPath(s string) string {
|
||||
r := strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func readTrimmed(path string) (string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
|
||||
// writePublicKey publishes the public key for later reporting; it is not secret.
|
||||
func writePublicKey(runDir, pub string) error {
|
||||
if err := os.MkdirAll(runDir, 0o750); err != nil {
|
||||
return fmt.Errorf("create %s: %w", runDir, err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(runDir, ".public-key-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("write public key: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.WriteString(pub + "\n"); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write public key: %w", err)
|
||||
}
|
||||
if err := tmp.Chmod(0o644); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write public key: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("write public key: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), filepath.Join(runDir, "public-key")); err != nil {
|
||||
return fmt.Errorf("write public key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
package workerboot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerkey"
|
||||
"github.com/nats-io/nkeys"
|
||||
)
|
||||
|
||||
const (
|
||||
testSerial = "vol0a1b2c3d4e5f60718"
|
||||
testMajMin = "253:16"
|
||||
testRegion = "ap-southeast-2"
|
||||
testGatewayURL = "https://10.15.8.1:9999"
|
||||
)
|
||||
|
||||
// guest is a fake guest: fw_cfg, sysfs, /dev and a mount table the fake disk
|
||||
// edits the way a real mount would.
|
||||
type guest struct {
|
||||
t *testing.T
|
||||
root string
|
||||
cfg Config
|
||||
disk *fakeDisk
|
||||
auth *authority
|
||||
logs []string
|
||||
}
|
||||
|
||||
// authority stands for the domain's first-store record behind the gateway:
|
||||
// it authorizes creation until a key is recorded, then only that key.
|
||||
type authority struct {
|
||||
recorded string
|
||||
claimErrs []error
|
||||
// recordErrs fail RecordPublicKey; landed ones record before failing,
|
||||
// as a reply lost after the write.
|
||||
recordErrs []recordErr
|
||||
claims int
|
||||
sent []string
|
||||
builds int
|
||||
buildErr error
|
||||
}
|
||||
|
||||
type recordErr struct {
|
||||
err error
|
||||
landed bool
|
||||
}
|
||||
|
||||
// transient is a refusal the boot may repeat, as a lost reply.
|
||||
type transient struct{}
|
||||
|
||||
func (transient) Error() string { return "connection reset" }
|
||||
func (transient) Retryable() bool { return true }
|
||||
|
||||
// notBoundError is the denial a started but not yet bound worker receives.
|
||||
type notBoundError struct{}
|
||||
|
||||
func (notBoundError) Error() string { return "AccessDenied" }
|
||||
func (notBoundError) Pending() bool { return true }
|
||||
|
||||
func (a *authority) Claim(context.Context) (bool, string, error) {
|
||||
a.claims++
|
||||
if len(a.claimErrs) > 0 {
|
||||
err := a.claimErrs[0]
|
||||
a.claimErrs = a.claimErrs[1:]
|
||||
return false, "", err
|
||||
}
|
||||
return a.recorded == "", a.recorded, nil
|
||||
}
|
||||
|
||||
func (a *authority) RecordPublicKey(_ context.Context, pub string) error {
|
||||
a.sent = append(a.sent, pub)
|
||||
if a.recorded != "" && a.recorded != pub {
|
||||
return errors.New("InvalidParameterValue")
|
||||
}
|
||||
if len(a.recordErrs) > 0 {
|
||||
e := a.recordErrs[0]
|
||||
a.recordErrs = a.recordErrs[1:]
|
||||
if e.landed {
|
||||
a.recorded = pub
|
||||
}
|
||||
return e.err
|
||||
}
|
||||
a.recorded = pub
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeDisk struct {
|
||||
g *guest
|
||||
checkErr error
|
||||
mountErr error
|
||||
noopMount bool
|
||||
checks int
|
||||
mounts int
|
||||
}
|
||||
|
||||
func (d *fakeDisk) Check(string) error {
|
||||
d.checks++
|
||||
return d.checkErr
|
||||
}
|
||||
|
||||
func (d *fakeDisk) Mount(dev, dir string) error {
|
||||
d.mounts++
|
||||
if d.mountErr != nil {
|
||||
return d.mountErr
|
||||
}
|
||||
if !d.noopMount {
|
||||
d.g.appendMount(testMajMin, "/", dir, "ext4", dev)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newGuest(t *testing.T) *guest {
|
||||
t.Helper()
|
||||
g := &guest{t: t, root: t.TempDir(), auth: &authority{}}
|
||||
g.disk = &fakeDisk{g: g}
|
||||
g.cfg = Config{
|
||||
FwCfgDir: filepath.Join(g.root, "fwcfg"),
|
||||
SysBlock: filepath.Join(g.root, "sys", "block"),
|
||||
DevDir: filepath.Join(g.root, "dev"),
|
||||
MountInfo: filepath.Join(g.root, "mountinfo"),
|
||||
DataDir: filepath.Join(g.root, "data"),
|
||||
RunDir: filepath.Join(g.root, "run"),
|
||||
Disk: g.disk,
|
||||
IsBlockDevice: func(p string) bool { _, err := os.Stat(p); return err == nil },
|
||||
NewClaimer: func(region, url string) (workerkey.Claimer, error) {
|
||||
g.auth.builds++
|
||||
if region != testRegion || url != testGatewayURL {
|
||||
t.Errorf("claimer built for %q %q", region, url)
|
||||
}
|
||||
if g.auth.buildErr != nil {
|
||||
return nil, g.auth.buildErr
|
||||
}
|
||||
return g.auth, nil
|
||||
},
|
||||
Attempts: 3,
|
||||
Log: func(msg string) { g.logs = append(g.logs, msg) },
|
||||
}
|
||||
for _, d := range []string{g.cfg.SysBlock, g.cfg.DevDir} {
|
||||
mustMkdir(t, d)
|
||||
}
|
||||
g.resetMounts()
|
||||
// An empty mountpoint, as the initramfs ships one.
|
||||
mustMkdir(t, g.cfg.DataDir)
|
||||
g.addDisk("vda", testSerial, testMajMin)
|
||||
return g
|
||||
}
|
||||
|
||||
func mustMkdir(t *testing.T, d string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
mustMkdir(t, filepath.Dir(path))
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *guest) addDisk(name, serial, majMin string) {
|
||||
mustWrite(g.t, filepath.Join(g.cfg.SysBlock, name, "serial"), serial+"\n")
|
||||
mustWrite(g.t, filepath.Join(g.cfg.SysBlock, name, "dev"), majMin+"\n")
|
||||
mustWrite(g.t, filepath.Join(g.cfg.DevDir, name), "")
|
||||
}
|
||||
|
||||
func (g *guest) fw(name, value string) {
|
||||
mustWrite(g.t, filepath.Join(g.cfg.FwCfgDir, name, "raw"), value)
|
||||
}
|
||||
|
||||
// contract supplies the whole fw_cfg contract.
|
||||
func (g *guest) contract() {
|
||||
g.fw("data-serial", testSerial+"\n")
|
||||
g.fw("region", testRegion+"\n")
|
||||
g.fw("gateway-url", testGatewayURL+"\n")
|
||||
}
|
||||
|
||||
func (g *guest) boot() (string, error) {
|
||||
g.t.Helper()
|
||||
return Boot(context.Background(), g.cfg)
|
||||
}
|
||||
|
||||
// assertSentOnlyPublicKeys checks nothing but valid public keys left the guest.
|
||||
func (g *guest) assertSentOnlyPublicKeys(seed string) {
|
||||
g.t.Helper()
|
||||
for _, v := range g.auth.sent {
|
||||
if !nkeys.IsValidPublicUserKey(v) || (seed != "" && strings.Contains(v, seed)) {
|
||||
g.t.Fatal("the guest sent something other than a public key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedKey stands for a worker key created when the store was first made and
|
||||
// recorded by the domain; it returns that key's public half.
|
||||
func (g *guest) seedKey() string {
|
||||
g.t.Helper()
|
||||
kp, err := workerkey.Store{Dir: g.keyDir()}.ForNewStore()
|
||||
if err != nil {
|
||||
g.t.Fatalf("seed worker key: %v", err)
|
||||
}
|
||||
defer kp.Wipe()
|
||||
pub, err := kp.PublicKey()
|
||||
if err != nil {
|
||||
g.t.Fatal(err)
|
||||
}
|
||||
g.auth.recorded = pub
|
||||
return pub
|
||||
}
|
||||
|
||||
func (g *guest) keyFile() string { return filepath.Join(g.keyDir(), "store-client.nk") }
|
||||
|
||||
// resetMounts is a reboot: only the initramfs root is mounted.
|
||||
func (g *guest) resetMounts() {
|
||||
mustWrite(g.t, g.cfg.MountInfo, "1 0 0:2 / / rw - rootfs rootfs rw\n")
|
||||
}
|
||||
|
||||
func (g *guest) appendMount(majMin, root, dir, fstype, source string) {
|
||||
f, err := os.OpenFile(g.cfg.MountInfo, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
g.t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := fmt.Fprintf(f, "30 1 %s %s %s rw,nodev - %s %s rw\n", majMin, root, strings.ReplaceAll(dir, " ", `\040`), fstype, source); err != nil {
|
||||
g.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *guest) keyDir() string { return filepath.Join(g.cfg.DataDir, keyDirName) }
|
||||
|
||||
func (g *guest) seed() string {
|
||||
b, err := os.ReadFile(filepath.Join(g.keyDir(), "store-client.nk"))
|
||||
if err != nil {
|
||||
g.t.Fatalf("read key: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (g *guest) assertNoKey() {
|
||||
g.t.Helper()
|
||||
if _, err := os.Lstat(g.keyDir()); !errors.Is(err, os.ErrNotExist) {
|
||||
g.t.Fatalf("a key directory exists although boot refused: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(g.cfg.RunDir, "public-key")); !errors.Is(err, os.ErrNotExist) {
|
||||
g.t.Fatal("a public key was published although boot refused")
|
||||
}
|
||||
}
|
||||
|
||||
func (g *guest) assertNoSeedLeak(err error, seed string) {
|
||||
g.t.Helper()
|
||||
if seed == "" {
|
||||
return
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), seed) {
|
||||
g.t.Fatal("an error exposes the seed")
|
||||
}
|
||||
for _, l := range g.logs {
|
||||
if strings.Contains(l, seed) {
|
||||
g.t.Fatal("a log line exposes the seed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// otherPub is a valid store key the worker does not hold.
|
||||
func otherPub(t *testing.T) string {
|
||||
t.Helper()
|
||||
pub, _ := userKey(t)
|
||||
return pub
|
||||
}
|
||||
|
||||
func userKey(t *testing.T) (pub, seed string) {
|
||||
t.Helper()
|
||||
kp, err := nkeys.CreateUser()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := kp.PublicKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := kp.Seed()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p, string(s)
|
||||
}
|
||||
|
||||
func TestBoot_FirstStoreCreatesKeyThenReusesItAcrossReboots(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
pub, err := g.boot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed := g.seed()
|
||||
if g.auth.recorded != pub || len(g.auth.sent) != 1 {
|
||||
t.Fatalf("recorded %q after %d sends, want %s once", g.auth.recorded, len(g.auth.sent), pub)
|
||||
}
|
||||
for boot := 2; boot <= 3; boot++ {
|
||||
g.resetMounts()
|
||||
got, err := g.boot()
|
||||
if err != nil || got != pub || g.seed() != seed {
|
||||
t.Fatalf("boot %d = %q, %v; the worker key must not change", boot, got, err)
|
||||
}
|
||||
}
|
||||
if len(g.auth.sent) != 1 {
|
||||
t.Fatalf("a recorded key was sent again: %d sends", len(g.auth.sent))
|
||||
}
|
||||
if g.disk.mounts != 3 {
|
||||
t.Fatalf("mounts = %d, want one per boot", g.disk.mounts)
|
||||
}
|
||||
published, err := os.ReadFile(filepath.Join(g.cfg.RunDir, "public-key"))
|
||||
if err != nil || strings.TrimSpace(string(published)) != pub {
|
||||
t.Fatalf("published key = %q, %v", published, err)
|
||||
}
|
||||
g.assertSentOnlyPublicKeys(seed)
|
||||
g.assertNoSeedLeak(nil, seed)
|
||||
}
|
||||
|
||||
// Once the domain holds a key, losing the guest's copy never yields a new
|
||||
// one, whatever a replayed first-start fw_cfg says.
|
||||
func TestBoot_KeyLostAfterRecordingIsNeverReplaced(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
g.seedKey()
|
||||
if err := os.Remove(g.keyFile()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.fw("store-status", "new\n")
|
||||
g.resetMounts()
|
||||
_, err := g.boot()
|
||||
if !errors.Is(err, workerkey.ErrKeyMissing) {
|
||||
t.Fatalf("err = %v, want ErrKeyMissing", err)
|
||||
}
|
||||
if _, err := os.Lstat(g.keyFile()); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("a replacement key was created for an existing store")
|
||||
}
|
||||
if len(g.auth.sent) != 0 || g.auth.claims != 1 {
|
||||
t.Fatalf("sends=%d claims=%d; a final refusal must not be retried or answered", len(g.auth.sent), g.auth.claims)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(g.cfg.RunDir, "public-key")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("a public key was published")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoot_RecordedKeyMismatchRefuses(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
g.seedKey()
|
||||
seed := g.seed()
|
||||
g.auth.recorded = otherPub(t)
|
||||
_, err := g.boot()
|
||||
if !errors.Is(err, workerkey.ErrKeyMismatch) {
|
||||
t.Fatalf("err = %v, want ErrKeyMismatch", err)
|
||||
}
|
||||
if g.seed() != seed || len(g.auth.sent) != 0 {
|
||||
t.Fatal("a mismatch changed or sent the worker key")
|
||||
}
|
||||
g.assertNoSeedLeak(err, seed)
|
||||
}
|
||||
|
||||
// A reply lost after the key was recorded is retried, and the retry is
|
||||
// answered with that same key: one key, recorded once.
|
||||
func TestBoot_LostRecordReplyKeepsTheSameKey(t *testing.T) {
|
||||
for _, landed := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("landed=%v", landed), func(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
g.auth.recordErrs = []recordErr{{err: transient{}, landed: landed}}
|
||||
pub, err := g.boot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.auth.recorded != pub || g.auth.claims != 2 {
|
||||
t.Fatalf("recorded=%q claims=%d", g.auth.recorded, g.auth.claims)
|
||||
}
|
||||
for _, sent := range g.auth.sent {
|
||||
if sent != pub {
|
||||
t.Fatal("a second key was sent")
|
||||
}
|
||||
}
|
||||
g.assertSentOnlyPublicKeys(g.seed())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoot_RetriesOnlyRetryableRefusals(t *testing.T) {
|
||||
t.Run("transient then decided", func(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
g.auth.claimErrs = []error{transient{}, transient{}}
|
||||
if _, err := g.boot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.auth.claims != 3 {
|
||||
t.Fatalf("claims = %d, want 3", g.auth.claims)
|
||||
}
|
||||
})
|
||||
t.Run("exhausted", func(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
g.auth.claimErrs = []error{transient{}, transient{}, transient{}, transient{}}
|
||||
if _, err := g.boot(); err == nil {
|
||||
t.Fatal("boot passed without a decision")
|
||||
}
|
||||
if g.auth.claims != 3 {
|
||||
t.Fatalf("claims = %d, want the 3 attempts", g.auth.claims)
|
||||
}
|
||||
g.assertNoKey()
|
||||
})
|
||||
t.Run("final", func(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
denied := errors.New("AccessDenied")
|
||||
g.auth.claimErrs = []error{denied}
|
||||
if _, err := g.boot(); !errors.Is(err, denied) {
|
||||
t.Fatalf("err = %v, want the denial", err)
|
||||
}
|
||||
if g.auth.claims != 1 {
|
||||
t.Fatalf("claims = %d; a denial must not be retried", g.auth.claims)
|
||||
}
|
||||
g.assertNoKey()
|
||||
})
|
||||
t.Run("pending then authorized", func(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
g.auth.claimErrs = []error{notBoundError{}, notBoundError{}}
|
||||
if _, err := g.boot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.auth.claims != 3 {
|
||||
t.Fatalf("claims = %d, want 3", g.auth.claims)
|
||||
}
|
||||
})
|
||||
t.Run("pending never authorized", func(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
g.auth.claimErrs = []error{notBoundError{}, notBoundError{}, notBoundError{}, notBoundError{}}
|
||||
if _, err := g.boot(); !errors.Is(err, notBoundError{}) {
|
||||
t.Fatalf("err = %v, want the denial", err)
|
||||
}
|
||||
if g.auth.claims != 3 {
|
||||
t.Fatalf("claims = %d, want the 3 attempts", g.auth.claims)
|
||||
}
|
||||
g.assertNoKey()
|
||||
})
|
||||
t.Run("cancelled while waiting", func(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
g.cfg.Wait = time.Hour
|
||||
g.auth.claimErrs = []error{transient{}}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
g.cfg.Log = func(string) { cancel() }
|
||||
if _, err := Boot(ctx, g.cfg); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled", err)
|
||||
}
|
||||
g.assertNoKey()
|
||||
})
|
||||
}
|
||||
|
||||
// The gateway is asked only once the worker disk is proven mounted, so a
|
||||
// disk refusal never reaches it and never creates a key.
|
||||
func TestBoot_DiskRefusalsNeverAskTheGateway(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(g *guest)
|
||||
want string
|
||||
}{
|
||||
{"wrong serial", func(g *guest) { g.fw("data-serial", "vol0ffffffffffffffff") }, "found 0"},
|
||||
{"no disk", func(g *guest) {
|
||||
if err := os.RemoveAll(filepath.Join(g.cfg.SysBlock, "vda")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, "found 0"},
|
||||
{"ambiguous", func(g *guest) { g.addDisk("vdb", testSerial, "253:32") }, "found 2"},
|
||||
{"no device node", func(g *guest) {
|
||||
if err := os.Remove(filepath.Join(g.cfg.DevDir, "vda")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, "no block device node"},
|
||||
{"check fails", func(g *guest) { g.disk.checkErr = errors.New("e2fsck refused") }, "e2fsck refused"},
|
||||
{"mount fails", func(g *guest) { g.disk.mountErr = errors.New("invalid argument") }, "invalid argument"},
|
||||
{"empty mountpoint", func(g *guest) { g.disk.noopMount = true }, "is not mounted at"},
|
||||
{"foreign device", func(g *guest) { g.appendMount("253:48", "/", g.cfg.DataDir, "ext4", "x") }, "not the worker disk"},
|
||||
{"bind of a subdirectory", func(g *guest) { g.appendMount(testMajMin, "/sub", g.cfg.DataDir, "ext4", "x") }, "not as a whole ext4 filesystem"},
|
||||
{"tmpfs", func(g *guest) { g.appendMount("0:40", "/", g.cfg.DataDir, "tmpfs", "x") }, "not as a whole ext4 filesystem"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
tc.setup(g)
|
||||
_, err := g.boot()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want %q", err, tc.want)
|
||||
}
|
||||
if g.auth.builds != 0 || g.auth.claims != 0 {
|
||||
t.Fatal("the gateway was asked before the disk was proven")
|
||||
}
|
||||
g.assertNoKey()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoot_AlreadyMountedWorkerDiskIsReused(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
g.seedKey()
|
||||
g.appendMount(testMajMin, "/", g.cfg.DataDir, "ext4", "/dev/vda")
|
||||
if _, err := g.boot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.disk.checks != 0 || g.disk.mounts != 0 {
|
||||
t.Fatal("a mounted worker disk was checked or mounted again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoot_ContractRefusalsTouchNothing(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(g *guest)
|
||||
want string
|
||||
}{
|
||||
{"no serial", func(g *guest) { mustRemove(t, filepath.Join(g.cfg.FwCfgDir, "data-serial")) }, "data-serial not supplied"},
|
||||
{"bad serial", func(g *guest) { g.fw("data-serial", "vol-0a1b2c3d4e5f6071") }, "not a volume serial"},
|
||||
{"no region", func(g *guest) { mustRemove(t, filepath.Join(g.cfg.FwCfgDir, "region")) }, "region not supplied"},
|
||||
{"empty region", func(g *guest) { g.fw("region", "") }, "exactly one value"},
|
||||
{"bad region", func(g *guest) { g.fw("region", "Sydney") }, "not a Region name"},
|
||||
{"no gateway", func(g *guest) { mustRemove(t, filepath.Join(g.cfg.FwCfgDir, "gateway-url")) }, "gateway-url not supplied"},
|
||||
{"plain http gateway", func(g *guest) { g.fw("gateway-url", "http://10.15.8.1:9999") }, "not an https URL"},
|
||||
{"no claimer", func(g *guest) { g.cfg.NewClaimer = nil }, "no gateway client"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
tc.setup(g)
|
||||
_, err := g.boot()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want %q", err, tc.want)
|
||||
}
|
||||
if g.auth.claims != 0 {
|
||||
t.Fatal("a refused contract asked the gateway")
|
||||
}
|
||||
g.assertNoKey()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoot_ClaimerBuildFailureCreatesNoKey(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
g.contract()
|
||||
g.auth.buildErr = errors.New("no IMDS credentials")
|
||||
if _, err := g.boot(); err == nil || !strings.Contains(err.Error(), "no IMDS credentials") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
g.assertNoKey()
|
||||
}
|
||||
|
||||
func mustRemove(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountedDevice_UsesTopmostMountAndUnescapes(t *testing.T) {
|
||||
g := newGuest(t)
|
||||
dir := filepath.Join(g.root, "data dir")
|
||||
g.appendMount("253:48", "/", dir, "ext4", "/dev/vdb")
|
||||
g.appendMount(testMajMin, "/", dir, "ext4", "/dev/vda")
|
||||
got, err := mountedDevice(g.cfg.MountInfo, dir)
|
||||
if err != nil || got != testMajMin {
|
||||
t.Fatalf("mountedDevice = %q, %v; want %s", got, err, testMajMin)
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package workerkey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/nats-io/nkeys"
|
||||
)
|
||||
|
||||
// Claimer is the SQS domain's first-store decision for this worker identity.
|
||||
type Claimer interface {
|
||||
// Claim returns createKey, or the one public key the store authorizes.
|
||||
Claim(ctx context.Context) (createKey bool, publicKey string, err error)
|
||||
RecordPublicKey(ctx context.Context, publicKey string) error
|
||||
}
|
||||
|
||||
// Acquire returns the worker's store key under a current domain decision. A
|
||||
// new key is usable only once its public key is recorded; a recorded key is
|
||||
// never replaced, so a missing or different key on disk fails closed.
|
||||
func (s Store) Acquire(ctx context.Context, c Claimer) (nkeys.KeyPair, error) {
|
||||
create, authorized, err := c.Claim(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !create {
|
||||
return s.ForExistingStore(authorized)
|
||||
}
|
||||
if authorized != "" {
|
||||
return nil, errors.New("first-store decision is contradictory")
|
||||
}
|
||||
// After a crash before recording, this loads the key already on disk.
|
||||
kp, err := s.ForNewStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub, err := kp.PublicKey()
|
||||
if err == nil {
|
||||
err = c.RecordPublicKey(ctx, pub)
|
||||
}
|
||||
if err != nil {
|
||||
kp.Wipe()
|
||||
return nil, err
|
||||
}
|
||||
return kp, nil
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
package workerkey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/sqs/firststore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/nats-io/nkeys"
|
||||
)
|
||||
|
||||
const region = "ap-southeast-2"
|
||||
|
||||
// domainClaimer drives the real first-store state machine for one worker
|
||||
// identity. A failing record either drops the write or applies it and loses
|
||||
// the reply.
|
||||
type domainClaimer struct {
|
||||
s firststore.Store
|
||||
id firststore.Identity
|
||||
failRecord bool
|
||||
applied bool
|
||||
}
|
||||
|
||||
func (c *domainClaimer) Claim(ctx context.Context) (bool, string, error) {
|
||||
d, err := c.s.Claim(ctx, region, c.id)
|
||||
return d.CreateKey, d.PublicKey, err
|
||||
}
|
||||
|
||||
func (c *domainClaimer) RecordPublicKey(ctx context.Context, pub string) error {
|
||||
if c.failRecord {
|
||||
c.failRecord = false
|
||||
if c.applied {
|
||||
c.s.RecordPublicKey(ctx, region, c.id, pub)
|
||||
}
|
||||
return errors.New("nats: timeout")
|
||||
}
|
||||
return c.s.RecordPublicKey(ctx, region, c.id, pub)
|
||||
}
|
||||
|
||||
func domain(t *testing.T) (firststore.Store, jetstream.KeyValue) {
|
||||
t.Helper()
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
kv, err := js.CreateKeyValue(context.Background(), firststore.BucketConfig(1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return firststore.Store{JS: js, KV: kv}, kv
|
||||
}
|
||||
|
||||
func worker(gen uint64, n byte) firststore.Identity {
|
||||
return firststore.Identity{InstanceID: "i-0000000000000000" + string('0'+n), ClaimID: "claim-" + string('a'+n) + "-0001", Generation: gen}
|
||||
}
|
||||
|
||||
func workerDisk(t *testing.T) Store {
|
||||
return Store{Dir: filepath.Join(t.TempDir(), "sqs-worker-key")}
|
||||
}
|
||||
|
||||
func keyPath(s Store) string { return filepath.Join(s.Dir, keyFile) }
|
||||
|
||||
func pubOf(t *testing.T, kp nkeys.KeyPair) string {
|
||||
t.Helper()
|
||||
p, err := kp.PublicKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func recorded(t *testing.T, s firststore.Store, id firststore.Identity) string {
|
||||
t.Helper()
|
||||
d, err := s.Claim(context.Background(), region, id)
|
||||
if err != nil || d.CreateKey {
|
||||
t.Fatalf("domain decision = %+v, %v; want a recorded key", d, err)
|
||||
}
|
||||
return d.PublicKey
|
||||
}
|
||||
|
||||
func TestAcquire_CrashBeforeRecordingReusesTheKeyOnDisk(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fs, _ := domain(t)
|
||||
disk := workerDisk(t)
|
||||
c := &domainClaimer{s: fs, id: worker(1, 1), failRecord: true}
|
||||
if _, err := disk.Acquire(ctx, c); err == nil {
|
||||
t.Fatal("a key was returned before its public key was recorded")
|
||||
}
|
||||
before, err := os.ReadFile(keyPath(disk))
|
||||
if err != nil {
|
||||
t.Fatalf("key not synced to disk before recording: %v", err)
|
||||
}
|
||||
kp, err := disk.Acquire(ctx, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer kp.Wipe()
|
||||
after, _ := os.ReadFile(keyPath(disk))
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("the retry replaced the key")
|
||||
}
|
||||
if recorded(t, fs, c.id) != pubOf(t, kp) {
|
||||
t.Fatal("the recorded key is not the key on disk")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquire_LostRecordReplyIsRecoveredOnReboot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fs, _ := domain(t)
|
||||
disk := workerDisk(t)
|
||||
c := &domainClaimer{s: fs, id: worker(1, 1), failRecord: true, applied: true}
|
||||
if _, err := disk.Acquire(ctx, c); err == nil {
|
||||
t.Fatal("expected the lost reply to surface")
|
||||
}
|
||||
kp, err := disk.Acquire(ctx, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer kp.Wipe()
|
||||
if recorded(t, fs, c.id) != pubOf(t, kp) {
|
||||
t.Fatal("reboot did not use the recorded key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquire_AfterRecordingKeyLossOrMismatchFailsClosed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
setup := func(t *testing.T, launch bool) (firststore.Store, Store, *domainClaimer, string) {
|
||||
fs, _ := domain(t)
|
||||
disk := workerDisk(t)
|
||||
c := &domainClaimer{s: fs, id: worker(1, 1)}
|
||||
kp, err := disk.Acquire(ctx, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub := pubOf(t, kp)
|
||||
kp.Wipe()
|
||||
if launch {
|
||||
if err := fs.RecordLaunchIntent(ctx, region, c.id, pub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return fs, disk, c, pub
|
||||
}
|
||||
for _, launch := range []bool{false, true} {
|
||||
name := map[bool]string{false: "key recorded", true: "launch intent"}[launch]
|
||||
t.Run(name+"/key lost then reboot", func(t *testing.T) {
|
||||
fs, disk, c, pub := setup(t, launch)
|
||||
os.Remove(keyPath(disk))
|
||||
// A relaunched worker generation gets the same answer.
|
||||
for _, id := range []firststore.Identity{c.id, worker(2, 2)} {
|
||||
c.id = id
|
||||
if _, err := disk.Acquire(ctx, c); !errors.Is(err, ErrKeyMissing) {
|
||||
t.Fatalf("acquire = %v, want ErrKeyMissing", err)
|
||||
}
|
||||
if _, err := os.Lstat(keyPath(disk)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("a replacement key was created")
|
||||
}
|
||||
}
|
||||
if recorded(t, fs, c.id) != pub {
|
||||
t.Fatal("the recorded key changed")
|
||||
}
|
||||
})
|
||||
t.Run(name+"/different key on disk", func(t *testing.T) {
|
||||
_, _, c, _ := setup(t, launch)
|
||||
other := workerDisk(t)
|
||||
kp, err := other.ForNewStore()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kp.Wipe()
|
||||
before, _ := os.ReadFile(keyPath(other))
|
||||
if _, err := other.Acquire(ctx, c); !errors.Is(err, ErrKeyMismatch) {
|
||||
t.Fatalf("acquire = %v, want ErrKeyMismatch", err)
|
||||
}
|
||||
after, _ := os.ReadFile(keyPath(other))
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("the mismatched key was replaced")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquire_RefusedClaimCreatesNoKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cases := map[string]func(t *testing.T, fs firststore.Store, kv jetstream.KeyValue) firststore.Identity{
|
||||
"rival holds the claim": func(t *testing.T, fs firststore.Store, _ jetstream.KeyValue) firststore.Identity {
|
||||
fs.Claim(ctx, region, worker(1, 1))
|
||||
return worker(1, 2)
|
||||
},
|
||||
"stale replayed generation": func(t *testing.T, fs firststore.Store, _ jetstream.KeyValue) firststore.Identity {
|
||||
fs.Claim(ctx, region, worker(1, 1))
|
||||
fs.Claim(ctx, region, worker(2, 2))
|
||||
return worker(1, 1)
|
||||
},
|
||||
"unreadable record": func(t *testing.T, _ firststore.Store, kv jetstream.KeyValue) firststore.Identity {
|
||||
kv.Put(ctx, "region."+region, []byte("{"))
|
||||
return worker(1, 1)
|
||||
},
|
||||
}
|
||||
for name, setup := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fs, kv := domain(t)
|
||||
id := setup(t, fs, kv)
|
||||
disk := workerDisk(t)
|
||||
if _, err := disk.Acquire(ctx, &domainClaimer{s: fs, id: id}); err == nil {
|
||||
t.Fatal("a refused claim returned a key")
|
||||
}
|
||||
if _, err := os.Lstat(keyPath(disk)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("a refused claim created a key")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type contradictoryClaimer struct{ pub string }
|
||||
|
||||
func (c contradictoryClaimer) Claim(context.Context) (bool, string, error) { return true, c.pub, nil }
|
||||
func (contradictoryClaimer) RecordPublicKey(context.Context, string) error {
|
||||
return errors.New("unexpected")
|
||||
}
|
||||
|
||||
func TestAcquire_ContradictoryDecisionCreatesNoKey(t *testing.T) {
|
||||
kp, _ := nkeys.CreateUser()
|
||||
disk := workerDisk(t)
|
||||
if _, err := disk.Acquire(context.Background(), contradictoryClaimer{pub: pubOf(t, kp)}); err == nil {
|
||||
t.Fatal("a contradictory decision returned a key")
|
||||
}
|
||||
if _, err := os.Lstat(keyPath(disk)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("a contradictory decision created a key")
|
||||
}
|
||||
}
|
||||
|
||||
// A replica that has applied the claim but not the key record must not lead a
|
||||
// worker whose key is lost to create a replacement.
|
||||
func TestAcquire_StaleClaimedReadAfterRecordingCreatesNoKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fs, kv := domain(t)
|
||||
disk := workerDisk(t)
|
||||
id := worker(1, 1)
|
||||
fs.Claim(ctx, region, id)
|
||||
claimed, err := kv.Get(ctx, "region."+region)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kp, err := disk.Acquire(ctx, &domainClaimer{s: fs, id: id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kp.Wipe()
|
||||
os.Remove(keyPath(disk))
|
||||
|
||||
_, _, replicaJS := testutil.StartTestJetStream(t)
|
||||
replica, err := replicaJS.CreateKeyValue(ctx, firststore.BucketConfig(1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replica.Put(ctx, "region."+region, claimed.Value())
|
||||
stale := firststore.Store{JS: replicaJS, KV: fs.KV}
|
||||
if _, err := disk.Acquire(ctx, &domainClaimer{s: stale, id: id}); err == nil {
|
||||
t.Fatal("a stale claimed read returned a key")
|
||||
}
|
||||
if _, err := os.Lstat(keyPath(disk)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("a stale claimed read created a replacement key")
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
// Package workerkey keeps the shared SQS worker's store-client NKey on the
|
||||
// worker's own persistent disk. The seed is generated and held only inside the
|
||||
// worker guest; the platform learns the public key alone.
|
||||
package workerkey
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/nats-io/nkeys"
|
||||
)
|
||||
|
||||
const (
|
||||
keyFile = "store-client.nk"
|
||||
tempPrefix = ".store-client-"
|
||||
tempSuffix = ".tmp"
|
||||
// A user seed is 58 bytes; anything much larger is not one.
|
||||
maxKeyFileSize = 256
|
||||
)
|
||||
|
||||
// Each refusal is fail-closed: the worker never replaces a key it cannot use.
|
||||
var (
|
||||
ErrKeyMissing = errors.New("worker store key is missing but the store already exists")
|
||||
ErrKeyMismatch = errors.New("worker store key does not match the store's authorized key")
|
||||
ErrUnsafe = errors.New("worker store key path is not owner-only")
|
||||
ErrCorrupt = errors.New("worker store key file does not hold a user NKey seed")
|
||||
)
|
||||
|
||||
// Store is the key directory on the worker disk. Workerkey cannot tell a
|
||||
// mounted disk from an empty mountpoint, so the caller must first verify the
|
||||
// expected data volume is mounted there.
|
||||
type Store struct {
|
||||
Dir string
|
||||
}
|
||||
|
||||
// ForNewStore returns the worker's store-client key for a store that the
|
||||
// caller has positively established does not exist yet, generating it on first
|
||||
// use. An unreadable or absent store record is not that proof.
|
||||
func (s Store) ForNewStore() (nkeys.KeyPair, error) {
|
||||
return s.ensure("")
|
||||
}
|
||||
|
||||
// ForExistingStore returns the worker's key only if it is already on disk and
|
||||
// is the one the store authorizes; it never generates a key.
|
||||
func (s Store) ForExistingStore(authorized string) (nkeys.KeyPair, error) {
|
||||
if !nkeys.IsValidPublicUserKey(authorized) {
|
||||
return nil, errors.New("authorized store key is not a user public NKey")
|
||||
}
|
||||
return s.ensure(authorized)
|
||||
}
|
||||
|
||||
func (s Store) ensure(expected string) (nkeys.KeyPair, error) {
|
||||
unlock, err := s.prepareDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unlock()
|
||||
kp, err := s.load()
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
if expected != "" {
|
||||
return nil, ErrKeyMissing
|
||||
}
|
||||
kp, err = s.create()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expected != "" {
|
||||
pub, err := kp.PublicKey()
|
||||
if err != nil {
|
||||
kp.Wipe()
|
||||
return nil, ErrCorrupt
|
||||
}
|
||||
if pub != expected {
|
||||
kp.Wipe()
|
||||
return nil, fmt.Errorf("%w: have %s, store authorizes %s", ErrKeyMismatch, pub, expected)
|
||||
}
|
||||
}
|
||||
// Re-synced on every return, so a key whose earlier sync failed is never
|
||||
// handed out before its name and directory are durable.
|
||||
if err := s.syncNames(); err != nil {
|
||||
kp.Wipe()
|
||||
return nil, err
|
||||
}
|
||||
return kp, nil
|
||||
}
|
||||
|
||||
func (s Store) path() string { return filepath.Join(s.Dir, keyFile) }
|
||||
|
||||
// prepareDir creates only the key directory, inside a parent that must already
|
||||
// exist, refuses one that is not owner-only, locks it and removes temporary
|
||||
// files a crash left behind.
|
||||
func (s Store) prepareDir() (unlock func(), err error) {
|
||||
if s.Dir == "" {
|
||||
return nil, errors.New("worker store key directory is not set")
|
||||
}
|
||||
parent, err := os.Stat(filepath.Dir(s.Dir))
|
||||
if err != nil || !parent.IsDir() {
|
||||
return nil, errors.New("worker key directory parent is missing")
|
||||
}
|
||||
if err := os.Mkdir(s.Dir, 0o700); err != nil && !errors.Is(err, fs.ErrExist) {
|
||||
return nil, fmt.Errorf("create worker key directory: %w", err)
|
||||
}
|
||||
fi, err := os.Lstat(s.Dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat worker key directory: %w", err)
|
||||
}
|
||||
if !fi.IsDir() || fi.Mode().Perm()&0o077 != 0 {
|
||||
return nil, ErrUnsafe
|
||||
}
|
||||
d, err := os.Open(s.Dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open worker key directory: %w", err)
|
||||
}
|
||||
if err := syscall.Flock(int(d.Fd()), syscall.LOCK_EX); err != nil {
|
||||
d.Close()
|
||||
return nil, fmt.Errorf("lock worker key directory: %w", err)
|
||||
}
|
||||
unlock = func() { d.Close() }
|
||||
stale, err := filepath.Glob(filepath.Join(s.Dir, tempPrefix+"*"+tempSuffix))
|
||||
if err != nil {
|
||||
unlock()
|
||||
return nil, fmt.Errorf("list stale worker key files: %w", err)
|
||||
}
|
||||
for _, f := range stale {
|
||||
if err := os.Remove(f); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
unlock()
|
||||
return nil, fmt.Errorf("remove stale worker key file: %w", err)
|
||||
}
|
||||
}
|
||||
return unlock, nil
|
||||
}
|
||||
|
||||
// load reads an existing key. Any file that is present but unusable refuses
|
||||
// rather than being replaced.
|
||||
func (s Store) load() (nkeys.KeyPair, error) {
|
||||
fi, err := os.Lstat(s.path())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !fi.Mode().IsRegular() || fi.Mode().Perm()&0o077 != 0 {
|
||||
return nil, ErrUnsafe
|
||||
}
|
||||
if fi.Size() > maxKeyFileSize {
|
||||
return nil, ErrCorrupt
|
||||
}
|
||||
seed, err := os.ReadFile(s.path())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read worker store key: %w", err)
|
||||
}
|
||||
kp, err := nkeys.FromSeed(bytes.TrimSpace(seed))
|
||||
if err != nil {
|
||||
return nil, ErrCorrupt
|
||||
}
|
||||
pub, err := kp.PublicKey()
|
||||
if err != nil || !nkeys.IsValidPublicUserKey(pub) {
|
||||
kp.Wipe()
|
||||
return nil, ErrCorrupt
|
||||
}
|
||||
return kp, nil
|
||||
}
|
||||
|
||||
// create writes a new seed to a synced temporary file and links it into place
|
||||
// without replacing anything; the caller syncs the directory names.
|
||||
func (s Store) create() (nkeys.KeyPair, error) {
|
||||
kp, err := nkeys.CreateUser()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate worker store key: %w", err)
|
||||
}
|
||||
seed, err := kp.Seed()
|
||||
if err != nil {
|
||||
kp.Wipe()
|
||||
return nil, fmt.Errorf("encode worker store key: %w", err)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(s.Dir, tempPrefix+"*"+tempSuffix)
|
||||
if err != nil {
|
||||
kp.Wipe()
|
||||
return nil, fmt.Errorf("create worker store key file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err := writeSynced(tmp, seed); err != nil {
|
||||
kp.Wipe()
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Link(tmpName, s.path()); err != nil {
|
||||
kp.Wipe()
|
||||
if errors.Is(err, fs.ErrExist) {
|
||||
return s.load()
|
||||
}
|
||||
return nil, fmt.Errorf("install worker store key file: %w", err)
|
||||
}
|
||||
return kp, nil
|
||||
}
|
||||
|
||||
// syncNames makes the key's directory entry and the key directory's own entry
|
||||
// in its parent durable.
|
||||
func (s Store) syncNames() error {
|
||||
if err := syncDirFn(s.Dir); err != nil {
|
||||
return err
|
||||
}
|
||||
return syncDirFn(filepath.Dir(s.Dir))
|
||||
}
|
||||
|
||||
func writeSynced(f *os.File, data []byte) error {
|
||||
if err := f.Chmod(0o600); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("restrict worker store key file: %w", err)
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("write worker store key file: %w", err)
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("sync worker store key file: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("close worker store key file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncDirFn is replaced in tests to fail a directory sync deterministically.
|
||||
var syncDirFn = syncDir
|
||||
|
||||
func syncDir(dir string) error {
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open worker key directory: %w", err)
|
||||
}
|
||||
defer d.Close()
|
||||
if err := d.Sync(); err != nil {
|
||||
return fmt.Errorf("sync worker key directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
package workerkey
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nkeys"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T) Store {
|
||||
t.Helper()
|
||||
return Store{Dir: filepath.Join(t.TempDir(), "keys")}
|
||||
}
|
||||
|
||||
func publicKey(t *testing.T, kp nkeys.KeyPair) string {
|
||||
t.Helper()
|
||||
pub, err := kp.PublicKey()
|
||||
if err != nil {
|
||||
t.Fatalf("public key: %v", err)
|
||||
}
|
||||
return pub
|
||||
}
|
||||
|
||||
func otherUserKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
kp, err := nkeys.CreateUser()
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
return publicKey(t, kp)
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func assertNoSeed(t *testing.T, err error, seed []byte) {
|
||||
t.Helper()
|
||||
if err != nil && strings.Contains(err.Error(), string(seed)) {
|
||||
t.Fatalf("error exposes the seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsure_FirstBootPersistsAndRestartReusesKey(t *testing.T) {
|
||||
s := newStore(t)
|
||||
kp, err := s.ForNewStore()
|
||||
if err != nil {
|
||||
t.Fatalf("first boot: %v", err)
|
||||
}
|
||||
pub := publicKey(t, kp)
|
||||
|
||||
dirInfo, err := os.Stat(s.Dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := dirInfo.Mode().Perm(); got != 0o700 {
|
||||
t.Fatalf("directory mode = %o, want 700", got)
|
||||
}
|
||||
fileInfo, err := os.Stat(s.path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fileInfo.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("key file mode = %o, want 600", got)
|
||||
}
|
||||
onDisk, err := nkeys.FromSeed(readFile(t, s.path()))
|
||||
if err != nil {
|
||||
t.Fatalf("persisted file is not a seed: %v", err)
|
||||
}
|
||||
if publicKey(t, onDisk) != pub {
|
||||
t.Fatal("persisted seed does not match the returned key")
|
||||
}
|
||||
|
||||
// A restart is a fresh Store over the same disk; neither path may
|
||||
// generate a replacement.
|
||||
again, err := Store{Dir: s.Dir}.ForExistingStore(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("restart for the existing store: %v", err)
|
||||
}
|
||||
if publicKey(t, again) != pub {
|
||||
t.Fatal("restart for the existing store changed the key")
|
||||
}
|
||||
again, err = Store{Dir: s.Dir}.ForNewStore()
|
||||
if err != nil {
|
||||
t.Fatalf("repeated first start: %v", err)
|
||||
}
|
||||
if publicKey(t, again) != pub {
|
||||
t.Fatal("repeated first start replaced the key")
|
||||
}
|
||||
entries, err := os.ReadDir(s.Dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("key directory holds %d entries, want only the key", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsure_ExistingStoreWithMissingKeyFailsClosed(t *testing.T) {
|
||||
s := newStore(t)
|
||||
_, err := s.ForExistingStore(otherUserKey(t))
|
||||
if !errors.Is(err, ErrKeyMissing) {
|
||||
t.Fatalf("err = %v, want ErrKeyMissing", err)
|
||||
}
|
||||
if _, err := os.Lstat(s.path()); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("a key was generated for an existing store: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsure_MismatchedKeyFailsClosedAndKeepsFile(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.ForNewStore(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := readFile(t, s.path())
|
||||
|
||||
_, err := Store{Dir: s.Dir}.ForExistingStore(otherUserKey(t))
|
||||
if !errors.Is(err, ErrKeyMismatch) {
|
||||
t.Fatalf("err = %v, want ErrKeyMismatch", err)
|
||||
}
|
||||
assertNoSeed(t, err, before)
|
||||
if string(readFile(t, s.path())) != string(before) {
|
||||
t.Fatal("mismatch replaced the key file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsure_UnusableKeyFileIsNeverReplaced(t *testing.T) {
|
||||
seedKP, err := nkeys.CreateUser()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userSeed, err := seedKP.Seed()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accountKP, err := nkeys.CreateAccount()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accountSeed, err := accountKP.Seed()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
content []byte
|
||||
mode os.FileMode
|
||||
want error
|
||||
}{
|
||||
{"empty", nil, 0o600, ErrCorrupt},
|
||||
{"garbage", []byte("not a seed"), 0o600, ErrCorrupt},
|
||||
{"account seed", accountSeed, 0o600, ErrCorrupt},
|
||||
{"oversized", make([]byte, maxKeyFileSize+1), 0o600, ErrCorrupt},
|
||||
{"group readable", userSeed, 0o640, ErrUnsafe},
|
||||
{"world readable", userSeed, 0o604, ErrUnsafe},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(s.path(), tc.content, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(s.path(), tc.mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := s.ForNewStore()
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("err = %v, want %v", err, tc.want)
|
||||
}
|
||||
assertNoSeed(t, err, userSeed)
|
||||
assertNoSeed(t, err, accountSeed)
|
||||
if string(readFile(t, s.path())) != string(tc.content) {
|
||||
t.Fatal("an unusable key file was replaced")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsure_RefusesUnsafeDirectoryAndSymlink(t *testing.T) {
|
||||
t.Run("open directory", func(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if err := os.MkdirAll(s.Dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(s.Dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.ForNewStore(); !errors.Is(err, ErrUnsafe) {
|
||||
t.Fatalf("err = %v, want ErrUnsafe", err)
|
||||
}
|
||||
if _, err := os.Lstat(s.path()); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("a key was written into an open directory")
|
||||
}
|
||||
})
|
||||
t.Run("symlinked key", func(t *testing.T) {
|
||||
s := newStore(t)
|
||||
target := newStore(t)
|
||||
if _, err := target.ForNewStore(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target.path(), s.path()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.ForNewStore(); !errors.Is(err, ErrUnsafe) {
|
||||
t.Fatalf("err = %v, want ErrUnsafe", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnsure_CrashLeftoverTempIsNotAKey(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := filepath.Join(s.Dir, tempPrefix+"123"+tempSuffix)
|
||||
if err := os.WriteFile(stale, []byte("partial"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A crash before the link means no store was authorized for it.
|
||||
if _, err := s.ForExistingStore(otherUserKey(t)); !errors.Is(err, ErrKeyMissing) {
|
||||
t.Fatalf("err = %v, want ErrKeyMissing", err)
|
||||
}
|
||||
if _, err := os.Lstat(stale); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("stale temporary key file was kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsure_ConcurrentFirstBootAgreesOnOneKey(t *testing.T) {
|
||||
const rounds, n = 20, 16
|
||||
for range rounds {
|
||||
s := newStore(t)
|
||||
pubs := make([]string, n)
|
||||
errs := make([]error, n)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i := range n {
|
||||
wg.Go(func() {
|
||||
<-start
|
||||
kp, err := Store{Dir: s.Dir}.ForNewStore()
|
||||
if err != nil {
|
||||
errs[i] = err
|
||||
return
|
||||
}
|
||||
pubs[i], errs[i] = kp.PublicKey()
|
||||
})
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
onDisk, err := nkeys.FromSeed(readFile(t, s.path()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := publicKey(t, onDisk)
|
||||
for i := range n {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("call %d: %v", i, errs[i])
|
||||
}
|
||||
if pubs[i] != want {
|
||||
t.Fatalf("call %d returned a key that is not the persisted one", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForExistingStore_RejectsInvalidAuthorizedKey(t *testing.T) {
|
||||
kp, err := nkeys.CreateUser()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed, err := kp.Seed()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An empty key is what an unreadable store record would yield; it must not
|
||||
// fall through to first-start generation.
|
||||
for _, authorized := range []string{"", string(seed)} {
|
||||
s := newStore(t)
|
||||
_, err := s.ForExistingStore(authorized)
|
||||
if err == nil {
|
||||
t.Fatalf("authorized key %q was accepted", authorized[:min(len(authorized), 2)])
|
||||
}
|
||||
assertNoSeed(t, err, seed)
|
||||
if _, err := os.Lstat(s.Dir); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("an invalid authorized key still touched the disk")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForNewStore_RefusesMissingParent(t *testing.T) {
|
||||
s := Store{Dir: filepath.Join(t.TempDir(), "missing-parent", "keys")}
|
||||
if _, err := s.ForNewStore(); err == nil {
|
||||
t.Fatal("a key directory was created without its parent")
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Dir(s.Dir)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("the missing parent was created")
|
||||
}
|
||||
}
|
||||
|
||||
// hookSyncs records every directory sync and fails call number failAt
|
||||
// (counted from one); zero fails none.
|
||||
func hookSyncs(t *testing.T, failAt int) *[]string {
|
||||
t.Helper()
|
||||
var synced []string
|
||||
orig := syncDirFn
|
||||
t.Cleanup(func() { syncDirFn = orig })
|
||||
syncDirFn = func(dir string) error {
|
||||
synced = append(synced, dir)
|
||||
if len(synced) == failAt {
|
||||
return errors.New("injected directory sync failure")
|
||||
}
|
||||
return orig(dir)
|
||||
}
|
||||
return &synced
|
||||
}
|
||||
|
||||
func TestEnsure_RetryAfterFailedDirectorySyncKeepsKeyAndSyncs(t *testing.T) {
|
||||
retries := map[string]func(s Store, pub string) (nkeys.KeyPair, error){
|
||||
"first start": func(s Store, _ string) (nkeys.KeyPair, error) { return s.ForNewStore() },
|
||||
"existing store": func(s Store, pub string) (nkeys.KeyPair, error) {
|
||||
return s.ForExistingStore(pub)
|
||||
},
|
||||
}
|
||||
for name, retry := range retries {
|
||||
for failAt := 1; failAt <= 2; failAt++ {
|
||||
t.Run(fmt.Sprintf("%s after sync %d failed", name, failAt), func(t *testing.T) {
|
||||
s := newStore(t)
|
||||
hookSyncs(t, failAt)
|
||||
if _, err := s.ForNewStore(); err == nil {
|
||||
t.Fatal("a key was returned although a directory sync failed")
|
||||
}
|
||||
first := readFile(t, s.path())
|
||||
onDisk, err := nkeys.FromSeed(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub := publicKey(t, onDisk)
|
||||
|
||||
synced := hookSyncs(t, 0)
|
||||
kp, err := retry(Store{Dir: s.Dir}, pub)
|
||||
if err != nil {
|
||||
t.Fatalf("retry: %v", err)
|
||||
}
|
||||
if string(readFile(t, s.path())) != string(first) {
|
||||
t.Fatal("retry replaced the key")
|
||||
}
|
||||
if publicKey(t, kp) != pub {
|
||||
t.Fatal("retry returned a key other than the persisted one")
|
||||
}
|
||||
want := []string{s.Dir, filepath.Dir(s.Dir)}
|
||||
if len(*synced) != 2 || (*synced)[0] != want[0] || (*synced)[1] != want[1] {
|
||||
t.Fatalf("retry synced %v, want %v", *synced, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Another holder of the directory lock, such as a second worker process on
|
||||
// the same disk, must finish before this one reads, cleans or creates a key.
|
||||
func TestEnsure_WaitsForDirectoryLock(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
holder, err := os.Open(s.Dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer holder.Close()
|
||||
if err := syscall.Flock(int(holder.Fd()), syscall.LOCK_EX); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := Store{Dir: s.Dir}.ForNewStore()
|
||||
done <- err
|
||||
}()
|
||||
deadline := time.Now().Add(300 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("Ensure returned while another holder had the lock: %v", err)
|
||||
default:
|
||||
}
|
||||
if _, err := os.Lstat(s.path()); err == nil {
|
||||
t.Fatal("a key was created while another holder had the lock")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
if err := syscall.Flock(int(holder.Fd()), syscall.LOCK_UN); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Ensure after unlock: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Ensure did not proceed after the lock was released")
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,6 @@ type Config struct {
|
||||
ACM ACMConfig `json:"ACM" mapstructure:"acm"`
|
||||
Bedrock BedrockConfig `json:"Bedrock" mapstructure:"bedrock"`
|
||||
OchreVector OchreVectorConfig `json:"OchreVector" mapstructure:"ochre_vector"`
|
||||
SQS SQSConfig `json:"SQS" mapstructure:"sqs"`
|
||||
|
||||
BaseDir string `json:"BaseDir" mapstructure:"base_dir"`
|
||||
WalDir string `json:"WalDir" mapstructure:"wal_dir"`
|
||||
@@ -448,19 +447,6 @@ type BedrockConfig struct {
|
||||
// (10.248.0.0/14) and EKS control-plane (10.252.0.0/14) supernets.
|
||||
const BedrockDefaultSystemVPCSupernet = "10.244.0.0/14"
|
||||
|
||||
// SQSConfig is a node's [sqs] section. The SQS worker and store VMs share one
|
||||
// private system VPC per region, with no internet or NAT gateway.
|
||||
type SQSConfig struct {
|
||||
// The IPv4 /14 the system VPC's /22 is carved from. It must not overlap the
|
||||
// RDS, Bedrock or EKS control-plane supernets or any customer VPC CIDR.
|
||||
SystemVPCSupernet string `json:"SystemVPCSupernet" mapstructure:"system_vpc_supernet"`
|
||||
}
|
||||
|
||||
// SQSDefaultSystemVPCSupernet anchors the SQS system VPC address space at
|
||||
// 10.240.0.0/14, immediately below and disjoint from the Bedrock, RDS and EKS
|
||||
// control-plane supernets.
|
||||
const SQSDefaultSystemVPCSupernet = "10.240.0.0/14"
|
||||
|
||||
// OchreVectorConfig gates the Ochre vector store's platform Postgres
|
||||
// appliance and VectorService NATS surface. Disabled by default: an unset
|
||||
// section constructs nothing and registers no ochre.vector.* subjects, so an
|
||||
|
||||
@@ -1,631 +0,0 @@
|
||||
// Package awsapi serves the EC2 query-protocol actions over composed capabilities.
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"uuid"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
ec2accountapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/account"
|
||||
ec2capacityreservationapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/capacityreservation"
|
||||
ec2eigwapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/eigw"
|
||||
ec2eipapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/eip"
|
||||
ec2igwapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/igw"
|
||||
ec2imageapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/image"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
ec2keyapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/key"
|
||||
ec2launchtemplateapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/launchtemplate"
|
||||
ec2natgwapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/natgw"
|
||||
ec2placementgroupapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/placementgroup"
|
||||
ec2routetableapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/routetable"
|
||||
ec2snapshotapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/snapshot"
|
||||
ec2spotinstanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/spotinstance"
|
||||
ec2tagsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/tags"
|
||||
ec2volumeapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/volume"
|
||||
ec2vpcapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/vpc"
|
||||
ec2zoneapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/zone"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsxml "github.com/mulgadc/spinifex/spinifex/foundation/aws/xml"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/idempotency"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ec2Action parses once so authorization and dispatch share one typed input.
|
||||
type ec2Action struct {
|
||||
parse func(map[string]string) (any, error)
|
||||
dispatch func(ctx context.Context, action string, parsed any, c *call, accountID string) ([]byte, error)
|
||||
// idempotent records that this action honours ClientToken, so the set of
|
||||
// actions that do is assertable rather than folklore.
|
||||
idempotent bool
|
||||
}
|
||||
|
||||
func (h ec2Action) withQueryPreprocessor(preprocess func(map[string]string)) ec2Action {
|
||||
parse := h.parse
|
||||
h.parse = func(q map[string]string) (any, error) {
|
||||
preprocess(q)
|
||||
return parse(q)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// requestContext returns r's context, or Background for a nil request
|
||||
// (some callers and tests dispatch without an *http.Request). The SDK's
|
||||
// invocation id rides along: it is repeated across retries of one call, so it
|
||||
// is what lets a mutation recognise its own resend instead of doing the work
|
||||
// twice.
|
||||
func requestContext(r *http.Request) context.Context {
|
||||
if r == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return idempotency.WithKey(r.Context(), r.Header.Get(idempotency.SDKInvocationIDHeader))
|
||||
}
|
||||
|
||||
// parseEC2Input parses query args into an action's input type. Shared by every
|
||||
// ec2Action constructor so they all consume the same parse.
|
||||
func parseEC2Input[In any](q map[string]string) (any, error) {
|
||||
input := new(In)
|
||||
if err := query.QueryParamsToStruct(q, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// ec2Handler creates a type-safe EC2Handler. Parsing is separate from dispatch
|
||||
// so authorization and execution consume the exact same input.
|
||||
func ec2Handler[In any](handler func(ctx context.Context, input *In, c *call, accountID string) (any, error)) ec2Action {
|
||||
return ec2Action{
|
||||
parse: parseEC2Input[In],
|
||||
dispatch: func(ctx context.Context, action string, parsed any, c *call, accountID string) ([]byte, error) {
|
||||
input, ok := parsed.(*In)
|
||||
if !ok {
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
output, err := handler(ctx, input, c, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
xmlOutput, err := marshalEC2Response(action, output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return xmlOutput, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ec2ListsAsSet names the list fields AWS omits when empty instead of
|
||||
// rendering them. Each is rendered as its handler set it, so nil omits it.
|
||||
var ec2ListsAsSet = map[reflect.Type][]string{
|
||||
reflect.TypeFor[ec2.CreateKeyPairOutput](): {"Tags"},
|
||||
reflect.TypeFor[ec2.Image](): {"Tags", "ProductCodes"},
|
||||
reflect.TypeFor[ec2.CreateSecurityGroupOutput](): {"Tags"},
|
||||
reflect.TypeFor[ec2.SecurityGroup](): {"Tags"},
|
||||
reflect.TypeFor[ec2.SecurityGroupRule](): {"Tags"},
|
||||
reflect.TypeFor[ec2.RevokeSecurityGroupIngressOutput](): {"UnknownIpPermissions"},
|
||||
reflect.TypeFor[ec2.RevokeSecurityGroupEgressOutput](): {"UnknownIpPermissions"},
|
||||
reflect.TypeFor[ec2.NetworkInterface](): {"Ipv4Prefixes", "Ipv6Prefixes"},
|
||||
reflect.TypeFor[ec2.Vpc](): {"Ipv6CidrBlockAssociationSet"},
|
||||
reflect.TypeFor[ec2.Subnet](): {"Tags"},
|
||||
}
|
||||
|
||||
// marshalEC2Response renders an EC2 handler's output into the action's XML
|
||||
// envelope. Every EC2 action funnels through here (via ec2Handler and
|
||||
// ec2HandlerWithReq), so wire-format fixes belong here, not in each handler.
|
||||
func marshalEC2Response(action string, output any) ([]byte, error) {
|
||||
// BuildXML omits a nil slice's container element entirely but renders an
|
||||
// empty one for a non-nil empty slice; AWS mostly renders the latter.
|
||||
normalized := awsxml.NormalizeOutput(output, ec2ListsAsSet)
|
||||
// The SDK's generated output structs never carry a RequestId field.
|
||||
withRequestID := awsxml.WithRequestID(normalized, uuid.NewV4().String())
|
||||
payload := awsxml.ResponsePayload(action+"Response", withRequestID)
|
||||
xmlOutput, err := awsxml.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to marshal response to XML")
|
||||
}
|
||||
return xmlOutput, nil
|
||||
}
|
||||
|
||||
var ec2Actions = map[string]ec2Action{
|
||||
"DescribeInstances": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstancesInput, c *call, accountID string) (any, error) {
|
||||
out, err := ec2instanceapi.DescribeInstancesChecked(ctx, input, c.NATS, 0, c.NodeIDs, accountID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
ec2instanceapi.EnrichInstanceProfileIDs(out, c.IAM, accountID)
|
||||
return out, nil
|
||||
}),
|
||||
"RunInstances": ec2Handler(func(ctx context.Context, input *ec2.RunInstancesInput, c *call, accountID string) (any, error) {
|
||||
passRoleCheck := func(roleARN string) error {
|
||||
return c.passRole(roleARN)
|
||||
}
|
||||
launchQuotaCheck := func() error {
|
||||
return c.Quota.EnforceLaunch(ctx, accountID, aws.StringValue(input.InstanceType), int(aws.Int64Value(input.MaxCount)))
|
||||
}
|
||||
reservation, err := ec2instanceapi.RunInstances(ctx, input, c.NATS, c.IAM, accountID, passRoleCheck, launchQuotaCheck, c.ExpectedNodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Charge the actual launched vCPUs; a counter write failure is drift for
|
||||
// reconcile to correct, so it must not fail the already-successful launch.
|
||||
if err := c.Quota.ChargeLaunch(ctx, accountID, &reservation); err != nil {
|
||||
slog.WarnContext(ctx, "RunInstances: vcpu quota charge failed, reconcile will correct", "account", accountID, "err", err)
|
||||
}
|
||||
return reservation, nil
|
||||
}),
|
||||
"AssociateIamInstanceProfile": ec2Handler(func(ctx context.Context, input *ec2.AssociateIamInstanceProfileInput, c *call, accountID string) (any, error) {
|
||||
passRoleCheck := func(roleARN string) error {
|
||||
return c.passRole(roleARN)
|
||||
}
|
||||
return ec2instanceapi.AssociateIamInstanceProfile(ctx, input, c.NATS, c.IAM, accountID, passRoleCheck)
|
||||
}),
|
||||
"DisassociateIamInstanceProfile": ec2Handler(func(ctx context.Context, input *ec2.DisassociateIamInstanceProfileInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DisassociateIamInstanceProfile(ctx, input, c.NATS, c.ActiveNodes(ctx), accountID)
|
||||
}),
|
||||
"ReplaceIamInstanceProfileAssociation": ec2Handler(func(ctx context.Context, input *ec2.ReplaceIamInstanceProfileAssociationInput, c *call, accountID string) (any, error) {
|
||||
passRoleCheck := func(roleARN string) error {
|
||||
return c.passRole(roleARN)
|
||||
}
|
||||
return ec2instanceapi.ReplaceIamInstanceProfileAssociation(ctx, input, c.NATS, c.IAM, c.ActiveNodes(ctx), accountID, passRoleCheck)
|
||||
}),
|
||||
"DescribeIamInstanceProfileAssociations": ec2Handler(func(ctx context.Context, input *ec2.DescribeIamInstanceProfileAssociationsInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeIamInstanceProfileAssociations(ctx, input, c.NATS, c.ActiveNodes(ctx), accountID)
|
||||
}),
|
||||
"StartInstances": ec2Handler(func(ctx context.Context, input *ec2.StartInstancesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.StartInstances(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"StopInstances": ec2Handler(func(ctx context.Context, input *ec2.StopInstancesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.StopInstances(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"RebootInstances": ec2Handler(func(ctx context.Context, input *ec2.RebootInstancesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.RebootInstances(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"MonitorInstances": ec2Handler(func(ctx context.Context, input *ec2.MonitorInstancesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.MonitorInstances(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"UnmonitorInstances": ec2Handler(func(ctx context.Context, input *ec2.UnmonitorInstancesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.UnmonitorInstances(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"TerminateInstances": ec2Handler(func(ctx context.Context, input *ec2.TerminateInstancesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.TerminateInstances(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeInstanceTypes": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceTypesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceTypes(ctx, input, c.NATS, c.ExpectedNodes, c.NodeIDs, accountID)
|
||||
}),
|
||||
"DescribeInstanceTypeOfferings": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceTypeOfferingsInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceTypeOfferings(ctx, input, c.NATS, c.ExpectedNodes, accountID, c.Region, c.AZ)
|
||||
}),
|
||||
"DescribeInstanceStatus": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceStatusInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceStatus(ctx, input, c.NATS, c.ActiveNodes(ctx), accountID, c.AZ, c.InstanceStatus)
|
||||
}),
|
||||
"GetConsoleOutput": ec2Handler(func(ctx context.Context, input *ec2.GetConsoleOutputInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.GetConsoleOutput(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"GetPasswordData": ec2Handler(func(ctx context.Context, input *ec2.GetPasswordDataInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.GetPasswordData(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifyInstanceAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifyInstanceAttributeInput, c *call, accountID string) (any, error) {
|
||||
var delta int
|
||||
if input.InstanceType != nil {
|
||||
d, err := c.Quota.EnforceRetype(ctx, c.NATS, func() []string { return c.NodeIDs }, accountID, aws.StringValue(input.InstanceId), aws.StringValue(input.InstanceType.Value))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delta = d
|
||||
}
|
||||
out, err := ec2instanceapi.ModifyInstanceAttribute(ctx, input, c.NATS, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Charge the retype's vCPU growth; a counter write failure is drift for
|
||||
// reconcile to correct, so it must not fail the applied retype.
|
||||
if err := c.Quota.AddVCPU(ctx, accountID, delta); err != nil {
|
||||
slog.WarnContext(ctx, "ModifyInstanceAttribute: vcpu quota charge failed, reconcile will correct", "account", accountID, "err", err)
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
"DescribeInstanceAttribute": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceAttribute(ctx, input, c.NATS, c.ActiveNodes(ctx), accountID)
|
||||
}),
|
||||
"ModifyInstanceMetadataOptions": ec2Handler(func(ctx context.Context, input *ec2.ModifyInstanceMetadataOptionsInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.ModifyInstanceMetadataOptions(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeInstanceCreditSpecifications": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceCreditSpecificationsInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceCreditSpecifications(input)
|
||||
}),
|
||||
"CreateKeyPair": ec2Handler(func(ctx context.Context, input *ec2.CreateKeyPairInput, c *call, accountID string) (any, error) {
|
||||
return ec2keyapi.CreateKeyPair(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteKeyPair": ec2Handler(func(ctx context.Context, input *ec2.DeleteKeyPairInput, c *call, accountID string) (any, error) {
|
||||
return ec2keyapi.DeleteKeyPair(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeKeyPairs": ec2Handler(func(ctx context.Context, input *ec2.DescribeKeyPairsInput, c *call, accountID string) (any, error) {
|
||||
return ec2keyapi.DescribeKeyPairs(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ImportKeyPair": ec2Handler(func(ctx context.Context, input *ec2.ImportKeyPairInput, c *call, accountID string) (any, error) {
|
||||
return ec2keyapi.ImportKeyPair(ctx, input, c.NATS, accountID)
|
||||
}).withQueryPreprocessor(func(q map[string]string) {
|
||||
// Parser leaves Base64 padding URL-encoded; decode it before dispatch.
|
||||
if strings.HasSuffix(q["PublicKeyMaterial"], "%3D%3D") {
|
||||
q["PublicKeyMaterial"] = strings.Replace(q["PublicKeyMaterial"], "%3D%3D", "==", 1)
|
||||
}
|
||||
}),
|
||||
"DescribeImages": ec2Handler(func(ctx context.Context, input *ec2.DescribeImagesInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.DescribeImages(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateImage": ec2Handler(func(ctx context.Context, input *ec2.CreateImageInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.CreateImage(ctx, input, c.NATS, c.ActiveNodes(ctx), accountID)
|
||||
}),
|
||||
"DeregisterImage": ec2Handler(func(ctx context.Context, input *ec2.DeregisterImageInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.DeregisterImage(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"RegisterImage": ec2Handler(func(ctx context.Context, input *ec2.RegisterImageInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.RegisterImage(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CopyImage": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CopyImageInput, c *call, accountID string) (ec2.CopyImageOutput, error) {
|
||||
return ec2imageapi.CopyImage(ctx, input, c.NATS, c.Region, accountID)
|
||||
}),
|
||||
"DescribeImageAttribute": ec2Handler(func(ctx context.Context, input *ec2.DescribeImageAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.DescribeImageAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifyImageAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifyImageAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.ModifyImageAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ResetImageAttribute": ec2Handler(func(ctx context.Context, input *ec2.ResetImageAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.ResetImageAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeRegions": ec2Handler(func(ctx context.Context, input *ec2.DescribeRegionsInput, c *call, accountID string) (any, error) {
|
||||
return ec2zoneapi.DescribeRegions(input, c.Region, c.Endpoint)
|
||||
}),
|
||||
"DescribeAvailabilityZones": ec2Handler(func(ctx context.Context, input *ec2.DescribeAvailabilityZonesInput, c *call, accountID string) (any, error) {
|
||||
return ec2zoneapi.DescribeAvailabilityZones(input, c.Region, c.AZ)
|
||||
}),
|
||||
"DescribeVolumes": ec2Handler(func(ctx context.Context, input *ec2.DescribeVolumesInput, c *call, accountID string) (any, error) {
|
||||
return ec2volumeapi.DescribeVolumes(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifyVolume": ec2Handler(func(ctx context.Context, input *ec2.ModifyVolumeInput, c *call, accountID string) (any, error) {
|
||||
if err := c.Quota.EnforceVolumeModify(ctx, c.NATS, accountID, aws.StringValue(input.VolumeId), int(aws.Int64Value(input.Size))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2volumeapi.ModifyVolume(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateVolume": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateVolumeInput, c *call, accountID string) (ec2.Volume, error) {
|
||||
if err := c.Quota.EnforceVolumeCreate(ctx, c.NATS, accountID, int(aws.Int64Value(input.Size))); err != nil {
|
||||
return ec2.Volume{}, err
|
||||
}
|
||||
return ec2volumeapi.CreateVolume(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteVolume": ec2Handler(func(ctx context.Context, input *ec2.DeleteVolumeInput, c *call, accountID string) (any, error) {
|
||||
return ec2volumeapi.DeleteVolume(ctx, input, c.NATS, c.NodeIDs, accountID)
|
||||
}),
|
||||
"AttachVolume": ec2Handler(func(ctx context.Context, input *ec2.AttachVolumeInput, c *call, accountID string) (any, error) {
|
||||
return ec2volumeapi.AttachVolume(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeVolumeStatus": ec2Handler(func(ctx context.Context, input *ec2.DescribeVolumeStatusInput, c *call, accountID string) (any, error) {
|
||||
return ec2volumeapi.DescribeVolumeStatus(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeVolumesModifications": ec2Handler(func(ctx context.Context, input *ec2.DescribeVolumesModificationsInput, c *call, accountID string) (any, error) {
|
||||
return ec2volumeapi.DescribeVolumesModifications(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DetachVolume": ec2Handler(func(ctx context.Context, input *ec2.DetachVolumeInput, c *call, accountID string) (any, error) {
|
||||
return ec2volumeapi.DetachVolume(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeAccountAttributes": ec2Handler(func(ctx context.Context, input *ec2.DescribeAccountAttributesInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.DescribeAccountAttributes(input, func() (string, error) {
|
||||
return defaultVPCID(ctx, c.NATS, accountID)
|
||||
})
|
||||
}),
|
||||
"EnableEbsEncryptionByDefault": ec2Handler(func(ctx context.Context, input *ec2.EnableEbsEncryptionByDefaultInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.EnableEbsEncryptionByDefault(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DisableEbsEncryptionByDefault": ec2Handler(func(ctx context.Context, input *ec2.DisableEbsEncryptionByDefaultInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.DisableEbsEncryptionByDefault(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"GetEbsEncryptionByDefault": ec2Handler(func(ctx context.Context, input *ec2.GetEbsEncryptionByDefaultInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.GetEbsEncryptionByDefault(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"GetSerialConsoleAccessStatus": ec2Handler(func(ctx context.Context, input *ec2.GetSerialConsoleAccessStatusInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.GetSerialConsoleAccessStatus(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"EnableSerialConsoleAccess": ec2Handler(func(ctx context.Context, input *ec2.EnableSerialConsoleAccessInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.EnableSerialConsoleAccess(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DisableSerialConsoleAccess": ec2Handler(func(ctx context.Context, input *ec2.DisableSerialConsoleAccessInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.DisableSerialConsoleAccess(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateTags": ec2Handler(func(ctx context.Context, input *ec2.CreateTagsInput, c *call, accountID string) (any, error) {
|
||||
return ec2tagsapi.CreateTags(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteTags": ec2Handler(func(ctx context.Context, input *ec2.DeleteTagsInput, c *call, accountID string) (any, error) {
|
||||
return ec2tagsapi.DeleteTags(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeTags": ec2Handler(func(ctx context.Context, input *ec2.DescribeTagsInput, c *call, accountID string) (any, error) {
|
||||
return ec2tagsapi.DescribeTags(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateSnapshot": ec2Handler(func(ctx context.Context, input *ec2.CreateSnapshotInput, c *call, accountID string) (any, error) {
|
||||
return ec2snapshotapi.CreateSnapshot(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteSnapshot": ec2Handler(func(ctx context.Context, input *ec2.DeleteSnapshotInput, c *call, accountID string) (any, error) {
|
||||
return ec2snapshotapi.DeleteSnapshot(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeSnapshots": ec2Handler(func(ctx context.Context, input *ec2.DescribeSnapshotsInput, c *call, accountID string) (any, error) {
|
||||
return ec2snapshotapi.DescribeSnapshots(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CopySnapshot": ec2Handler(func(ctx context.Context, input *ec2.CopySnapshotInput, c *call, accountID string) (any, error) {
|
||||
return ec2snapshotapi.CopySnapshot(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.CreateInternetGatewayInput, c *call, accountID string) (any, error) {
|
||||
return ec2igwapi.CreateInternetGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.DeleteInternetGatewayInput, c *call, accountID string) (any, error) {
|
||||
return ec2igwapi.DeleteInternetGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeInternetGateways": ec2Handler(func(ctx context.Context, input *ec2.DescribeInternetGatewaysInput, c *call, accountID string) (any, error) {
|
||||
return ec2igwapi.DescribeInternetGateways(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AttachInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.AttachInternetGatewayInput, c *call, accountID string) (any, error) {
|
||||
return ec2igwapi.AttachInternetGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DetachInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.DetachInternetGatewayInput, c *call, accountID string) (any, error) {
|
||||
return ec2igwapi.DetachInternetGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateEgressOnlyInternetGateway": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateEgressOnlyInternetGatewayInput, c *call, accountID string) (ec2.CreateEgressOnlyInternetGatewayOutput, error) {
|
||||
return ec2eigwapi.CreateEgressOnlyInternetGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteEgressOnlyInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.DeleteEgressOnlyInternetGatewayInput, c *call, accountID string) (any, error) {
|
||||
return ec2eigwapi.DeleteEgressOnlyInternetGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeEgressOnlyInternetGateways": ec2Handler(func(ctx context.Context, input *ec2.DescribeEgressOnlyInternetGatewaysInput, c *call, accountID string) (any, error) {
|
||||
return ec2eigwapi.DescribeEgressOnlyInternetGateways(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreatePlacementGroup": ec2Handler(func(ctx context.Context, input *ec2.CreatePlacementGroupInput, c *call, accountID string) (any, error) {
|
||||
return ec2placementgroupapi.CreatePlacementGroup(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeletePlacementGroup": ec2Handler(func(ctx context.Context, input *ec2.DeletePlacementGroupInput, c *call, accountID string) (any, error) {
|
||||
return ec2placementgroupapi.DeletePlacementGroup(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribePlacementGroups": ec2Handler(func(ctx context.Context, input *ec2.DescribePlacementGroupsInput, c *call, accountID string) (any, error) {
|
||||
return ec2placementgroupapi.DescribePlacementGroups(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateCapacityReservation": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateCapacityReservationInput, c *call, accountID string) (ec2.CreateCapacityReservationOutput, error) {
|
||||
return ec2capacityreservationapi.CreateCapacityReservation(ctx, input, c.NATS, c.ExpectedNodes, accountID)
|
||||
}),
|
||||
"DescribeCapacityReservations": ec2Handler(func(ctx context.Context, input *ec2.DescribeCapacityReservationsInput, c *call, accountID string) (any, error) {
|
||||
return ec2capacityreservationapi.DescribeCapacityReservations(ctx, input, c.NATS, c.ExpectedNodes, accountID)
|
||||
}),
|
||||
"CancelCapacityReservation": ec2Handler(func(ctx context.Context, input *ec2.CancelCapacityReservationInput, c *call, accountID string) (any, error) {
|
||||
return ec2capacityreservationapi.CancelCapacityReservation(ctx, input, c.NATS, c.ExpectedNodes, accountID)
|
||||
}),
|
||||
"CreateLaunchTemplate": ec2Handler(func(ctx context.Context, input *ec2.CreateLaunchTemplateInput, c *call, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.CreateLaunchTemplate(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateLaunchTemplateVersion": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateLaunchTemplateVersionInput, c *call, accountID string) (ec2.CreateLaunchTemplateVersionOutput, error) {
|
||||
return ec2launchtemplateapi.CreateLaunchTemplateVersion(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteLaunchTemplate": ec2Handler(func(ctx context.Context, input *ec2.DeleteLaunchTemplateInput, c *call, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.DeleteLaunchTemplate(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteLaunchTemplateVersions": ec2Handler(func(ctx context.Context, input *ec2.DeleteLaunchTemplateVersionsInput, c *call, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.DeleteLaunchTemplateVersions(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifyLaunchTemplate": ec2Handler(func(ctx context.Context, input *ec2.ModifyLaunchTemplateInput, c *call, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.ModifyLaunchTemplate(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeLaunchTemplates": ec2Handler(func(ctx context.Context, input *ec2.DescribeLaunchTemplatesInput, c *call, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.DescribeLaunchTemplates(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeLaunchTemplateVersions": ec2Handler(func(ctx context.Context, input *ec2.DescribeLaunchTemplateVersionsInput, c *call, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.DescribeLaunchTemplateVersions(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"RequestSpotInstances": ec2Handler(func(ctx context.Context, input *ec2.RequestSpotInstancesInput, c *call, accountID string) (any, error) {
|
||||
passRoleCheck := func(roleARN string) error {
|
||||
return c.passRole(roleARN)
|
||||
}
|
||||
return ec2spotinstanceapi.RequestSpotInstances(ctx, input, c.NATS, c.IAM, accountID, c.AZ, passRoleCheck, c.Quota, c.ExpectedNodes)
|
||||
}),
|
||||
"DescribeSpotInstanceRequests": ec2Handler(func(ctx context.Context, input *ec2.DescribeSpotInstanceRequestsInput, c *call, accountID string) (any, error) {
|
||||
return ec2spotinstanceapi.DescribeSpotInstanceRequests(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CancelSpotInstanceRequests": ec2Handler(func(ctx context.Context, input *ec2.CancelSpotInstanceRequestsInput, c *call, accountID string) (any, error) {
|
||||
return ec2spotinstanceapi.CancelSpotInstanceRequests(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateVpc": ec2Handler(func(ctx context.Context, input *ec2.CreateVpcInput, c *call, accountID string) (any, error) {
|
||||
if err := c.Quota.EnforceVPCs(ctx, c.NATS, accountID, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2vpcapi.CreateVpc(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteVpc": ec2Handler(func(ctx context.Context, input *ec2.DeleteVpcInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DeleteVpc(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeVpcs": ec2Handler(func(ctx context.Context, input *ec2.DescribeVpcsInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeVpcs(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifyVpcAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifyVpcAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.ModifyVpcAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeVpcAttribute": ec2Handler(func(ctx context.Context, input *ec2.DescribeVpcAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeVpcAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
// No VPC endpoint can exist here, because CreateVpcEndpoint is not served, so
|
||||
// the empty list is the true answer rather than a stand-in for one. It has to
|
||||
// be answered all the same: the Terraform AWS provider calls this on every VPC
|
||||
// delete to sweep GuardDuty-managed endpoints, and an InvalidAction there fails
|
||||
// the destroy and strands the VPC.
|
||||
"DescribeVpcEndpoints": ec2Handler(func(_ context.Context, _ *ec2.DescribeVpcEndpointsInput, _ *call, _ string) (any, error) {
|
||||
return ec2.DescribeVpcEndpointsOutput{VpcEndpoints: []*ec2.VpcEndpoint{}}, nil
|
||||
}),
|
||||
"CreateSubnet": ec2Handler(func(ctx context.Context, input *ec2.CreateSubnetInput, c *call, accountID string) (any, error) {
|
||||
if err := c.Quota.EnforceSubnets(ctx, c.NATS, accountID, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2vpcapi.CreateSubnet(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteSubnet": ec2Handler(func(ctx context.Context, input *ec2.DeleteSubnetInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DeleteSubnet(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeSubnets": ec2Handler(func(ctx context.Context, input *ec2.DescribeSubnetsInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeSubnets(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifySubnetAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifySubnetAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.ModifySubnetAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateRouteTable": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateRouteTableInput, c *call, accountID string) (ec2.CreateRouteTableOutput, error) {
|
||||
return ec2routetableapi.CreateRouteTable(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteRouteTable": ec2Handler(func(ctx context.Context, input *ec2.DeleteRouteTableInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.DeleteRouteTable(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeRouteTables": ec2Handler(func(ctx context.Context, input *ec2.DescribeRouteTablesInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.DescribeRouteTables(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateRoute": ec2Handler(func(ctx context.Context, input *ec2.CreateRouteInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.CreateRoute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteRoute": ec2Handler(func(ctx context.Context, input *ec2.DeleteRouteInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.DeleteRoute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ReplaceRoute": ec2Handler(func(ctx context.Context, input *ec2.ReplaceRouteInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.ReplaceRoute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AssociateRouteTable": ec2Handler(func(ctx context.Context, input *ec2.AssociateRouteTableInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.AssociateRouteTable(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DisassociateRouteTable": ec2Handler(func(ctx context.Context, input *ec2.DisassociateRouteTableInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.DisassociateRouteTable(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ReplaceRouteTableAssociation": ec2Handler(func(ctx context.Context, input *ec2.ReplaceRouteTableAssociationInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.ReplaceRouteTableAssociation(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateNetworkInterface": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateNetworkInterfaceInput, c *call, accountID string) (ec2.CreateNetworkInterfaceOutput, error) {
|
||||
return ec2vpcapi.CreateNetworkInterface(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteNetworkInterface": ec2Handler(func(ctx context.Context, input *ec2.DeleteNetworkInterfaceInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DeleteNetworkInterface(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeNetworkInterfaces": ec2Handler(func(ctx context.Context, input *ec2.DescribeNetworkInterfacesInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeNetworkInterfaces(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifyNetworkInterfaceAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifyNetworkInterfaceAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.ModifyNetworkInterfaceAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AttachNetworkInterface": ec2Handler(func(ctx context.Context, input *ec2.AttachNetworkInterfaceInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.AttachNetworkInterface(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DetachNetworkInterface": ec2Handler(func(ctx context.Context, input *ec2.DetachNetworkInterfaceInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DetachNetworkInterface(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateSecurityGroup": ec2Handler(func(ctx context.Context, input *ec2.CreateSecurityGroupInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.CreateSecurityGroup(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteSecurityGroup": ec2Handler(func(ctx context.Context, input *ec2.DeleteSecurityGroupInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DeleteSecurityGroup(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeSecurityGroups": ec2Handler(func(ctx context.Context, input *ec2.DescribeSecurityGroupsInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeSecurityGroups(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeSecurityGroupRules": ec2Handler(func(ctx context.Context, input *ec2.DescribeSecurityGroupRulesInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeSecurityGroupRules(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"GetSecurityGroupsForVpc": ec2Handler(func(ctx context.Context, input *ec2.GetSecurityGroupsForVpcInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.GetSecurityGroupsForVpc(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AuthorizeSecurityGroupIngress": ec2Handler(func(ctx context.Context, input *ec2.AuthorizeSecurityGroupIngressInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.AuthorizeSecurityGroupIngress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AuthorizeSecurityGroupEgress": ec2Handler(func(ctx context.Context, input *ec2.AuthorizeSecurityGroupEgressInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.AuthorizeSecurityGroupEgress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"RevokeSecurityGroupIngress": ec2Handler(func(ctx context.Context, input *ec2.RevokeSecurityGroupIngressInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.RevokeSecurityGroupIngress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"RevokeSecurityGroupEgress": ec2Handler(func(ctx context.Context, input *ec2.RevokeSecurityGroupEgressInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.RevokeSecurityGroupEgress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"UpdateSecurityGroupRuleDescriptionsIngress": ec2Handler(func(ctx context.Context, input *ec2.UpdateSecurityGroupRuleDescriptionsIngressInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.UpdateSecurityGroupRuleDescriptionsIngress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"UpdateSecurityGroupRuleDescriptionsEgress": ec2Handler(func(ctx context.Context, input *ec2.UpdateSecurityGroupRuleDescriptionsEgressInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.UpdateSecurityGroupRuleDescriptionsEgress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifySecurityGroupRules": ec2Handler(func(ctx context.Context, input *ec2.ModifySecurityGroupRulesInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.ModifySecurityGroupRules(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AllocateAddress": ec2Handler(func(ctx context.Context, input *ec2.AllocateAddressInput, c *call, accountID string) (any, error) {
|
||||
if err := c.Quota.EnforceEIPs(ctx, c.NATS, accountID, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2eipapi.AllocateAddress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ReleaseAddress": ec2Handler(func(ctx context.Context, input *ec2.ReleaseAddressInput, c *call, accountID string) (any, error) {
|
||||
return ec2eipapi.ReleaseAddress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AssociateAddress": ec2Handler(func(ctx context.Context, input *ec2.AssociateAddressInput, c *call, accountID string) (any, error) {
|
||||
return ec2eipapi.AssociateAddress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DisassociateAddress": ec2Handler(func(ctx context.Context, input *ec2.DisassociateAddressInput, c *call, accountID string) (any, error) {
|
||||
return ec2eipapi.DisassociateAddress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeAddresses": ec2Handler(func(ctx context.Context, input *ec2.DescribeAddressesInput, c *call, accountID string) (any, error) {
|
||||
return ec2eipapi.DescribeAddresses(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeAddressesAttribute": ec2Handler(func(ctx context.Context, input *ec2.DescribeAddressesAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2eipapi.DescribeAddressesAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateNatGateway": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateNatGatewayInput, c *call, accountID string) (ec2.CreateNatGatewayOutput, error) {
|
||||
return ec2natgwapi.CreateNatGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteNatGateway": ec2Handler(func(ctx context.Context, input *ec2.DeleteNatGatewayInput, c *call, accountID string) (any, error) {
|
||||
return ec2natgwapi.DeleteNatGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeNatGateways": ec2Handler(func(ctx context.Context, input *ec2.DescribeNatGatewaysInput, c *call, accountID string) (any, error) {
|
||||
return ec2natgwapi.DescribeNatGateways(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
}
|
||||
|
||||
// defaultVPCID returns the ID of accountID's default VPC, or "" when it has
|
||||
// none, as the account's own DescribeVpcs sees it.
|
||||
func defaultVPCID(ctx context.Context, natsConn *nats.Conn, accountID string) (string, error) {
|
||||
if natsConn == nil {
|
||||
return "", errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
out, err := ec2vpcapi.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("is-default"), Values: aws.StringSlice([]string{"true"})}},
|
||||
}, natsConn, accountID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(out.Vpcs) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return aws.StringValue(out.Vpcs[0].VpcId), nil
|
||||
}
|
||||
|
||||
// ec2LocalActions are actions that don't require NATS. DescribeAccountAttributes
|
||||
// needs it only for default-vpc.
|
||||
var ec2LocalActions = map[string]bool{
|
||||
"DescribeRegions": true,
|
||||
"DescribeAvailabilityZones": true,
|
||||
"DescribeAccountAttributes": true,
|
||||
}
|
||||
|
||||
// dryRunRequested reports whether a parsed EC2 input carries DryRun=true. Only
|
||||
// actions whose SDK input models the parameter can answer DryRunOperation.
|
||||
func dryRunRequested(input any) bool {
|
||||
v := reflect.ValueOf(input)
|
||||
if v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Kind() != reflect.Struct {
|
||||
return false
|
||||
}
|
||||
field := v.Elem().FieldByName("DryRun")
|
||||
if !field.IsValid() {
|
||||
return false
|
||||
}
|
||||
dryRun, ok := reflect.TypeAssert[*bool](field)
|
||||
return ok && aws.BoolValue(dryRun)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//test:in-package — reads the unexported ec2Actions route table.
|
||||
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestImportKeyPair_Base64PaddingWorkaround(t *testing.T) {
|
||||
// The ImportKeyPair handler decodes URL-encoded Base64 padding (%3D%3D → ==)
|
||||
// before passing to the generic handler.
|
||||
handler := ec2Actions["ImportKeyPair"]
|
||||
require.NotNil(t, handler)
|
||||
|
||||
q := map[string]string{
|
||||
"Action": "ImportKeyPair",
|
||||
"KeyName": "test-key",
|
||||
"PublicKeyMaterial": "c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCZ1FD%3D%3D",
|
||||
}
|
||||
|
||||
_, err := handler.parse(q)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, strings.HasSuffix(q["PublicKeyMaterial"], "=="),
|
||||
"Expected PublicKeyMaterial to end with == but got: %s", q["PublicKeyMaterial"])
|
||||
assert.NotContains(t, q["PublicKeyMaterial"], "%3D",
|
||||
"Expected no URL-encoded padding remaining")
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Quota is the account quota the EC2 actions consult and charge. It is declared
|
||||
// here because domains/admission/quota imports the EC2 action packages to count
|
||||
// live resources, and its retype gate takes a resolver type this package must
|
||||
// not name.
|
||||
type Quota interface {
|
||||
EnforceLaunch(ctx context.Context, accountID, instanceType string, maxCount int) error
|
||||
ChargeLaunch(ctx context.Context, accountID string, reservation *ec2.Reservation) error
|
||||
// EnforceRetype returns the vCPU delta AddVCPU charges once the retype applies.
|
||||
EnforceRetype(ctx context.Context, nc *nats.Conn, nodeIDs func() []string, accountID, instanceID, newType string) (int, error)
|
||||
AddVCPU(ctx context.Context, accountID string, delta int) error
|
||||
EnforceVolumeCreate(ctx context.Context, nc *nats.Conn, accountID string, requestedGiB int) error
|
||||
EnforceVolumeModify(ctx context.Context, nc *nats.Conn, accountID, volumeID string, newGiB int) error
|
||||
EnforceEIPs(ctx context.Context, nc *nats.Conn, accountID string, want int) error
|
||||
EnforceSubnets(ctx context.Context, nc *nats.Conn, accountID string, want int) error
|
||||
EnforceVPCs(ctx context.Context, nc *nats.Conn, accountID string, want int) error
|
||||
}
|
||||
|
||||
// Deps are the composed capabilities the EC2 control plane dispatches to.
|
||||
type Deps struct {
|
||||
NATS *nats.Conn
|
||||
IAM ec2instanceapi.InstanceProfiles
|
||||
Quota Quota
|
||||
// ActiveNodes is the number of daemons currently answering.
|
||||
ActiveNodes func(ctx context.Context) int
|
||||
ExpectedNodes int
|
||||
NodeIDs []string
|
||||
AZ string
|
||||
// Endpoint is the gateway's own dialable base URL DescribeRegions advertises.
|
||||
Endpoint string
|
||||
InstanceStatus ec2instanceapi.StatusSynthesis
|
||||
}
|
||||
|
||||
// Handler serves EC2 requests over its Deps. It binds the client-token store on
|
||||
// first use and is safe for concurrent requests.
|
||||
type Handler struct {
|
||||
Deps
|
||||
|
||||
tokenOnce sync.Once
|
||||
tokenKV jetstream.KeyValue
|
||||
tokenErr error
|
||||
}
|
||||
|
||||
// NewHandler returns the handler over deps.
|
||||
func NewHandler(deps Deps) *Handler { return &Handler{Deps: deps} }
|
||||
|
||||
// call is one request's view of the handler: the handler's capabilities plus the
|
||||
// region and the PassRole gate of the authenticated caller.
|
||||
type call struct {
|
||||
*Handler
|
||||
|
||||
Region string
|
||||
passRole func(roleARN string) error
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
//test:in-package — drives the unexported action table without a request.
|
||||
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// DescribeRegions must advertise the endpoint the handler was composed with, so
|
||||
// a caller on a concrete-host deployment is never told to dial its own loopback.
|
||||
func TestDescribeRegions_UsesAdvertisedEndpoint(t *testing.T) {
|
||||
c := &call{Handler: NewHandler(Deps{Endpoint: "https://10.0.0.5:9999"}), Region: "ap-southeast-2"}
|
||||
h := ec2Actions["DescribeRegions"]
|
||||
input, err := h.parse(map[string]string{})
|
||||
require.NoError(t, err)
|
||||
xmlOutput, err := h.dispatch(t.Context(), "DescribeRegions", input, c, "acct-123")
|
||||
require.NoError(t, err)
|
||||
|
||||
body := string(xmlOutput)
|
||||
assert.Contains(t, body, "<regionEndpoint>https://10.0.0.5:9999</regionEndpoint>")
|
||||
assert.NotContains(t, body, "localhost")
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
//test:in-package — drives the unexported action table through Dispatch.
|
||||
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type allowQuota struct{}
|
||||
|
||||
var _ Quota = allowQuota{}
|
||||
|
||||
func (allowQuota) EnforceLaunch(context.Context, string, string, int) error { return nil }
|
||||
|
||||
func (allowQuota) ChargeLaunch(context.Context, string, *ec2.Reservation) error { return nil }
|
||||
|
||||
func (allowQuota) EnforceRetype(context.Context, *nats.Conn, func() []string, string, string, string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (allowQuota) AddVCPU(context.Context, string, int) error { return nil }
|
||||
|
||||
func (allowQuota) EnforceVolumeCreate(context.Context, *nats.Conn, string, int) error { return nil }
|
||||
|
||||
func (allowQuota) EnforceVolumeModify(context.Context, *nats.Conn, string, string, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (allowQuota) EnforceEIPs(context.Context, *nats.Conn, string, int) error { return nil }
|
||||
|
||||
func (allowQuota) EnforceSubnets(context.Context, *nats.Conn, string, int) error { return nil }
|
||||
|
||||
func (allowQuota) EnforceVPCs(context.Context, *nats.Conn, string, int) error { return nil }
|
||||
|
||||
type noProfiles struct{}
|
||||
|
||||
func (noProfiles) ResolveInstanceProfile(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
|
||||
func (noProfiles) CanonicalResourceARN(string, arn.IAMResourceType, string) (string, error) {
|
||||
return "", errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
|
||||
func allowAll(string, string, []string, iampolicy.ConditionKeys) error { return nil }
|
||||
|
||||
func formRequest(body string) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return r
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
// A daemon that answers every EC2 subject with an empty result, so each
|
||||
// action reaches its domain call and returns instead of waiting.
|
||||
sub, err := nc.Subscribe("ec2.>", func(m *nats.Msg) { _ = m.Respond([]byte("{}")) })
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
|
||||
return NewHandler(Deps{
|
||||
NATS: nc,
|
||||
IAM: noProfiles{},
|
||||
Quota: allowQuota{},
|
||||
ActiveNodes: func(context.Context) int { return 1 },
|
||||
ExpectedNodes: 1,
|
||||
NodeIDs: []string{"node-1"},
|
||||
AZ: "ap-southeast-2a",
|
||||
Endpoint: "https://localhost:9999",
|
||||
})
|
||||
}
|
||||
|
||||
// Every routed action must reach its handler through Dispatch. Inputs are empty,
|
||||
// so the domain call may reject them, but the wrapper must run and Dispatch must
|
||||
// answer with a result or an error rather than panic.
|
||||
func TestDispatch_EveryActionReachesItsHandler(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
for _, action := range ActionNames() {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
inv := dispatch.Invocation{
|
||||
Request: formRequest("Action=" + action),
|
||||
AccountID: testAccountID,
|
||||
Service: ServiceName,
|
||||
Region: testRegion,
|
||||
Authorize: allowAll,
|
||||
}
|
||||
assert.NotPanics(t, func() { _ = h.Dispatch(w, inv) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_RequestErrors(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
deny := func(string, string, []string, iampolicy.ConditionKeys) error {
|
||||
return errors.New(awserrors.ErrorAccessDenied)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
accountID string
|
||||
authorize dispatch.AuthorizeFunc
|
||||
want string
|
||||
}{
|
||||
{"missing action", "Foo=bar", testAccountID, allowAll, awserrors.ErrorMissingAction},
|
||||
{"unknown action", "Action=NoSuchAction", testAccountID, allowAll, awserrors.ErrorInvalidAction},
|
||||
{"no account", "Action=DescribeVpcs", "", allowAll, awserrors.ErrorInternalError},
|
||||
{"policy denial", "Action=DescribeVpcs", testAccountID, deny, awserrors.ErrorUnauthorizedOperation},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := h.Dispatch(httptest.NewRecorder(), dispatch.Invocation{
|
||||
Request: formRequest(tc.body), AccountID: tc.accountID, Service: ServiceName,
|
||||
Region: testRegion, Authorize: tc.authorize,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.want, err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_SuccessWritesXML(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
w := httptest.NewRecorder()
|
||||
err := h.Dispatch(w, dispatch.Invocation{
|
||||
Request: formRequest("Action=DescribeRegions"), AccountID: testAccountID,
|
||||
Service: ServiceName, Region: testRegion, Authorize: allowAll,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "text/xml", w.Header().Get("Content-Type"))
|
||||
assert.Contains(t, w.Body.String(), "ap-southeast-2")
|
||||
}
|
||||
|
||||
// Without a NATS connection the gateway-local actions still answer and every
|
||||
// other action reports ServerInternal.
|
||||
func TestDispatch_NoNATS(t *testing.T) {
|
||||
h := NewHandler(Deps{AZ: "ap-southeast-2a", Endpoint: "https://localhost:9999"})
|
||||
inv := func(body string) dispatch.Invocation {
|
||||
return dispatch.Invocation{
|
||||
Request: formRequest(body), AccountID: testAccountID, Service: ServiceName,
|
||||
Region: testRegion, Authorize: allowAll,
|
||||
}
|
||||
}
|
||||
require.NoError(t, h.Dispatch(httptest.NewRecorder(), inv("Action=DescribeRegions")))
|
||||
err := h.Dispatch(httptest.NewRecorder(), inv("Action=DescribeVpcs"))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
}
|
||||
|
||||
func TestInputHasField(t *testing.T) {
|
||||
assert.True(t, InputHasField("CreateVpc", "DryRun"))
|
||||
assert.False(t, InputHasField("CreateVpc", "NoSuchField"))
|
||||
assert.False(t, InputHasField("NoSuchAction", "DryRun"))
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
@@ -464,7 +465,7 @@ func judgeIdentityCompleteness(ctx context.Context, frames []natsmsg.Frame, sum
|
||||
// that carries only an ARN. Results are cached per ARN to avoid repeated RPCs.
|
||||
// Misses are warn-logged and leave Id empty (graceful degradation for deleted profiles).
|
||||
// Safe to call with a nil output or nil IAMService (no-op).
|
||||
func EnrichInstanceProfileIDs(out *ec2.DescribeInstancesOutput, iamSvc InstanceProfiles, accountID string) {
|
||||
func EnrichInstanceProfileIDs(out *ec2.DescribeInstancesOutput, iamSvc iamdomain.IAMService, accountID string) {
|
||||
if out == nil || iamSvc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const fanOutTimeout = 3 * time.Second
|
||||
|
||||
// resolveAndAuthorizeProfile resolves a profile (name or ARN) and enforces iam:PassRole.
|
||||
// Profiles without a role skip PassRole; missing profile → InvalidIamInstanceProfile.NotFound.
|
||||
func resolveAndAuthorizeProfile(spec *ec2.IamInstanceProfileSpecification, iamSvc InstanceProfiles, accountID string, passRoleCheck PassRoleChecker) (*iamdomain.InstanceProfile, error) {
|
||||
func resolveAndAuthorizeProfile(spec *ec2.IamInstanceProfileSpecification, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker) (*iamdomain.InstanceProfile, error) {
|
||||
nameOrARN := profileNameOrARN(spec)
|
||||
if nameOrARN == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
@@ -55,7 +55,7 @@ func resolveAndAuthorizeProfile(spec *ec2.IamInstanceProfileSpecification, iamSv
|
||||
// AssociateIamInstanceProfile attaches a profile to a running instance.
|
||||
// The gateway resolves the profile and enforces PassRole; the daemon validates
|
||||
// no existing profile and generates a fresh iip-assoc-… ID.
|
||||
func AssociateIamInstanceProfile(ctx context.Context, input *ec2.AssociateIamInstanceProfileInput, natsConn *nats.Conn, iamSvc InstanceProfiles, accountID string, passRoleCheck PassRoleChecker) (*ec2.AssociateIamInstanceProfileOutput, error) {
|
||||
func AssociateIamInstanceProfile(ctx context.Context, input *ec2.AssociateIamInstanceProfileInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker) (*ec2.AssociateIamInstanceProfileOutput, error) {
|
||||
if input == nil || input.InstanceId == nil || *input.InstanceId == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func DisassociateIamInstanceProfile(ctx context.Context, input *ec2.Disassociate
|
||||
|
||||
// ReplaceIamInstanceProfileAssociation swaps the profile for an existing association.
|
||||
// The gateway resolves the new profile and enforces PassRole; the daemon swaps atomically.
|
||||
func ReplaceIamInstanceProfileAssociation(ctx context.Context, input *ec2.ReplaceIamInstanceProfileAssociationInput, natsConn *nats.Conn, iamSvc InstanceProfiles, expectedNodes int, accountID string, passRoleCheck PassRoleChecker) (*ec2.ReplaceIamInstanceProfileAssociationOutput, error) {
|
||||
func ReplaceIamInstanceProfileAssociation(ctx context.Context, input *ec2.ReplaceIamInstanceProfileAssociationInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, expectedNodes int, accountID string, passRoleCheck PassRoleChecker) (*ec2.ReplaceIamInstanceProfileAssociationOutput, error) {
|
||||
if input == nil || input.AssociationId == nil || *input.AssociationId == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
@@ -245,10 +245,3 @@ func broadcastDescribeAssociations(ctx context.Context, natsConn *nats.Conn, inp
|
||||
}
|
||||
return associations, nil
|
||||
}
|
||||
|
||||
// InstanceProfiles is the IAM capability the instance-profile actions consume:
|
||||
// resolving a profile and naming the role it holds.
|
||||
type InstanceProfiles interface {
|
||||
ResolveInstanceProfile(accountID, nameOrARN string) (*iamdomain.InstanceProfile, error)
|
||||
CanonicalResourceARN(accountID string, kind arn.IAMResourceType, name string) (string, error)
|
||||
}
|
||||
|
||||
@@ -747,27 +747,3 @@ func TestResolveAndAuthorizeProfile_CanonicalLookupError(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Current behaviour, not a statement of what AWS does: a profile whose role no
|
||||
// longer exists surfaces the store's NoSuchEntity unchanged and PassRole is
|
||||
// never evaluated.
|
||||
func TestResolveAndAuthorizeProfile_DanglingRoleSurfacesNoSuchEntityBeforePassRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeIAMService{
|
||||
resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) { return profileWithRole(), nil },
|
||||
canonicalFn: func(string, arn.IAMResourceType, string) (string, error) {
|
||||
return "", errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
},
|
||||
}
|
||||
checkCalled := false
|
||||
check := func(string) error {
|
||||
checkCalled = true
|
||||
return nil
|
||||
}
|
||||
_, err := resolveAndAuthorizeProfile(
|
||||
&ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)},
|
||||
svc, testGwAccountID, check)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
|
||||
assert.False(t, checkCalled, "a dangling role is reported without evaluating iam:PassRole")
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func hasPinnedPrivateIP(input *ec2.RunInstancesInput) bool {
|
||||
// passRoleCheck may be nil to skip PassRole enforcement.
|
||||
// launchQuotaCheck may be nil to skip quota enforcement; when present it runs
|
||||
// after validation and PassRole authorization, but before any launch dispatch.
|
||||
func RunInstances(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc InstanceProfiles, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
|
||||
func RunInstances(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
|
||||
// Expand a referenced launch template before validation so validation, quota,
|
||||
// and downstream routing all see the effective (post-merge) parameters.
|
||||
if err = ExpandLaunchTemplate(ctx, natsConn, input, accountID); err != nil {
|
||||
@@ -163,7 +163,7 @@ func RunInstances(ctx context.Context, input *ec2.RunInstancesInput, natsConn *n
|
||||
|
||||
// runInstancesInner performs the actual launch: profile resolution, placement
|
||||
// routing, and capacity-aware distribution. Wrapped by RunInstances for idempotency.
|
||||
func runInstancesInner(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc InstanceProfiles, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
|
||||
func runInstancesInner(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
|
||||
resolvedProfile, err := resolveAndAuthorizeInstanceProfile(input, iamSvc, accountID, passRoleCheck)
|
||||
if err != nil {
|
||||
return reservation, err
|
||||
@@ -236,7 +236,7 @@ func runInstancesInner(ctx context.Context, input *ec2.RunInstancesInput, natsCo
|
||||
|
||||
// resolveAndAuthorizeInstanceProfile resolves an optional instance profile,
|
||||
// enforces PassRole, and normalises input to the canonical ARN only.
|
||||
func resolveAndAuthorizeInstanceProfile(input *ec2.RunInstancesInput, iamSvc InstanceProfiles, accountID string, passRoleCheck PassRoleChecker) (*iamdomain.InstanceProfile, error) {
|
||||
func resolveAndAuthorizeInstanceProfile(input *ec2.RunInstancesInput, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker) (*iamdomain.InstanceProfile, error) {
|
||||
if input.IamInstanceProfile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
|
||||
)
|
||||
|
||||
const (
|
||||
// ServiceName is the SigV4 credential-scope service EC2 clients sign for.
|
||||
ServiceName = "ec2"
|
||||
|
||||
ec2ServicePrincipal = "ec2.amazonaws.com"
|
||||
)
|
||||
|
||||
// readQueryArgs parses the Query body and rewinds it, so the same bytes can be
|
||||
// read again. Any read or parse failure is a malformed query string.
|
||||
func readQueryArgs(r *http.Request) (map[string]string, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, sigv4.MaxPayloadLen+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if int64(len(body)) > sigv4.MaxPayloadLen {
|
||||
return nil, errors.New(awserrors.ErrorRequestEntityTooLarge)
|
||||
}
|
||||
return query.ParseArgs(string(body))
|
||||
}
|
||||
|
||||
// Dispatch parses, authorizes and runs one EC2 request, then writes the XML
|
||||
// response. A returned error is an awserrors code the gateway renders.
|
||||
func (h *Handler) Dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
r := inv.Request
|
||||
queryArgs, err := readQueryArgs(r)
|
||||
if err != nil {
|
||||
slog.Debug("EC2: malformed query string", "err", err)
|
||||
return errors.New(awserrors.ErrorMalformedQueryString)
|
||||
}
|
||||
|
||||
action := queryArgs["Action"]
|
||||
if action == "" {
|
||||
return errors.New(awserrors.ErrorMissingAction)
|
||||
}
|
||||
handler, ok := ec2Actions[action]
|
||||
if !ok {
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
// Hoisted above the policy check because the resolver builds ARNs from it.
|
||||
// inv.Region is the gateway's own region, never the caller-supplied
|
||||
// credential-scope region, which would let a caller sign for another region
|
||||
// and slide out from under a Deny.
|
||||
accountID := inv.AccountID
|
||||
if accountID == "" {
|
||||
slog.Error("EC2 dispatch: no account ID in auth context")
|
||||
// InternalError, not ServerInternal: the policy gate used to reach this
|
||||
// case first and that is the code the caller has always seen.
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
input, err := handler.parse(queryArgs)
|
||||
if err != nil {
|
||||
if errors.Is(err, query.ErrSliceTooLarge) {
|
||||
return errors.New(awserrors.ErrorMalformedQueryString)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Launch templates supply effective RunInstances resources. Resolve them
|
||||
// before authorization, and dispatch the same expanded input afterwards.
|
||||
if runInput, ok := input.(*ec2.RunInstancesInput); ok && action == "RunInstances" {
|
||||
if err := ec2instanceapi.ExpandLaunchTemplate(r.Context(), h.NATS, runInput, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
resources, err := ResourceARNs(action, inv.Region, accountID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.Authorize(ServiceName, action, resources, nil); err != nil {
|
||||
// EC2 answers a policy denial with UnauthorizedOperation, not AccessDenied.
|
||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorAccessDenied {
|
||||
return errors.New(awserrors.ErrorUnauthorizedOperation)
|
||||
}
|
||||
return err
|
||||
}
|
||||
// AWS refuses a bad tag resource type even on a DryRun request.
|
||||
if err := validateTagSpecificationTypes(action, input); err != nil {
|
||||
return err
|
||||
}
|
||||
if dryRunRequested(input) {
|
||||
return errors.New(awserrors.ErrorDryRunOperation)
|
||||
}
|
||||
if err := validateMaxResults(action, input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if h.NATS == nil && !ec2LocalActions[action] {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
c := &call{
|
||||
Handler: h,
|
||||
Region: inv.Region,
|
||||
passRole: func(roleARN string) error {
|
||||
return inv.Authorize("iam", "PassRole", []string{roleARN},
|
||||
iampolicy.ConditionKeys{iampolicy.KeyPassedToService: ec2ServicePrincipal})
|
||||
},
|
||||
}
|
||||
xmlOutput, err := handler.dispatch(requestContext(r), action, input, c, accountID)
|
||||
if err != nil {
|
||||
// A gateway iam:PassRole denial is EC2's UnauthorizedOperation too; an
|
||||
// AccessDenied a daemon returns is left as it is.
|
||||
if _, denied := errors.AsType[*dispatch.PolicyDenialError](err); denied {
|
||||
return errors.New(awserrors.ErrorUnauthorizedOperation)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(xmlOutput); err != nil {
|
||||
slog.Error("Failed to write EC2 response", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActionNames lists every action the handler dispatches, sorted.
|
||||
func ActionNames() []string {
|
||||
return slices.Sorted(maps.Keys(ec2Actions))
|
||||
}
|
||||
|
||||
// InputHasField reports whether the SDK input type an action parses into has
|
||||
// the named field, so callers can ask what the model declares for an action
|
||||
// without reaching the route table.
|
||||
func InputHasField(action, field string) bool {
|
||||
entry, ok := ec2Actions[action]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
input, err := entry.parse(map[string]string{"Action": action})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, has := reflect.TypeOf(input).Elem().FieldByName(field)
|
||||
return has
|
||||
}
|
||||
@@ -14,8 +14,10 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
admissionquota "github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
ec2spotinstance "github.com/mulgadc/spinifex/spinifex/domains/ec2/spotinstance"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
@@ -62,7 +64,7 @@ func ValidateRequestSpotInstancesInput(input *ec2.RequestSpotInstancesInput) err
|
||||
// ClientToken passed through, same vCPU quota gate), then builds and persists one
|
||||
// active/fulfilled SpotInstanceRequest per launched instance. On a launch failure
|
||||
// (including InsufficientInstanceCapacity) it returns the error and persists nothing.
|
||||
func RequestSpotInstances(ctx context.Context, input *ec2.RequestSpotInstancesInput, natsConn *nats.Conn, iamSvc ec2instanceapi.InstanceProfiles, accountID, az string, passRoleCheck ec2instanceapi.PassRoleChecker, quota LaunchQuota, expectedNodes int) (ec2.RequestSpotInstancesOutput, error) {
|
||||
func RequestSpotInstances(ctx context.Context, input *ec2.RequestSpotInstancesInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, accountID, az string, passRoleCheck ec2instanceapi.PassRoleChecker, quota *admissionquota.Service, expectedNodes int) (ec2.RequestSpotInstancesOutput, error) {
|
||||
var output ec2.RequestSpotInstancesOutput
|
||||
|
||||
if err := ValidateRequestSpotInstancesInput(input); err != nil {
|
||||
@@ -222,9 +224,3 @@ func buildSpotRequests(input *ec2.RequestSpotInstancesInput, runInput *ec2.RunIn
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
// LaunchQuota is the vCPU quota a spot request launches under.
|
||||
type LaunchQuota interface {
|
||||
EnforceLaunch(ctx context.Context, accountID, instanceType string, maxCount int) error
|
||||
ChargeLaunch(ctx context.Context, accountID string, reservation *ec2.Reservation) error
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
//test:in-package — walks the unexported ec2Actions route table.
|
||||
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// An action added to the route table with TagSpecifications must say which
|
||||
// resource types it tags, or a wrong type would be silently dropped again.
|
||||
func TestEC2TaggableTypes_CoverEveryAction(t *testing.T) {
|
||||
for action, handler := range ec2Actions {
|
||||
input, err := handler.parse(map[string]string{"Action": action})
|
||||
require.NoError(t, err, action)
|
||||
_, tags := reflect.TypeOf(input).Elem().FieldByName("TagSpecifications")
|
||||
_, listed := ec2TaggableTypes[action]
|
||||
assert.Equal(t, tags, listed, "%s: TagSpecifications modelled=%v, listed in ec2TaggableTypes=%v", action, tags, listed)
|
||||
}
|
||||
for action := range ec2TaggableTypes {
|
||||
assert.Contains(t, ec2Actions, action, "ec2TaggableTypes lists an action the registration does not route")
|
||||
}
|
||||
}
|
||||
@@ -111,10 +111,6 @@ func TestCurrentBehaviour_AccountTermination_CreateImageSnapshotDoesNotPinSource
|
||||
imageID := s.createImage(t, "vol-imaged", "ct-image")
|
||||
require.NoError(t, s.deleteVolume("vol-imaged"), "a CreateImage snapshot does not block deleting its source volume")
|
||||
|
||||
// DeleteVolume records the obligation and returns, so the document is gone
|
||||
// once the reclaimer has converged it rather than when the call returns.
|
||||
_, err = s.volumes.NewVolumeReclaimReaper().Sweep(ctx)
|
||||
require.NoError(t, err)
|
||||
_, err = s.metadata.GetVolume(ctx, ctAccountID, "vol-imaged")
|
||||
require.Error(t, err, "the source volume's document is gone")
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
// Drives launchClaimedInstance down its capacity refusal, which is where a node
|
||||
// decides whether the start may fall back to another one.
|
||||
//
|
||||
//test:in-package — launchClaimedInstance and the service's dependencies are unexported.
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeUnsealedChecker struct {
|
||||
held []string
|
||||
err error
|
||||
asked []string
|
||||
nodeID string
|
||||
}
|
||||
|
||||
func (f *fakeUnsealedChecker) HoldsUnsealedWrites(_ context.Context, nodeID string, volumeIDs []string) ([]string, error) {
|
||||
f.nodeID = nodeID
|
||||
f.asked = volumeIDs
|
||||
return f.held, f.err
|
||||
}
|
||||
|
||||
func refusedLaunch(t *testing.T, checker *fakeUnsealedChecker) error {
|
||||
t.Helper()
|
||||
svc, _ := claimedRelaunchService(t, func(*vm.VM) error { return nil })
|
||||
svc.SetUnsealedWritesChecker(checker, "node-a")
|
||||
|
||||
// An instance type this node does not offer is the simplest capacity refusal.
|
||||
instance := &vm.VM{ID: "i-held", AccountID: "acc", InstanceType: "no.such.type"}
|
||||
instance.EBSRequests.Requests = []viperblocklegacyv1.EBSRequest{{Name: "vol-root", Boot: true}, {Name: "vol-data"}}
|
||||
return svc.launchClaimedInstance(t.Context(), instance, func() {})
|
||||
}
|
||||
|
||||
func TestLaunchClaimedInstance_CapacityRefusalWhileHoldingUnsealedWrites(t *testing.T) {
|
||||
checker := &fakeUnsealedChecker{held: []string{"vol-root"}}
|
||||
err := refusedLaunch(t, checker)
|
||||
|
||||
typed, ok := errors.AsType[*UnsealedWritesError](err)
|
||||
require.True(t, ok, "got %v", err)
|
||||
assert.Equal(t, []string{"vol-root"}, typed.Volumes)
|
||||
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, awserrors.ValidErrorCodeFromError(err))
|
||||
assert.Equal(t, "node-a", checker.nodeID)
|
||||
assert.Equal(t, []string{"vol-root", "vol-data"}, checker.asked)
|
||||
}
|
||||
|
||||
func TestLaunchClaimedInstance_CapacityRefusalWithNothingUnsealedIsPlain(t *testing.T) {
|
||||
err := refusedLaunch(t, &fakeUnsealedChecker{})
|
||||
|
||||
_, typed := errors.AsType[*UnsealedWritesError](err)
|
||||
assert.False(t, typed)
|
||||
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, awserrors.ValidErrorCodeFromError(err))
|
||||
}
|
||||
|
||||
func TestLaunchClaimedInstance_CapacityRefusalFailsSafeWhenTheCheckFails(t *testing.T) {
|
||||
err := refusedLaunch(t, &fakeUnsealedChecker{err: errors.New("nats: timeout")})
|
||||
|
||||
typed, ok := errors.AsType[*UnsealedWritesError](err)
|
||||
require.True(t, ok, "an unanswered check must refuse the fallback, got %v", err)
|
||||
require.Error(t, typed.CheckErr)
|
||||
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, awserrors.ValidErrorCodeFromError(err))
|
||||
}
|
||||
|
||||
type blockingUnsealedChecker struct{}
|
||||
|
||||
func (blockingUnsealedChecker) HoldsUnsealedWrites(ctx context.Context, _ string, _ []string) ([]string, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// A provider that never answers must not hold the refusal past the forwarder's
|
||||
// patience: the typed refusal has to arrive inside the bound.
|
||||
func TestLaunchClaimedInstance_UnansweredCheckIsBoundedAndFailsSafe(t *testing.T) {
|
||||
old := unsealedCheckTimeout
|
||||
unsealedCheckTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { unsealedCheckTimeout = old })
|
||||
|
||||
svc, _ := claimedRelaunchService(t, func(*vm.VM) error { return nil })
|
||||
svc.SetUnsealedWritesChecker(blockingUnsealedChecker{}, "node-a")
|
||||
instance := &vm.VM{ID: "i-wedged", AccountID: "acc", InstanceType: "no.such.type"}
|
||||
instance.EBSRequests.Requests = []viperblocklegacyv1.EBSRequest{{Name: "vol-root", Boot: true}}
|
||||
|
||||
start := time.Now()
|
||||
err := svc.launchClaimedInstance(t.Context(), instance, func() {})
|
||||
|
||||
assert.Less(t, time.Since(start), 5*time.Second)
|
||||
typed, ok := errors.AsType[*UnsealedWritesError](err)
|
||||
require.True(t, ok, "got %v", err)
|
||||
require.ErrorIs(t, typed.CheckErr, context.DeadlineExceeded)
|
||||
}
|
||||
@@ -237,8 +237,6 @@ type InstanceServiceImpl struct {
|
||||
gpuClaimer GPUClaimer
|
||||
amiLoader AMIMetaLoader
|
||||
ebsProvider ebsprovider.EBSProvider
|
||||
unsealedChecker ebsprovider.UnsealedWritesChecker
|
||||
nodeID string
|
||||
metadata *ebsmetadata.Store
|
||||
keyValidator KeyPairValidator
|
||||
eniCreator ENICreator
|
||||
@@ -254,14 +252,6 @@ func (s *InstanceServiceImpl) SetEBSProvider(provider ebsprovider.EBSProvider) {
|
||||
s.ebsProvider = provider
|
||||
}
|
||||
|
||||
// SetUnsealedWritesChecker injects the check a capacity refusal makes before
|
||||
// letting a start fall back to another node, and the name this node is known by
|
||||
// to that check.
|
||||
func (s *InstanceServiceImpl) SetUnsealedWritesChecker(checker ebsprovider.UnsealedWritesChecker, nodeID string) {
|
||||
s.unsealedChecker = checker
|
||||
s.nodeID = nodeID
|
||||
}
|
||||
|
||||
// SetVolumeCreator injects the volume service a launch uses for the volumes it
|
||||
// names beyond the root device.
|
||||
func (s *InstanceServiceImpl) SetVolumeCreator(vc VolumeCreator) {
|
||||
@@ -2607,63 +2597,6 @@ func (s *InstanceServiceImpl) StartStoppedInstance(ctx context.Context, input *S
|
||||
return &StartStoppedInstanceOutput{Status: "running", InstanceID: instance.ID}, nil
|
||||
}
|
||||
|
||||
// unsealedCheckTimeout bounds the unsealed-writes check. It must stay well under
|
||||
// the forwarder's wait for this node, or a wedged provider answers after the
|
||||
// forwarder has already started the instance elsewhere. A var so tests shrink it.
|
||||
var unsealedCheckTimeout = 5 * time.Second
|
||||
|
||||
// errInsufficientCapacity is the plain capacity refusal.
|
||||
var errInsufficientCapacity = errors.New(awserrors.ErrorInsufficientInstanceCapacity)
|
||||
|
||||
// UnsealedWritesError is a capacity refusal from a node that still holds
|
||||
// unsealed writes for the instance's volumes, or could not say whether it does.
|
||||
// It reads as InsufficientInstanceCapacity everywhere; only the ec2.start
|
||||
// forwarder looks for it, to refuse a start on a node with the older copy.
|
||||
type UnsealedWritesError struct {
|
||||
InstanceID string
|
||||
Volumes []string
|
||||
// CheckErr is set when the node could not read the markers.
|
||||
CheckErr error
|
||||
}
|
||||
|
||||
func (e *UnsealedWritesError) Error() string { return awserrors.ErrorInsufficientInstanceCapacity }
|
||||
|
||||
func (e *UnsealedWritesError) Unwrap() error { return errInsufficientCapacity }
|
||||
|
||||
// capacityRefusal is the error a launch that cannot take the instance returns.
|
||||
// When this node holds unsealed writes for the instance's volumes the refusal is
|
||||
// typed, because the volumes would otherwise be opened elsewhere from the backend
|
||||
// checkpoint. A check that cannot be made counts as held.
|
||||
func (s *InstanceServiceImpl) capacityRefusal(ctx context.Context, instance *vm.VM) error {
|
||||
if s.unsealedChecker == nil {
|
||||
return errInsufficientCapacity
|
||||
}
|
||||
instance.EBSRequests.Mu.Lock()
|
||||
volumes := make([]string, 0, len(instance.EBSRequests.Requests))
|
||||
for _, req := range instance.EBSRequests.Requests {
|
||||
volumes = append(volumes, req.Name)
|
||||
}
|
||||
instance.EBSRequests.Mu.Unlock()
|
||||
if len(volumes) == 0 {
|
||||
return errInsufficientCapacity
|
||||
}
|
||||
|
||||
checkCtx, cancel := context.WithTimeout(ctx, unsealedCheckTimeout)
|
||||
defer cancel()
|
||||
held, err := s.unsealedChecker.HoldsUnsealedWrites(checkCtx, s.nodeID, volumes)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "launchClaimedInstance: could not check for unsealed writes, refusing to let the start fall back",
|
||||
"instanceId", instance.ID, "err", err)
|
||||
return &UnsealedWritesError{InstanceID: instance.ID, Volumes: volumes, CheckErr: err}
|
||||
}
|
||||
if len(held) == 0 {
|
||||
return errInsufficientCapacity
|
||||
}
|
||||
slog.WarnContext(ctx, "launchClaimedInstance: at capacity while holding unsealed writes",
|
||||
"instanceId", instance.ID, "volumes", held)
|
||||
return &UnsealedWritesError{InstanceID: instance.ID, Volumes: held}
|
||||
}
|
||||
|
||||
// launchClaimedInstance runs an instance this node has already won the right
|
||||
// to run, and is the whole of the launch both claims share.
|
||||
//
|
||||
@@ -2679,12 +2612,12 @@ func (s *InstanceServiceImpl) launchClaimedInstance(ctx context.Context, instanc
|
||||
slog.ErrorContext(ctx, "launchClaimedInstance: instance type not available on this node",
|
||||
"instanceId", instance.ID, "instanceType", instance.InstanceType)
|
||||
undo()
|
||||
return s.capacityRefusal(ctx, instance)
|
||||
return errors.New(awserrors.ErrorInsufficientInstanceCapacity)
|
||||
}
|
||||
if err := s.resourceMgr.Allocate(instanceType); err != nil {
|
||||
slog.ErrorContext(ctx, "launchClaimedInstance: failed to allocate resources", "instanceId", instance.ID, "err", err)
|
||||
undo()
|
||||
return s.capacityRefusal(ctx, instance)
|
||||
return errors.New(awserrors.ErrorInsufficientInstanceCapacity)
|
||||
}
|
||||
|
||||
// Add to local map + clear the stop intent before launch.
|
||||
@@ -2705,7 +2638,7 @@ func (s *InstanceServiceImpl) launchClaimedInstance(ctx context.Context, instanc
|
||||
s.resourceMgr.Deallocate(instanceType)
|
||||
s.vmMgr.Delete(instance.ID)
|
||||
undo()
|
||||
return s.capacityRefusal(ctx, instance)
|
||||
return errors.New(awserrors.ErrorInsufficientInstanceCapacity)
|
||||
}
|
||||
profileName := instancetypes.MIGProfileFromType(aws.StringValue(instanceType.InstanceType))
|
||||
gpuCount := instancetypes.GPUCountForType(instance.InstanceType)
|
||||
@@ -2729,7 +2662,7 @@ func (s *InstanceServiceImpl) launchClaimedInstance(ctx context.Context, instanc
|
||||
s.resourceMgr.Deallocate(instanceType)
|
||||
s.vmMgr.Delete(instance.ID)
|
||||
undo()
|
||||
return s.capacityRefusal(ctx, instance)
|
||||
return errors.New(awserrors.ErrorInsufficientInstanceCapacity)
|
||||
}
|
||||
instance.GPUAttachments = attachments
|
||||
gpuClaimed = true
|
||||
@@ -2813,11 +2746,11 @@ func (s *InstanceServiceImpl) RecoverInstance(ctx context.Context, instance *vm.
|
||||
}
|
||||
|
||||
// volumeExclusionMarkers are the refusals that mean another node is writing this
|
||||
// volume right now, or is still live and holds its unsealed writes. A previous
|
||||
// owner that has stopped heartbeating is opened over with a warning instead. The
|
||||
// text arrives having crossed NATS from viperblockd.
|
||||
// volume right now. Only the lease refuses: a volume whose writes are merely
|
||||
// unsealed is opened here with a warning rather than blocked, so the instance
|
||||
// still runs. The text arrives having crossed NATS from viperblockd.
|
||||
var volumeExclusionMarkers = []string{
|
||||
ec2v1.VolumeHeldElsewhereText,
|
||||
"volume is leased by another owner",
|
||||
}
|
||||
|
||||
// ErrVolumeHeldElsewhere reports a launch refused because another node still
|
||||
|
||||
@@ -88,11 +88,9 @@ type SystemInstanceInput struct {
|
||||
IamInstanceProfileArn string `json:"iam_instance_profile_arn,omitempty"`
|
||||
|
||||
// Kind selects a non-load-balancer direct-boot guest; empty is the load
|
||||
// balancer. SQSStore carries the KindSQSStore guest's volume and key, and
|
||||
// SQSWorker the KindSQSWorker guest's volume and store decision.
|
||||
Kind Kind `json:"kind,omitempty"`
|
||||
SQSStore *SQSStoreInput `json:"sqs_store,omitempty"`
|
||||
SQSWorker *SQSWorkerInput `json:"sqs_worker,omitempty"`
|
||||
// balancer. SQSStore carries the KindSQSStore guest's volume and key.
|
||||
Kind Kind `json:"kind,omitempty"`
|
||||
SQSStore *SQSStoreInput `json:"sqs_store,omitempty"`
|
||||
}
|
||||
|
||||
// ExtraENIInput describes an additional pre-created ENI to attach to a system
|
||||
|
||||
@@ -54,8 +54,6 @@ func (in *SystemInstanceInput) ValidateSQSStore() error {
|
||||
return fmt.Errorf("%w: boot mode %q, the store boots direct only", ErrInvalidSQSStoreInput, in.BootMode)
|
||||
case in.SQSStore == nil:
|
||||
return fmt.Errorf("%w: no data volume or client key", ErrInvalidSQSStoreInput)
|
||||
case in.SQSWorker != nil:
|
||||
return fmt.Errorf("%w: SQS worker settings on an SQS store launch", ErrInvalidSQSStoreInput)
|
||||
case !volumeIDPattern.MatchString(in.SQSStore.DataVolumeID):
|
||||
return fmt.Errorf("%w: data volume ID %q is malformed", ErrInvalidSQSStoreInput, in.SQSStore.DataVolumeID)
|
||||
case !nkeys.IsValidPublicUserKey(in.SQSStore.ClientNKey):
|
||||
@@ -71,7 +69,7 @@ func (in *SystemInstanceInput) ValidateSQSStore() error {
|
||||
// RefuseNonLoadBalancerKind keeps every non-load-balancer guest off the
|
||||
// legacy launch path, which would boot the load balancer image instead.
|
||||
func (in *SystemInstanceInput) RefuseNonLoadBalancerKind() error {
|
||||
if in.Kind != "" || in.SQSStore != nil || in.SQSWorker != nil {
|
||||
if in.Kind != "" || in.SQSStore != nil {
|
||||
return fmt.Errorf("%w: kind %q must use its own launch subject", ErrInvalidSQSStoreInput, in.Kind)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package systeminstance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// KindSQSWorker is the shared SQS worker guest. It holds the store's private
|
||||
// key on its own data volume and reaches the gateway over its mgmt NIC.
|
||||
const KindSQSWorker Kind = "sqs-worker"
|
||||
|
||||
// SQSWorkerInput is what an SQS worker guest needs beyond its network: its
|
||||
// existing encrypted data volume and the Region it signs gateway calls for.
|
||||
// The first-store decision is not here: the guest asks the gateway at boot.
|
||||
type SQSWorkerInput struct {
|
||||
DataVolumeID string `json:"data_volume_id"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
var regionPattern = regexp.MustCompile(`^[a-z]{2}(-[a-z]+)+-[0-9]+$`)
|
||||
|
||||
// ErrInvalidSQSWorkerInput is returned for a malformed SQS worker launch.
|
||||
var ErrInvalidSQSWorkerInput = errors.New("sysinstance: invalid SQS worker launch")
|
||||
|
||||
// ValidateSQSWorker checks an SQS worker launch's shape. The worker must have
|
||||
// an instance profile and a mgmt NIC placeholder: it has no other path to the
|
||||
// gateway and no static credential.
|
||||
func (in *SystemInstanceInput) ValidateSQSWorker() error {
|
||||
switch {
|
||||
case in.Kind != KindSQSWorker:
|
||||
return fmt.Errorf("%w: kind %q is not %q", ErrInvalidSQSWorkerInput, in.Kind, KindSQSWorker)
|
||||
case in.BootMode != "" && in.BootMode != BootDirect:
|
||||
return fmt.Errorf("%w: boot mode %q, the worker boots direct only", ErrInvalidSQSWorkerInput, in.BootMode)
|
||||
case in.SQSWorker == nil:
|
||||
return fmt.Errorf("%w: no data volume or Region", ErrInvalidSQSWorkerInput)
|
||||
case in.SQSStore != nil:
|
||||
return fmt.Errorf("%w: SQS store settings on an SQS worker launch", ErrInvalidSQSWorkerInput)
|
||||
case in.IamInstanceProfileArn == "":
|
||||
return fmt.Errorf("%w: no instance profile", ErrInvalidSQSWorkerInput)
|
||||
case in.LBAgentEnv != "" || in.CACert != "" || in.Scheme != "":
|
||||
// The daemon supplies the CA from its own trust store, never the caller.
|
||||
return fmt.Errorf("%w: load balancer settings on an SQS worker launch", ErrInvalidSQSWorkerInput)
|
||||
case len(in.NICs) != 2 || !in.NICs[0].IsDefault || in.NICs[1].IsDefault || in.NICs[1].MAC != "" || in.NICs[1].CIDR != "":
|
||||
return fmt.Errorf("%w: want a default VPC NIC and an unallocated mgmt NIC", ErrInvalidSQSWorkerInput)
|
||||
}
|
||||
return in.SQSWorker.Validate()
|
||||
}
|
||||
|
||||
// Validate checks the worker's volume and Region. It reads nothing.
|
||||
func (w *SQSWorkerInput) Validate() error {
|
||||
if !volumeIDPattern.MatchString(w.DataVolumeID) {
|
||||
return fmt.Errorf("%w: data volume ID %q is malformed", ErrInvalidSQSWorkerInput, w.DataVolumeID)
|
||||
}
|
||||
if !regionPattern.MatchString(w.Region) {
|
||||
// Not echoed: a caller-supplied value may be anything, a seed included.
|
||||
return fmt.Errorf("%w: Region is malformed", ErrInvalidSQSWorkerInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -103,10 +103,8 @@ func (s *racingDeleteStore) DeleteObject(ctx context.Context, input *s3.DeleteOb
|
||||
return s.MemoryObjectStore.DeleteObject(ctx, input)
|
||||
}
|
||||
|
||||
// A document another sweep removed after this one read it is deleted, so the
|
||||
// reclaimer must count it done rather than fail the sweep for a finished
|
||||
// delete. The record is the obligation, so retiring it is the reclaimer's step
|
||||
// and this is where a concurrent remover is now observed.
|
||||
// A document another delete removed after this one read it is deleted, so the
|
||||
// call must succeed rather than answer InternalError for a finished delete.
|
||||
func TestDeleteVolume_ConcurrentlyDeletedDocumentIsSuccess(t *testing.T) {
|
||||
memory := objectstore.NewMemoryObjectStore()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", memory)
|
||||
@@ -117,18 +115,14 @@ func TestDeleteVolume_ConcurrentlyDeletedDocumentIsSuccess(t *testing.T) {
|
||||
VolumeID: volumeID, CapacityGiB: 10, State: "available",
|
||||
})
|
||||
|
||||
_, err := svc.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{
|
||||
VolumeId: aws.String(volumeID),
|
||||
}, testVolAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
racing := &racingDeleteStore{MemoryObjectStore: memory}
|
||||
svc.store = racing
|
||||
svc.metadata = ebsmetadata.NewStore(racing, "test-bucket")
|
||||
|
||||
reclaimed, err := svc.NewVolumeReclaimReaper().Sweep(context.Background())
|
||||
require.NoError(t, err, "a document a concurrent sweep removed is a finished delete, not a sweep failure")
|
||||
assert.Equal(t, 1, reclaimed)
|
||||
_, err := svc.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{
|
||||
VolumeId: aws.String(volumeID),
|
||||
}, testVolAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, getErr := ebsmetadata.NewStore(memory, "test-bucket").GetVolume(context.Background(), testVolAccountID, volumeID)
|
||||
assert.True(t, objectstore.IsNoSuchKeyError(getErr), "the document must be gone, got %v", getErr)
|
||||
|
||||
@@ -97,52 +97,6 @@ func TestRLC3_VolumeDeleteRequiresDetach(t *testing.T) {
|
||||
"ADR-0003:S2 behaviour; historic PROP-LIFECYCLE-005 §2 mechanism: deleting an attached volume must return VolumeInUse (detach-before-delete, rule #3)")
|
||||
}
|
||||
|
||||
// TestS2_VolumeDeleteRecordsTheObligationBeforeRemovingTheRecord enforces
|
||||
// ADR-0003:S2's delete contract: "Delete first creates a durable obligation and
|
||||
// prevents new dependants ... The active record is removed only when all
|
||||
// required cleanup is durably complete."
|
||||
//
|
||||
// A delete that removes the record as part of the request cannot satisfy this,
|
||||
// because the cleanup it depends on costs one round trip per object and so has
|
||||
// no bound a request can carry. On prod a 100 GiB volume's delete overran its
|
||||
// 30 s budget, the tenant was told it failed, and the teardown that had been
|
||||
// started was left with no record accountable for it.
|
||||
func TestS2_VolumeDeleteRecordsTheObligationBeforeRemovingTheRecord(t *testing.T) {
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
|
||||
svc.snapshotKV = setupTestVolumeKV(t)
|
||||
seedVolume(t, svc, "vol-obligation000", "available", "")
|
||||
|
||||
_, err := svc.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{VolumeId: aws.String("vol-obligation000")}, testVolAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
owed, err := svc.GetVolumeMetadata(testVolAccountID, "vol-obligation000")
|
||||
require.NoErrorf(t, err, "ADR-0003:S2: the active record must survive the request so the obligation stays readable (service_impl.go DeleteVolume: record the state, do not remove the document)")
|
||||
assert.Equalf(t, volumeStateDeleting, owed.State,
|
||||
"ADR-0003:S2: delete must first create a durable obligation (service_impl.go DeleteVolume: write State=%q before returning)", volumeStateDeleting)
|
||||
|
||||
// The obligation is what callers see, so describe must report it rather than
|
||||
// the available state the volume no longer has. The attach refusal reads the
|
||||
// same field (runtime/daemon/daemon_handlers_volume.go: State != "available").
|
||||
described, err := svc.DescribeVolumes(context.Background(), &ec2.DescribeVolumesInput{
|
||||
VolumeIds: []*string{aws.String("vol-obligation000")},
|
||||
}, testVolAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, described.Volumes, 1)
|
||||
assert.Equalf(t, volumeStateDeleting, aws.StringValue(described.Volumes[0].State),
|
||||
"ADR-0003:S2: a delete must prevent new dependants, and %q is the state every dependant path gates on", volumeStateDeleting)
|
||||
|
||||
// A second delete adds nothing rather than racing the first teardown.
|
||||
_, err = svc.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{VolumeId: aws.String("vol-obligation000")}, testVolAccountID)
|
||||
require.NoErrorf(t, err, "ADR-0003:S2: the obligation is durable, so a repeated delete is answered from it (service_impl.go DeleteVolume: return early on State==%q)", volumeStateDeleting)
|
||||
|
||||
reclaimed, err := svc.NewVolumeReclaimReaper().Sweep(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, reclaimed)
|
||||
_, err = svc.GetVolumeMetadata(testVolAccountID, "vol-obligation000")
|
||||
require.Errorf(t, err, "ADR-0003:S2: the active record is removed once cleanup is durably complete (volume_reclaim_reaper.go Sweep: delete the document last)")
|
||||
}
|
||||
|
||||
// TestVolumeTagMirror_WritesOnlyControlPlaneDocument locks the single-writer
|
||||
// invariant: tag mutations must never rewrite config.json or route an encrypted
|
||||
// volume through the ebs.config keyholder queue.
|
||||
|
||||
@@ -33,11 +33,6 @@ import (
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// volumeStateDeleting is the state a delete records as its durable obligation.
|
||||
// The record carries it until the teardown is complete, which is what makes a
|
||||
// delete survive a restart and what prevents a second one repeating the work.
|
||||
const volumeStateDeleting = string(ebsprovider.VolumeStateDeleting)
|
||||
|
||||
// Ensure VolumeServiceImpl implements VolumeService.
|
||||
var _ VolumeService = (*VolumeServiceImpl)(nil)
|
||||
|
||||
@@ -1083,14 +1078,6 @@ func (s *VolumeServiceImpl) ModifyVolume(ctx context.Context, input *ec2.ModifyV
|
||||
// is always a metadata-only clear, never a QMP call. Errors from either step
|
||||
// are returned, not swallowed.
|
||||
func (s *VolumeServiceImpl) DeleteVolumeOnTerminate(ctx context.Context, volumeID, accountID string) error {
|
||||
// Clearing the attachment would overwrite a recorded deletion, so a volume
|
||||
// already owed is left alone: the reclaimer owns it from here, and resetting
|
||||
// it to available would let a second teardown run against the same data.
|
||||
meta, err := s.metadata.GetVolume(ctx, accountID, volumeID)
|
||||
if err == nil && meta.State == volumeStateDeleting {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.UpdateVolumeState(accountID, volumeID, "available", "", ""); err != nil {
|
||||
// No metadata doc means the volume is already gone, per GetVolume's own
|
||||
// contract, so absence here is success, not a teardown failure.
|
||||
@@ -1099,7 +1086,7 @@ func (s *VolumeServiceImpl) DeleteVolumeOnTerminate(ctx context.Context, volumeI
|
||||
}
|
||||
return fmt.Errorf("clear attachment before terminate delete: %w", err)
|
||||
}
|
||||
_, err = s.DeleteVolume(ctx, &ec2.DeleteVolumeInput{VolumeId: &volumeID}, accountID)
|
||||
_, err := s.DeleteVolume(ctx, &ec2.DeleteVolumeInput{VolumeId: &volumeID}, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1167,17 +1154,8 @@ func (s *VolumeServiceImpl) ForceDetachVolume(ctx context.Context, input *ec2.De
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteVolume records a durable obligation to destroy an EBS volume and
|
||||
// returns, matching AWS: DeleteVolume's output is an empty shape and the
|
||||
// documented state machine has the volume "remain in the deleting state for
|
||||
// several minutes". It validates, decides every AWS-returnable error, moves the
|
||||
// volume to "deleting", and leaves the object teardown to VolumeReclaimReaper.
|
||||
//
|
||||
// The caller never waits on data. Object deletion costs one round trip per
|
||||
// object, so the old synchronous form made this call's latency a function of
|
||||
// the volume's size: no timeout can be both short enough for a control-plane
|
||||
// request and long enough for a large volume, and the overrun contributed no
|
||||
// error a caller could act on.
|
||||
// DeleteVolume deletes an EBS volume: validates state, asks the EBS provider to
|
||||
// destroy the backing data, then removes the control-plane metadata document.
|
||||
func (s *VolumeServiceImpl) DeleteVolume(ctx context.Context, input *ec2.DeleteVolumeInput, accountID string) (*ec2.DeleteVolumeOutput, error) {
|
||||
if input == nil || input.VolumeId == nil || *input.VolumeId == "" {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
@@ -1204,14 +1182,6 @@ func (s *VolumeServiceImpl) DeleteVolume(ctx context.Context, input *ec2.DeleteV
|
||||
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
|
||||
}
|
||||
|
||||
// The obligation is already recorded, so this request has nothing to add.
|
||||
// That makes a tenant's retry and DeleteOnTermination's own delete for the
|
||||
// same volume collapse into one, rather than racing each other's teardown.
|
||||
if meta.State == volumeStateDeleting {
|
||||
slog.InfoContext(ctx, "DeleteVolume: already deleting", "volumeId", volumeID)
|
||||
return &ec2.DeleteVolumeOutput{}, nil
|
||||
}
|
||||
|
||||
// An unattached volume is deletable. State must be "available" OR empty: a
|
||||
// detach/terminate that failed to write back "available" leaves the State
|
||||
// drifted to empty with no attachment, and gating on State=="available"
|
||||
@@ -1226,16 +1196,19 @@ func (s *VolumeServiceImpl) DeleteVolume(ctx context.Context, input *ec2.DeleteV
|
||||
if err := s.checkVolumeHasNoSnapshots(ctx, volumeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Last write of the request, and the only one that can fail from here: the
|
||||
// record is the obligation, so until this lands nothing is owed and the
|
||||
// caller must not be told otherwise.
|
||||
if err := s.UpdateVolumeState(accountID, volumeID, volumeStateDeleting, "", ""); err != nil {
|
||||
slog.ErrorContext(ctx, "DeleteVolume: failed to record the deletion", "volumeId", volumeID, "err", err)
|
||||
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "mark volume %s deleting: %v", volumeID, err)
|
||||
if err := s.provider.DeleteVolume(ctx, ebsprovider.DeleteVolumeRequest{
|
||||
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, Handle: meta.ProviderHandle,
|
||||
}); err != nil {
|
||||
slog.ErrorContext(ctx, "DeleteVolume: provider deletion failed", "volumeId", volumeID, "err", err)
|
||||
// The terminate reaper retries this every two minutes and logs whatever
|
||||
// it gets back, so a bare code makes a persistent fault unreadable.
|
||||
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "delete volume %s: %v", volumeID, err)
|
||||
}
|
||||
if err := s.metadata.DeleteVolume(ctx, accountID, volumeID); err != nil && !objectstore.IsNoSuchKeyError(err) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "delete volume metadata %s: %v", volumeID, err)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "DeleteVolume accepted", "volumeId", volumeID)
|
||||
slog.InfoContext(ctx, "DeleteVolume completed", "volumeId", volumeID)
|
||||
|
||||
return &ec2.DeleteVolumeOutput{}, nil
|
||||
}
|
||||
|
||||
@@ -326,12 +326,6 @@ func TestCreateVolume_UsesInjectedProvider(t *testing.T) {
|
||||
require.NoError(t, svc.UpdateVolumeState(testVolAccountID, aws.StringValue(vol.VolumeId), "available", "", ""))
|
||||
_, err = svc.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{VolumeId: vol.VolumeId}, testVolAccountID)
|
||||
require.NoError(t, err)
|
||||
deleting, err := svc.metadata.GetVolume(context.Background(), testVolAccountID, aws.StringValue(vol.VolumeId))
|
||||
require.NoError(t, err, "the record carries the obligation until the teardown is complete")
|
||||
assert.Equal(t, "deleting", deleting.State)
|
||||
reclaimed, err := svc.NewVolumeReclaimReaper().Sweep(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, reclaimed)
|
||||
_, err = svc.metadata.GetVolume(context.Background(), testVolAccountID, aws.StringValue(vol.VolumeId))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -1770,16 +1764,8 @@ func TestDeleteVolumeOnTerminate_ClearsAttachmentThenDeletes(t *testing.T) {
|
||||
err := svc.DeleteVolumeOnTerminate(context.Background(), volumeID, testVolAccountID)
|
||||
require.NoError(t, err, "terminate implies detach: a stale attachment must not block the terminate delete")
|
||||
|
||||
deleting, err := svc.GetVolumeMetadata(testVolAccountID, volumeID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "deleting", deleting.State, "the volume must be recorded as deleting, not merely detached")
|
||||
assert.Empty(t, deleting.AttachedInstance)
|
||||
|
||||
reclaimed, err := svc.NewVolumeReclaimReaper().Sweep(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, reclaimed)
|
||||
_, err = svc.GetVolumeMetadata(testVolAccountID, volumeID)
|
||||
require.Error(t, err, "the volume must actually be deleted once the teardown completes")
|
||||
require.Error(t, err, "the volume must actually be deleted, not merely detached")
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorInvalidVolumeNotFound)
|
||||
}
|
||||
|
||||
@@ -1823,12 +1809,11 @@ func (p *failingDeleteProvider) DeleteVolume(context.Context, ebsprovider.Delete
|
||||
return p.err
|
||||
}
|
||||
|
||||
// TestDeleteVolume_ProviderFailureDoesNotFailTheRequest pins the behaviour a
|
||||
// 100 GiB volume on prod needed: an unreachable blob node must not turn an
|
||||
// accepted delete into an error the tenant retries. The obligation is already
|
||||
// recorded, so the request succeeds and the teardown is the reclaimer's to
|
||||
// retry until it lands.
|
||||
func TestDeleteVolume_ProviderFailureDoesNotFailTheRequest(t *testing.T) {
|
||||
// TestDeleteVolume_ProviderFailureCarriesItsCause pins that a provider delete
|
||||
// failure keeps its cause on the way out. The terminate reaper retries this
|
||||
// every two minutes and logs whatever it is handed, so a bare ServerInternal
|
||||
// makes a persistent fault indistinguishable from a transient one.
|
||||
func TestDeleteVolume_ProviderFailureCarriesItsCause(t *testing.T) {
|
||||
kv := setupTestVolumeKV(t)
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
|
||||
@@ -1844,74 +1829,12 @@ func TestDeleteVolume_ProviderFailureDoesNotFailTheRequest(t *testing.T) {
|
||||
})
|
||||
|
||||
_, err := svc.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{VolumeId: aws.String(volumeID)}, testVolAccountID)
|
||||
require.NoError(t, err, "a provider that cannot be reached must not fail a delete the control plane has accepted")
|
||||
require.Error(t, err)
|
||||
|
||||
meta, err := svc.metadata.GetVolume(context.Background(), testVolAccountID, volumeID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "deleting", meta.State)
|
||||
|
||||
reclaimed, err := svc.NewVolumeReclaimReaper().Sweep(context.Background())
|
||||
require.NoError(t, err, "a failed teardown is retried, not reported as a sweep failure")
|
||||
assert.Equal(t, 0, reclaimed)
|
||||
meta, err = svc.metadata.GetVolume(context.Background(), testVolAccountID, volumeID)
|
||||
require.NoError(t, err, "the record survives so the obligation is still readable")
|
||||
assert.Equal(t, "deleting", meta.State)
|
||||
|
||||
// The provider comes back, and the same record is what drives the retry.
|
||||
svc.SetEBSProvider(&failingDeleteProvider{EBSProvider: svc.provider, err: nil})
|
||||
reclaimed, err = svc.NewVolumeReclaimReaper().Sweep(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, reclaimed)
|
||||
_, err = svc.metadata.GetVolume(context.Background(), testVolAccountID, volumeID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// TestDeleteVolume_SecondDeleteIsAcceptedWithoutASecondTeardown pins the dedupe
|
||||
// the deleting state buys. A tenant retry and DeleteOnTermination's own delete
|
||||
// both arrive for the same volume, and on prod three destructive handlers ran
|
||||
// one volume's teardown concurrently because nothing collapsed them.
|
||||
func TestDeleteVolume_SecondDeleteIsAcceptedWithoutASecondTeardown(t *testing.T) {
|
||||
kv := setupTestVolumeKV(t)
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
|
||||
svc.snapshotKV = kv
|
||||
|
||||
volumeID := "vol-deleted-twice"
|
||||
createVolumeInStoreWithMeta(t, svc, store, volumeID, ebsmetadata.Volume{
|
||||
VolumeID: volumeID, CapacityGiB: 10, State: "available",
|
||||
})
|
||||
counting := &countingDeleteProvider{EBSProvider: svc.provider}
|
||||
svc.SetEBSProvider(counting)
|
||||
|
||||
for range 3 {
|
||||
_, err := svc.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{VolumeId: aws.String(volumeID)}, testVolAccountID)
|
||||
require.NoError(t, err, "AWS answers a repeated delete with the same empty success")
|
||||
}
|
||||
assert.Equal(t, 0, counting.calls, "no teardown runs in the request path at all")
|
||||
|
||||
// DeleteOnTermination arriving for a volume already owed must not reset it
|
||||
// to available, which would let a second teardown through.
|
||||
require.NoError(t, svc.DeleteVolumeOnTerminate(context.Background(), volumeID, testVolAccountID))
|
||||
meta, err := svc.metadata.GetVolume(context.Background(), testVolAccountID, volumeID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "deleting", meta.State)
|
||||
|
||||
reclaimed, err := svc.NewVolumeReclaimReaper().Sweep(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, reclaimed)
|
||||
assert.Equal(t, 1, counting.calls, "one recorded obligation is one teardown")
|
||||
}
|
||||
|
||||
// countingDeleteProvider counts the deletes that reach the provider boundary.
|
||||
type countingDeleteProvider struct {
|
||||
ebsprovider.EBSProvider
|
||||
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *countingDeleteProvider) DeleteVolume(context.Context, ebsprovider.DeleteVolumeRequest) error {
|
||||
p.calls++
|
||||
return nil
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, awserrors.ValidErrorCodeFromError(err),
|
||||
"the code stays ServerInternal so callers classify it unchanged")
|
||||
assert.Contains(t, err.Error(), "dial node 6",
|
||||
"an operator reading the reaper log must learn why the delete keeps failing")
|
||||
}
|
||||
|
||||
// TestDeleteVolumeOnTerminate_SurfacesDeleteFailure verifies a DeleteVolume
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
ebsprovider "github.com/mulgadc/spinifex/spinifex/providers/ebs"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
)
|
||||
|
||||
// VolumeReclaimReaper converges the volumes a tenant has already asked to
|
||||
// delete. DeleteVolume records the obligation as State="deleting" and returns,
|
||||
// so the object teardown runs here: its cost is one round trip per object and
|
||||
// therefore a function of the volume, which is not work a caller can be held
|
||||
// on. The active record is removed only once the teardown reports complete.
|
||||
//
|
||||
// Its licence to delete volume data is narrow and is not VolumeLeakReaper's.
|
||||
// That reaper may only mark and alarm because it would be deciding, on its own
|
||||
// judgement, that a volume is garbage. This one decides nothing: the tenant
|
||||
// decided, the decision is recorded on the volume itself, and the sweep only
|
||||
// finishes what the API accepted.
|
||||
type VolumeReclaimReaper struct {
|
||||
svc *VolumeServiceImpl
|
||||
}
|
||||
|
||||
var _ vm.Reaper = (*VolumeReclaimReaper)(nil)
|
||||
|
||||
// NewVolumeReclaimReaper builds the reclaimer for volumes recorded as deleting.
|
||||
func (s *VolumeServiceImpl) NewVolumeReclaimReaper() *VolumeReclaimReaper {
|
||||
return &VolumeReclaimReaper{svc: s}
|
||||
}
|
||||
|
||||
func (r *VolumeReclaimReaper) Class() string { return "volume-reclaim" }
|
||||
|
||||
// Scope is cluster-wide, which is also what serialises the teardown. A delete
|
||||
// that ran on every node would put several destructive handlers on one volume's
|
||||
// objects at once, which is what the synchronous path did when a caller retried.
|
||||
func (r *VolumeReclaimReaper) Scope() vm.ReaperScope { return vm.ScopeClusterWide }
|
||||
|
||||
// Sweep finishes every recorded deletion it can and reports how many records it
|
||||
// retired. A volume whose teardown fails keeps its record and is retried on the
|
||||
// next sweep: predastore being slow, saturated or down delays reclamation and
|
||||
// must not undo a delete the tenant has already been told succeeded.
|
||||
func (r *VolumeReclaimReaper) Sweep(ctx context.Context) (int, error) {
|
||||
if r.svc.provider == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// The sweep has no caller account, so each document supplies the account its
|
||||
// own key is under.
|
||||
volumes, err := r.svc.metadata.ListAllVolumes(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
reclaimed := 0
|
||||
for _, meta := range volumes {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return reclaimed, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if meta.State != volumeStateDeleting {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := r.svc.provider.DeleteVolume(ctx, ebsprovider.DeleteVolumeRequest{
|
||||
Versioned: ebsprovider.NewVersioned(),
|
||||
VolumeID: meta.VolumeID,
|
||||
Handle: meta.ProviderHandle,
|
||||
}); err != nil {
|
||||
slog.WarnContext(ctx, "volume-reclaim: teardown incomplete, retrying next sweep",
|
||||
"volumeId", meta.VolumeID, "accountId", meta.TenantID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Removing the record is the last step by design: while it exists the
|
||||
// obligation is readable, so a sweep interrupted anywhere above resumes
|
||||
// rather than leaving objects nobody is accountable for.
|
||||
if err := r.svc.metadata.DeleteVolume(ctx, meta.TenantID, meta.VolumeID); err != nil && !objectstore.IsNoSuchKeyError(err) {
|
||||
slog.ErrorContext(ctx, "volume-reclaim: failed to remove the volume record",
|
||||
"volumeId", meta.VolumeID, "accountId", meta.TenantID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "volume-reclaim: deletion complete",
|
||||
"volumeId", meta.VolumeID, "accountId", meta.TenantID)
|
||||
reclaimed++
|
||||
}
|
||||
return reclaimed, nil
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
package ecrregistry
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/policy"
|
||||
)
|
||||
|
||||
// TokenVerifier verifies a registry token's signature, expiry, issuer and audience.
|
||||
type TokenVerifier interface {
|
||||
Verify(raw string) (*ecrauth.Claims, error)
|
||||
}
|
||||
|
||||
// TokenIssuer mints a registry token.
|
||||
type TokenIssuer interface {
|
||||
Mint(p ecrauth.Principal) (token string, expiresAt time.Time, err error)
|
||||
}
|
||||
|
||||
// Principal is the caller a registry token resolves to, as current identity state
|
||||
// reports it. The fields mirror what authorization needs to build its condition keys.
|
||||
type Principal struct {
|
||||
Identity string
|
||||
AccountID string
|
||||
PrincipalType string
|
||||
AssumedRoleARN string
|
||||
AssumedRoleID string
|
||||
UnderlyingRoleARN string
|
||||
UserID string
|
||||
UserARN string
|
||||
// CallerARN is the caller's canonical ARN, which a token's subject must equal.
|
||||
CallerARN string
|
||||
}
|
||||
|
||||
// ErrInvalidCredential marks an identity that no longer resolves: revoked, deleted or
|
||||
// expired. It answers 401. ErrUnavailable marks a dependency fault and answers 503, so
|
||||
// an outage is never read as a denial or an allow.
|
||||
var (
|
||||
ErrInvalidCredential = errors.New("invalid credential")
|
||||
ErrUnavailable = errors.New("identity dependency unavailable")
|
||||
)
|
||||
|
||||
// Rehydrator rebuilds the principal behind an access key from current identity state.
|
||||
// A failure wraps ErrInvalidCredential or ErrUnavailable.
|
||||
type Rehydrator interface {
|
||||
Rehydrate(accessKeyID, accountID string) (Principal, error)
|
||||
}
|
||||
|
||||
// Authorizer evaluates one IAM action against resources for a principal. A dependency
|
||||
// fault wraps ErrUnavailable; any other error is a denial.
|
||||
type Authorizer interface {
|
||||
Authorize(r *http.Request, p Principal, iamAction string, resources []string) error
|
||||
}
|
||||
|
||||
// Admission reports whether an account may proceed. An error means its status could
|
||||
// not be read.
|
||||
type Admission interface {
|
||||
Active(accountID, logIP string) (bool, error)
|
||||
}
|
||||
|
||||
// BridgeConfig is what a Bridge needs. Everything but Registry is required.
|
||||
type BridgeConfig struct {
|
||||
// Registry serves an authorized request. Nil answers 501.
|
||||
Registry http.Handler
|
||||
// Verifier and Issuer are the registry token pair.
|
||||
Verifier TokenVerifier
|
||||
Issuer TokenIssuer
|
||||
// Rehydrator rebuilds the token's principal from current identity state.
|
||||
Rehydrator Rehydrator
|
||||
Authorizer Authorizer
|
||||
Admission Admission
|
||||
// Target reports the account and region the request's registry host names.
|
||||
Target func(r *http.Request) (account, region string, ok bool)
|
||||
// ClientIP reports the client address, for logging.
|
||||
ClientIP func(r *http.Request) string
|
||||
// Region is the region repository ARNs are built in.
|
||||
Region string
|
||||
}
|
||||
|
||||
// Bridge serves the OCI /v2 surface: token authentication, principal rehydration,
|
||||
// per-operation authorization and the token exchange endpoint. It is not SigV4 and
|
||||
// is mounted outside the AWS seam.
|
||||
type Bridge struct {
|
||||
registry http.Handler
|
||||
verifier TokenVerifier
|
||||
issuer TokenIssuer
|
||||
rehydrator Rehydrator
|
||||
authorizer Authorizer
|
||||
admission Admission
|
||||
target func(r *http.Request) (account, region string, ok bool)
|
||||
clientIP func(r *http.Request) string
|
||||
region string
|
||||
}
|
||||
|
||||
// NewBridge returns a Bridge. A missing verifier, issuer, rehydrator, authorizer or
|
||||
// admission is a composition fault, so it is refused rather than served open.
|
||||
func NewBridge(cfg BridgeConfig) (*Bridge, error) {
|
||||
switch {
|
||||
case cfg.Verifier == nil:
|
||||
return nil, errors.New("registry bridge: token verifier is required")
|
||||
case cfg.Issuer == nil:
|
||||
return nil, errors.New("registry bridge: token issuer is required")
|
||||
case cfg.Rehydrator == nil:
|
||||
return nil, errors.New("registry bridge: principal rehydrator is required")
|
||||
case cfg.Authorizer == nil:
|
||||
return nil, errors.New("registry bridge: authorizer is required")
|
||||
case cfg.Admission == nil:
|
||||
return nil, errors.New("registry bridge: account admission is required")
|
||||
case cfg.Target == nil:
|
||||
return nil, errors.New("registry bridge: host target resolver is required")
|
||||
case cfg.ClientIP == nil:
|
||||
return nil, errors.New("registry bridge: client address resolver is required")
|
||||
}
|
||||
return &Bridge{
|
||||
registry: cfg.Registry,
|
||||
verifier: cfg.Verifier,
|
||||
issuer: cfg.Issuer,
|
||||
rehydrator: cfg.Rehydrator,
|
||||
authorizer: cfg.Authorizer,
|
||||
admission: cfg.Admission,
|
||||
target: cfg.Target,
|
||||
clientIP: cfg.ClientIP,
|
||||
region: cfg.Region,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Handler serves the /v2 subtree. It expects the full request path, so mount it at
|
||||
// /v2 without stripping the prefix.
|
||||
func (b *Bridge) Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/v2/token" {
|
||||
b.serveToken(w, r)
|
||||
return
|
||||
}
|
||||
b.serveRegistry(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// serveRegistry runs authentication, then operation authorization, then the registry.
|
||||
func (b *Bridge) serveRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
claims, principal, ok := b.authenticate(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r = r.WithContext(WithAuthAccount(r.Context(), claims.AccountID))
|
||||
|
||||
op, ok := ClassifyOperation(r.Method, r.URL.Path, r.URL.Query())
|
||||
if !ok {
|
||||
// Registry itself would 404 this path/method combination; refusing it here
|
||||
// too means an operation added to Registry without a matching classifier
|
||||
// case can never dispatch unauthorized.
|
||||
WriteError(w, http.StatusNotFound, "NAME_UNKNOWN", "unrecognized registry operation")
|
||||
return
|
||||
}
|
||||
|
||||
for _, req := range op.Requirements {
|
||||
resource, err := resourceARN(b.region, principal.AccountID, req.Scope, op)
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusServiceUnavailable, "UNKNOWN", "authorization unavailable")
|
||||
return
|
||||
}
|
||||
if err := b.authorizer.Authorize(r, principal, policy.IAMAction("ecr", req.Action), []string{resource}); err != nil {
|
||||
if errors.Is(err, ErrUnavailable) {
|
||||
// IAM/STS/NATS dependency failure: fail closed, never dispatch.
|
||||
WriteError(w, http.StatusServiceUnavailable, "UNKNOWN", "authorization unavailable")
|
||||
return
|
||||
}
|
||||
WriteError(w, http.StatusForbidden, "DENIED", "insufficient permissions")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.Method == http.MethodGet && (r.URL.Path == "/v2" || r.URL.Path == "/v2/"):
|
||||
APIVersion(w, r)
|
||||
case b.registry != nil:
|
||||
b.registry.ServeHTTP(w, r)
|
||||
default:
|
||||
NotImplemented(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// authenticate verifies the request's registry token and rehydrates its principal
|
||||
// against current identity state, so a revoked key, session, user, role or account
|
||||
// takes effect on this request even though the token is still valid and unexpired.
|
||||
// It writes the failure response and reports false when the request must stop.
|
||||
// challenge selects the 401 Bearer challenge; the token endpoint is itself the
|
||||
// challenge target, so it answers a bare 401.
|
||||
func (b *Bridge) authenticate(w http.ResponseWriter, r *http.Request, challenge bool) (*ecrauth.Claims, Principal, bool) {
|
||||
deny := func(message string) {
|
||||
if challenge {
|
||||
writeChallenge(w, r)
|
||||
return
|
||||
}
|
||||
WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", message)
|
||||
}
|
||||
|
||||
authz := r.Header.Values("Authorization")
|
||||
if len(authz) > 1 {
|
||||
WriteError(w, http.StatusBadRequest, "UNAUTHORIZED", "multiple Authorization headers")
|
||||
return nil, Principal{}, false
|
||||
}
|
||||
raw := ""
|
||||
if len(authz) == 1 {
|
||||
raw = authz[0]
|
||||
}
|
||||
token, ok := extractToken(raw)
|
||||
if !ok {
|
||||
deny("authentication required")
|
||||
return nil, Principal{}, false
|
||||
}
|
||||
claims, err := b.verifier.Verify(token)
|
||||
if err != nil {
|
||||
slog.Debug("ECR registry: token verify failed", "err", err)
|
||||
deny("invalid credentials")
|
||||
return nil, Principal{}, false
|
||||
}
|
||||
|
||||
// A token is account-scoped, so it must match the account in the registry host it
|
||||
// is presented against.
|
||||
if target, _, ok := b.target(r); ok && target != "" && target != claims.AccountID {
|
||||
WriteError(w, http.StatusForbidden, "DENIED", "token account does not match registry host")
|
||||
return nil, Principal{}, false
|
||||
}
|
||||
|
||||
principal, err := b.rehydrate(r, claims)
|
||||
if err != nil {
|
||||
if _, ok := errors.AsType[*dependencyError](err); ok {
|
||||
slog.Error("ECR registry: principal rehydration dependency failure", "err", err)
|
||||
WriteError(w, http.StatusServiceUnavailable, "UNKNOWN", "authorization unavailable")
|
||||
return nil, Principal{}, false
|
||||
}
|
||||
slog.Warn("ECR registry: principal rehydration rejected token", "err", err)
|
||||
deny("invalid credentials")
|
||||
return nil, Principal{}, false
|
||||
}
|
||||
return claims, principal, true
|
||||
}
|
||||
|
||||
// dependencyError marks a rehydration failure caused by an outage rather than by an
|
||||
// invalid identity, so the response is 503 and never an allow.
|
||||
type dependencyError struct{ err error }
|
||||
|
||||
func (e *dependencyError) Error() string { return e.err.Error() }
|
||||
func (e *dependencyError) Unwrap() error { return e.err }
|
||||
|
||||
// rehydrate rebuilds the token's principal and cross-checks the claims against it. It
|
||||
// never trusts claims.PrincipalType or claims.Subject on their own: both must match the
|
||||
// freshly resolved record, so a forged or stale claim cannot widen access.
|
||||
func (b *Bridge) rehydrate(r *http.Request, claims *ecrauth.Claims) (Principal, error) {
|
||||
p, err := b.rehydrator.Rehydrate(claims.AccessKeyID, claims.AccountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnavailable) {
|
||||
return Principal{}, &dependencyError{fmt.Errorf("rehydrate: %w", err)}
|
||||
}
|
||||
return Principal{}, fmt.Errorf("rehydrate: %w", err)
|
||||
}
|
||||
|
||||
// An account suspended or being torn down stops working at once, not at the
|
||||
// token's natural expiry.
|
||||
active, err := b.admission.Active(p.AccountID, b.clientIP(r))
|
||||
if err != nil {
|
||||
return Principal{}, &dependencyError{fmt.Errorf("account status: %w", err)}
|
||||
}
|
||||
if !active {
|
||||
return Principal{}, fmt.Errorf("account %q is not active", p.AccountID)
|
||||
}
|
||||
|
||||
if claims.PrincipalType != p.PrincipalType {
|
||||
return Principal{}, fmt.Errorf("principalType claim %q does not match resolved principal", claims.PrincipalType)
|
||||
}
|
||||
if claims.Subject != p.CallerARN {
|
||||
return Principal{}, errors.New("subject claim does not match canonical principal ARN")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ociTokenResponse is the Docker Registry v2 token-endpoint body. token and
|
||||
// access_token carry the same JWT for compatibility across client versions.
|
||||
type ociTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IssuedAt string `json:"issued_at"`
|
||||
}
|
||||
|
||||
// serveToken implements the OCI Bearer token endpoint the 401 challenge advertises. The
|
||||
// caller presents its existing credential; this verifies it, rehydrates the principal
|
||||
// and re-mints a fresh full-TTL token from that record, never from the presented claims,
|
||||
// so a revoked identity is not refreshed. Re-minting is the refresh path: a client near
|
||||
// expiry obtains a new token without re-running SigV4.
|
||||
func (b *Bridge) serveToken(w http.ResponseWriter, r *http.Request) {
|
||||
claims, principal, ok := b.authenticate(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
fresh, expiresAt, err := b.issuer.Mint(ecrauth.Principal{
|
||||
AccountID: principal.AccountID,
|
||||
ARN: principal.CallerARN,
|
||||
Type: principal.PrincipalType,
|
||||
AccessKeyID: claims.AccessKeyID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("ECR token endpoint: mint failed", "err", err)
|
||||
WriteError(w, http.StatusInternalServerError, "SERVER_ERROR", "token mint failed")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(ociTokenResponse{
|
||||
Token: fresh,
|
||||
AccessToken: fresh,
|
||||
ExpiresIn: int(time.Until(expiresAt).Seconds()),
|
||||
IssuedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("ECR token endpoint: marshal failed", "err", err)
|
||||
WriteError(w, http.StatusInternalServerError, "SERVER_ERROR", "response encode failed")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
slog.Error("ECR token endpoint: write failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// extractToken pulls the JWT from a single Authorization header value, accepting
|
||||
// "Bearer <jwt>" or "Basic base64(AWS:<jwt>)". ok is false for any other scheme,
|
||||
// malformed value, or empty token. SigV4 is not accepted on /v2.
|
||||
func extractToken(authz string) (string, bool) {
|
||||
scheme, rest, found := strings.Cut(authz, " ")
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
switch {
|
||||
case strings.EqualFold(scheme, "Bearer"):
|
||||
rest = strings.TrimSpace(rest)
|
||||
return rest, rest != ""
|
||||
case strings.EqualFold(scheme, "Basic"):
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(rest))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
user, pass, found := strings.Cut(string(decoded), ":")
|
||||
if !found || user != "AWS" || pass == "" {
|
||||
return "", false
|
||||
}
|
||||
return pass, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// writeChallenge emits the 401 Bearer challenge advertising the /v2/token endpoint.
|
||||
// OCI clients (docker, crane, skopeo) fetch a token from the realm using their stored
|
||||
// AWS:<jwt> credential, then replay it as Bearer. The realm and service use the
|
||||
// request Host so the client negotiates against the address it actually dialed.
|
||||
func writeChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
realm := "https://" + r.Host + "/v2/token"
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="`+r.Host+`"`)
|
||||
WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required")
|
||||
}
|
||||
|
||||
// resourceARN builds the repository ARN an ActionRequirement's scope resolves to, from
|
||||
// the configured region and the token's account.
|
||||
func resourceARN(region, accountID string, scope ResourceScope, op ClassifiedOperation) (string, error) {
|
||||
switch scope {
|
||||
case ScopeAccountWildcard:
|
||||
return fmt.Sprintf("arn:aws:ecr:%s:%s:repository/*", region, accountID), nil
|
||||
case ScopeDestination:
|
||||
return fmt.Sprintf("arn:aws:ecr:%s:%s:repository/%s", region, accountID, op.Repo), nil
|
||||
case ScopeSource:
|
||||
return fmt.Sprintf("arn:aws:ecr:%s:%s:repository/%s", region, accountID, op.Source), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unhandled ECR resource scope %d", scope)
|
||||
}
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
package ecrregistry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
bridgeRegion = "ap-southeast-2"
|
||||
bridgeAccount = "000000000001"
|
||||
bridgeAKID = "AKIABRIDGETESTUSER001"
|
||||
bridgeUserARN = "arn:aws:iam::" + bridgeAccount + ":user/dev"
|
||||
bridgeHost = bridgeAccount + ".dkr.ecr." + bridgeRegion + ".spinifex.internal"
|
||||
)
|
||||
|
||||
type targetKey struct{}
|
||||
|
||||
// hostMiddleware records the account named by the host's first label, as the
|
||||
// gateway's host matcher does.
|
||||
func hostMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
account, _, _ := strings.Cut(r.Host, ".")
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), targetKey{}, account)))
|
||||
})
|
||||
}
|
||||
|
||||
func hostTarget(r *http.Request) (account, region string, ok bool) {
|
||||
account, ok = r.Context().Value(targetKey{}).(string)
|
||||
return account, bridgeRegion, ok
|
||||
}
|
||||
|
||||
func noTarget(*http.Request) (string, string, bool) { return "", "", false }
|
||||
func noIP(*http.Request) string { return "" }
|
||||
|
||||
type fakeRehydrator struct {
|
||||
principal Principal
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRehydrator) Rehydrate(_, _ string) (Principal, error) { return f.principal, f.err }
|
||||
|
||||
// rule allows or denies the listed actions on resources; a trailing * in a resource
|
||||
// matches any suffix. An explicit deny wins.
|
||||
type rule struct {
|
||||
deny bool
|
||||
actions []string
|
||||
resources []string
|
||||
}
|
||||
|
||||
func (r rule) matches(action, resource string) bool {
|
||||
hit := false
|
||||
for _, a := range r.actions {
|
||||
hit = hit || a == action
|
||||
}
|
||||
if !hit {
|
||||
return false
|
||||
}
|
||||
for _, p := range r.resources {
|
||||
if p == resource || (strings.HasSuffix(p, "*") && strings.HasPrefix(resource, strings.TrimSuffix(p, "*"))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type fakeAuthorizer struct {
|
||||
mu sync.Mutex
|
||||
rules []rule
|
||||
err error
|
||||
seen []string
|
||||
}
|
||||
|
||||
func (f *fakeAuthorizer) Authorize(_ *http.Request, _ Principal, action string, resources []string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
for _, res := range resources {
|
||||
f.seen = append(f.seen, action+" "+res)
|
||||
allowed := false
|
||||
for _, r := range f.rules {
|
||||
if !r.matches(action, res) {
|
||||
continue
|
||||
}
|
||||
if r.deny {
|
||||
return errors.New(awserrors.ErrorAccessDenied)
|
||||
}
|
||||
allowed = true
|
||||
}
|
||||
if !allowed {
|
||||
return errors.New(awserrors.ErrorAccessDenied)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAdmission struct {
|
||||
active bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeAdmission) Active(_, _ string) (bool, error) { return f.active, f.err }
|
||||
|
||||
type bridgeEnv struct {
|
||||
handler http.Handler
|
||||
issuer *ecrauth.Issuer
|
||||
verifier *ecrauth.Verifier
|
||||
rehyd *fakeRehydrator
|
||||
authz *fakeAuthorizer
|
||||
adm *fakeAdmission
|
||||
reached *int
|
||||
}
|
||||
|
||||
func newBridgeEnv(t *testing.T, rules ...rule) *bridgeEnv {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
master := make([]byte, 32)
|
||||
_, err := rand.Read(master)
|
||||
require.NoError(t, err)
|
||||
key, verify, err := ecrauth.LoadOrCreateSigningKey(t.Context(), js, master)
|
||||
require.NoError(t, err)
|
||||
const aud = "ecr." + bridgeRegion + ".spinifex.internal"
|
||||
iss := ecrauth.NewIssuer(key, aud)
|
||||
|
||||
vf := ecrauth.NewVerifier(verify, aud)
|
||||
e := &bridgeEnv{
|
||||
issuer: iss, verifier: vf,
|
||||
rehyd: &fakeRehydrator{principal: Principal{
|
||||
Identity: "dev", AccountID: bridgeAccount, PrincipalType: "user", CallerARN: bridgeUserARN,
|
||||
}},
|
||||
authz: &fakeAuthorizer{rules: rules},
|
||||
adm: &fakeAdmission{active: true},
|
||||
reached: new(int),
|
||||
}
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
*e.reached++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
b, err := NewBridge(BridgeConfig{
|
||||
Registry: next, Verifier: vf, Issuer: iss,
|
||||
Rehydrator: e.rehyd, Authorizer: e.authz, Admission: e.adm, Region: bridgeRegion, Target: hostTarget, ClientIP: noIP,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
e.handler = hostMiddleware(b.Handler())
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *bridgeEnv) token(t *testing.T, account, arn, principalType string) string {
|
||||
t.Helper()
|
||||
tok, _, err := e.issuer.Mint(ecrauth.Principal{AccountID: account, ARN: arn, Type: principalType, AccessKeyID: bridgeAKID})
|
||||
require.NoError(t, err)
|
||||
return tok
|
||||
}
|
||||
|
||||
func (e *bridgeEnv) userToken(t *testing.T) string {
|
||||
return e.token(t, bridgeAccount, bridgeUserARN, "user")
|
||||
}
|
||||
|
||||
func (e *bridgeEnv) do(method, target string, authz ...string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
req.Host = bridgeHost
|
||||
for _, a := range authz {
|
||||
req.Header.Add("Authorization", a)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
e.handler.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func basicAuth(token string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte("AWS:"+token))
|
||||
}
|
||||
|
||||
func errCode(t *testing.T, w *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Errors []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), w.Body.String())
|
||||
require.Len(t, body.Errors, 1)
|
||||
return body.Errors[0].Code
|
||||
}
|
||||
|
||||
func repoARN(repo string) string {
|
||||
return "arn:aws:ecr:" + bridgeRegion + ":" + bridgeAccount + ":repository/" + repo
|
||||
}
|
||||
|
||||
func allow(actions []string, resources ...string) rule {
|
||||
return rule{actions: actions, resources: resources}
|
||||
}
|
||||
|
||||
func TestNewBridge_RefusesMissingDependencies(t *testing.T) {
|
||||
full := BridgeConfig{
|
||||
Verifier: &ecrauth.Verifier{}, Issuer: &ecrauth.Issuer{}, Rehydrator: &fakeRehydrator{},
|
||||
Authorizer: &fakeAuthorizer{}, Admission: fakeAdmission{}, Target: noTarget, ClientIP: noIP,
|
||||
}
|
||||
_, err := NewBridge(full)
|
||||
require.NoError(t, err)
|
||||
|
||||
for name, mutate := range map[string]func(*BridgeConfig){
|
||||
"verifier": func(c *BridgeConfig) { c.Verifier = nil },
|
||||
"issuer": func(c *BridgeConfig) { c.Issuer = nil },
|
||||
"rehydrator": func(c *BridgeConfig) { c.Rehydrator = nil },
|
||||
"authorizer": func(c *BridgeConfig) { c.Authorizer = nil },
|
||||
"admission": func(c *BridgeConfig) { c.Admission = nil },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := full
|
||||
mutate(&cfg)
|
||||
b, err := NewBridge(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, b)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_NilRegistryAnswers501(t *testing.T) {
|
||||
e := newBridgeEnv(t, allow([]string{"ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:BatchCheckLayerAvailability"}, repoARN("*")))
|
||||
b, err := NewBridge(BridgeConfig{
|
||||
Verifier: e.verifier, Issuer: e.issuer, Rehydrator: e.rehyd, Authorizer: e.authz, Admission: e.adm, Region: bridgeRegion, Target: hostTarget, ClientIP: noIP,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
e.handler = hostMiddleware(b.Handler())
|
||||
|
||||
w := e.do(http.MethodGet, "/v2/team/app/manifests/latest", "Bearer "+e.userToken(t))
|
||||
assert.Equal(t, http.StatusNotImplemented, w.Code)
|
||||
assert.Equal(t, "UNSUPPORTED", errCode(t, w))
|
||||
}
|
||||
|
||||
func TestExtractToken(t *testing.T) {
|
||||
good := base64.StdEncoding.EncodeToString([]byte("AWS:abc.def.ghi"))
|
||||
cases := []struct {
|
||||
name string
|
||||
authz string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"bearer", "Bearer abc.def.ghi", "abc.def.ghi", true},
|
||||
{"basic AWS", "Basic " + good, "abc.def.ghi", true},
|
||||
{"basic wrong user", "Basic " + base64.StdEncoding.EncodeToString([]byte("root:x")), "", false},
|
||||
{"basic empty pass", "Basic " + base64.StdEncoding.EncodeToString([]byte("AWS:")), "", false},
|
||||
{"basic bad base64", "Basic !!!notbase64", "", false},
|
||||
{"unknown scheme", "AWS4-HMAC-SHA256 cred", "", false},
|
||||
{"empty", "", "", false},
|
||||
{"bearer empty", "Bearer ", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, ok := extractToken(c.authz)
|
||||
assert.Equal(t, c.ok, ok)
|
||||
assert.Equal(t, c.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_Authentication(t *testing.T) {
|
||||
read := allow([]string{"ecr:BatchGetImage"}, repoARN("*"))
|
||||
|
||||
t.Run("no credential challenges 401", func(t *testing.T) {
|
||||
e := newBridgeEnv(t, read)
|
||||
w := e.do(http.MethodGet, "/v2/")
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, `Bearer realm="https://`+bridgeHost+`/v2/token",service="`+bridgeHost+`"`, w.Header().Get("WWW-Authenticate"))
|
||||
assert.Zero(t, *e.reached)
|
||||
})
|
||||
t.Run("invalid token challenges 401", func(t *testing.T) {
|
||||
e := newBridgeEnv(t, read)
|
||||
w := e.do(http.MethodGet, "/v2/", "Bearer not.a.token")
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.NotEmpty(t, w.Header().Get("WWW-Authenticate"))
|
||||
})
|
||||
t.Run("multiple Authorization headers", func(t *testing.T) {
|
||||
e := newBridgeEnv(t, read)
|
||||
w := e.do(http.MethodGet, "/v2/", "Bearer a.b.c", "Bearer d.e.f")
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
t.Run("token for another account than the host", func(t *testing.T) {
|
||||
e := newBridgeEnv(t, read)
|
||||
tok := e.token(t, "000000000099", "arn:aws:iam::000000000099:user/dev", "user")
|
||||
w := e.do(http.MethodGet, "/v2/", "Bearer "+tok)
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Equal(t, "DENIED", errCode(t, w))
|
||||
})
|
||||
t.Run("valid token reaches the registry", func(t *testing.T) {
|
||||
e := newBridgeEnv(t, read)
|
||||
for _, authz := range []string{"Bearer " + e.userToken(t), basicAuth(e.userToken(t))} {
|
||||
w := e.do(http.MethodGet, "/v2/team/app/manifests/latest", authz)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
assert.Equal(t, 2, *e.reached)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBridge_Rehydration(t *testing.T) {
|
||||
read := allow([]string{"ecr:BatchGetImage"}, repoARN("*"))
|
||||
get := func(e *bridgeEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.do(http.MethodGet, "/v2/team/app/manifests/latest", "Bearer "+e.userToken(t))
|
||||
}
|
||||
|
||||
t.Run("rejected identity challenges 401", func(t *testing.T) {
|
||||
e := newBridgeEnv(t, read)
|
||||
e.rehyd.err = ErrInvalidCredential
|
||||
w := get(e, t)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.NotEmpty(t, w.Header().Get("WWW-Authenticate"))
|
||||
assert.Zero(t, *e.reached)
|
||||
})
|
||||
t.Run("dependency outage is 503", func(t *testing.T) {
|
||||
e := newBridgeEnv(t, read)
|
||||
e.rehyd.err = ErrUnavailable
|
||||
w := get(e, t)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
assert.Equal(t, "UNKNOWN", errCode(t, w))
|
||||
assert.Zero(t, *e.reached)
|
||||
})
|
||||
t.Run("inactive account stops at once", func(t *testing.T) {
|
||||
e := newBridgeEnv(t, read)
|
||||
e.adm.active = false
|
||||
w := get(e, t)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Zero(t, *e.reached)
|
||||
})
|
||||
t.Run("unreadable account status is 503", func(t *testing.T) {
|
||||
e := newBridgeEnv(t, read)
|
||||
e.adm.err = errors.New("nats: timeout")
|
||||
w := get(e, t)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
assert.Zero(t, *e.reached)
|
||||
})
|
||||
t.Run("principalType claim mismatch", func(t *testing.T) {
|
||||
e := newBridgeEnv(t, read)
|
||||
e.rehyd.principal.PrincipalType = "assumed-role"
|
||||
assert.Equal(t, http.StatusUnauthorized, get(e, t).Code)
|
||||
})
|
||||
t.Run("subject does not match canonical ARN", func(t *testing.T) {
|
||||
e := newBridgeEnv(t, read)
|
||||
e.rehyd.principal.CallerARN = "arn:aws:iam::" + bridgeAccount + ":user/other"
|
||||
assert.Equal(t, http.StatusUnauthorized, get(e, t).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBridge_OperationAuthorization(t *testing.T) {
|
||||
account := "arn:aws:ecr:" + bridgeRegion + ":" + bridgeAccount + ":repository/*"
|
||||
pullOnly := allow([]string{"ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:BatchCheckLayerAvailability"}, repoARN("team/app"))
|
||||
readOnly := allow([]string{
|
||||
"ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:BatchCheckLayerAvailability",
|
||||
"ecr:ListImages", "ecr:DescribeRepositories",
|
||||
}, repoARN("team/app"), account)
|
||||
|
||||
type op struct{ method, path string }
|
||||
run := func(t *testing.T, e *bridgeEnv, o op) *httptest.ResponseRecorder {
|
||||
return e.do(o.method, o.path, "Bearer "+e.userToken(t))
|
||||
}
|
||||
matrix := func(t *testing.T, rules []rule, allowed, denied []op) {
|
||||
for _, o := range allowed {
|
||||
t.Run("allow "+o.method+" "+o.path, func(t *testing.T) {
|
||||
e := newBridgeEnv(t, rules...)
|
||||
assert.Equal(t, http.StatusOK, run(t, e, o).Code)
|
||||
assert.Equal(t, 1, *e.reached)
|
||||
})
|
||||
}
|
||||
for _, o := range denied {
|
||||
t.Run("deny "+o.method+" "+o.path, func(t *testing.T) {
|
||||
e := newBridgeEnv(t, rules...)
|
||||
w := run(t, e, o)
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Equal(t, "DENIED", errCode(t, w))
|
||||
assert.Zero(t, *e.reached, "denied operation must never dispatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("pull only", func(t *testing.T) {
|
||||
matrix(t, []rule{pullOnly},
|
||||
[]op{
|
||||
{http.MethodHead, "/v2/team/app/blobs/sha256:abc"},
|
||||
{http.MethodGet, "/v2/team/app/blobs/sha256:abc"},
|
||||
{http.MethodHead, "/v2/team/app/manifests/latest"},
|
||||
{http.MethodGet, "/v2/team/app/manifests/latest"},
|
||||
{http.MethodGet, "/v2/team/app/referrers/sha256:abc"},
|
||||
},
|
||||
[]op{
|
||||
{http.MethodPost, "/v2/team/app/blobs/uploads/"},
|
||||
{http.MethodPatch, "/v2/team/app/blobs/uploads/upload-1"},
|
||||
{http.MethodPut, "/v2/team/app/blobs/uploads/upload-1"},
|
||||
{http.MethodDelete, "/v2/team/app/blobs/uploads/upload-1"},
|
||||
{http.MethodPut, "/v2/team/app/manifests/latest"},
|
||||
{http.MethodDelete, "/v2/team/app/manifests/sha256:abc"},
|
||||
})
|
||||
})
|
||||
t.Run("read only", func(t *testing.T) {
|
||||
matrix(t, []rule{readOnly},
|
||||
[]op{
|
||||
{http.MethodGet, "/v2/_catalog"},
|
||||
{http.MethodGet, "/v2/team/app/tags/list"},
|
||||
{http.MethodHead, "/v2/team/app/blobs/sha256:abc"},
|
||||
{http.MethodGet, "/v2/team/app/manifests/latest"},
|
||||
{http.MethodGet, "/v2/team/app/referrers/sha256:abc"},
|
||||
},
|
||||
[]op{
|
||||
{http.MethodPost, "/v2/team/app/blobs/uploads/"},
|
||||
{http.MethodPut, "/v2/team/app/manifests/latest"},
|
||||
{http.MethodDelete, "/v2/team/app/manifests/sha256:abc"},
|
||||
})
|
||||
})
|
||||
t.Run("an allow scoped to one repository does not cross repositories", func(t *testing.T) {
|
||||
matrix(t, []rule{allow([]string{"ecr:BatchGetImage"}, repoARN("team/a"))},
|
||||
[]op{{http.MethodGet, "/v2/team/a/manifests/latest"}, {http.MethodGet, "/v2/team/a/referrers/sha256:abc"}},
|
||||
[]op{{http.MethodGet, "/v2/team/b/manifests/latest"}, {http.MethodGet, "/v2/team/b/referrers/sha256:abc"}})
|
||||
})
|
||||
t.Run("referrers need BatchGetImage and nothing a push grants", func(t *testing.T) {
|
||||
pushOnly := allow([]string{"ecr:PutImage", "ecr:InitiateLayerUpload", "ecr:ListImages", "ecr:BatchCheckLayerAvailability"}, repoARN("team/app"))
|
||||
matrix(t, []rule{pushOnly}, nil, []op{{http.MethodGet, "/v2/team/app/referrers/sha256:abc"}})
|
||||
})
|
||||
t.Run("explicit deny wins", func(t *testing.T) {
|
||||
deny := rule{deny: true, actions: []string{"ecr:BatchGetImage"}, resources: []string{repoARN("team/app")}}
|
||||
matrix(t, []rule{allow([]string{"ecr:BatchGetImage"}, account), deny}, nil,
|
||||
[]op{{http.MethodGet, "/v2/team/app/manifests/latest"}, {http.MethodGet, "/v2/team/app/referrers/sha256:abc"}})
|
||||
})
|
||||
t.Run("cross-repository mount needs destination write and source read", func(t *testing.T) {
|
||||
mount := op{http.MethodPost, "/v2/team/app/blobs/uploads/?mount=sha256:abc&from=team/base"}
|
||||
dest := allow([]string{"ecr:InitiateLayerUpload"}, repoARN("team/app"))
|
||||
matrix(t, []rule{dest}, nil, []op{mount})
|
||||
matrix(t, []rule{dest, allow([]string{"ecr:BatchCheckLayerAvailability"}, repoARN("team/base"))}, []op{mount}, nil)
|
||||
})
|
||||
t.Run("an unclassified operation is refused before dispatch", func(t *testing.T) {
|
||||
e := newBridgeEnv(t, readOnly)
|
||||
w := run(t, e, op{http.MethodPost, "/v2/"})
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Zero(t, *e.reached)
|
||||
})
|
||||
t.Run("the base path needs no action", func(t *testing.T) {
|
||||
e := newBridgeEnv(t)
|
||||
w := run(t, e, op{http.MethodGet, "/v2/"})
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "registry/2.0", w.Header().Get("Docker-Distribution-Api-Version"))
|
||||
assert.Empty(t, e.authz.seen)
|
||||
})
|
||||
t.Run("an authorization dependency failure is 503 and never dispatches", func(t *testing.T) {
|
||||
e := newBridgeEnv(t, readOnly)
|
||||
e.authz.err = ErrUnavailable
|
||||
w := run(t, e, op{http.MethodGet, "/v2/team/app/manifests/latest"})
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
assert.Zero(t, *e.reached)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBridge_TokenEndpoint(t *testing.T) {
|
||||
t.Run("exchange mints a fresh token for the same principal", func(t *testing.T) {
|
||||
e := newBridgeEnv(t)
|
||||
old := e.userToken(t)
|
||||
for _, authz := range []string{basicAuth(old), "Bearer " + old} {
|
||||
w := e.do(http.MethodGet, "/v2/token", authz)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
|
||||
var body ociTokenResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.NotEmpty(t, body.Token)
|
||||
assert.Equal(t, body.Token, body.AccessToken)
|
||||
assert.Positive(t, body.ExpiresIn)
|
||||
assert.NotEmpty(t, body.IssuedAt)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/v2/", "Bearer "+body.Token).Code)
|
||||
}
|
||||
})
|
||||
t.Run("no credential is a 401 with no challenge", func(t *testing.T) {
|
||||
w := newBridgeEnv(t).do(http.MethodGet, "/v2/token")
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Empty(t, w.Header().Get("WWW-Authenticate"))
|
||||
assert.Equal(t, "UNAUTHORIZED", errCode(t, w))
|
||||
})
|
||||
t.Run("invalid credential is a 401 with no challenge", func(t *testing.T) {
|
||||
w := newBridgeEnv(t).do(http.MethodGet, "/v2/token", "Bearer not.a.token")
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Empty(t, w.Header().Get("WWW-Authenticate"))
|
||||
})
|
||||
t.Run("two authorization headers", func(t *testing.T) {
|
||||
w := newBridgeEnv(t).do(http.MethodGet, "/v2/token", "Bearer a.b.c", "Bearer d.e.f")
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
t.Run("token for another account than the host", func(t *testing.T) {
|
||||
e := newBridgeEnv(t)
|
||||
other := e.token(t, "000000000099", "arn:aws:iam::000000000099:user/dev", "user")
|
||||
w := e.do(http.MethodGet, "/v2/token", "Bearer "+other)
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Equal(t, "DENIED", errCode(t, w))
|
||||
})
|
||||
t.Run("a revoked identity is not refreshed", func(t *testing.T) {
|
||||
e := newBridgeEnv(t)
|
||||
e.rehyd.err = ErrInvalidCredential
|
||||
w := e.do(http.MethodGet, "/v2/token", basicAuth(e.userToken(t)))
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Empty(t, w.Header().Get("WWW-Authenticate"))
|
||||
})
|
||||
t.Run("a dependency outage is a 503", func(t *testing.T) {
|
||||
e := newBridgeEnv(t)
|
||||
e.rehyd.err = ErrUnavailable
|
||||
w := e.do(http.MethodGet, "/v2/token", basicAuth(e.userToken(t)))
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
assert.Equal(t, "UNKNOWN", errCode(t, w))
|
||||
})
|
||||
}
|
||||
@@ -277,11 +277,6 @@ func runTask(ctx context.Context, svc ecsdomain.ECSService, accountID string, in
|
||||
if err := checkTaskLaunchRoles(ctx, svc, accountID, input.TaskDefinition, passRoleCheck); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.Overrides != nil {
|
||||
if err := checkTaskDefinitionRoles(overrideRoleCheck(ctx), input.Overrides.TaskRoleArn, input.Overrides.ExecutionRoleArn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return svc.RunTask(ctx, input, accountID)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/auth"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
|
||||
)
|
||||
|
||||
// ecsUnableToAssume is the ClientException text ECS gives when a RunTask role
|
||||
// override names a role that does not exist.
|
||||
const ecsUnableToAssume = "ECS was unable to assume the role '%s' that was provided for this task. " +
|
||||
"Please verify that the role being passed has the proper trust relationship and permissions " +
|
||||
"and that your IAM user has permissions to pass this role."
|
||||
|
||||
// accountRoles classifies a role reference by its account alone. ECS accepts a
|
||||
// role that does not exist when a task definition is registered, so only a
|
||||
// malformed or foreign reference is told apart.
|
||||
type accountRoles struct{}
|
||||
|
||||
func (accountRoles) ResolveRole(accountID, roleARN string) (passrole.Role, error) {
|
||||
roleAccount, name, err := auth.ParseRoleARN(roleARN)
|
||||
if err != nil {
|
||||
return passrole.Role{}, passrole.ErrMalformed
|
||||
}
|
||||
if roleAccount != accountID {
|
||||
return passrole.Role{}, passrole.ErrForeignAccount
|
||||
}
|
||||
return passrole.Role{ARN: roleARN, Name: name}, nil
|
||||
}
|
||||
|
||||
var _ passrole.Resolver = accountRoles{}
|
||||
|
||||
// roleChecker returns the PassRole check for roles named by a caller. A foreign
|
||||
// role is refused before authorization, a role that is not an ARN is authorized
|
||||
// as written and then accepted, and a missing role is reported with notFound
|
||||
// only to a caller allowed to pass it.
|
||||
func roleChecker(r passrole.Resolver, authorize passrole.AuthorizeFunc, accountID string, notFound func(roleARN string) error) PassRoleChecker {
|
||||
return func(roleARN string) error {
|
||||
_, err := passrole.Check(r, authorize, accountID, roleARN, tasksServicePrincipal)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, passrole.ErrForeignAccount):
|
||||
return awserrors.Errorf(awserrors.ErrorECSClientException, "Role is not valid")
|
||||
case errors.Is(err, passrole.ErrMalformed):
|
||||
return authorizePassRole(authorize, roleARN)
|
||||
case errors.Is(err, passrole.ErrNotFound) && notFound != nil:
|
||||
return notFound(roleARN)
|
||||
case errors.Is(err, passrole.ErrNotFound):
|
||||
return nil
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func authorizePassRole(authorize passrole.AuthorizeFunc, roleARN string) error {
|
||||
_, err := passrole.Check(literalRole{}, authorize, "", roleARN, tasksServicePrincipal)
|
||||
return err
|
||||
}
|
||||
|
||||
// literalRole resolves any reference to itself, so Check authorizes it as written.
|
||||
type literalRole struct{}
|
||||
|
||||
func (literalRole) ResolveRole(_, roleARN string) (passrole.Role, error) {
|
||||
return passrole.Role{ARN: roleARN}, nil
|
||||
}
|
||||
|
||||
var _ passrole.Resolver = literalRole{}
|
||||
|
||||
func unableToAssume(roleARN string) error {
|
||||
return awserrors.Errorf(awserrors.ErrorECSClientException, ecsUnableToAssume, roleARN)
|
||||
}
|
||||
|
||||
type overrideRoleCheckKey struct{}
|
||||
|
||||
// withOverrideRoleCheck carries the check for roles a RunTask override names,
|
||||
// which also reports a role that does not exist.
|
||||
func withOverrideRoleCheck(ctx context.Context, check PassRoleChecker) context.Context {
|
||||
return context.WithValue(ctx, overrideRoleCheckKey{}, check)
|
||||
}
|
||||
|
||||
func overrideRoleCheck(ctx context.Context) PassRoleChecker {
|
||||
check, _ := ctx.Value(overrideRoleCheckKey{}).(PassRoleChecker)
|
||||
return check
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ecs"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
ecsdomain "github.com/mulgadc/spinifex/spinifex/domains/ecs"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testAccount = "123456789012"
|
||||
ownRole = "arn:aws:iam::123456789012:role/task-role"
|
||||
)
|
||||
|
||||
// recordingAuth records the resources PassRole was asked about and denies
|
||||
// them when deny is set.
|
||||
type recordingAuth struct {
|
||||
deny bool
|
||||
resources []string
|
||||
}
|
||||
|
||||
func (a *recordingAuth) authorize(_, _ string, resources []string, _ iampolicy.ConditionKeys) error {
|
||||
a.resources = append(a.resources, resources...)
|
||||
if a.deny {
|
||||
return errors.New(awserrors.ErrorAccessDenied)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapRoles is a Resolver over a fixed set of existing role ARNs.
|
||||
type mapRoles map[string]bool
|
||||
|
||||
func (m mapRoles) ResolveRole(accountID, roleARN string) (passrole.Role, error) {
|
||||
role, err := accountRoles{}.ResolveRole(accountID, roleARN)
|
||||
if err != nil {
|
||||
return passrole.Role{}, err
|
||||
}
|
||||
if !m[roleARN] {
|
||||
return passrole.Role{}, passrole.ErrNotFound
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func errCode(err error) string {
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
return code
|
||||
}
|
||||
|
||||
// The ECS expectations below are the 2026-10-30 AWS capture in
|
||||
// scripts/aws-compliance/reports/evidence/ecs-eks-passrole-aws-baseline-2026-10-30.tar.xz.
|
||||
func TestRoleChecker_RegisterTaskDefinition_AwsCapture(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
role string
|
||||
deny bool
|
||||
wantCode string
|
||||
wantAuthed []string
|
||||
wantErrorMsg string
|
||||
}{
|
||||
{name: "foreign role is refused before PassRole, even when PassRole is denied", role: "arn:aws:iam::999999999999:role/r", deny: true, wantCode: awserrors.ErrorECSClientException, wantErrorMsg: "Role is not valid"},
|
||||
{name: "foreign role without PassRole", role: "arn:aws:iam::999999999999:role/r", wantCode: awserrors.ErrorECSClientException, wantErrorMsg: "Role is not valid"},
|
||||
{name: "not an ARN is authorized as written then accepted", role: "not-an-arn", wantAuthed: []string{"not-an-arn"}},
|
||||
{name: "not an ARN without PassRole is denied", role: "not-an-arn", deny: true, wantCode: awserrors.ErrorAccessDenied, wantAuthed: []string{"not-an-arn"}},
|
||||
{name: "nonexistent role is authorized then accepted", role: "arn:aws:iam::123456789012:role/missing", wantAuthed: []string{"arn:aws:iam::123456789012:role/missing"}},
|
||||
{name: "nonexistent role without PassRole is denied, not reported missing", role: "arn:aws:iam::123456789012:role/missing", deny: true, wantCode: awserrors.ErrorAccessDenied, wantAuthed: []string{"arn:aws:iam::123456789012:role/missing"}},
|
||||
{name: "valid role is authorized then accepted", role: ownRole, wantAuthed: []string{ownRole}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
auth := &recordingAuth{deny: tt.deny}
|
||||
check := roleChecker(accountRoles{}, auth.authorize, testAccount, nil)
|
||||
|
||||
err := checkTaskDefinitionRoles(check, aws.String(tt.role))
|
||||
|
||||
if tt.wantCode == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tt.wantCode, errCode(err))
|
||||
}
|
||||
if tt.wantErrorMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.wantErrorMsg)
|
||||
}
|
||||
assert.Equal(t, tt.wantAuthed, auth.resources)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRunTaskECS answers the two calls RunTask makes.
|
||||
type fakeRunTaskECS struct {
|
||||
ecsdomain.ECSService
|
||||
|
||||
launched bool
|
||||
}
|
||||
|
||||
func (f *fakeRunTaskECS) RunTask(context.Context, *ecs.RunTaskInput, string) (*ecs.RunTaskOutput, error) {
|
||||
f.launched = true
|
||||
return &ecs.RunTaskOutput{}, nil
|
||||
}
|
||||
|
||||
func TestRunTask_OverrideRole_AwsCapture(t *testing.T) {
|
||||
roles := mapRoles{ownRole: true}
|
||||
tests := []struct {
|
||||
name string
|
||||
role string
|
||||
deny bool
|
||||
wantCode string
|
||||
wantMsg string
|
||||
launched bool
|
||||
}{
|
||||
{name: "foreign override", role: "arn:aws:iam::999999999999:role/r", wantCode: awserrors.ErrorECSClientException, wantMsg: "Role is not valid"},
|
||||
{name: "nonexistent override reports ECS could not assume it", role: "arn:aws:iam::123456789012:role/missing", wantCode: awserrors.ErrorECSClientException, wantMsg: "ECS was unable to assume the role 'arn:aws:iam::123456789012:role/missing'"},
|
||||
{name: "nonexistent override without PassRole is denied", role: "arn:aws:iam::123456789012:role/missing", deny: true, wantCode: awserrors.ErrorAccessDenied},
|
||||
{name: "invented path on a real role", role: "arn:aws:iam::123456789012:role/invented/task-role", wantCode: awserrors.ErrorECSClientException, wantMsg: "ECS was unable to assume the role"},
|
||||
{name: "valid override launches", role: ownRole, launched: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
auth := &recordingAuth{deny: tt.deny}
|
||||
ctx := withOverrideRoleCheck(t.Context(), roleChecker(roles, auth.authorize, testAccount, unableToAssume))
|
||||
svc := &fakeRunTaskECS{}
|
||||
input := &ecs.RunTaskInput{Overrides: &ecs.TaskOverride{TaskRoleArn: aws.String(tt.role)}}
|
||||
|
||||
_, err := runTask(ctx, svc, testAccount, input, nil)
|
||||
|
||||
if tt.wantCode == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tt.wantCode, errCode(err))
|
||||
}
|
||||
if tt.wantMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.wantMsg)
|
||||
}
|
||||
assert.Equal(t, tt.launched, svc.launched)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ServiceName is the SigV4 credential-scope service ECS clients sign for.
|
||||
const ServiceName = "ecs"
|
||||
|
||||
// tasksServicePrincipal is the service a task role is passed to.
|
||||
const tasksServicePrincipal = "ecs-tasks.amazonaws.com"
|
||||
|
||||
// Deps are the composed capabilities the ECS control plane dispatches to. A nil
|
||||
// NATS connection fails the actions that reach the daemon.
|
||||
type Deps struct {
|
||||
NATS *nats.Conn
|
||||
// Roles resolves a role a RunTask override names. Nil leaves a missing
|
||||
// override role undetected.
|
||||
Roles passrole.Resolver
|
||||
}
|
||||
|
||||
// NewRegistration returns the ECS AWS JSON 1.1 control-plane registration.
|
||||
func NewRegistration(deps Deps) dispatch.Registration {
|
||||
return dispatch.Registration{
|
||||
Service: ServiceName,
|
||||
ResolveAction: resolveAction,
|
||||
Dispatch: deps.dispatch,
|
||||
Errors: dispatch.ErrorEnvelopeJSON,
|
||||
Inventory: OperationInventory(),
|
||||
}
|
||||
}
|
||||
|
||||
// OperationInventory classifies the actions the registration serves.
|
||||
func OperationInventory() dispatch.Inventory {
|
||||
return dispatch.Inventory{
|
||||
Registered: slices.Sorted(maps.Keys(Actions)),
|
||||
Stubbed: StubbedActionNames(),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveAction(r *http.Request) string {
|
||||
return actionFromTarget(r.Header.Get("X-Amz-Target"))
|
||||
}
|
||||
|
||||
// actionFromTarget extracts the action suffix from an X-Amz-Target header.
|
||||
// Any "<Prefix>.<Action>" or bare "<Action>" form is accepted.
|
||||
func actionFromTarget(target string) string {
|
||||
if _, after, ok := strings.CutLast(target, "."); ok {
|
||||
return after
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func readBoundedBody(r *http.Request) ([]byte, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, sigv4.MaxPayloadLen+1))
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if int64(len(body)) > sigv4.MaxPayloadLen {
|
||||
return nil, errors.New(awserrors.ErrorRequestEntityTooLarge)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// dispatch resolves, scopes and authorizes the request, then runs the action.
|
||||
// Errors are awserrors codes the gateway renders.
|
||||
func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
r := inv.Request
|
||||
action := resolveAction(r)
|
||||
if action == "" {
|
||||
return errors.New(awserrors.ErrorMissingAction)
|
||||
}
|
||||
|
||||
handler, ok := Actions[action]
|
||||
if !ok {
|
||||
slog.DebugContext(r.Context(), "ECS: unknown action", "action", action)
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
// Ahead of the policy check because the resolver builds ARNs from the
|
||||
// caller's account and from the same body bytes the handler unmarshals.
|
||||
accountID := inv.AccountID
|
||||
if accountID == "" {
|
||||
slog.ErrorContext(r.Context(), "ECS dispatch: no account ID in auth context")
|
||||
// InternalError, not ServerInternal: the policy gate used to reach this
|
||||
// case first and that is the code the caller has always seen.
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
body, err := readBoundedBody(r)
|
||||
if err != nil {
|
||||
slog.ErrorContext(r.Context(), "ECS dispatch: failed to read body", "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
resources, err := ResourceARNs(action, inv.Region, accountID, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.Authorize(ServiceName, action, resources, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// iam:PassRole is enforced on the role ARN as written, for whichever role
|
||||
// the action later names.
|
||||
passRoleCheck := roleChecker(accountRoles{}, passrole.AuthorizeFunc(inv.Authorize), accountID, nil)
|
||||
var roles passrole.Resolver = accountRoles{}
|
||||
if d.Roles != nil {
|
||||
roles = d.Roles
|
||||
}
|
||||
ctx := withOverrideRoleCheck(r.Context(), roleChecker(roles, passrole.AuthorizeFunc(inv.Authorize), accountID, unableToAssume))
|
||||
|
||||
output, err := handler(ctx, d.NATS, accountID, body, passRoleCheck)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if RawJSONActions[action] {
|
||||
WriteRawJSONResponse(w, output)
|
||||
return nil
|
||||
}
|
||||
WriteJSONResponse(w, output)
|
||||
return nil
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package awsapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
ecsawsapi "github.com/mulgadc/spinifex/spinifex/domains/ecs/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const dispatchAccount = "123456789012"
|
||||
|
||||
type authCall struct {
|
||||
service, action string
|
||||
resources []string
|
||||
}
|
||||
|
||||
func ecsInvoke(t *testing.T, target, body, account string, deny error) ([]authCall, error) {
|
||||
t.Helper()
|
||||
var calls []authCall
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
req.Header.Set("X-Amz-Target", target)
|
||||
inv := dispatch.Invocation{
|
||||
Request: req,
|
||||
AccountID: account,
|
||||
Region: "us-east-1",
|
||||
Authorize: func(service, action string, resources []string, _ iampolicy.ConditionKeys) error {
|
||||
calls = append(calls, authCall{service, action, resources})
|
||||
if action == "PassRole" || service == "iam" {
|
||||
return nil
|
||||
}
|
||||
return deny
|
||||
},
|
||||
}
|
||||
err := ecsawsapi.NewRegistration(ecsawsapi.Deps{}).Dispatch(httptest.NewRecorder(), inv)
|
||||
return calls, err
|
||||
}
|
||||
|
||||
func TestRegistration_ResolvesActionFromTarget(t *testing.T) {
|
||||
reg := ecsawsapi.NewRegistration(ecsawsapi.Deps{})
|
||||
assert.Equal(t, "ecs", reg.Service)
|
||||
for target, want := range map[string]string{
|
||||
"AmazonEC2ContainerServiceV20141113.RunTask": "RunTask",
|
||||
"RunTask": "RunTask",
|
||||
"": "",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("X-Amz-Target", target)
|
||||
assert.Equal(t, want, reg.ResolveAction(req), target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistration_InventoryListsRegisteredActions(t *testing.T) {
|
||||
inv := ecsawsapi.OperationInventory()
|
||||
assert.Contains(t, inv.Registered, "RegisterTaskDefinition")
|
||||
assert.NotContains(t, inv.Registered, "NoSuchAction")
|
||||
}
|
||||
|
||||
func TestRegistration_RequestShapeErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, target, account, body, want string
|
||||
}{
|
||||
{"missing action", "", dispatchAccount, "{}", awserrors.ErrorMissingAction},
|
||||
{"unknown action", "X.NoSuchAction", dispatchAccount, "{}", awserrors.ErrorInvalidAction},
|
||||
{"no account", "X.ListClusters", "", "{}", awserrors.ErrorInternalError},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ecsInvoke(t, tt.target, tt.body, tt.account, nil)
|
||||
require.Error(t, err)
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, tt.want, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistration_OversizedBodyIsRefused(t *testing.T) {
|
||||
body := strings.Repeat("a", int(sigv4.MaxPayloadLen)+1)
|
||||
_, err := ecsInvoke(t, "X.ListClusters", body, dispatchAccount, nil)
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, awserrors.ErrorRequestEntityTooLarge, code)
|
||||
}
|
||||
|
||||
func TestRegistration_DenialStopsBeforeTheHandler(t *testing.T) {
|
||||
deny := awserrors.Errorf(awserrors.ErrorAccessDenied, "no")
|
||||
calls, err := ecsInvoke(t, "X.RegisterTaskDefinition", `{"family":"web","taskRoleArn":"arn:aws:iam::999999999999:role/r"}`, dispatchAccount, deny)
|
||||
|
||||
require.ErrorIs(t, err, deny)
|
||||
require.Len(t, calls, 1)
|
||||
assert.Equal(t, authCall{service: "ecs", action: "RegisterTaskDefinition"}, authCall{calls[0].service, calls[0].action, nil})
|
||||
}
|
||||
|
||||
// The foreign role is refused after the action is authorized and before the
|
||||
// request reaches the daemon.
|
||||
func TestRegistration_ForeignTaskRoleIsRefused(t *testing.T) {
|
||||
_, err := ecsInvoke(t, "X.RegisterTaskDefinition", `{"family":"web","taskRoleArn":"arn:aws:iam::999999999999:role/r"}`, dispatchAccount, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, awserrors.ErrorECSClientException, code)
|
||||
assert.Contains(t, err.Error(), "Role is not valid")
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestActionFromTarget(t *testing.T) {
|
||||
assert.Equal(t, "RunTask", actionFromTarget("AmazonEC2ContainerServiceV20141113.RunTask"))
|
||||
assert.Equal(t, "CreateCluster", actionFromTarget("CreateCluster"))
|
||||
assert.Empty(t, actionFromTarget(""))
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ServiceName is the SigV4 credential-scope service EKS clients sign for.
|
||||
const ServiceName = "eks"
|
||||
|
||||
// servicePrincipal is the service a cluster, node or add-on role is passed to.
|
||||
const servicePrincipal = "eks.amazonaws.com"
|
||||
|
||||
// Deps are the composed capabilities the EKS control plane dispatches to. A
|
||||
// nil NATS connection fails the actions that reach the daemon.
|
||||
type Deps struct {
|
||||
NATS *nats.Conn
|
||||
Roles passrole.Resolver
|
||||
}
|
||||
|
||||
// NewRegistration returns the EKS REST-JSON control-plane registration.
|
||||
func NewRegistration(deps Deps) dispatch.Registration {
|
||||
return dispatch.Registration{
|
||||
Service: ServiceName,
|
||||
ResolveAction: resolveAction,
|
||||
Dispatch: deps.dispatch,
|
||||
Errors: dispatch.ErrorEnvelopeJSON,
|
||||
Inventory: OperationInventory(),
|
||||
}
|
||||
}
|
||||
|
||||
// OperationInventory classifies the actions the registration serves.
|
||||
func OperationInventory() dispatch.Inventory {
|
||||
return dispatch.Inventory{Registered: actionNames()}
|
||||
}
|
||||
|
||||
func resolveAction(r *http.Request) string {
|
||||
action, _, _, _ := router.Lookup(r.Method, r.URL.EscapedPath())
|
||||
return action
|
||||
}
|
||||
|
||||
// CallerFromActor projects the verified actor onto the EKS caller. The role name
|
||||
// comes from the underlying role ARN, never the session name, which the caller
|
||||
// picks at AssumeRole time and could name its way past the gate.
|
||||
func CallerFromActor(actor dispatch.Actor) Caller {
|
||||
name := actor.UserName
|
||||
if actor.PrincipalType == principalTypeAssumedRole {
|
||||
name = actor.SessionName
|
||||
}
|
||||
return Caller{
|
||||
AccountID: actor.AccountID,
|
||||
PrincipalType: actor.PrincipalType,
|
||||
SessionName: name,
|
||||
RoleName: actor.RoleName(),
|
||||
}
|
||||
}
|
||||
|
||||
func readBoundedBody(r *http.Request) ([]byte, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, sigv4.MaxPayloadLen+1))
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if int64(len(body)) > sigv4.MaxPayloadLen {
|
||||
return nil, errors.New(awserrors.ErrorRequestEntityTooLarge)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// dispatch runs the action and renames the generic parameter error to the code
|
||||
// EKS declares. The internal routes keep theirs: the CP-VM agents match on it.
|
||||
func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
err := d.serve(w, inv)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if action, _, _, ok := router.Lookup(inv.Request.Method, inv.Request.URL.EscapedPath()); ok && IsInternalAction(action) {
|
||||
return err
|
||||
}
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
||||
if !ok || code != awserrors.ErrorInvalidParameterValue {
|
||||
return err
|
||||
}
|
||||
if msg == "" {
|
||||
return errors.New(awserrors.ErrorEKSInvalidParameter)
|
||||
}
|
||||
return awserrors.Errorf(awserrors.ErrorEKSInvalidParameter, "%s", msg)
|
||||
}
|
||||
|
||||
// serve resolves the route, authorizes the request, then runs the action.
|
||||
// Errors are awserrors codes the gateway renders.
|
||||
func (d Deps) serve(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
r := inv.Request
|
||||
action, params, handler, ok := router.Lookup(r.Method, r.URL.EscapedPath())
|
||||
if !ok {
|
||||
slog.DebugContext(r.Context(), "EKS: no route for request", "method", r.Method, "path", r.URL.Path)
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
// Ahead of the policy check because the resolver builds ARNs from it. The
|
||||
// region is the gateway's own, never the caller-supplied credential-scope
|
||||
// region, which would let a caller sign for another region and slide out
|
||||
// from under a Deny.
|
||||
accountID := inv.AccountID
|
||||
if accountID == "" {
|
||||
slog.ErrorContext(r.Context(), "EKS dispatch: no account ID in auth context")
|
||||
// InternalError, not ServerInternal: the policy gate used to reach this
|
||||
// case first and that is the code the caller has always seen.
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
body, err := readBoundedBody(r)
|
||||
if err != nil {
|
||||
slog.ErrorContext(r.Context(), "EKS dispatch: failed to read body", "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Some REST-JSON actions carry their non-path inputs as query params with
|
||||
// an empty body (UntagResource's tagKeys arrive as
|
||||
// DELETE /tags/{arn}?tagKeys=k1&tagKeys=k2). url.Values marshals straight to
|
||||
// the JSON the per-action unmarshal expects. Only folds when the body is
|
||||
// empty so it never shadows a real payload.
|
||||
if len(body) == 0 {
|
||||
if q := r.URL.Query(); len(q) > 0 {
|
||||
if qb, err := json.Marshal(map[string][]string(q)); err == nil {
|
||||
body = qb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor, actorErr := inv.Actor()
|
||||
|
||||
// Ahead of the policy check: the internal routes name the target account in
|
||||
// the path or body, so an eks:* grant evaluates as permitted and only the
|
||||
// principal class plus the caller's own instance say whether it is its own.
|
||||
if IsInternalAction(action) {
|
||||
if err := AuthorizeInternal(r.Context(), d.NATS, action, CallerFromActor(actor), params, body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// After the body read: CreateCluster and the other create actions name their
|
||||
// resource there rather than in the path.
|
||||
resources, err := ResourceARNs(action, inv.Region, accountID, params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.Authorize(ServiceName, action, resources, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.checkPassRole(inv, action, accountID, body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.NATS == nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
// Best-effort caller ARN; only CreateCluster consumes it, degrades to "".
|
||||
callerARN := actor.CallerARN
|
||||
if actorErr != nil {
|
||||
slog.DebugContext(r.Context(), "EKS dispatch: could not resolve caller principal ARN", "err", actorErr)
|
||||
callerARN = ""
|
||||
}
|
||||
|
||||
output, err := handler(r.Context(), d.NATS, accountID, callerARN, params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
WriteJSONResponse(w, output)
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkPassRole enforces iam:PassRole on each role the action hands to EKS. Only
|
||||
// the exact ARN the store holds resolves, so the grant is evaluated against the
|
||||
// role's real path and an invented one cannot match a narrower Resource.
|
||||
func (d Deps) checkPassRole(inv dispatch.Invocation, action, accountID string, body []byte) error {
|
||||
for _, roleARN := range PassedRoleARNs(action, body) {
|
||||
_, err := passrole.Check(d.Roles, passrole.AuthorizeFunc(inv.Authorize), accountID, roleARN, servicePrincipal)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, passrole.ErrMalformed), errors.Is(err, passrole.ErrForeignAccount):
|
||||
if action == "CreateCluster" || action == "CreateNodegroup" {
|
||||
return awserrors.Errorf(awserrors.ErrorAccessDeniedException, "Cross-account pass role is not allowed.")
|
||||
}
|
||||
return awserrors.Errorf(awserrors.ErrorEKSInvalidParameter, "%s is not a role ARN in account %s", roleARN, accountID)
|
||||
case errors.Is(err, passrole.ErrNotFound):
|
||||
return awserrors.Errorf(awserrors.ErrorEKSInvalidParameter, "role %s does not exist", roleARN)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
package awsapi_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
eksawsapi "github.com/mulgadc/spinifex/spinifex/domains/eks/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
dispatchAccount = "123456789012"
|
||||
existingRole = "arn:aws:iam::123456789012:role/eks-role"
|
||||
)
|
||||
|
||||
// rolesByARN resolves the listed roles and reports every other own-account
|
||||
// role as missing.
|
||||
type rolesByARN map[string]bool
|
||||
|
||||
func (m rolesByARN) ResolveRole(accountID, roleARN string) (passrole.Role, error) {
|
||||
prefix := "arn:aws:iam::" + accountID + ":role/"
|
||||
if !strings.HasPrefix(roleARN, prefix) {
|
||||
if strings.HasPrefix(roleARN, "arn:aws:iam::") {
|
||||
return passrole.Role{}, passrole.ErrForeignAccount
|
||||
}
|
||||
return passrole.Role{}, passrole.ErrMalformed
|
||||
}
|
||||
if !m[roleARN] {
|
||||
return passrole.Role{}, passrole.ErrNotFound
|
||||
}
|
||||
return passrole.Role{ARN: roleARN, Name: strings.TrimPrefix(roleARN, prefix)}, nil
|
||||
}
|
||||
|
||||
type authCall struct{ service, action string }
|
||||
|
||||
func eksInvoke(t *testing.T, method, path, body, account string, deny error) ([]authCall, []string, error) {
|
||||
t.Helper()
|
||||
var calls []authCall
|
||||
var passed []string
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
inv := dispatch.Invocation{
|
||||
Request: req,
|
||||
AccountID: account,
|
||||
Region: "us-east-1",
|
||||
Authorize: func(service, action string, resources []string, _ iampolicy.ConditionKeys) error {
|
||||
calls = append(calls, authCall{service, action})
|
||||
if service == "iam" {
|
||||
passed = append(passed, resources...)
|
||||
return nil
|
||||
}
|
||||
return deny
|
||||
},
|
||||
Actor: func() (dispatch.Actor, error) {
|
||||
return dispatch.Actor{AccountID: account, PrincipalType: "user", UserName: "alice"}, nil
|
||||
},
|
||||
}
|
||||
deps := eksawsapi.Deps{Roles: rolesByARN{existingRole: true}}
|
||||
err := eksawsapi.NewRegistration(deps).Dispatch(httptest.NewRecorder(), inv)
|
||||
return calls, passed, err
|
||||
}
|
||||
|
||||
func TestRegistration_ResolvesRoutes(t *testing.T) {
|
||||
reg := eksawsapi.NewRegistration(eksawsapi.Deps{})
|
||||
assert.Equal(t, "eks", reg.Service)
|
||||
for _, tt := range []struct{ method, path, want string }{
|
||||
{http.MethodGet, "/clusters", "ListClusters"},
|
||||
{http.MethodPost, "/clusters", "CreateCluster"},
|
||||
{http.MethodGet, "/no/such/route", ""},
|
||||
} {
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
assert.Equal(t, tt.want, reg.ResolveAction(req), tt.method+" "+tt.path)
|
||||
}
|
||||
assert.Contains(t, eksawsapi.OperationInventory().Registered, "CreateCluster")
|
||||
}
|
||||
|
||||
func TestRegistration_RequestShapeErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, method, path, account, want string
|
||||
}{
|
||||
{"no route", http.MethodGet, "/no/such/route", dispatchAccount, awserrors.ErrorInvalidAction},
|
||||
{"no account", http.MethodGet, "/clusters", "", awserrors.ErrorInternalError},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, err := eksInvoke(t, tt.method, tt.path, "", tt.account, nil)
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, tt.want, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistration_OversizedBodyIsRefused(t *testing.T) {
|
||||
body := strings.Repeat("a", int(sigv4.MaxPayloadLen)+1)
|
||||
_, _, err := eksInvoke(t, http.MethodPost, "/clusters", body, dispatchAccount, nil)
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, awserrors.ErrorRequestEntityTooLarge, code)
|
||||
}
|
||||
|
||||
func TestRegistration_DenialStopsBeforePassRole(t *testing.T) {
|
||||
deny := errors.New("denied")
|
||||
calls, passed, err := eksInvoke(t, http.MethodPost, "/clusters", `{"name":"c","roleArn":"`+existingRole+`"}`, dispatchAccount, deny)
|
||||
|
||||
require.ErrorIs(t, err, deny)
|
||||
assert.Equal(t, []authCall{{"eks", "CreateCluster"}}, calls)
|
||||
assert.Empty(t, passed)
|
||||
}
|
||||
|
||||
func TestRegistration_PassRoleCases(t *testing.T) {
|
||||
missing := "arn:aws:iam::123456789012:role/missing"
|
||||
tests := []struct {
|
||||
name, body, wantCode, wantMsg string
|
||||
wantPassed []string
|
||||
}{
|
||||
{"foreign role", `{"name":"c","roleArn":"arn:aws:iam::999999999999:role/r"}`, awserrors.ErrorAccessDeniedException, "Cross-account pass role is not allowed.", nil},
|
||||
{"not an ARN", `{"name":"c","roleArn":"x"}`, awserrors.ErrorAccessDeniedException, "Cross-account pass role is not allowed.", nil},
|
||||
{"missing role is authorized before it is reported", `{"name":"c","roleArn":"` + missing + `"}`, awserrors.ErrorEKSInvalidParameter, "does not exist", []string{missing}},
|
||||
{"existing role reaches the daemon", `{"name":"c","roleArn":"` + existingRole + `"}`, awserrors.ErrorServerInternal, "", []string{existingRole}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, passed, err := eksInvoke(t, http.MethodPost, "/clusters", tt.body, dispatchAccount, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, tt.wantCode, code)
|
||||
if tt.wantMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.wantMsg)
|
||||
}
|
||||
assert.Equal(t, tt.wantPassed, passed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassedRoleARNs(t *testing.T) {
|
||||
tests := []struct {
|
||||
action, body string
|
||||
want []string
|
||||
}{
|
||||
{"CreateCluster", `{"roleArn":"r1"}`, []string{"r1"}},
|
||||
{"CreateNodegroup", `{"nodeRole":"r2"}`, []string{"r2"}},
|
||||
{"CreateAddon", `{"serviceAccountRoleArn":"r3"}`, []string{"r3"}},
|
||||
{"UpdateAddon", `{"serviceAccountRoleArn":"r4"}`, []string{"r4"}},
|
||||
{"CreateCluster", `{"name":"c"}`, nil},
|
||||
{"CreateCluster", `{`, nil},
|
||||
{"ListClusters", `{"roleArn":"r5"}`, nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, eksawsapi.PassedRoleARNs(tt.action, []byte(tt.body)), tt.action+" "+tt.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallerFromActor_RoleNameComesFromTheRoleNotTheSession(t *testing.T) {
|
||||
actor := dispatch.Actor{
|
||||
AccountID: dispatchAccount,
|
||||
PrincipalType: "assumed-role",
|
||||
SessionName: "i-123",
|
||||
PrincipalARN: "arn:aws:iam::123456789012:role/real-role",
|
||||
}
|
||||
caller := eksawsapi.CallerFromActor(actor)
|
||||
|
||||
assert.Equal(t, "i-123", caller.SessionName)
|
||||
assert.Equal(t, "real-role", caller.RoleName)
|
||||
|
||||
user := eksawsapi.CallerFromActor(dispatch.Actor{AccountID: dispatchAccount, PrincipalType: "user", UserName: "alice"})
|
||||
assert.Equal(t, "alice", user.SessionName)
|
||||
assert.Empty(t, user.RoleName)
|
||||
}
|
||||
|
||||
// The addon role is not a cluster or nodegroup role, so a foreign one is a
|
||||
// parameter fault, reported under the code EKS declares.
|
||||
func TestRegistration_AddonForeignRoleIsInvalidParameter(t *testing.T) {
|
||||
_, _, err := eksInvoke(t, http.MethodPost, "/clusters/c/addons", `{"addonName":"a","serviceAccountRoleArn":"arn:aws:iam::999999999999:role/r"}`, dispatchAccount, nil)
|
||||
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, awserrors.ErrorEKSInvalidParameter, code)
|
||||
assert.Contains(t, err.Error(), "is not a role ARN in account")
|
||||
}
|
||||
|
||||
type failingBody struct{}
|
||||
|
||||
func (failingBody) Read([]byte) (int, error) { return 0, errors.New("read failed") }
|
||||
func (failingBody) Close() error { return nil }
|
||||
|
||||
// An unreadable body is a bare parameter fault on the public routes, renamed
|
||||
// to the EKS code. The internal routes keep the generic one their agents match.
|
||||
func TestRegistration_UnreadableBodyCode(t *testing.T) {
|
||||
for _, tt := range []struct{ path, want string }{
|
||||
{"/clusters", awserrors.ErrorEKSInvalidParameter},
|
||||
{"/clusters/c/token-review", awserrors.ErrorInvalidParameterValue},
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, tt.path, io.NopCloser(failingBody{}))
|
||||
inv := dispatch.Invocation{Request: req, AccountID: dispatchAccount, Region: "us-east-1"}
|
||||
|
||||
err := eksawsapi.NewRegistration(eksawsapi.Deps{}).Dispatch(httptest.NewRecorder(), inv)
|
||||
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, tt.want, code, tt.path)
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/rest"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// route maps one HTTP method + chi path pattern to an AWS action and handler.
|
||||
type route = rest.Route[routeHandler]
|
||||
|
||||
// routeHandler invokes a per-action EKS gateway function. callerARN is used
|
||||
// by CreateCluster for the bootstrap-creator-admin AccessEntry; ignored by others.
|
||||
type routeHandler func(ctx context.Context, nc *nats.Conn, accountID, callerARN string, params []string, body []byte) (any, error)
|
||||
|
||||
// routes is the dispatch table. Order is presentational: the router matches
|
||||
// through a chi trie, which prefers a literal segment over a {param} one.
|
||||
var routes = []route{
|
||||
// Cluster
|
||||
{Method: "POST", Pattern: "/clusters", Action: "CreateCluster",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return CreateCluster(ctx, nc, acct, callerARN, b)
|
||||
}},
|
||||
{Method: "GET", Pattern: "/clusters", Action: "ListClusters",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return ListClusters(ctx, nc, acct)
|
||||
}},
|
||||
// Control-plane VM broker: relays bootstrap/state/addon POSTs onto eks.bus/state/addon subjects.
|
||||
// acct and callerARN are ignored; the cluster account comes from the body, and
|
||||
// AuthorizeInternal binds that account and cluster to the caller's own.
|
||||
{Method: "POST", Pattern: "/clusters/{clusterName}/internal-publish", Action: "PublishInternal",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return PublishInternal(ctx, nc, p[0], b)
|
||||
}},
|
||||
// Token review broker: the eks-token-webhook POSTs bearer tokens here and the
|
||||
// gateway resolves them host-side. The account comes from the body, and
|
||||
// AuthorizeInternal binds that account and cluster to the caller's own.
|
||||
{Method: "POST", Pattern: "/clusters/{clusterName}/token-review", Action: "WebhookTokenReview",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return WebhookTokenReview(ctx, nc, p[0], b)
|
||||
}},
|
||||
// Control-plane VM add-on delivery: the on-VM addon-sync agent GETs the set
|
||||
// of staged add-on manifests for its cluster (system SigV4 creds) to render
|
||||
// the baked bundles into the K3s auto-deploy dir. acct (system account) is
|
||||
// ignored — the cluster account is the {accountId} path segment, since a GET
|
||||
// carries no body to hold it (cf. PublishInternal). AuthorizeInternal binds
|
||||
// that segment to the caller's own cluster.
|
||||
{Method: "GET", Pattern: "/clusters/{clusterName}/internal-addons/{accountId}", Action: "ListInternalAddons",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return ListInternalAddons(ctx, nc, p[0], p[1])
|
||||
}},
|
||||
|
||||
// Internal control-plane route: the on-VM k3s-recovery agent pulls its
|
||||
// per-member recovery directive (cluster-reset / wipe-rejoin) at boot. Same
|
||||
// system-cred carve-out as internal-addons; instance ID is the third segment.
|
||||
{Method: "GET", Pattern: "/clusters/{clusterName}/internal-recovery/{accountId}/{instanceId}", Action: "GetRecoveryDirective",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return GetRecoveryDirective(ctx, nc, p[0], p[1], p[2])
|
||||
}},
|
||||
|
||||
// Nodegroup
|
||||
{Method: "POST", Pattern: "/clusters/{clusterName}/node-groups", Action: "CreateNodegroup",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return CreateNodegroup(ctx, nc, acct, p[0], b)
|
||||
}},
|
||||
{Method: "GET", Pattern: "/clusters/{clusterName}/node-groups", Action: "ListNodegroups",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return ListNodegroups(ctx, nc, acct, p[0])
|
||||
}},
|
||||
{Method: "POST", Pattern: "/clusters/{clusterName}/node-groups/{nodegroupName}/update-config", Action: "UpdateNodegroupConfig",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return UpdateNodegroupConfig(ctx, nc, acct, p[0], p[1], b)
|
||||
}},
|
||||
{Method: "GET", Pattern: "/clusters/{clusterName}/node-groups/{nodegroupName}", Action: "DescribeNodegroup",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return DescribeNodegroup(ctx, nc, acct, p[0], p[1])
|
||||
}},
|
||||
{Method: "DELETE", Pattern: "/clusters/{clusterName}/node-groups/{nodegroupName}", Action: "DeleteNodegroup",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return DeleteNodegroup(ctx, nc, acct, p[0], p[1])
|
||||
}},
|
||||
|
||||
// AccessEntry / AccessPolicy
|
||||
{Method: "POST", Pattern: "/clusters/{clusterName}/access-entries", Action: "CreateAccessEntry",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return CreateAccessEntry(ctx, nc, acct, p[0], b)
|
||||
}},
|
||||
{Method: "GET", Pattern: "/clusters/{clusterName}/access-entries", Action: "ListAccessEntries",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return ListAccessEntries(ctx, nc, acct, p[0])
|
||||
}},
|
||||
{Method: "POST", Pattern: "/clusters/{clusterName}/access-entries/{principalArn}/access-policies", Action: "AssociateAccessPolicy",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return AssociateAccessPolicy(ctx, nc, acct, p[0], p[1], b)
|
||||
}},
|
||||
{Method: "DELETE", Pattern: "/clusters/{clusterName}/access-entries/{principalArn}/access-policies/{policyArn}", Action: "DisassociateAccessPolicy",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return DisassociateAccessPolicy(ctx, nc, acct, p[0], p[1], p[2])
|
||||
}},
|
||||
{Method: "GET", Pattern: "/clusters/{clusterName}/access-entries/{principalArn}/access-policies", Action: "ListAssociatedAccessPolicies",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return ListAssociatedAccessPolicies(ctx, nc, acct, p[0], p[1])
|
||||
}},
|
||||
{Method: "GET", Pattern: "/clusters/{clusterName}/access-entries/{principalArn}", Action: "DescribeAccessEntry",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return DescribeAccessEntry(ctx, nc, acct, p[0], p[1])
|
||||
}},
|
||||
{Method: "POST", Pattern: "/clusters/{clusterName}/access-entries/{principalArn}", Action: "UpdateAccessEntry",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return UpdateAccessEntry(ctx, nc, acct, p[0], p[1], b)
|
||||
}},
|
||||
{Method: "DELETE", Pattern: "/clusters/{clusterName}/access-entries/{principalArn}", Action: "DeleteAccessEntry",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return DeleteAccessEntry(ctx, nc, acct, p[0], p[1])
|
||||
}},
|
||||
{Method: "GET", Pattern: "/access-policies", Action: "ListAccessPolicies",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return ListAccessPolicies(ctx, nc, acct)
|
||||
}},
|
||||
|
||||
// Addons
|
||||
{Method: "GET", Pattern: "/addons/supported-versions", Action: "DescribeAddonVersions",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return DescribeAddonVersions(ctx, nc, acct)
|
||||
}},
|
||||
{Method: "GET", Pattern: "/clusters/{clusterName}/addons", Action: "ListAddons",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return ListAddons(ctx, nc, acct, p[0])
|
||||
}},
|
||||
{Method: "POST", Pattern: "/clusters/{clusterName}/addons", Action: "CreateAddon",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return CreateAddon(ctx, nc, acct, p[0], b)
|
||||
}},
|
||||
{Method: "POST", Pattern: "/clusters/{clusterName}/addons/{addonName}/update", Action: "UpdateAddon",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return UpdateAddon(ctx, nc, acct, p[0], p[1], b)
|
||||
}},
|
||||
{Method: "GET", Pattern: "/clusters/{clusterName}/addons/{addonName}", Action: "DescribeAddon",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return DescribeAddon(ctx, nc, acct, p[0], p[1])
|
||||
}},
|
||||
{Method: "DELETE", Pattern: "/clusters/{clusterName}/addons/{addonName}", Action: "DeleteAddon",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return DeleteAddon(ctx, nc, acct, p[0], p[1])
|
||||
}},
|
||||
|
||||
// Cluster CRUD — listed after more-specific /clusters/{name}/... routes.
|
||||
{Method: "GET", Pattern: "/clusters/{clusterName}", Action: "DescribeCluster",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return DescribeCluster(ctx, nc, acct, p[0])
|
||||
}},
|
||||
{Method: "DELETE", Pattern: "/clusters/{clusterName}", Action: "DeleteCluster",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return DeleteCluster(ctx, nc, acct, p[0])
|
||||
}},
|
||||
|
||||
// Tags
|
||||
{Method: "POST", Pattern: "/tags/*", Action: "TagResource",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return TagResource(ctx, nc, acct, p[0], b)
|
||||
}},
|
||||
{Method: "DELETE", Pattern: "/tags/*", Action: "UntagResource",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return UntagResource(ctx, nc, acct, p[0], b)
|
||||
}},
|
||||
{Method: "GET", Pattern: "/tags/*", Action: "ListTagsForResource",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return ListTagsForResource(ctx, nc, acct, p[0])
|
||||
}},
|
||||
}
|
||||
|
||||
// actionNames returns the distinct actions in routes in stable order.
|
||||
// Deduplicated because an action may be reachable by more than one route.
|
||||
func actionNames() []string {
|
||||
names := make([]string, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
names = append(names, route.Action)
|
||||
}
|
||||
slices.Sort(names)
|
||||
return slices.Compact(names)
|
||||
}
|
||||
|
||||
// router matches an escaped request path against routes.
|
||||
var router = rest.NewRouter("eks", routes)
|
||||
@@ -1,232 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLookupEKSAction_ResolvesKnownRoutes(t *testing.T) {
|
||||
cases := []struct {
|
||||
method, path string
|
||||
wantAction string
|
||||
wantParams []string
|
||||
}{
|
||||
{"POST", "/clusters", "CreateCluster", nil},
|
||||
{"GET", "/clusters", "ListClusters", nil},
|
||||
{"GET", "/clusters/alpha", "DescribeCluster", []string{"alpha"}},
|
||||
{"DELETE", "/clusters/alpha", "DeleteCluster", []string{"alpha"}},
|
||||
{"POST", "/clusters/alpha/node-groups", "CreateNodegroup", []string{"alpha"}},
|
||||
{"GET", "/clusters/alpha/node-groups", "ListNodegroups", []string{"alpha"}},
|
||||
{"GET", "/clusters/alpha/node-groups/ng1", "DescribeNodegroup", []string{"alpha", "ng1"}},
|
||||
{"DELETE", "/clusters/alpha/node-groups/ng1", "DeleteNodegroup", []string{"alpha", "ng1"}},
|
||||
{"POST", "/clusters/alpha/node-groups/ng1/update-config", "UpdateNodegroupConfig", []string{"alpha", "ng1"}},
|
||||
{"POST", "/clusters/alpha/access-entries", "CreateAccessEntry", []string{"alpha"}},
|
||||
{"GET", "/clusters/alpha/access-entries", "ListAccessEntries", []string{"alpha"}},
|
||||
{"GET", "/clusters/alpha/access-entries/arn-user", "DescribeAccessEntry", []string{"alpha", "arn-user"}},
|
||||
{"POST", "/clusters/alpha/access-entries/arn-user", "UpdateAccessEntry", []string{"alpha", "arn-user"}},
|
||||
{"DELETE", "/clusters/alpha/access-entries/arn-user", "DeleteAccessEntry", []string{"alpha", "arn-user"}},
|
||||
{"POST", "/clusters/alpha/access-entries/arn-user/access-policies", "AssociateAccessPolicy", []string{"alpha", "arn-user"}},
|
||||
{"DELETE", "/clusters/alpha/access-entries/arn-user/access-policies/policy-arn", "DisassociateAccessPolicy", []string{"alpha", "arn-user", "policy-arn"}},
|
||||
{"GET", "/clusters/alpha/access-entries/arn-user/access-policies", "ListAssociatedAccessPolicies", []string{"alpha", "arn-user"}},
|
||||
{"GET", "/access-policies", "ListAccessPolicies", nil},
|
||||
{"GET", "/clusters/alpha/addons", "ListAddons", []string{"alpha"}},
|
||||
{"GET", "/addons/supported-versions", "DescribeAddonVersions", nil},
|
||||
{"POST", "/clusters/alpha/addons", "CreateAddon", []string{"alpha"}},
|
||||
{"GET", "/clusters/alpha/addons/vpc-cni", "DescribeAddon", []string{"alpha", "vpc-cni"}},
|
||||
{"DELETE", "/clusters/alpha/addons/vpc-cni", "DeleteAddon", []string{"alpha", "vpc-cni"}},
|
||||
{"POST", "/clusters/alpha/addons/vpc-cni/update", "UpdateAddon", []string{"alpha", "vpc-cni"}},
|
||||
{"POST", "/tags/arn-cluster", "TagResource", []string{"arn-cluster"}},
|
||||
{"DELETE", "/tags/arn-cluster", "UntagResource", []string{"arn-cluster"}},
|
||||
{"GET", "/tags/arn-cluster", "ListTagsForResource", []string{"arn-cluster"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.method+"_"+tc.path, func(t *testing.T) {
|
||||
action, params, handler, ok := router.Lookup(tc.method, tc.path)
|
||||
require.True(t, ok, "expected route to match for %s %s", tc.method, tc.path)
|
||||
require.NotNil(t, handler)
|
||||
assert.Equal(t, tc.wantAction, action)
|
||||
assert.Equal(t, tc.wantParams, params)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// IAM principal ARNs are percent-encoded in the request path; router.lookup is
|
||||
// fed EscapedPath() so %2F stays a single segment and is unescaped before the handler.
|
||||
func TestLookupEKSAction_EncodedPrincipalARN(t *testing.T) {
|
||||
const (
|
||||
arn = "arn:aws:iam::000000000001:user/admin"
|
||||
escaped = "arn%3Aaws%3Aiam%3A%3A000000000001%3Auser%2Fadmin"
|
||||
)
|
||||
cases := []struct {
|
||||
method, path string
|
||||
wantAction string
|
||||
}{
|
||||
{"GET", "/clusters/alpha/access-entries/" + escaped, "DescribeAccessEntry"},
|
||||
{"POST", "/clusters/alpha/access-entries/" + escaped, "UpdateAccessEntry"},
|
||||
{"DELETE", "/clusters/alpha/access-entries/" + escaped, "DeleteAccessEntry"},
|
||||
{"POST", "/clusters/alpha/access-entries/" + escaped + "/access-policies", "AssociateAccessPolicy"},
|
||||
{"GET", "/clusters/alpha/access-entries/" + escaped + "/access-policies", "ListAssociatedAccessPolicies"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.method+"_"+tc.wantAction, func(t *testing.T) {
|
||||
action, params, handler, ok := router.Lookup(tc.method, tc.path)
|
||||
require.True(t, ok, "encoded ARN path should match: %s %s", tc.method, tc.path)
|
||||
require.NotNil(t, handler)
|
||||
assert.Equal(t, tc.wantAction, action)
|
||||
assert.Equal(t, []string{"alpha", arn}, params)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DisassociateAccessPolicy carries the policy ARN as the final path segment
|
||||
// (DELETE .../access-policies/{policyArn}); both principal and policy ARNs are
|
||||
// percent-encoded and must survive as separate single segments.
|
||||
func TestLookupEKSAction_DisassociateEncodedARNs(t *testing.T) {
|
||||
const (
|
||||
principal = "arn:aws:iam::000000000001:user/admin"
|
||||
principalEscaped = "arn%3Aaws%3Aiam%3A%3A000000000001%3Auser%2Fadmin"
|
||||
policy = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy"
|
||||
policyEscaped = "arn%3Aaws%3Aeks%3A%3Aaws%3Acluster-access-policy%2FAmazonEKSViewPolicy"
|
||||
)
|
||||
path := "/clusters/alpha/access-entries/" + principalEscaped + "/access-policies/" + policyEscaped
|
||||
action, params, handler, ok := router.Lookup("DELETE", path)
|
||||
require.True(t, ok, "encoded disassociate path should match: %s", path)
|
||||
require.NotNil(t, handler)
|
||||
assert.Equal(t, "DisassociateAccessPolicy", action)
|
||||
assert.Equal(t, []string{"alpha", principal, policy}, params)
|
||||
}
|
||||
|
||||
func TestLookupEKSAction_CoversAllActions(t *testing.T) {
|
||||
expected := map[string]bool{
|
||||
"CreateCluster": false,
|
||||
// Internal control-plane broker routes, not AWS-SDK EKS actions.
|
||||
"PublishInternal": false,
|
||||
"WebhookTokenReview": false,
|
||||
"ListInternalAddons": false,
|
||||
"GetRecoveryDirective": false,
|
||||
"DescribeCluster": false,
|
||||
"ListClusters": false,
|
||||
"DeleteCluster": false,
|
||||
"CreateNodegroup": false,
|
||||
"DescribeNodegroup": false,
|
||||
"ListNodegroups": false,
|
||||
"UpdateNodegroupConfig": false,
|
||||
"DeleteNodegroup": false,
|
||||
"CreateAccessEntry": false,
|
||||
"DescribeAccessEntry": false,
|
||||
"ListAccessEntries": false,
|
||||
"UpdateAccessEntry": false,
|
||||
"DeleteAccessEntry": false,
|
||||
"AssociateAccessPolicy": false,
|
||||
"DisassociateAccessPolicy": false,
|
||||
"ListAssociatedAccessPolicies": false,
|
||||
"ListAccessPolicies": false,
|
||||
"ListAddons": false,
|
||||
"DescribeAddonVersions": false,
|
||||
"CreateAddon": false,
|
||||
"DeleteAddon": false,
|
||||
"DescribeAddon": false,
|
||||
"UpdateAddon": false,
|
||||
"TagResource": false,
|
||||
"UntagResource": false,
|
||||
"ListTagsForResource": false,
|
||||
}
|
||||
for _, route := range routes {
|
||||
if _, ok := expected[route.Action]; ok {
|
||||
expected[route.Action] = true
|
||||
}
|
||||
}
|
||||
for action, seen := range expected {
|
||||
assert.True(t, seen, "action %q is not registered in routes", action)
|
||||
}
|
||||
assert.Len(t, routes, len(expected), "routes should have exactly one entry per AWS action")
|
||||
}
|
||||
|
||||
func TestLookupEKSAction_UnknownReturnsFalse(t *testing.T) {
|
||||
_, _, _, ok := router.Lookup("PATCH", "/clusters/alpha")
|
||||
assert.False(t, ok)
|
||||
|
||||
_, _, _, ok = router.Lookup("GET", "/clusters/alpha/wat")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestRESTRouter_EveryEKSRouteResolves(t *testing.T) {
|
||||
for _, route := range routes {
|
||||
path := samplePath(route.Pattern)
|
||||
action, params, _, ok := router.Lookup(route.Method, path)
|
||||
require.True(t, ok, "%s %s (pattern %s) should match", route.Method, path, route.Pattern)
|
||||
assert.Equal(t, route.Action, action, "%s %s", route.Method, path)
|
||||
assert.Len(t, params, strings.Count(route.Pattern, "{")+strings.Count(route.Pattern, "*"), "%s %s", route.Method, path)
|
||||
}
|
||||
}
|
||||
|
||||
// samplePath fills a chi pattern's params with placeholder segments.
|
||||
func samplePath(pattern string) string {
|
||||
segments := strings.Split(pattern, "/")
|
||||
for i, segment := range segments {
|
||||
if strings.HasPrefix(segment, "{") || segment == "*" {
|
||||
segments[i] = "sample"
|
||||
}
|
||||
}
|
||||
return strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
func TestRESTRouter_EKSNoMatchCases(t *testing.T) {
|
||||
cases := []struct{ name, method, path string }{
|
||||
{"empty param segment", "GET", "/clusters/"},
|
||||
{"empty wildcard", "GET", "/tags/"},
|
||||
{"unregistered method", "PATCH", "/clusters/alpha"},
|
||||
{"trailing slash on a slashless pattern", "GET", "/clusters/alpha/"},
|
||||
{"unknown suffix", "GET", "/clusters/alpha/wat"},
|
||||
{"wrong method on a deep route", "GET", "/clusters/alpha/node-groups/ng/update-config"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
action, params, _, ok := router.Lookup(tc.method, tc.path)
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, action)
|
||||
assert.Nil(t, params)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Params arrive percent-encoded so an ARN stays one path segment; the wildcard
|
||||
// route keeps the slashes the encoding hides.
|
||||
func TestRESTRouter_EKSUnescapesParams(t *testing.T) {
|
||||
const (
|
||||
arn = "arn:aws:iam::000000000001:user/admin"
|
||||
escaped = "arn%3Aaws%3Aiam%3A%3A000000000001%3Auser%2Fadmin"
|
||||
)
|
||||
|
||||
action, params, _, ok := router.Lookup("GET", "/clusters/alpha/access-entries/"+escaped)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "DescribeAccessEntry", action)
|
||||
assert.Equal(t, []string{"alpha", arn}, params)
|
||||
|
||||
action, params, _, ok = router.Lookup("GET", "/tags/"+escaped)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "ListTagsForResource", action)
|
||||
assert.Equal(t, []string{arn}, params)
|
||||
}
|
||||
|
||||
// chi keeps the last registration of a duplicated method+pattern silently,
|
||||
// leaving the shadowed route's action unreachable.
|
||||
func TestRESTRouter_NoDuplicateEKSRoutes(t *testing.T) {
|
||||
seen := make(map[string]string, len(routes))
|
||||
for _, route := range routes {
|
||||
key := route.Method + " " + strings.TrimSuffix(route.Pattern, "/")
|
||||
assert.NotContains(t, seen, key, "%s shadows %s on %s", route.Action, seen[key], key)
|
||||
seen[key] = route.Action
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionNamesAreSortedAndDistinct(t *testing.T) {
|
||||
names := actionNames()
|
||||
|
||||
require.True(t, slices.IsSorted(names), "actions are not sorted: %v", names)
|
||||
require.Len(t, slices.Compact(slices.Clone(names)), len(names), "actions contain duplicates: %v", names)
|
||||
}
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
@@ -755,45 +753,19 @@ func (r *ClusterReconciler) maybeRecoverControlPlane(ctx context.Context, meta *
|
||||
|
||||
r.lastRestartAt = now
|
||||
r.restartAttempts++
|
||||
counted := false
|
||||
for _, id := range restartable {
|
||||
slog.Warn("ClusterReconciler: restarting wedged control-plane member in place",
|
||||
"cluster", r.clusterName, "instanceId", id,
|
||||
"attempt", r.restartAttempts, "members", len(members), "issue", issue)
|
||||
if err := r.cpControl.StartInstance(ctx, id); err != nil {
|
||||
if retryableStartRefusal(err) {
|
||||
// Clears without anyone fixing anything: another node's room or
|
||||
// its hold on the volume. Spent attempts would stop the retries
|
||||
// while the member is still waiting to reuse its etcd disk.
|
||||
slog.Warn("ClusterReconciler: CP restart refused, will retry",
|
||||
"cluster", r.clusterName, "instanceId", id, "err", err)
|
||||
continue
|
||||
}
|
||||
counted = true
|
||||
slog.Warn("ClusterReconciler: CP restart failed",
|
||||
"cluster", r.clusterName, "instanceId", id,
|
||||
"attempt", r.restartAttempts, "err", err)
|
||||
continue
|
||||
}
|
||||
counted = true
|
||||
slog.Info("ClusterReconciler: control-plane restart issued",
|
||||
"cluster", r.clusterName, "instanceId", id, "attempt", r.restartAttempts)
|
||||
}
|
||||
if !counted {
|
||||
// Every member was refused for a reason that clears by itself. The
|
||||
// backoff still spaces the next pass.
|
||||
r.restartAttempts--
|
||||
}
|
||||
}
|
||||
|
||||
// retryableStartRefusal reports a start refused for a reason that clears by
|
||||
// itself: no capacity on the owning node, or its volume still held elsewhere.
|
||||
func retryableStartRefusal(err error) bool {
|
||||
if code, ok := awserrors.ResolveErrorCode(err); ok && code == awserrors.ErrorInsufficientInstanceCapacity {
|
||||
return true
|
||||
}
|
||||
_, message, _ := awserrors.ResolveErrorDetail(err)
|
||||
return strings.Contains(message, ec2v1.VolumeHeldElsewhereText) || strings.Contains(err.Error(), ec2v1.VolumeHeldElsewhereText)
|
||||
}
|
||||
|
||||
// maybeReformEtcdQuorum escalates a wedged HA control plane that the in-place
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// refusingCPControl reports every member stopped and refuses every start with
|
||||
// err, which is what a restart sees while the host that last ran the member is
|
||||
// at capacity or still holds its volume.
|
||||
type refusingCPControl struct {
|
||||
err error
|
||||
startCalls int
|
||||
}
|
||||
|
||||
func (c *refusingCPControl) InstanceState(context.Context, string) (string, error) {
|
||||
return "stopped", nil
|
||||
}
|
||||
|
||||
func (c *refusingCPControl) StartInstance(context.Context, string) error {
|
||||
c.startCalls++
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c *refusingCPControl) StopInstance(context.Context, string) error { return nil }
|
||||
|
||||
// What the start path returns after crossing NATS: the code and the message the
|
||||
// owning node sent, with no sentinel left to match on.
|
||||
var (
|
||||
errStartCapacity = errors.New(awserrors.ErrorInsufficientInstanceCapacity)
|
||||
errStartHeld = awserrors.Errorf(awserrors.ErrorIncorrectState, "%s",
|
||||
"volume is leased by another owner: held by node-a since 2026-10-30T01:00:00Z")
|
||||
)
|
||||
|
||||
// A refusal that clears by itself must not use up the restart budget, or the
|
||||
// reconciler gives up on a control plane that would have started a minute later.
|
||||
// Replacing the member instead would discard the etcd disk it is waiting to reuse.
|
||||
func TestMaybeRecoverCP_RetryableStartRefusalIsRetriedNotReplaced(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, refusal := range map[string]error{
|
||||
"insufficient capacity": errStartCapacity,
|
||||
"volume held elsewhere": errStartHeld,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
nodes := threeMemberCP()
|
||||
meta := haMeta(nodes)
|
||||
cp := &refusingCPControl{err: refusal}
|
||||
prov := &fakeCPProvisioner{node: ControlPlaneNode{NodeID: "node-c", InstanceID: "i-cp-new", ENIIP: "10.0.0.20"}}
|
||||
r, _ := newReplaceReconciler(t, cp, prov, meta)
|
||||
r.degradedSince = time.Now().Add(-10 * time.Minute)
|
||||
|
||||
passes := r.maxRestartAttempts + 2
|
||||
for range passes {
|
||||
r.lastRestartAt = time.Time{} // the backoff has elapsed
|
||||
r.maybeRecoverControlPlane(t.Context(), meta, "stale")
|
||||
}
|
||||
|
||||
assert.Equal(t, passes*len(nodes), cp.startCalls, "every pass still tries every member, past the attempt cap")
|
||||
assert.Zero(t, r.restartAttempts, "a refusal that clears on its own is not a failed attempt")
|
||||
|
||||
r.replacingSince = time.Now().Add(-10 * time.Minute)
|
||||
r.maybeReplaceControlPlaneMember(t.Context(), meta, "")
|
||||
assert.Zero(t, prov.calls, "a member that cannot start yet is not a lost member")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The cap still bounds the faults nothing says will clear.
|
||||
func TestMaybeRecoverCP_UnknownStartFailureStillCountsAgainstTheBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
cp := &refusingCPControl{err: errors.New(awserrors.ErrorServerInternal)}
|
||||
r := newRecoveryReconciler(t, cp)
|
||||
r.degradedSince = time.Now().Add(-10 * time.Minute)
|
||||
|
||||
for range r.maxRestartAttempts + 2 {
|
||||
r.lastRestartAt = time.Time{}
|
||||
r.maybeRecoverControlPlane(t.Context(), metaWithCP("i-cp"), "stale")
|
||||
}
|
||||
|
||||
require.Equal(t, r.maxRestartAttempts, cp.startCalls, "stops at the attempt cap")
|
||||
assert.Equal(t, r.maxRestartAttempts, r.restartAttempts)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIAMScopeTableIsExhaustive(t *testing.T) {
|
||||
for action := range actions {
|
||||
assert.True(t, HasScope(action),
|
||||
"IAM action %q has no resource scope entry", action)
|
||||
}
|
||||
for _, action := range ScopedActions() {
|
||||
_, ok := actions[action]
|
||||
assert.True(t, ok, "IAM scope table contains unserved action %q", action)
|
||||
}
|
||||
}
|
||||
@@ -1,529 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
awsxml "github.com/mulgadc/spinifex/spinifex/foundation/aws/xml"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
|
||||
)
|
||||
|
||||
// ServiceName is the SigV4 credential-scope service IAM clients sign for.
|
||||
const ServiceName = "iam"
|
||||
|
||||
const (
|
||||
principalTypeUser = "user"
|
||||
principalTypeRoot = "root"
|
||||
principalTypeAssumedRole = "assumed-role"
|
||||
|
||||
// ec2ServicePrincipal is the service an instance profile's role is passed to.
|
||||
ec2ServicePrincipal = "ec2.amazonaws.com"
|
||||
)
|
||||
|
||||
// Deps are the composed capabilities the IAM control plane dispatches to. A
|
||||
// nil IAM answers every action with InternalError.
|
||||
type Deps struct {
|
||||
IAM iamdomain.IAMService
|
||||
// CountInstanceProfileAssociations counts the live instances holding the
|
||||
// instance profile, so DeleteInstanceProfile can refuse while it is in use.
|
||||
CountInstanceProfileAssociations func(ctx context.Context, accountID, profileARN string) (int, error)
|
||||
}
|
||||
|
||||
// action parses once so authorization and dispatch share one typed input.
|
||||
type action struct {
|
||||
parse func(map[string]string) (any, error)
|
||||
dispatch func(name string, parsed any, d Deps, accountID string) ([]byte, error)
|
||||
}
|
||||
|
||||
// handler creates a type-safe IAM action with separate parse and dispatch
|
||||
// stages so policy resolution consumes the exact handler input.
|
||||
func handler[In any](h func(string, *In, Deps) (any, error)) action {
|
||||
return action{
|
||||
parse: func(q map[string]string) (any, error) {
|
||||
input := new(In)
|
||||
if err := query.QueryParamsToStruct(q, input); err != nil {
|
||||
if errors.Is(err, query.ErrSliceTooLarge) {
|
||||
return nil, errors.New(awserrors.ErrorMalformedQueryString)
|
||||
}
|
||||
return nil, awserrors.Errorf(awserrors.ErrorIAMInvalidInput, "The request parameters are invalid: %v", err)
|
||||
}
|
||||
return input, nil
|
||||
},
|
||||
dispatch: func(name string, parsed any, d Deps, accountID string) ([]byte, error) {
|
||||
input, ok := parsed.(*In)
|
||||
if !ok {
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
output, err := h(accountID, input, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := awsxml.QueryResponsePayload(name, output)
|
||||
xmlOutput, err := awsxml.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
return xmlOutput, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var actions = map[string]action{
|
||||
"CreateUser": handler(func(accountID string, input *iam.CreateUserInput, d Deps) (any, error) {
|
||||
return CreateUser(accountID, input, d.IAM)
|
||||
}),
|
||||
"GetUser": handler(func(accountID string, input *iam.GetUserInput, d Deps) (any, error) {
|
||||
return GetUser(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListUsers": handler(func(accountID string, input *iam.ListUsersInput, d Deps) (any, error) {
|
||||
return ListUsers(accountID, input, d.IAM)
|
||||
}),
|
||||
"DeleteUser": handler(func(accountID string, input *iam.DeleteUserInput, d Deps) (any, error) {
|
||||
return DeleteUser(accountID, input, d.IAM)
|
||||
}),
|
||||
"CreateAccessKey": handler(func(accountID string, input *iam.CreateAccessKeyInput, d Deps) (any, error) {
|
||||
return CreateAccessKey(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListAccessKeys": handler(func(accountID string, input *iam.ListAccessKeysInput, d Deps) (any, error) {
|
||||
return ListAccessKeys(accountID, input, d.IAM)
|
||||
}),
|
||||
"DeleteAccessKey": handler(func(accountID string, input *iam.DeleteAccessKeyInput, d Deps) (any, error) {
|
||||
return DeleteAccessKey(accountID, input, d.IAM)
|
||||
}),
|
||||
"UpdateAccessKey": handler(func(accountID string, input *iam.UpdateAccessKeyInput, d Deps) (any, error) {
|
||||
return UpdateAccessKey(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// Policy CRUD
|
||||
"CreatePolicy": handler(func(accountID string, input *iam.CreatePolicyInput, d Deps) (any, error) {
|
||||
return CreatePolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"GetPolicy": handler(func(accountID string, input *iam.GetPolicyInput, d Deps) (any, error) {
|
||||
return GetPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"GetPolicyVersion": handler(func(accountID string, input *iam.GetPolicyVersionInput, d Deps) (any, error) {
|
||||
return GetPolicyVersion(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListPolicyVersions": handler(func(accountID string, input *iam.ListPolicyVersionsInput, d Deps) (any, error) {
|
||||
return ListPolicyVersions(accountID, input, d.IAM)
|
||||
}),
|
||||
"CreatePolicyVersion": handler(func(accountID string, input *iam.CreatePolicyVersionInput, d Deps) (any, error) {
|
||||
return CreatePolicyVersion(accountID, input, d.IAM)
|
||||
}),
|
||||
"SetDefaultPolicyVersion": handler(func(accountID string, input *iam.SetDefaultPolicyVersionInput, d Deps) (any, error) {
|
||||
return SetDefaultPolicyVersion(accountID, input, d.IAM)
|
||||
}),
|
||||
"DeletePolicyVersion": handler(func(accountID string, input *iam.DeletePolicyVersionInput, d Deps) (any, error) {
|
||||
return DeletePolicyVersion(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListPolicies": handler(func(accountID string, input *iam.ListPoliciesInput, d Deps) (any, error) {
|
||||
return ListPolicies(accountID, input, d.IAM)
|
||||
}),
|
||||
"DeletePolicy": handler(func(accountID string, input *iam.DeletePolicyInput, d Deps) (any, error) {
|
||||
return DeletePolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListEntitiesForPolicy": handler(func(accountID string, input *iam.ListEntitiesForPolicyInput, d Deps) (any, error) {
|
||||
return ListEntitiesForPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// Policy attachment
|
||||
"AttachUserPolicy": handler(func(accountID string, input *iam.AttachUserPolicyInput, d Deps) (any, error) {
|
||||
return AttachUserPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"DetachUserPolicy": handler(func(accountID string, input *iam.DetachUserPolicyInput, d Deps) (any, error) {
|
||||
return DetachUserPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListAttachedUserPolicies": handler(func(accountID string, input *iam.ListAttachedUserPoliciesInput, d Deps) (any, error) {
|
||||
return ListAttachedUserPolicies(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// User inline policies
|
||||
"PutUserPolicy": handler(func(accountID string, input *iam.PutUserPolicyInput, d Deps) (any, error) {
|
||||
return PutUserPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"GetUserPolicy": handler(func(accountID string, input *iam.GetUserPolicyInput, d Deps) (any, error) {
|
||||
return GetUserPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"DeleteUserPolicy": handler(func(accountID string, input *iam.DeleteUserPolicyInput, d Deps) (any, error) {
|
||||
return DeleteUserPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListUserPolicies": handler(func(accountID string, input *iam.ListUserPoliciesInput, d Deps) (any, error) {
|
||||
return ListUserPolicies(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// Role CRUD
|
||||
"CreateRole": handler(func(accountID string, input *iam.CreateRoleInput, d Deps) (any, error) {
|
||||
return CreateRole(accountID, input, d.IAM)
|
||||
}),
|
||||
"GetRole": handler(func(accountID string, input *iam.GetRoleInput, d Deps) (any, error) {
|
||||
return GetRole(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListRoles": handler(func(accountID string, input *iam.ListRolesInput, d Deps) (any, error) {
|
||||
return ListRoles(accountID, input, d.IAM)
|
||||
}),
|
||||
"DeleteRole": handler(func(accountID string, input *iam.DeleteRoleInput, d Deps) (any, error) {
|
||||
return DeleteRole(accountID, input, d.IAM)
|
||||
}),
|
||||
"UpdateRole": handler(func(accountID string, input *iam.UpdateRoleInput, d Deps) (any, error) {
|
||||
return UpdateRole(accountID, input, d.IAM)
|
||||
}),
|
||||
"UpdateRoleDescription": handler(func(accountID string, input *iam.UpdateRoleDescriptionInput, d Deps) (any, error) {
|
||||
return UpdateRoleDescription(accountID, input, d.IAM)
|
||||
}),
|
||||
"UpdateAssumeRolePolicy": handler(func(accountID string, input *iam.UpdateAssumeRolePolicyInput, d Deps) (any, error) {
|
||||
return UpdateAssumeRolePolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// Role policy attachment
|
||||
"AttachRolePolicy": handler(func(accountID string, input *iam.AttachRolePolicyInput, d Deps) (any, error) {
|
||||
return AttachRolePolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"DetachRolePolicy": handler(func(accountID string, input *iam.DetachRolePolicyInput, d Deps) (any, error) {
|
||||
return DetachRolePolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"PutRolePolicy": handler(func(accountID string, input *iam.PutRolePolicyInput, d Deps) (any, error) {
|
||||
return PutRolePolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"GetRolePolicy": handler(func(accountID string, input *iam.GetRolePolicyInput, d Deps) (any, error) {
|
||||
return GetRolePolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"DeleteRolePolicy": handler(func(accountID string, input *iam.DeleteRolePolicyInput, d Deps) (any, error) {
|
||||
return DeleteRolePolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListAttachedRolePolicies": handler(func(accountID string, input *iam.ListAttachedRolePoliciesInput, d Deps) (any, error) {
|
||||
return ListAttachedRolePolicies(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListRolePolicies": handler(func(accountID string, input *iam.ListRolePoliciesInput, d Deps) (any, error) {
|
||||
return ListRolePolicies(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// Instance profile CRUD
|
||||
"CreateInstanceProfile": handler(func(accountID string, input *iam.CreateInstanceProfileInput, d Deps) (any, error) {
|
||||
return CreateInstanceProfile(accountID, input, d.IAM)
|
||||
}),
|
||||
"GetInstanceProfile": handler(func(accountID string, input *iam.GetInstanceProfileInput, d Deps) (any, error) {
|
||||
return GetInstanceProfile(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListInstanceProfiles": handler(func(accountID string, input *iam.ListInstanceProfilesInput, d Deps) (any, error) {
|
||||
return ListInstanceProfiles(accountID, input, d.IAM)
|
||||
}),
|
||||
"DeleteInstanceProfile": handler(func(accountID string, input *iam.DeleteInstanceProfileInput, d Deps) (any, error) {
|
||||
countLive := func(profileARN string) (int, error) {
|
||||
if d.CountInstanceProfileAssociations == nil {
|
||||
return 0, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
return d.CountInstanceProfileAssociations(context.Background(), accountID, profileARN)
|
||||
}
|
||||
return DeleteInstanceProfile(accountID, input, d.IAM, countLive)
|
||||
}),
|
||||
"ListInstanceProfilesForRole": handler(func(accountID string, input *iam.ListInstanceProfilesForRoleInput, d Deps) (any, error) {
|
||||
return ListInstanceProfilesForRole(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// Instance profile ↔ role binding
|
||||
"AddRoleToInstanceProfile": handler(func(accountID string, input *iam.AddRoleToInstanceProfileInput, d Deps) (any, error) {
|
||||
return AddRoleToInstanceProfile(accountID, input, d.IAM)
|
||||
}),
|
||||
"RemoveRoleFromInstanceProfile": handler(func(accountID string, input *iam.RemoveRoleFromInstanceProfileInput, d Deps) (any, error) {
|
||||
return RemoveRoleFromInstanceProfile(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// OIDC identity-provider registry (IRSA)
|
||||
"CreateOpenIDConnectProvider": handler(func(accountID string, input *iam.CreateOpenIDConnectProviderInput, d Deps) (any, error) {
|
||||
return CreateOpenIDConnectProvider(accountID, input, d.IAM)
|
||||
}),
|
||||
"GetOpenIDConnectProvider": handler(func(accountID string, input *iam.GetOpenIDConnectProviderInput, d Deps) (any, error) {
|
||||
return GetOpenIDConnectProvider(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListOpenIDConnectProviders": handler(func(accountID string, input *iam.ListOpenIDConnectProvidersInput, d Deps) (any, error) {
|
||||
return ListOpenIDConnectProviders(accountID, input, d.IAM)
|
||||
}),
|
||||
"DeleteOpenIDConnectProvider": handler(func(accountID string, input *iam.DeleteOpenIDConnectProviderInput, d Deps) (any, error) {
|
||||
return DeleteOpenIDConnectProvider(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// Resource tagging
|
||||
"TagUser": handler(func(accountID string, input *iam.TagUserInput, d Deps) (any, error) {
|
||||
return TagUser(accountID, input, d.IAM)
|
||||
}),
|
||||
"UntagUser": handler(func(accountID string, input *iam.UntagUserInput, d Deps) (any, error) {
|
||||
return UntagUser(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListUserTags": handler(func(accountID string, input *iam.ListUserTagsInput, d Deps) (any, error) {
|
||||
return ListUserTags(accountID, input, d.IAM)
|
||||
}),
|
||||
"TagRole": handler(func(accountID string, input *iam.TagRoleInput, d Deps) (any, error) {
|
||||
return TagRole(accountID, input, d.IAM)
|
||||
}),
|
||||
"UntagRole": handler(func(accountID string, input *iam.UntagRoleInput, d Deps) (any, error) {
|
||||
return UntagRole(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListRoleTags": handler(func(accountID string, input *iam.ListRoleTagsInput, d Deps) (any, error) {
|
||||
return ListRoleTags(accountID, input, d.IAM)
|
||||
}),
|
||||
"TagPolicy": handler(func(accountID string, input *iam.TagPolicyInput, d Deps) (any, error) {
|
||||
return TagPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"UntagPolicy": handler(func(accountID string, input *iam.UntagPolicyInput, d Deps) (any, error) {
|
||||
return UntagPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListPolicyTags": handler(func(accountID string, input *iam.ListPolicyTagsInput, d Deps) (any, error) {
|
||||
return ListPolicyTags(accountID, input, d.IAM)
|
||||
}),
|
||||
"TagInstanceProfile": handler(func(accountID string, input *iam.TagInstanceProfileInput, d Deps) (any, error) {
|
||||
return TagInstanceProfile(accountID, input, d.IAM)
|
||||
}),
|
||||
"UntagInstanceProfile": handler(func(accountID string, input *iam.UntagInstanceProfileInput, d Deps) (any, error) {
|
||||
return UntagInstanceProfile(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListInstanceProfileTags": handler(func(accountID string, input *iam.ListInstanceProfileTagsInput, d Deps) (any, error) {
|
||||
return ListInstanceProfileTags(accountID, input, d.IAM)
|
||||
}),
|
||||
"TagOpenIDConnectProvider": handler(func(accountID string, input *iam.TagOpenIDConnectProviderInput, d Deps) (any, error) {
|
||||
return TagOpenIDConnectProvider(accountID, input, d.IAM)
|
||||
}),
|
||||
"UntagOpenIDConnectProvider": handler(func(accountID string, input *iam.UntagOpenIDConnectProviderInput, d Deps) (any, error) {
|
||||
return UntagOpenIDConnectProvider(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListOpenIDConnectProviderTags": handler(func(accountID string, input *iam.ListOpenIDConnectProviderTagsInput, d Deps) (any, error) {
|
||||
return ListOpenIDConnectProviderTags(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// Group CRUD
|
||||
"CreateGroup": handler(func(accountID string, input *iam.CreateGroupInput, d Deps) (any, error) {
|
||||
return CreateGroup(accountID, input, d.IAM)
|
||||
}),
|
||||
"GetGroup": handler(func(accountID string, input *iam.GetGroupInput, d Deps) (any, error) {
|
||||
return GetGroup(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListGroups": handler(func(accountID string, input *iam.ListGroupsInput, d Deps) (any, error) {
|
||||
return ListGroups(accountID, input, d.IAM)
|
||||
}),
|
||||
"DeleteGroup": handler(func(accountID string, input *iam.DeleteGroupInput, d Deps) (any, error) {
|
||||
return DeleteGroup(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// Group membership
|
||||
"AddUserToGroup": handler(func(accountID string, input *iam.AddUserToGroupInput, d Deps) (any, error) {
|
||||
return AddUserToGroup(accountID, input, d.IAM)
|
||||
}),
|
||||
"RemoveUserFromGroup": handler(func(accountID string, input *iam.RemoveUserFromGroupInput, d Deps) (any, error) {
|
||||
return RemoveUserFromGroup(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListGroupsForUser": handler(func(accountID string, input *iam.ListGroupsForUserInput, d Deps) (any, error) {
|
||||
return ListGroupsForUser(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// Group policy attachment
|
||||
"AttachGroupPolicy": handler(func(accountID string, input *iam.AttachGroupPolicyInput, d Deps) (any, error) {
|
||||
return AttachGroupPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"DetachGroupPolicy": handler(func(accountID string, input *iam.DetachGroupPolicyInput, d Deps) (any, error) {
|
||||
return DetachGroupPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListAttachedGroupPolicies": handler(func(accountID string, input *iam.ListAttachedGroupPoliciesInput, d Deps) (any, error) {
|
||||
return ListAttachedGroupPolicies(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// Group inline policies
|
||||
"PutGroupPolicy": handler(func(accountID string, input *iam.PutGroupPolicyInput, d Deps) (any, error) {
|
||||
return PutGroupPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"GetGroupPolicy": handler(func(accountID string, input *iam.GetGroupPolicyInput, d Deps) (any, error) {
|
||||
return GetGroupPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"DeleteGroupPolicy": handler(func(accountID string, input *iam.DeleteGroupPolicyInput, d Deps) (any, error) {
|
||||
return DeleteGroupPolicy(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListGroupPolicies": handler(func(accountID string, input *iam.ListGroupPoliciesInput, d Deps) (any, error) {
|
||||
return ListGroupPolicies(accountID, input, d.IAM)
|
||||
}),
|
||||
|
||||
// Account
|
||||
"GetAccountSummary": handler(func(accountID string, input *iam.GetAccountSummaryInput, d Deps) (any, error) {
|
||||
return GetAccountSummary(accountID, input, d.IAM)
|
||||
}),
|
||||
"ListAccountAliases": handler(func(accountID string, input *iam.ListAccountAliasesInput, d Deps) (any, error) {
|
||||
return ListAccountAliases(accountID, input, d.IAM)
|
||||
}),
|
||||
}
|
||||
|
||||
// getRootUser answers GetUser without UserName for root, which has no IAM user
|
||||
// record of the shape AWS returns.
|
||||
var getRootUser = handler(func(accountID string, _ *iam.GetUserInput, d Deps) (any, error) {
|
||||
return GetRootUser(accountID, d.IAM)
|
||||
})
|
||||
|
||||
// NewRegistration returns the IAM AWS Query control-plane registration.
|
||||
func NewRegistration(deps Deps) dispatch.Registration {
|
||||
return dispatch.Registration{
|
||||
Service: ServiceName,
|
||||
ResolveAction: resolveAction,
|
||||
Dispatch: deps.dispatch,
|
||||
Errors: dispatch.ErrorEnvelopeXML,
|
||||
Inventory: OperationInventory(),
|
||||
}
|
||||
}
|
||||
|
||||
// ActionNames lists every action the registration dispatches, sorted.
|
||||
func ActionNames() []string {
|
||||
return slices.Sorted(maps.Keys(actions))
|
||||
}
|
||||
|
||||
// OperationInventory classifies the actions the registration serves. Every IAM
|
||||
// action is implemented, so none is Stubbed or Unsupported.
|
||||
func OperationInventory() dispatch.Inventory {
|
||||
return dispatch.Inventory{Registered: ActionNames()}
|
||||
}
|
||||
|
||||
// resolveAction is the one Query Action parser telemetry and dispatch share.
|
||||
// It leaves the body readable for the dispatcher and answers "" on any failure.
|
||||
func resolveAction(r *http.Request) string {
|
||||
args, err := readQueryArgs(r)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return args["Action"]
|
||||
}
|
||||
|
||||
func readQueryArgs(r *http.Request) (map[string]string, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, sigv4.MaxPayloadLen+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if int64(len(body)) > sigv4.MaxPayloadLen {
|
||||
return nil, errors.New(awserrors.ErrorRequestEntityTooLarge)
|
||||
}
|
||||
return query.ParseArgs(string(body))
|
||||
}
|
||||
|
||||
// dispatch parses, scopes and authorizes the request from one parse of its
|
||||
// body, then runs the action. Errors are awserrors codes the gateway renders.
|
||||
func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
r := inv.Request
|
||||
queryArgs, err := readQueryArgs(r)
|
||||
if err != nil {
|
||||
slog.DebugContext(r.Context(), "IAM: malformed query string", "err", err)
|
||||
return errors.New(awserrors.ErrorMalformedQueryString)
|
||||
}
|
||||
|
||||
name := queryArgs["Action"]
|
||||
if name == "" {
|
||||
return errors.New(awserrors.ErrorMissingAction)
|
||||
}
|
||||
h, ok := actions[name]
|
||||
if !ok {
|
||||
slog.DebugContext(r.Context(), "IAM: unknown action", "action", name)
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
if d.IAM == nil {
|
||||
slog.ErrorContext(r.Context(), "IAM: service not initialized")
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
accountID := inv.AccountID
|
||||
if accountID == "" {
|
||||
slog.ErrorContext(r.Context(), "IAM dispatch: no account ID in auth context")
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
input, err := h.parse(queryArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in, ok := input.(*iam.GetUserInput); ok && aws.StringValue(in.UserName) == "" {
|
||||
if h, err = getUserForCaller(r.Context(), inv, accountID, in); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
resources, err := ResourceARNs(name, accountID, input, d.IAM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.Authorize(ServiceName, name, resources, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if in, ok := input.(*iam.AddRoleToInstanceProfileInput); ok {
|
||||
if err := d.checkInstanceProfilePassRole(r.Context(), inv, accountID, in); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
xmlOutput, err := h.dispatch(name, input, d, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(xmlOutput); err != nil {
|
||||
slog.ErrorContext(r.Context(), "Failed to write IAM response", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getUserForCaller applies AWS's default for GetUser without UserName before
|
||||
// authorization: a user is treated as naming themselves, root is described as
|
||||
// the account root, and a role session must name a user.
|
||||
func getUserForCaller(ctx context.Context, inv dispatch.Invocation, accountID string, in *iam.GetUserInput) (action, error) {
|
||||
// The actor is populated even when no caller ARN can be composed, and
|
||||
// GetUser needs only its principal type and name.
|
||||
actor, _ := inv.Actor()
|
||||
switch actor.PrincipalType {
|
||||
case principalTypeUser:
|
||||
if actor.UserName == "root" && accountID == awsidentifiers.GlobalAccountID {
|
||||
return getRootUser, nil
|
||||
}
|
||||
in.UserName = aws.String(actor.UserName)
|
||||
return actions["GetUser"], nil
|
||||
case principalTypeRoot:
|
||||
return getRootUser, nil
|
||||
case principalTypeAssumedRole:
|
||||
return action{}, awserrors.Errorf(awserrors.ErrorValidationError,
|
||||
"Must specify userName when calling with non-User credentials")
|
||||
default:
|
||||
slog.ErrorContext(ctx, "IAM GetUser: unknown principal type, cannot default UserName",
|
||||
"principal_type", actor.PrincipalType)
|
||||
return action{}, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
}
|
||||
|
||||
// checkInstanceProfilePassRole requires iam:PassRole on the role being added to
|
||||
// an instance profile: once added, every instance holding the profile receives
|
||||
// its credentials, and the EC2 association check never sees the role.
|
||||
func (d Deps) checkInstanceProfilePassRole(ctx context.Context, inv dispatch.Invocation, accountID string, input *iam.AddRoleToInstanceProfileInput) error {
|
||||
roleName := aws.StringValue(input.RoleName)
|
||||
if roleName == "" {
|
||||
return nil
|
||||
}
|
||||
roleARN, err := d.IAM.CanonicalResourceARN(accountID, arn.IAMRole, roleName)
|
||||
switch {
|
||||
case awserrors.IsErrorCode(err, awserrors.ErrorIAMNoSuchEntity):
|
||||
// Still evaluated, so a caller who cannot pass the role cannot use
|
||||
// the handler's NoSuchEntity to probe which roles exist.
|
||||
roleARN = arn.FormatIAMPath(arn.IAMRole, accountID, "/", roleName)
|
||||
case err != nil:
|
||||
slog.ErrorContext(ctx, "IAM: role lookup for iam:PassRole failed, failing closed",
|
||||
"account_id", accountID, "role_name", roleName, "err", err)
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
return inv.Authorize("iam", "PassRole", []string{roleARN},
|
||||
iampolicy.ConditionKeys{iampolicy.KeyPassedToService: ec2ServicePrincipal})
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// regService records the calls the dispatcher makes so a test can assert what
|
||||
// ran, and in which order relative to authorization.
|
||||
type regService struct {
|
||||
stubIAMService
|
||||
|
||||
getUserName string
|
||||
getUserCalls int
|
||||
addRoleCalls int
|
||||
canonicalARN string
|
||||
canonicalErr error
|
||||
accountResult *iamdomain.Account
|
||||
accountErr error
|
||||
}
|
||||
|
||||
func (s *regService) GetUser(_ string, in *iam.GetUserInput) (*iam.GetUserOutput, error) {
|
||||
s.getUserCalls++
|
||||
if in.UserName != nil {
|
||||
s.getUserName = *in.UserName
|
||||
}
|
||||
return &iam.GetUserOutput{}, nil
|
||||
}
|
||||
|
||||
func (s *regService) AddRoleToInstanceProfile(_ string, _ *iam.AddRoleToInstanceProfileInput) (*iam.AddRoleToInstanceProfileOutput, error) {
|
||||
s.addRoleCalls++
|
||||
return &iam.AddRoleToInstanceProfileOutput{}, nil
|
||||
}
|
||||
|
||||
// CanonicalResourceARN serves the scope resolver for every kind; only the role
|
||||
// lookup the PassRole check makes is scripted, the rest resolve like a live store.
|
||||
func (s *regService) CanonicalResourceARN(accountID string, kind arn.IAMResourceType, name string) (string, error) {
|
||||
if kind == arn.IAMRole {
|
||||
return s.canonicalARN, s.canonicalErr
|
||||
}
|
||||
return arn.FormatIAMPath(kind, accountID, "/", name), nil
|
||||
}
|
||||
|
||||
func (s *regService) GetAccount(_ string) (*iamdomain.Account, error) {
|
||||
return s.accountResult, s.accountErr
|
||||
}
|
||||
|
||||
type authCall struct {
|
||||
service, action string
|
||||
resources []string
|
||||
keys iampolicy.ConditionKeys
|
||||
}
|
||||
|
||||
func invoke(t *testing.T, d Deps, accountID string, actor dispatch.Actor, authorize func(authCall) error, form url.Values) (*httptest.ResponseRecorder, []authCall, error) {
|
||||
t.Helper()
|
||||
r := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(form.Encode()))
|
||||
var calls []authCall
|
||||
inv := dispatch.Invocation{
|
||||
Request: r,
|
||||
AccountID: accountID,
|
||||
Service: ServiceName,
|
||||
Authorize: func(service, action string, resources []string, keys iampolicy.ConditionKeys) error {
|
||||
c := authCall{service, action, resources, keys}
|
||||
calls = append(calls, c)
|
||||
if authorize != nil {
|
||||
return authorize(c)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Actor: func() (dispatch.Actor, error) { return actor, nil },
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
err := NewRegistration(d).Dispatch(w, inv)
|
||||
return w, calls, err
|
||||
}
|
||||
|
||||
func TestRegistration_RequestShapeErrors(t *testing.T) {
|
||||
svc := ®Service{}
|
||||
tests := []struct {
|
||||
name string
|
||||
deps Deps
|
||||
acct string
|
||||
form url.Values
|
||||
want string
|
||||
}{
|
||||
{"missing action", Deps{IAM: svc}, testAccountID, url.Values{}, awserrors.ErrorMissingAction},
|
||||
{"unknown action", Deps{IAM: svc}, testAccountID, url.Values{"Action": {"NoSuchThing"}}, awserrors.ErrorInvalidAction},
|
||||
{"nil service", Deps{}, testAccountID, url.Values{"Action": {"ListUsers"}}, awserrors.ErrorInternalError},
|
||||
{"no account", Deps{IAM: svc}, "", url.Values{"Action": {"ListUsers"}}, awserrors.ErrorInternalError},
|
||||
{"unknown action beats nil service", Deps{}, testAccountID, url.Values{"Action": {"NoSuchThing"}}, awserrors.ErrorInvalidAction},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w, calls, err := invoke(t, tc.deps, tc.acct, dispatch.Actor{}, nil, tc.form)
|
||||
require.Error(t, err)
|
||||
assert.True(t, awserrors.IsErrorCode(err, tc.want), "got %v want %s", err, tc.want)
|
||||
assert.Empty(t, calls, "no authorization before the request shape is valid")
|
||||
assert.Zero(t, w.Body.Len(), "dispatcher writes nothing on error")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistration_MalformedBody(t *testing.T) {
|
||||
r := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader("Action=%G"))
|
||||
inv := dispatch.Invocation{Request: r, AccountID: testAccountID}
|
||||
err := NewRegistration(Deps{IAM: ®Service{}}).Dispatch(httptest.NewRecorder(), inv)
|
||||
require.Error(t, err)
|
||||
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorMalformedQueryString))
|
||||
}
|
||||
|
||||
func TestRegistration_ResolveAction(t *testing.T) {
|
||||
reg := NewRegistration(Deps{})
|
||||
r := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader("Action=ListUsers&Version=2010-05-08"))
|
||||
assert.Equal(t, "ListUsers", reg.ResolveAction(r))
|
||||
// The dispatcher still reads the body after resolution.
|
||||
w, _, err := invokeRequest(t, Deps{IAM: ®Service{}}, r)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
bad := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader("Action=%G"))
|
||||
assert.Empty(t, reg.ResolveAction(bad))
|
||||
}
|
||||
|
||||
func invokeRequest(t *testing.T, d Deps, r *http.Request) (*httptest.ResponseRecorder, []authCall, error) {
|
||||
t.Helper()
|
||||
var calls []authCall
|
||||
inv := dispatch.Invocation{
|
||||
Request: r,
|
||||
AccountID: testAccountID,
|
||||
Authorize: func(service, action string, resources []string, keys iampolicy.ConditionKeys) error {
|
||||
calls = append(calls, authCall{service, action, resources, keys})
|
||||
return nil
|
||||
},
|
||||
Actor: func() (dispatch.Actor, error) { return dispatch.Actor{}, nil },
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
err := NewRegistration(d).Dispatch(w, inv)
|
||||
return w, calls, err
|
||||
}
|
||||
|
||||
func TestRegistration_AuthorizeBeforeHandler(t *testing.T) {
|
||||
svc := ®Service{}
|
||||
deny := errors.New(awserrors.ErrorAccessDenied)
|
||||
_, calls, err := invoke(t, Deps{IAM: svc}, testAccountID, dispatch.Actor{},
|
||||
func(authCall) error { return deny },
|
||||
url.Values{"Action": {"GetUser"}, "UserName": {"alice"}})
|
||||
require.ErrorIs(t, err, deny)
|
||||
require.Len(t, calls, 1)
|
||||
assert.Equal(t, "iam", calls[0].service)
|
||||
assert.Equal(t, "GetUser", calls[0].action)
|
||||
assert.Equal(t, []string{"arn:aws:iam::" + testAccountID + ":user/alice"}, calls[0].resources)
|
||||
assert.Zero(t, svc.getUserCalls, "a denied request must not reach the handler")
|
||||
}
|
||||
|
||||
func TestRegistration_SuccessWritesXML(t *testing.T) {
|
||||
svc := ®Service{}
|
||||
w, calls, err := invoke(t, Deps{IAM: svc}, testAccountID, dispatch.Actor{}, nil,
|
||||
url.Values{"Action": {"GetUser"}, "UserName": {"alice"}})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, calls, 1)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "text/xml", w.Header().Get("Content-Type"))
|
||||
assert.Contains(t, w.Body.String(), "GetUserResponse")
|
||||
assert.Equal(t, 1, svc.getUserCalls)
|
||||
}
|
||||
|
||||
func TestRegistration_GetUserWithoutName(t *testing.T) {
|
||||
root := &iamdomain.Account{CreatedAt: "2024-01-02T03:04:05Z"}
|
||||
tests := []struct {
|
||||
name string
|
||||
actor dispatch.Actor
|
||||
account string
|
||||
wantErr string
|
||||
wantUserName string
|
||||
wantRoot bool
|
||||
}{
|
||||
{"user names themselves", dispatch.Actor{PrincipalType: "user", UserName: "alice"}, testAccountID, "", "alice", false},
|
||||
{"session token user", dispatch.Actor{PrincipalType: "user", UserName: "bob"}, testAccountID, "", "bob", false},
|
||||
{"root principal", dispatch.Actor{PrincipalType: "root"}, testAccountID, "", "", true},
|
||||
{"tenant user named root", dispatch.Actor{PrincipalType: "user", UserName: "root"}, "123456789012", "", "root", false},
|
||||
{"global user named root is root", dispatch.Actor{PrincipalType: "user", UserName: "root"}, awsidentifiers.GlobalAccountID, "", "", true},
|
||||
{"assumed role must name a user", dispatch.Actor{PrincipalType: "assumed-role", SessionName: "s1"}, testAccountID, awserrors.ErrorValidationError, "", false},
|
||||
{"unknown principal fails closed", dispatch.Actor{PrincipalType: "mystery"}, testAccountID, awserrors.ErrorInternalError, "", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := ®Service{accountResult: root}
|
||||
w, calls, err := invoke(t, Deps{IAM: svc}, tc.account, tc.actor, nil, url.Values{"Action": {"GetUser"}})
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.True(t, awserrors.IsErrorCode(err, tc.wantErr), "got %v", err)
|
||||
assert.Empty(t, calls, "defaulting fails before authorization")
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Len(t, calls, 1)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
if tc.wantRoot {
|
||||
assert.Zero(t, svc.getUserCalls, "root is described without a user record")
|
||||
assert.Contains(t, w.Body.String(), tc.account)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tc.wantUserName, svc.getUserName)
|
||||
assert.Equal(t, []string{"arn:aws:iam::" + tc.account + ":user/" + tc.wantUserName}, calls[0].resources)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistration_AddRoleToInstanceProfilePassRole(t *testing.T) {
|
||||
form := url.Values{"Action": {"AddRoleToInstanceProfile"}, "InstanceProfileName": {"p"}, "RoleName": {"r"}}
|
||||
roleARN := "arn:aws:iam::" + testAccountID + ":role/svc/r"
|
||||
|
||||
t.Run("passrole checked with the ec2 service key", func(t *testing.T) {
|
||||
svc := ®Service{canonicalARN: roleARN}
|
||||
_, calls, err := invoke(t, Deps{IAM: svc}, testAccountID, dispatch.Actor{}, nil, form)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, calls, 2)
|
||||
assert.Equal(t, "AddRoleToInstanceProfile", calls[0].action)
|
||||
assert.Equal(t, "PassRole", calls[1].action)
|
||||
assert.Equal(t, []string{roleARN}, calls[1].resources)
|
||||
assert.Equal(t, iampolicy.ConditionKeys{iampolicy.KeyPassedToService: "ec2.amazonaws.com"}, calls[1].keys)
|
||||
assert.Equal(t, 1, svc.addRoleCalls)
|
||||
})
|
||||
|
||||
t.Run("denied passrole blocks the handler", func(t *testing.T) {
|
||||
svc := ®Service{canonicalARN: roleARN}
|
||||
deny := errors.New(awserrors.ErrorAccessDenied)
|
||||
_, _, err := invoke(t, Deps{IAM: svc}, testAccountID, dispatch.Actor{},
|
||||
func(c authCall) error {
|
||||
if c.action == "PassRole" {
|
||||
return deny
|
||||
}
|
||||
return nil
|
||||
}, form)
|
||||
require.ErrorIs(t, err, deny)
|
||||
assert.Zero(t, svc.addRoleCalls)
|
||||
})
|
||||
|
||||
t.Run("missing role is still evaluated", func(t *testing.T) {
|
||||
svc := ®Service{canonicalErr: errors.New(awserrors.ErrorIAMNoSuchEntity)}
|
||||
_, calls, err := invoke(t, Deps{IAM: svc}, testAccountID, dispatch.Actor{}, nil, form)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, calls, 2)
|
||||
assert.Equal(t, []string{"arn:aws:iam::" + testAccountID + ":role/r"}, calls[1].resources)
|
||||
})
|
||||
|
||||
t.Run("lookup failure fails closed", func(t *testing.T) {
|
||||
svc := ®Service{canonicalErr: errors.New("boom")}
|
||||
_, calls, err := invoke(t, Deps{IAM: svc}, testAccountID, dispatch.Actor{}, nil, form)
|
||||
require.Error(t, err)
|
||||
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError))
|
||||
assert.Len(t, calls, 1, "PassRole is never evaluated on a failed lookup")
|
||||
assert.Zero(t, svc.addRoleCalls)
|
||||
})
|
||||
}
|
||||
|
||||
func TestActionNames_SortedAndInventory(t *testing.T) {
|
||||
names := ActionNames()
|
||||
require.NotEmpty(t, names)
|
||||
assert.IsIncreasing(t, names)
|
||||
assert.Equal(t, names, OperationInventory().Registered)
|
||||
assert.Contains(t, names, "GetUser")
|
||||
}
|
||||
|
||||
// TestRegistration_EveryActionRoutesToItsHandler sends each action through the
|
||||
// dispatcher and checks authorization sees that action and, once it passes, the
|
||||
// action's own handler runs. The service is a stub: a handler that reaches a
|
||||
// method past the embedded interface panics, which proves it was selected.
|
||||
func TestRegistration_EveryActionRoutesToItsHandler(t *testing.T) {
|
||||
for _, name := range ActionNames() {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var authorized []string
|
||||
form := url.Values{"Action": {name}, "UserName": {"alice"}}
|
||||
r := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(form.Encode()))
|
||||
inv := dispatch.Invocation{
|
||||
Request: r,
|
||||
AccountID: testAccountID,
|
||||
Service: ServiceName,
|
||||
Authorize: func(_, action string, _ []string, _ iampolicy.ConditionKeys) error {
|
||||
authorized = append(authorized, action)
|
||||
return nil
|
||||
},
|
||||
Actor: func() (dispatch.Actor, error) {
|
||||
return dispatch.Actor{AccountID: testAccountID, PrincipalType: "user", UserName: "alice"}, nil
|
||||
},
|
||||
}
|
||||
handlerReached := false
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
handlerReached = true
|
||||
}
|
||||
}()
|
||||
err := NewRegistration(Deps{IAM: ®Service{}}).Dispatch(httptest.NewRecorder(), inv)
|
||||
handlerReached = err == nil || !awserrors.IsErrorCode(err, awserrors.ErrorInvalidAction)
|
||||
}()
|
||||
require.Equal(t, []string{name}, authorized, "authorization sees exactly this action")
|
||||
assert.True(t, handlerReached)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/auth"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
|
||||
)
|
||||
|
||||
// RoleResolver resolves a caller-supplied role ARN to a canonical same-account
|
||||
// role. It exposes only the ARN and name, never the stored record.
|
||||
type RoleResolver struct {
|
||||
svc *IAMServiceImpl
|
||||
}
|
||||
|
||||
var _ passrole.Resolver = (*RoleResolver)(nil)
|
||||
|
||||
// NewRoleResolver returns a resolver over the service's role store.
|
||||
func NewRoleResolver(svc *IAMServiceImpl) *RoleResolver {
|
||||
return &RoleResolver{svc: svc}
|
||||
}
|
||||
|
||||
// ResolveRole classifies roleARN against accountID. A spelling that differs
|
||||
// from the stored ARN (a different path, say) is ErrNotFound.
|
||||
func (r *RoleResolver) ResolveRole(accountID, roleARN string) (passrole.Role, error) {
|
||||
roleAccount, name, err := auth.ParseRoleARN(roleARN)
|
||||
if err != nil {
|
||||
return passrole.Role{}, passrole.ErrMalformed
|
||||
}
|
||||
if roleAccount != accountID {
|
||||
return passrole.Role{}, passrole.ErrForeignAccount
|
||||
}
|
||||
role, err := r.svc.getRole(context.Background(), accountID, name)
|
||||
if err != nil {
|
||||
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||
return passrole.Role{}, passrole.ErrNotFound
|
||||
}
|
||||
return passrole.Role{}, err
|
||||
}
|
||||
if role.ARN != roleARN {
|
||||
return passrole.Role{}, passrole.ErrNotFound
|
||||
}
|
||||
return passrole.Role{ARN: role.ARN, Name: name}, nil
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/passrole"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newResolverFixture(t *testing.T) (*IAMServiceImpl, *RoleResolver, func()) {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
masterKey, err := GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
svc, err := NewIAMServiceImpl(t.Context(), nc, masterKey)
|
||||
require.NoError(t, err)
|
||||
return svc, NewRoleResolver(svc), nc.Close
|
||||
}
|
||||
|
||||
func TestRoleResolver_ResolveRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, res, _ := newResolverFixture(t)
|
||||
|
||||
for _, in := range []*iam.CreateRoleInput{
|
||||
{RoleName: aws.String("plain"), AssumeRolePolicyDocument: aws.String(validTrustPolicy())},
|
||||
{RoleName: aws.String("pathed"), Path: aws.String("/svc/"), AssumeRolePolicyDocument: aws.String(validTrustPolicy())},
|
||||
} {
|
||||
_, err := svc.CreateRole(testAccountID, in)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
acct := testAccountID
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
arn string
|
||||
want passrole.Role
|
||||
wantErr error
|
||||
}{
|
||||
{"existing", "arn:aws:iam::" + acct + ":role/plain", passrole.Role{ARN: "arn:aws:iam::" + acct + ":role/plain", Name: "plain"}, nil},
|
||||
{"existing with path", "arn:aws:iam::" + acct + ":role/svc/pathed", passrole.Role{ARN: "arn:aws:iam::" + acct + ":role/svc/pathed", Name: "pathed"}, nil},
|
||||
{"path omitted is a different spelling", "arn:aws:iam::" + acct + ":role/pathed", passrole.Role{}, passrole.ErrNotFound},
|
||||
{"nonexistent", "arn:aws:iam::" + acct + ":role/ghost", passrole.Role{}, passrole.ErrNotFound},
|
||||
{"foreign account", "arn:aws:iam::999999999999:role/plain", passrole.Role{}, passrole.ErrForeignAccount},
|
||||
{"not an ARN", "plain", passrole.Role{}, passrole.ErrMalformed},
|
||||
{"empty", "", passrole.Role{}, passrole.ErrMalformed},
|
||||
{"user ARN", "arn:aws:iam::" + acct + ":user/plain", passrole.Role{}, passrole.ErrMalformed},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := res.ResolveRole(acct, tt.arn)
|
||||
if tt.wantErr != nil {
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleResolver_OutageIsNotClassified(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, res, closeConn := newResolverFixture(t)
|
||||
closeConn()
|
||||
_, err := res.ResolveRole(testAccountID, "arn:aws:iam::"+testAccountID+":role/plain")
|
||||
require.Error(t, err)
|
||||
for _, s := range []error{passrole.ErrMalformed, passrole.ErrForeignAccount, passrole.ErrNotFound} {
|
||||
assert.NotErrorIs(t, err, s, "an outage must not be classified as %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassroleCheck_ThroughRealResolver(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, res, closeConn := newResolverFixture(t)
|
||||
_, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{
|
||||
RoleName: aws.String("worker"), AssumeRolePolicyDocument: aws.String(validTrustPolicy()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
acct := testAccountID
|
||||
arn := "arn:aws:iam::" + acct + ":role/worker"
|
||||
denied := errors.New("denied")
|
||||
|
||||
var gotKeys iampolicy.ConditionKeys
|
||||
var gotRes []string
|
||||
allow := func(_, action string, r []string, k iampolicy.ConditionKeys) error {
|
||||
assert.Equal(t, "PassRole", action)
|
||||
gotRes, gotKeys = r, k
|
||||
return nil
|
||||
}
|
||||
deny := func(_, _ string, _ []string, _ iampolicy.ConditionKeys) error { return denied }
|
||||
|
||||
role, err := passrole.Check(res, allow, acct, arn, "lambda.amazonaws.com")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "worker", role.Name)
|
||||
assert.Equal(t, []string{arn}, gotRes)
|
||||
assert.Equal(t, "lambda.amazonaws.com", gotKeys[iampolicy.KeyPassedToService])
|
||||
|
||||
_, err = passrole.Check(res, deny, acct, arn, "lambda.amazonaws.com")
|
||||
assert.ErrorIs(t, err, denied)
|
||||
|
||||
// A denied caller gets the denial, not NotFound, for a role that is absent.
|
||||
ghost := "arn:aws:iam::" + acct + ":role/ghost"
|
||||
_, err = passrole.Check(res, deny, acct, ghost, "lambda.amazonaws.com")
|
||||
assert.ErrorIs(t, err, denied)
|
||||
_, err = passrole.Check(res, allow, acct, ghost, "lambda.amazonaws.com")
|
||||
assert.ErrorIs(t, err, passrole.ErrNotFound)
|
||||
|
||||
// Foreign and malformed references never reach authorization.
|
||||
called := false
|
||||
spy := func(_, _ string, _ []string, _ iampolicy.ConditionKeys) error { called = true; return nil }
|
||||
_, err = passrole.Check(res, spy, acct, "arn:aws:iam::999999999999:role/worker", "lambda.amazonaws.com")
|
||||
require.ErrorIs(t, err, passrole.ErrForeignAccount)
|
||||
_, err = passrole.Check(res, spy, acct, "worker", "lambda.amazonaws.com")
|
||||
require.ErrorIs(t, err, passrole.ErrMalformed)
|
||||
assert.False(t, called)
|
||||
|
||||
// An outage is returned unclassified with nothing authorized.
|
||||
closeConn()
|
||||
_, err = passrole.Check(res, spy, acct, arn, "lambda.amazonaws.com")
|
||||
require.Error(t, err)
|
||||
assert.NotErrorIs(t, err, passrole.ErrNotFound)
|
||||
assert.False(t, called)
|
||||
}
|
||||
@@ -783,7 +783,7 @@ func ValidateTrustPolicyDocument(docJSON string) (*TrustPolicyDocument, error) {
|
||||
|
||||
// validateWebIdentityCondition gates Condition blocks to the two actions STS
|
||||
// evaluates conditions for: sts:AssumeRoleWithWebIdentity (the IRSA {iss}:sub /
|
||||
// {iss}:aud keys, StringEquals and StringLike) and sts:AssumeRole (aws:SourceAccount,
|
||||
// {iss}:aud keys, StringEquals only) and sts:AssumeRole (aws:SourceAccount,
|
||||
// StringEquals only). Everything else is rejected at write time — accepting a
|
||||
// condition nothing evaluates would store a security control that never fires.
|
||||
func validateWebIdentityCondition(i int, stmt TrustStatement) error {
|
||||
@@ -802,8 +802,8 @@ func validateWebIdentityCondition(i int, stmt TrustStatement) error {
|
||||
|
||||
if action == STSActionAssumeRoleWithWebIdentity {
|
||||
for op := range ops {
|
||||
if op != "StringEquals" && op != "StringLike" {
|
||||
return fmt.Errorf("statement %d: Condition operator %q on action %q is not supported in this release; supported operators are StringEquals and StringLike", i, op, action)
|
||||
if op != "StringEquals" {
|
||||
return fmt.Errorf("statement %d: Condition operator %q on action %q is not supported in this release; only StringEquals is supported", i, op, action)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1758,55 +1758,3 @@ func TestDeleteRole_RevocationFailureKeepsDelete(t *testing.T) {
|
||||
_, err = svc.GetRole(testAccountID, &iam.GetRoleInput{RoleName: role.RoleName})
|
||||
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorIAMNoSuchEntity))
|
||||
}
|
||||
|
||||
func webIdentityTrustDoc(action, condition string) string {
|
||||
return `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::` + testAccountID + `:oidc-provider/oidc.eks.au-mel-1.amazonaws.com/id/ABCDEF"},"Action":"` + action + `","Condition":` + condition + `}]}`
|
||||
}
|
||||
|
||||
func TestCreateRole_TrustPolicy_WebIdentityStringLikeAccepted(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestIAMService(t)
|
||||
|
||||
doc := webIdentityTrustDoc("sts:AssumeRoleWithWebIdentity",
|
||||
`{"StringEquals":{"oidc.eks.au-mel-1.amazonaws.com/id/ABCDEF:aud":"sts.amazonaws.com"},"StringLike":{"oidc.eks.au-mel-1.amazonaws.com/id/ABCDEF:sub":"system:serviceaccount:kube-system:*"}}`)
|
||||
_, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{
|
||||
RoleName: aws.String("irsa-stringlike"),
|
||||
AssumeRolePolicyDocument: aws.String(doc),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreateRole_TrustPolicy_WebIdentityStringNotEqualsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestIAMService(t)
|
||||
|
||||
doc := webIdentityTrustDoc("sts:AssumeRoleWithWebIdentity",
|
||||
`{"StringNotEquals":{"oidc.eks.au-mel-1.amazonaws.com/id/ABCDEF:sub":"system:serviceaccount:default:bad"}}`)
|
||||
_, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{
|
||||
RoleName: aws.String("irsa-notequals"),
|
||||
AssumeRolePolicyDocument: aws.String(doc),
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
code, message, ok := awserrors.ResolveErrorDetail(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorIAMMalformedPolicyDocument, code)
|
||||
assert.Contains(t, message, "StringNotEquals")
|
||||
}
|
||||
|
||||
func TestCreateRole_TrustPolicy_AssumeRoleStringLikeRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestIAMService(t)
|
||||
|
||||
doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"StringLike":{"aws:SourceAccount":"` + testAccountID + `"}}}]}`
|
||||
_, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{
|
||||
RoleName: aws.String("assume-stringlike"),
|
||||
AssumeRolePolicyDocument: aws.String(doc),
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
code, message, ok := awserrors.ResolveErrorDetail(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorIAMMalformedPolicyDocument, code)
|
||||
assert.Contains(t, message, "StringLike")
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package systemvpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// PrivateRefs identifies a private-only system VPC and its one subnet.
|
||||
type PrivateRefs struct {
|
||||
VpcID string
|
||||
VpcCIDR string
|
||||
SubnetID string
|
||||
SubnetCIDR string
|
||||
}
|
||||
|
||||
// EnsurePrivate idempotently builds a VPC with one private subnet and nothing
|
||||
// else. It takes only the VPC and subnet surface, so it cannot create an IGW,
|
||||
// route table, NAT gateway or EIP. Use a Spec with either this or Ensure.
|
||||
func EnsurePrivate(ctx context.Context, vpcp VPCProvisioner, spec Spec, accountID string) (*PrivateRefs, error) {
|
||||
if err := spec.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vpcCIDR, _, privateCIDRs, err := spec.cidrs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs := &PrivateRefs{VpcCIDR: vpcCIDR, SubnetCIDR: privateCIDRs[0]}
|
||||
if refs.VpcID, err = ensureVPC(ctx, vpcp, spec, accountID, vpcCIDR); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if refs.SubnetID, err = ensureSubnet(ctx, vpcp, spec, accountID, refs.VpcID, refs.SubnetCIDR,
|
||||
spec.Roles().PrivateSubnet, spec.az(0)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.InfoContext(ctx, "systemvpc: private system VPC ready",
|
||||
"name", spec.Name, "managedBy", spec.ManagedBy, "vpc", refs.VpcID, "subnet", refs.SubnetID)
|
||||
return refs, nil
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package systemvpc
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The private shape is a VPC and one subnet carved from the spec's supernet,
|
||||
// owned and role-tagged like Ensure's, with no gateway, route table or EIP.
|
||||
func TestEnsurePrivateBuildsOnlyAVPCAndOneSubnet(t *testing.T) {
|
||||
f := newFakeEC2()
|
||||
spec := testSpec("sqs-demo")
|
||||
|
||||
refs, err := EnsurePrivate(t.Context(), f, spec, "000000000000")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, map[string]int{"vpc": 1, "subnet": 1}, f.creates)
|
||||
assert.Empty(t, f.igws)
|
||||
assert.Empty(t, f.rts)
|
||||
assert.Empty(t, f.ngws)
|
||||
assert.Empty(t, f.eips)
|
||||
|
||||
vpcCIDR, _, privateCIDRs, err := spec.cidrs()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, vpcCIDR, refs.VpcCIDR)
|
||||
assert.Equal(t, privateCIDRs[0], refs.SubnetCIDR)
|
||||
sub := f.subnets[refs.SubnetID]
|
||||
require.NotNil(t, sub)
|
||||
assert.Equal(t, refs.VpcID, aws.StringValue(sub.VpcId))
|
||||
assert.Equal(t, refs.SubnetCIDR, aws.StringValue(sub.CidrBlock))
|
||||
assert.Equal(t, spec.Roles().PrivateSubnet, tagValue(sub.Tags, spec.RoleTagKey))
|
||||
assert.Equal(t, spec.Name, tagValue(sub.Tags, spec.OwnerTagKey))
|
||||
assert.Equal(t, spec.Roles().VPC, tagValue(f.vpcs[refs.VpcID].Tags, spec.RoleTagKey))
|
||||
|
||||
super := netip.MustParsePrefix(spec.Supernet)
|
||||
assert.True(t, super.Contains(netip.MustParsePrefix(refs.SubnetCIDR).Addr()), "subnet outside the supernet")
|
||||
}
|
||||
|
||||
func TestEnsurePrivateIsIdempotent(t *testing.T) {
|
||||
f := newFakeEC2()
|
||||
spec := testSpec("sqs-demo")
|
||||
first, err := EnsurePrivate(t.Context(), f, spec, "000000000000")
|
||||
require.NoError(t, err)
|
||||
creates := maps.Clone(f.creates)
|
||||
|
||||
second, err := EnsurePrivate(t.Context(), f, spec, "000000000000")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first, second)
|
||||
assert.Equal(t, creates, f.creates, "a re-run must create nothing")
|
||||
}
|
||||
|
||||
// A VPC left by a run that failed before its subnet is reused, not duplicated.
|
||||
func TestEnsurePrivateConvergesAfterPartialFailure(t *testing.T) {
|
||||
f := newFakeEC2()
|
||||
spec := testSpec("sqs-demo")
|
||||
vpcCIDR, _, _, err := spec.cidrs()
|
||||
require.NoError(t, err)
|
||||
vpcID, err := ensureVPC(t.Context(), f, spec, "000000000000", vpcCIDR)
|
||||
require.NoError(t, err)
|
||||
|
||||
refs, err := EnsurePrivate(t.Context(), f, spec, "000000000000")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, vpcID, refs.VpcID)
|
||||
assert.Equal(t, 1, f.creates["vpc"])
|
||||
}
|
||||
|
||||
func TestEnsurePrivateRejectsAnUnusableSpec(t *testing.T) {
|
||||
f := newFakeEC2()
|
||||
spec := testSpec("sqs-demo")
|
||||
spec.Supernet = "10.0.0.0/16"
|
||||
_, err := EnsurePrivate(t.Context(), f, spec, "000000000000")
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, f.creates)
|
||||
}
|
||||
@@ -60,10 +60,10 @@ func ParseProvisionedModelARN(arn, region, accountID string) (ParsedProvisionedM
|
||||
return ParsedProvisionedModelARN{Region: parts[3], AccountID: parts[4], ID: id}, nil
|
||||
}
|
||||
|
||||
// ResolveProvisionedModelID accepts either a bare id or a full ARN (the shape
|
||||
// resolveProvisionedModelID accepts either a bare id or a full ARN (the shape
|
||||
// every PT op's ProvisionedModelId field allows) and returns the bare id,
|
||||
// validating region/account ownership when an ARN is given.
|
||||
func ResolveProvisionedModelID(idOrARN, region, accountID string) (string, error) {
|
||||
func resolveProvisionedModelID(idOrARN, region, accountID string) (string, error) {
|
||||
if !strings.HasPrefix(idOrARN, "arn:") {
|
||||
return idOrARN, nil
|
||||
}
|
||||
@@ -78,12 +78,12 @@ func ptARNError(arn, why string) error {
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "%q is not a valid provisioned-model ARN: %s", arn, why)
|
||||
}
|
||||
|
||||
// LooksLikeProvisionedModelARN is the cheap shape guard the inference path
|
||||
// looksLikeProvisionedModelARN is the cheap shape guard the inference path
|
||||
// uses to decide whether a modelId needs the (heavier) parse-and-load
|
||||
// translation at all: a bare modelId or a foundation-model ARN
|
||||
// ("arn:aws:bedrock:*::foundation-model/...") must fall straight through to
|
||||
// the existing GlobalAccountID-shorthand path unchanged.
|
||||
func LooksLikeProvisionedModelARN(modelID string) bool {
|
||||
func looksLikeProvisionedModelARN(modelID string) bool {
|
||||
return strings.HasPrefix(modelID, "arn:") && strings.Contains(modelID, ":"+provisionedModelResourceType+"/")
|
||||
}
|
||||
|
||||
@@ -144,10 +144,10 @@ func ParseGuardrailARN(arn, region, accountID string) (ParsedGuardrailARN, error
|
||||
return ParsedGuardrailARN{Region: parts[3], AccountID: parts[4], ID: id}, nil
|
||||
}
|
||||
|
||||
// ResolveGuardrailID accepts either a bare id or a full ARN (the shape every
|
||||
// resolveGuardrailID accepts either a bare id or a full ARN (the shape every
|
||||
// guardrail op's GuardrailIdentifier field allows) and returns the bare id,
|
||||
// validating region/account ownership when an ARN is given.
|
||||
func ResolveGuardrailID(idOrARN, region, accountID string) (string, error) {
|
||||
func resolveGuardrailID(idOrARN, region, accountID string) (string, error) {
|
||||
if !strings.HasPrefix(idOrARN, "arn:") {
|
||||
return idOrARN, nil
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func guardrailARNError(arn, why string) error {
|
||||
// is the original modelID unchanged, so a caller logging the attempt still
|
||||
// records what was actually requested.
|
||||
func resolveInferenceTarget(ctx context.Context, accountID, modelID string, store *ProvisionedStore) (targetAccountID, targetModelID string, err error) {
|
||||
if !LooksLikeProvisionedModelARN(modelID) {
|
||||
if !looksLikeProvisionedModelARN(modelID) {
|
||||
return "", modelID, nil
|
||||
}
|
||||
if store == nil {
|
||||
|
||||
@@ -69,17 +69,17 @@ func TestParseProvisionedModelARN_RejectsMalformed(t *testing.T) {
|
||||
// TestResolveProvisionedModelID_AcceptsRawIDOrARN covers the shape every PT
|
||||
// op's ProvisionedModelId field allows: a bare id or a full ARN.
|
||||
func TestResolveProvisionedModelID_AcceptsRawIDOrARN(t *testing.T) {
|
||||
id, err := ResolveProvisionedModelID(ptTestID, ptTestRegion, ptTestAccount)
|
||||
id, err := resolveProvisionedModelID(ptTestID, ptTestRegion, ptTestAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ptTestID, id)
|
||||
|
||||
arn := FormatProvisionedModelARN(ptTestRegion, ptTestAccount, ptTestID)
|
||||
id, err = ResolveProvisionedModelID(arn, ptTestRegion, ptTestAccount)
|
||||
id, err = resolveProvisionedModelID(arn, ptTestRegion, ptTestAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ptTestID, id)
|
||||
|
||||
foreignARN := FormatProvisionedModelARN(ptTestRegion, ptOtherAccount, ptTestID)
|
||||
_, err = ResolveProvisionedModelID(foreignARN, ptTestRegion, ptTestAccount)
|
||||
_, err = resolveProvisionedModelID(foreignARN, ptTestRegion, ptTestAccount)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -137,16 +137,16 @@ func TestParseGuardrailARN_RejectsMalformed(t *testing.T) {
|
||||
// TestResolveGuardrailID_AcceptsRawIDOrARN covers the shape every guardrail
|
||||
// op's GuardrailIdentifier field allows: a bare id or a full ARN.
|
||||
func TestResolveGuardrailID_AcceptsRawIDOrARN(t *testing.T) {
|
||||
id, err := ResolveGuardrailID(ptTestID, ptTestRegion, ptTestAccount)
|
||||
id, err := resolveGuardrailID(ptTestID, ptTestRegion, ptTestAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ptTestID, id)
|
||||
|
||||
arn := FormatGuardrailARN(ptTestRegion, ptTestAccount, ptTestID)
|
||||
id, err = ResolveGuardrailID(arn, ptTestRegion, ptTestAccount)
|
||||
id, err = resolveGuardrailID(arn, ptTestRegion, ptTestAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ptTestID, id)
|
||||
|
||||
foreignARN := FormatGuardrailARN(ptTestRegion, ptOtherAccount, ptTestID)
|
||||
_, err = ResolveGuardrailID(foreignARN, ptTestRegion, ptTestAccount)
|
||||
_, err = resolveGuardrailID(foreignARN, ptTestRegion, ptTestAccount)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package awsapi
|
||||
package ochre
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ochre"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/bodyscope"
|
||||
)
|
||||
@@ -175,13 +174,13 @@ func resolve(source resourceSource, service, action, region, accountID string, p
|
||||
return guardrailARN(region, accountID, param(params, 0)), nil
|
||||
|
||||
case sourceNewGuardrail:
|
||||
return ochre.FormatGuardrailARN(region, accountID, anyID), nil
|
||||
return FormatGuardrailARN(region, accountID, anyID), nil
|
||||
|
||||
case sourceProvisionedModelPath:
|
||||
return provisionedModelARN(region, accountID, param(params, 0)), nil
|
||||
|
||||
case sourceNewProvisionedModel:
|
||||
return ochre.FormatProvisionedModelARN(region, accountID, anyID), nil
|
||||
return FormatProvisionedModelARN(region, accountID, anyID), nil
|
||||
|
||||
case sourceProvisionedModelSourceBody:
|
||||
return foundationModelARN(region, scope.String("modelId")), nil
|
||||
@@ -190,7 +189,7 @@ func resolve(source resourceSource, service, action, region, accountID string, p
|
||||
return knowledgeBaseARN(region, accountID, param(params, 0)), nil
|
||||
|
||||
case sourceNewKnowledgeBase:
|
||||
return ochre.FormatKnowledgeBaseARN(region, accountID, anyID), nil
|
||||
return FormatKnowledgeBaseARN(region, accountID, anyID), nil
|
||||
|
||||
case sourceKnowledgeBaseBody:
|
||||
config, err := scope.Object("retrieveAndGenerateConfiguration")
|
||||
@@ -223,7 +222,7 @@ func foundationModelARN(region, modelID string) string {
|
||||
// modelId is a provisioned-throughput ARN, and the foundation model itself
|
||||
// otherwise, matching the translation resolveInferenceTarget performs.
|
||||
func inferenceTargetARN(region, accountID, modelID string) string {
|
||||
if !ochre.LooksLikeProvisionedModelARN(modelID) {
|
||||
if !looksLikeProvisionedModelARN(modelID) {
|
||||
return foundationModelARN(region, modelID)
|
||||
}
|
||||
return provisionedModelARN(region, accountID, modelID)
|
||||
@@ -237,11 +236,11 @@ func provisionedModelARN(region, accountID, idOrARN string) string {
|
||||
if region == "" || accountID == "" || idOrARN == "" {
|
||||
return anyResource
|
||||
}
|
||||
id, err := ochre.ResolveProvisionedModelID(idOrARN, region, accountID)
|
||||
id, err := resolveProvisionedModelID(idOrARN, region, accountID)
|
||||
if err != nil || id == "" {
|
||||
return anyResource
|
||||
}
|
||||
return ochre.FormatProvisionedModelARN(region, accountID, id)
|
||||
return FormatProvisionedModelARN(region, accountID, id)
|
||||
}
|
||||
|
||||
// guardrailARN accepts the id-or-ARN spelling every guardrail op allows,
|
||||
@@ -250,18 +249,18 @@ func guardrailARN(region, accountID, idOrARN string) string {
|
||||
if region == "" || accountID == "" || idOrARN == "" {
|
||||
return anyResource
|
||||
}
|
||||
id, err := ochre.ResolveGuardrailID(idOrARN, region, accountID)
|
||||
id, err := resolveGuardrailID(idOrARN, region, accountID)
|
||||
if err != nil || id == "" {
|
||||
return anyResource
|
||||
}
|
||||
return ochre.FormatGuardrailARN(region, accountID, id)
|
||||
return FormatGuardrailARN(region, accountID, id)
|
||||
}
|
||||
|
||||
func knowledgeBaseARN(region, accountID, id string) string {
|
||||
if region == "" || accountID == "" || id == "" {
|
||||
return anyResource
|
||||
}
|
||||
return ochre.FormatKnowledgeBaseARN(region, accountID, id)
|
||||
return FormatKnowledgeBaseARN(region, accountID, id)
|
||||
}
|
||||
|
||||
func param(params []string, i int) string {
|
||||
@@ -1,10 +1,9 @@
|
||||
package awsapi_test
|
||||
package ochre_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ochre"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ochre/awsapi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -20,7 +19,7 @@ func bedrockARN(resource string) string {
|
||||
|
||||
func resolveScope(t *testing.T, service, action string, params []string, body string) []string {
|
||||
t.Helper()
|
||||
resources, err := awsapi.ResourceARNs(service, action, authzRegion, authzAccountID, params, []byte(body))
|
||||
resources, err := ochre.ResourceARNs(service, action, authzRegion, authzAccountID, params, []byte(body))
|
||||
require.NoError(t, err)
|
||||
return resources
|
||||
}
|
||||
@@ -123,8 +122,8 @@ func TestResourceARNs_AccountLevelActionsStayAccountWide(t *testing.T) {
|
||||
// An action that is served but has no entry, or a service with no table at all,
|
||||
// fails closed rather than defaulting to an account-wide grant.
|
||||
func TestResourceARNs_UnknownActionOrServiceIsRejected(t *testing.T) {
|
||||
_, err := awsapi.ResourceARNs("bedrock", "MadeUpAction", authzRegion, authzAccountID, nil, nil)
|
||||
_, err := ochre.ResourceARNs("bedrock", "MadeUpAction", authzRegion, authzAccountID, nil, nil)
|
||||
require.Error(t, err)
|
||||
_, err = awsapi.ResourceARNs("bedrock-madeup", "GetGuardrail", authzRegion, authzAccountID, nil, nil)
|
||||
_, err = ochre.ResourceARNs("bedrock-madeup", "GetGuardrail", authzRegion, authzAccountID, nil, nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
// Package awsapi is the Bedrock family's ingress adapter. One registration
|
||||
// serves the four Bedrock services, which share a signing name.
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ochre"
|
||||
ochrevector "github.com/mulgadc/spinifex/spinifex/domains/ochre/vector"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
)
|
||||
|
||||
// ServiceName is the SigV4 credential-scope service Bedrock clients sign for.
|
||||
const ServiceName = "bedrock"
|
||||
|
||||
// The sub-services share ServiceName and are told apart by request path. Each
|
||||
// is also accepted as a credential scope of its own, and a request signed that
|
||||
// way is dispatched to that sub-service whatever its path.
|
||||
const (
|
||||
serviceRuntime = "bedrock-runtime"
|
||||
serviceAgent = "bedrock-agent"
|
||||
serviceAgentRuntime = "bedrock-agent-runtime"
|
||||
)
|
||||
|
||||
// Quota meters Bedrock usage per account.
|
||||
type Quota interface {
|
||||
CheckBedrockRPM(accountID string) error
|
||||
CheckBedrockTokens(ctx context.Context, accountID string) error
|
||||
}
|
||||
|
||||
// Deps are the composed Ochre capabilities the Bedrock services dispatch to.
|
||||
// A nil capability takes the fallback its accessor documents.
|
||||
type Deps struct {
|
||||
// Credentials resolves per-account provider API keys. Nil resolves none.
|
||||
Credentials ochre.CredentialResolver
|
||||
// Endpoints resolves self-host model endpoints. Nil resolves none.
|
||||
Endpoints ochre.EndpointResolver
|
||||
// Access resolves model-access grants. Nil denies every model.
|
||||
Access ochre.AccessResolver
|
||||
// Recorder records invocations. Nil discards them.
|
||||
Recorder ochre.Recorder
|
||||
// Logging, Provisioned and Guardrails persist control-plane records. Nil
|
||||
// answers every request that reaches them with an error.
|
||||
Logging *ochre.LoggingConfigStore
|
||||
Provisioned *ochre.ProvisionedStore
|
||||
Guardrails *ochre.GuardrailStore
|
||||
// Embedder drives guardrail topic matching. Nil matches literally.
|
||||
Embedder ochre.Embedder
|
||||
// KB, DataSources and Vector back the knowledge-base services. Any nil
|
||||
// fails those services with ServerInternal.
|
||||
KB *ochrevector.KBStore
|
||||
DataSources *ochrevector.DataSourceStore
|
||||
Vector ochrevector.VectorService
|
||||
// Quota meters runtime requests. Nil meters nothing.
|
||||
Quota Quota
|
||||
}
|
||||
|
||||
// NewRegistration returns the Bedrock registration: AWS JSON 1.1 errors, with
|
||||
// the three sub-service names as further credential scopes.
|
||||
func NewRegistration(deps Deps) dispatch.Registration {
|
||||
return dispatch.Registration{
|
||||
Service: ServiceName,
|
||||
Aliases: []string{serviceRuntime, serviceAgent, serviceAgentRuntime},
|
||||
ResolveAction: resolveAction,
|
||||
Dispatch: deps.dispatch,
|
||||
Errors: dispatch.ErrorEnvelopeJSON,
|
||||
Inventory: OperationInventory(),
|
||||
}
|
||||
}
|
||||
|
||||
// OperationInventory classifies the actions the registration serves.
|
||||
func OperationInventory() dispatch.Inventory {
|
||||
names := map[string]struct{}{}
|
||||
for _, routes := range [][]string{
|
||||
actionsOf(bedrockRoutes, func(r bedrockRoute) string { return r.Action }),
|
||||
actionsOf(bedrockRuntimeRoutes, func(r bedrockRuntimeRoute) string { return r.Action }),
|
||||
actionsOf(bedrockAgentRoutes, func(r bedrockAgentRoute) string { return r.Action }),
|
||||
actionsOf(bedrockAgentRuntimeRoutes, func(r bedrockAgentRuntimeRoute) string { return r.Action }),
|
||||
} {
|
||||
for _, n := range routes {
|
||||
names[n] = struct{}{}
|
||||
}
|
||||
}
|
||||
return dispatch.Inventory{Registered: slices.Sorted(maps.Keys(names))}
|
||||
}
|
||||
|
||||
func actionsOf[T any](routes []T, action func(T) string) []string {
|
||||
actions := make([]string, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
actions = append(actions, action(route))
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// resolveAction labels a request by the path-selected sub-service. It is
|
||||
// advisory: a request signed with a sub-service scope may be labelled for a
|
||||
// different sub-service than the one that serves it.
|
||||
func resolveAction(r *http.Request) string {
|
||||
method, path := r.Method, r.URL.EscapedPath()
|
||||
var action string
|
||||
var ok bool
|
||||
switch resolveSubservice(r.URL.Path) {
|
||||
case serviceRuntime:
|
||||
action, _, _, ok = bedrockRuntimeRouter.Lookup(method, path)
|
||||
case serviceAgentRuntime:
|
||||
action, _, _, ok = bedrockAgentRuntimeRouter.Lookup(method, path)
|
||||
case serviceAgent:
|
||||
action, _, _, ok = bedrockAgentRouter.Lookup(method, path)
|
||||
default:
|
||||
action, _, _, ok = bedrockRouter.Lookup(method, path)
|
||||
}
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
// resolveSubservice maps a request path to the sub-service that serves it.
|
||||
func resolveSubservice(path string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/model/") || strings.HasPrefix(path, "/guardrail/"):
|
||||
return serviceRuntime
|
||||
case path == "/retrieveAndGenerate" || (strings.HasPrefix(path, "/knowledgebases/") && strings.HasSuffix(path, "/retrieve")):
|
||||
return serviceAgentRuntime
|
||||
case strings.HasPrefix(path, "/knowledgebases/"):
|
||||
return serviceAgent
|
||||
default:
|
||||
return ServiceName
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch hands the request to its sub-service. Only the shared signing name
|
||||
// is split by path; a sub-service scope selects that sub-service outright.
|
||||
func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
sub := inv.Service
|
||||
switch sub {
|
||||
case serviceRuntime, serviceAgent, serviceAgentRuntime:
|
||||
default:
|
||||
sub = resolveSubservice(inv.Request.URL.Path)
|
||||
}
|
||||
switch sub {
|
||||
case serviceRuntime:
|
||||
return d.bedrockRuntime(w, inv)
|
||||
case serviceAgent:
|
||||
return d.bedrockAgent(w, inv)
|
||||
case serviceAgentRuntime:
|
||||
return d.bedrockAgentRuntime(w, inv)
|
||||
default:
|
||||
return d.bedrock(w, inv)
|
||||
}
|
||||
}
|
||||
|
||||
func readBoundedBody(r *http.Request) ([]byte, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, sigv4.MaxPayloadLen+1))
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if int64(len(body)) > sigv4.MaxPayloadLen {
|
||||
return nil, errors.New(awserrors.ErrorRequestEntityTooLarge)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (d Deps) resolver() ochre.CredentialResolver {
|
||||
if d.Credentials != nil {
|
||||
return d.Credentials
|
||||
}
|
||||
return ochre.NoopCredentialResolver
|
||||
}
|
||||
|
||||
func (d Deps) loggingStore() *ochre.LoggingConfigStore {
|
||||
if d.Logging != nil {
|
||||
return d.Logging
|
||||
}
|
||||
return ochre.NewLoggingConfigStore(nil)
|
||||
}
|
||||
|
||||
func (d Deps) recorder() ochre.Recorder {
|
||||
if d.Recorder != nil {
|
||||
return d.Recorder
|
||||
}
|
||||
return ochre.NoopRecorder
|
||||
}
|
||||
|
||||
// accessResolver wraps a configured grant store so a staged self-host model is
|
||||
// granted to every account. Without one every model is denied, unwrapped.
|
||||
func (d Deps) accessResolver() ochre.AccessResolver {
|
||||
if d.Access != nil {
|
||||
return ochre.NewStagedOpenAccessResolver(d.Access)
|
||||
}
|
||||
return ochre.DenyAllAccessResolver
|
||||
}
|
||||
|
||||
func (d Deps) provisionedStore(region string) *ochre.ProvisionedStore {
|
||||
if d.Provisioned != nil {
|
||||
return d.Provisioned
|
||||
}
|
||||
return ochre.NewProvisionedStore(nil, region, nil)
|
||||
}
|
||||
|
||||
func (d Deps) guardrailStore(region string) *ochre.GuardrailStore {
|
||||
if d.Guardrails != nil {
|
||||
return d.Guardrails
|
||||
}
|
||||
return ochre.NewGuardrailStore(nil, region)
|
||||
}
|
||||
|
||||
func (d Deps) endpointResolver() ochre.EndpointResolver {
|
||||
if d.Endpoints != nil {
|
||||
return d.Endpoints
|
||||
}
|
||||
return ochre.NewStaticEndpointResolver(nil)
|
||||
}
|
||||
|
||||
func (d Deps) checkRPM(accountID string) error {
|
||||
if d.Quota == nil {
|
||||
return nil
|
||||
}
|
||||
return d.Quota.CheckBedrockRPM(accountID)
|
||||
}
|
||||
|
||||
func (d Deps) checkTokens(ctx context.Context, accountID string) error {
|
||||
if d.Quota == nil {
|
||||
return nil
|
||||
}
|
||||
return d.Quota.CheckBedrockTokens(ctx, accountID)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
//test:in-package — exercises the unexported dispatch split and accessors.
|
||||
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewRegistration_RegistersEveryScopeName(t *testing.T) {
|
||||
b := dispatch.NewBuilder()
|
||||
require.NoError(t, b.Register(NewRegistration(Deps{})))
|
||||
reg := b.Build()
|
||||
for _, name := range []string{"bedrock", "bedrock-runtime", "bedrock-agent", "bedrock-agent-runtime"} {
|
||||
entry, ok := reg.Lookup(name)
|
||||
require.True(t, ok, name)
|
||||
assert.Equal(t, ServiceName, entry.Service(), name)
|
||||
}
|
||||
_, ok := reg.Lookup("bedrock-foo")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestOperationInventory_ListsEverySubserviceActionOnce(t *testing.T) {
|
||||
inv := OperationInventory()
|
||||
assert.Empty(t, inv.Stubbed)
|
||||
assert.True(t, slices.IsSorted(inv.Registered))
|
||||
for _, want := range []string{"ListFoundationModels", "Converse", "CreateKnowledgeBase", "Retrieve"} {
|
||||
assert.Contains(t, inv.Registered, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAction_LabelsByPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
method, path, want string
|
||||
}{
|
||||
{"GET", "/foundation-models", "ListFoundationModels"},
|
||||
{"POST", "/model/m/converse", "Converse"},
|
||||
{"POST", "/guardrail/g/version/1/apply", "ApplyGuardrail"},
|
||||
{"POST", "/knowledgebases/", "ListKnowledgeBases"},
|
||||
{"POST", "/knowledgebases/kb/retrieve", "Retrieve"},
|
||||
{"POST", "/retrieveAndGenerate", "RetrieveAndGenerate"},
|
||||
{"GET", "/nope", ""},
|
||||
{"GET", "/knowledgebases/kb/retrieve", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := httptest.NewRequest(c.method, c.path, nil)
|
||||
assert.Equal(t, c.want, resolveAction(r), c.method+" "+c.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_SubserviceScopeSelectsDispatcherOutright(t *testing.T) {
|
||||
g := &testGateway{}
|
||||
cases := []struct {
|
||||
service string
|
||||
path string
|
||||
method string
|
||||
wantErr string
|
||||
}{
|
||||
{ServiceName, "/nope", "GET", awserrors.ErrorInvalidAction},
|
||||
{serviceRuntime, "/guardrails", "GET", awserrors.ErrorInvalidAction},
|
||||
{serviceAgent, "/guardrails", "GET", awserrors.ErrorInvalidAction},
|
||||
{serviceAgentRuntime, "/guardrails", "GET", awserrors.ErrorInvalidAction},
|
||||
{serviceAgent, "/model/m/converse", "POST", awserrors.ErrorInvalidAction},
|
||||
{"", "/model/m/nope", "POST", awserrors.ErrorInvalidAction},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := g.serve(c.service, httptest.NewRecorder(), requestForAccount("111122223333", c.method, c.path, ""))
|
||||
require.Error(t, err, c.service+" "+c.path)
|
||||
assert.Equal(t, c.wantErr, err.Error(), c.service+" "+c.path)
|
||||
}
|
||||
}
|
||||
|
||||
type countingQuota struct {
|
||||
rpm, tokens int
|
||||
rpmErr error
|
||||
}
|
||||
|
||||
func (q *countingQuota) CheckBedrockRPM(string) error { q.rpm++; return q.rpmErr }
|
||||
func (q *countingQuota) CheckBedrockTokens(context.Context, string) error {
|
||||
q.tokens++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDispatch_RuntimeMeteringRunsBeforeTheBodyIsRead(t *testing.T) {
|
||||
q := &countingQuota{rpmErr: assertErr(awserrors.ErrorThrottlingException)}
|
||||
g := &testGateway{Deps: Deps{Quota: q}}
|
||||
err := g.BedrockRuntime_Request(httptest.NewRecorder(), requestForAccount("111122223333", "POST", "/model/m/converse", "{"))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorThrottlingException, err.Error())
|
||||
assert.Equal(t, 1, q.rpm)
|
||||
assert.Zero(t, q.tokens)
|
||||
}
|
||||
|
||||
func assertErr(code string) error { return errors.New(code) }
|
||||
|
||||
func TestDeps_NilCapabilitiesFallBack(t *testing.T) {
|
||||
var d Deps
|
||||
assert.NoError(t, d.checkRPM("a"))
|
||||
assert.NoError(t, d.checkTokens(context.Background(), "a"))
|
||||
assert.NotNil(t, d.resolver())
|
||||
assert.NotNil(t, d.recorder())
|
||||
assert.NotNil(t, d.loggingStore())
|
||||
assert.NotNil(t, d.provisionedStore("us-east-1"))
|
||||
assert.NotNil(t, d.guardrailStore("us-east-1"))
|
||||
_, ok, err := d.endpointResolver().Endpoint(context.Background(), "m")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
granted, err := d.accessResolver().Granted(context.Background(), "111122223333", "m")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, granted, "no grant store denies every model")
|
||||
}
|
||||
|
||||
func TestResolveSubservice_SplitsTheSharedSigningNameByPath(t *testing.T) {
|
||||
cases := []struct{ path, want string }{
|
||||
{"/model/meta.llama3-70b-instruct-v1:0/converse", serviceRuntime},
|
||||
{"/model/meta.llama3-70b-instruct-v1:0/converse-stream", serviceRuntime},
|
||||
{"/guardrail/gr-abc123/version/1/apply", serviceRuntime},
|
||||
{"/foundation-models", ServiceName},
|
||||
{"/guardrails", ServiceName},
|
||||
{"/guardrails/gr-abc123", ServiceName},
|
||||
{"/knowledgebases/", serviceAgent},
|
||||
{"/knowledgebases/kb-123", serviceAgent},
|
||||
{"/knowledgebases/kb-123/datasources/", serviceAgent},
|
||||
{"/knowledgebases/kb-123/retrieve", serviceAgentRuntime},
|
||||
{"/retrieveAndGenerate", serviceAgentRuntime},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.want, resolveSubservice(c.path), c.path)
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//test:in-package — drives the unexported per-service dispatchers.
|
||||
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
)
|
||||
|
||||
type accountKey struct{}
|
||||
|
||||
// testGateway holds the capabilities under test and serves a request to one
|
||||
// service the way the gateway would after authentication, with every policy
|
||||
// check permitted.
|
||||
type testGateway struct {
|
||||
Deps
|
||||
|
||||
region string
|
||||
}
|
||||
|
||||
func (g *testGateway) serve(service string, w http.ResponseWriter, r *http.Request) error {
|
||||
account, _ := r.Context().Value(accountKey{}).(string)
|
||||
return g.dispatch(w, dispatch.Invocation{
|
||||
Request: r,
|
||||
AccountID: account,
|
||||
Service: service,
|
||||
Region: g.region,
|
||||
Authorize: func(_, _ string, _ []string, _ iampolicy.ConditionKeys) error { return nil },
|
||||
})
|
||||
}
|
||||
|
||||
func (g *testGateway) Bedrock_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
return g.serve(ServiceName, w, r)
|
||||
}
|
||||
|
||||
func (g *testGateway) BedrockRuntime_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
return g.serve(serviceRuntime, w, r)
|
||||
}
|
||||
|
||||
func (g *testGateway) BedrockAgent_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
return g.serve(serviceAgent, w, r)
|
||||
}
|
||||
|
||||
func (g *testGateway) BedrockAgentRuntime_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
return g.serve(serviceAgentRuntime, w, r)
|
||||
}
|
||||
|
||||
// requestForAccount builds a request whose authenticated account is account.
|
||||
func requestForAccount(account, method, path, body string) *http.Request {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
return req.WithContext(context.WithValue(req.Context(), accountKey{}, account))
|
||||
}
|
||||
@@ -347,7 +347,7 @@ func GetGuardrail(ctx context.Context, accountID string, store *GuardrailStore,
|
||||
if input == nil || aws.StringValue(input.GuardrailIdentifier) == "" {
|
||||
return nil, errors.New(awserrors.ErrorValidationException)
|
||||
}
|
||||
id, err := ResolveGuardrailID(aws.StringValue(input.GuardrailIdentifier), store.region, accountID)
|
||||
id, err := resolveGuardrailID(aws.StringValue(input.GuardrailIdentifier), store.region, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -413,7 +413,7 @@ func UpdateGuardrail(ctx context.Context, accountID string, store *GuardrailStor
|
||||
aws.StringValue(input.BlockedInputMessaging) == "" || aws.StringValue(input.BlockedOutputsMessaging) == "" {
|
||||
return nil, errors.New(awserrors.ErrorValidationException)
|
||||
}
|
||||
id, err := ResolveGuardrailID(aws.StringValue(input.GuardrailIdentifier), store.region, accountID)
|
||||
id, err := resolveGuardrailID(aws.StringValue(input.GuardrailIdentifier), store.region, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -458,7 +458,7 @@ func DeleteGuardrail(ctx context.Context, accountID string, store *GuardrailStor
|
||||
if input == nil || aws.StringValue(input.GuardrailIdentifier) == "" {
|
||||
return nil, errors.New(awserrors.ErrorValidationException)
|
||||
}
|
||||
id, err := ResolveGuardrailID(aws.StringValue(input.GuardrailIdentifier), store.region, accountID)
|
||||
id, err := resolveGuardrailID(aws.StringValue(input.GuardrailIdentifier), store.region, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -500,7 +500,7 @@ func CreateGuardrailVersion(ctx context.Context, accountID string, store *Guardr
|
||||
if input == nil || aws.StringValue(input.GuardrailIdentifier) == "" {
|
||||
return nil, errors.New(awserrors.ErrorValidationException)
|
||||
}
|
||||
id, err := ResolveGuardrailID(aws.StringValue(input.GuardrailIdentifier), store.region, accountID)
|
||||
id, err := resolveGuardrailID(aws.StringValue(input.GuardrailIdentifier), store.region, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -540,7 +540,7 @@ func ApplyGuardrail(ctx context.Context, accountID string, store *GuardrailStore
|
||||
aws.StringValue(input.GuardrailVersion) == "" || aws.StringValue(input.Source) == "" {
|
||||
return nil, errors.New(awserrors.ErrorValidationException)
|
||||
}
|
||||
id, err := ResolveGuardrailID(aws.StringValue(input.GuardrailIdentifier), store.region, accountID)
|
||||
id, err := resolveGuardrailID(aws.StringValue(input.GuardrailIdentifier), store.region, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user