Compare commits

..

1 Commits

Author SHA1 Message Date
Ben Duncan 81cd67b12d fix: keep needrestart from restarting the Spinifex, OVS and OVN units
Guest QEMU runs in spinifex-daemon's cgroup, so needrestart charged QEMU's
replaced libraries to the daemon and restarted it after every dpkg run. The
restart never cleared them, so an unattended upgrade ran the unit into its
start limit and left the control plane down.

setup.sh now writes /etc/needrestart/conf.d/90-spinifex.conf, deselecting
spinifex-, ovs-, ovsdb-, ovn- and openvswitch- units. needrestart still lists
them, so restarting after an upgrade stays the operator's choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:34:24 +10:00
188 changed files with 4566 additions and 12760 deletions
+2 -2
View File
@@ -102,9 +102,9 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot + SQS store image)
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot)
if: steps.version.outputs.exists != 'true'
run: cd spinifex && make build-microvm-image build-sqs-store-microvm-image
run: cd spinifex && make build-microvm-image
- name: Build amd64 Tarball
if: steps.version.outputs.exists != 'true'
+2 -2
View File
@@ -57,8 +57,8 @@ jobs:
go-version-file: spinifex/go.mod
cache: false
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot + SQS store image)
run: cd spinifex && make build-microvm-image build-sqs-store-microvm-image
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot)
run: cd spinifex && make build-microvm-image
- name: Build amd64 Tarball
env:
-3
View File
@@ -21,9 +21,6 @@ build/microvm/vmlinuz
build/microvm/initramfs.cpio.gz
build/microvm/qboot.rom
build/microvm/qboot.LICENSE
build/microvm/sqs-store/vmlinuz
build/microvm/sqs-store/initramfs.cpio.gz
build/microvm/sqs-store/.stage.*/
build/qboot/
!spinifex/runtime/roles/spinifexui/frontend/dist/
tmp/
+2 -26
View File
@@ -140,23 +140,6 @@ $(MICROVM_ARTIFACTS) &: $(MICROVM_INPUTS)
bin/lb-agent:
$(MAKE) build-lb-agent
# The SQS store microVM image: a separate artifact from the LB image above, not
# installed or released by any target yet.
SQS_STORE_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/sqs-store
SQS_STORE_MICROVM_ARTIFACTS := $(SQS_STORE_MICROVM_OUT_DIR)/vmlinuz $(SQS_STORE_MICROVM_OUT_DIR)/initramfs.cpio.gz
SQS_STORE_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/sqs-store-microvm-publish.sh $(SQS_STORE_MICROVM_OUT_DIR)/init.sh $(SQS_STORE_MICROVM_OUT_DIR)/inittab $(addprefix $(SQS_STORE_MICROVM_OUT_DIR)/,sqs-store-lib.sh sqs-store-prepare sqs-store-run sqs-store-stop) bin/nats-server
$(SQS_STORE_MICROVM_ARTIFACTS) &: $(SQS_STORE_MICROVM_INPUTS)
./scripts/build-sqs-store-microvm-image.sh
# The dedicated SQS store server, built from the go.mod-pinned module so the
# image ships exactly the reviewed version. Static for the musl guest.
bin/nats-server: go.mod go.sum
@mkdir -p bin
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ github.com/nats-io/nats-server/v2
build-sqs-store-microvm-image: $(SQS_STORE_MICROVM_ARTIFACTS) ## Build the SQS store microVM kernel and initramfs
# qboot is the microvm firmware. The source tarball ships with each release
# because qboot is GPL-2.0.
QBOOT_COMMIT := $(shell sed -n 's/^QBOOT_COMMIT="\(.*\)"$$/\1/p' scripts/build-qboot.sh)
@@ -168,12 +151,6 @@ $(QBOOT_ARTIFACTS) &: scripts/build-qboot.sh
build-microvm-image: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Build microVM kernel, initramfs and qboot firmware (incremental — skips when up to date)
install-sqs-store-microvm: $(SQS_STORE_MICROVM_ARTIFACTS) ## Install the SQS store microVM image to /usr/share/spinifex/microvm/sqs-store/
sudo install -d /usr/share/spinifex/microvm/sqs-store
sudo rm -f /usr/share/spinifex/microvm/sqs-store/initramfs.cpio.gz
sudo install -m 0644 $(SQS_STORE_MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/sqs-store/vmlinuz
sudo install -m 0644 $(SQS_STORE_MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/sqs-store/initramfs.cpio.gz
install-microvm: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Install microVM artifacts to /usr/share/spinifex/microvm/
sudo install -d /usr/share/spinifex/microvm
sudo install -m 0644 $(MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/vmlinuz
@@ -200,7 +177,7 @@ preflight:
# reason: between them they install the software, wire OVN, erase the state and
# remove the install, so a shellcheck warning in one is an outage or a wrong
# deletion, not a style note.
NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh scripts/lib/distro.sh scripts/lib/stage-sqs-store-microvm.sh
NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh scripts/lib/distro.sh
test-build-scripts:
@echo -e "\n....Running build/scripts/**/*_test.sh...."
@@ -474,7 +451,6 @@ distro-amd64:
echo "[distro-amd64] WARNING: microvm artifacts missing — tarball will not include them"; \
echo "[distro-amd64] run 'make build-microvm-image' before 'make distro-amd64'"; \
fi
./scripts/lib/stage-sqs-store-microvm.sh $(SQS_STORE_MICROVM_OUT_DIR) dist/amd64
tar -czf dist/spinifex-$(VERSION)-linux-amd64.tar.gz -C dist/amd64 .
sha256sum dist/spinifex-$(VERSION)-linux-amd64.tar.gz > dist/spinifex-$(VERSION)-linux-amd64.tar.gz.sha256
@@ -493,7 +469,7 @@ distro-arm64:
distro-clean:
rm -rf dist/
.PHONY: test-package-check build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image build-sqs-store-microvm-image install-microvm install-sqs-store-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
.PHONY: test-package-check build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck nilaway \
+6 -5
View File
@@ -33,11 +33,6 @@ var allowlist = []allowance{
{From: "cmd/aws-model-coverage", To: "domains/ecr/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/route53/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/elbv2/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/rds/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/ecs/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/eks/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/iam/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/sts/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "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"},
@@ -128,7 +123,11 @@ var allowlist = []allowance{
{From: "domains/sts/awsapi", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "GetCallerIdentity takes IAMService; needs an IAM capability"},
{From: "gateway", To: "domains/eks/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/iam/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/rds/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/sts/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ecs/awsapi", Debt: "gateway (service registration)", Reason: "ECS and ELBv2 dispatch stays in gateway until the ingress registration seam"},
{From: "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,4 +160,6 @@ var allowlist = []allowance{
{From: "domains/rds/instance", To: "runtime/compute/vm", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
{From: "domains/sts", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "STS reads IAM service, trust-policy and OIDC-provider state; needs an IAM capability"},
{From: "operator/cli", To: "gateway", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
}
-96
View File
@@ -1,96 +0,0 @@
#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t devtmpfs dev /dev
# virtio_mmio is built-in (CONFIG_VIRTIO_MMIO=y) so virtio-net devices are
# already enumerated by the time PID 1 runs — no modprobe needed.
# qemu_fw_cfg and virtio_net are =m in stock Alpine linux-virt, so load them.
#
# modprobe needs a usable modules.dep; when the image is built on a host whose
# depmod can't produce one, busybox modprobe silently no-ops and the module
# never loads — fw_cfg then never appears and init panics. insmod by explicit
# path needs no modules.dep, so fall back to it. Handles .ko and .ko.gz.
load_mod() {
modprobe "$1" 2>/dev/null && return 0
for ko in $(find /lib/modules -name "$1.ko" -o -name "$1.ko.gz" 2>/dev/null); do
insmod "$ko" 2>/dev/null && return 0
case "$ko" in
*.gz) gunzip -c "$ko" >/tmp/mod.ko 2>/dev/null \
&& insmod /tmp/mod.ko 2>/dev/null && return 0 ;;
esac
done
return 1
}
load_mod qemu_fw_cfg || load_mod fw_cfg_sysfs || true
# virtio_net depends on net_failover, which depends on failover. The load_mod
# insmod fallback loads a single .ko with no dependency resolution, and
# modules.dep is absent when the image is built on a kmod-less host (depmod
# skipped), so modprobe can't resolve the chain either. Load it explicitly
# bottom-up so the NIC comes up regardless of how the image was built —
# without this the agent has no network and the LB hangs in provisioning.
load_mod failover || true
load_mod net_failover || true
load_mod virtio_net || true
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
i=0
while [ "$i" -lt 25 ]; do
[ -f "$NETCFG" ] && break
sleep 0.04
i=$((i + 1))
done
if [ ! -f "$NETCFG" ]; then
# Dump host/guest fw_cfg state to the serial console before init exits so a
# "netcfg not available" panic is diagnosable from the captured console log
# (module-not-loaded vs qemu-not-exposing-by_name vs wrong machine type).
echo "[init] FATAL: fw_cfg netcfg not available" >&2
echo "[init] dmi: vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null) product=$(cat /sys/class/dmi/id/product_name 2>/dev/null) bios=$(cat /sys/class/dmi/id/bios_version 2>/dev/null)" >&2
echo "[init] qemu_fw_cfg dir present: $([ -d /sys/firmware/qemu_fw_cfg ] && echo yes || echo NO)" >&2
echo "[init] modules: $(lsmod 2>/dev/null | awk 'NR>1{print $1}' | tr '\n' ',')" >&2
echo "[init] by_name listing:" >&2
ls -la /sys/firmware/qemu_fw_cfg/by_name/ 2>&1 | sed 's/^/[init] /' >&2
ls -la /sys/firmware/qemu_fw_cfg/by_name/opt/ 2>&1 | sed 's/^/[init] /' >&2
echo "[init] dmesg fw_cfg:" >&2
dmesg 2>/dev/null | grep -i fw_cfg | sed 's/^/[init] /' >&2
exit 1
fi
. "$NETCFG"
for n in 0 1 2 3 4 5; do
eval mac=\$NIC${n}_MAC
[ -z "$mac" ] && continue
iface=""
for attempt in 1 2 3 4 5; do
iface=$(ip -o link | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $2}' | tr -d :)
[ -n "$iface" ] && break
sleep 0.02
done
if [ -z "$iface" ]; then
echo "[init] WARNING: no interface found for MAC $mac (NIC$n), skipping" >&2
continue
fi
eval cidr=\$NIC${n}_CIDR
eval gw=\$NIC${n}_GW
eval default=\$NIC${n}_DEFAULT
eval rdst=\$NIC${n}_ROUTE_DST
eval rvia=\$NIC${n}_ROUTE_VIA
ip link set "$iface" up
[ -n "$cidr" ] && ip addr add "$cidr" dev "$iface"
[ "$default" = "1" ] && [ -n "$gw" ] && ip route add default via "$gw" dev "$iface"
[ -n "$rdst" ] && [ -n "$rvia" ] && ip route add "$rdst" via "$rvia" dev "$iface"
done
# The store image takes no lb-agent environment. A CA bundle is optional here:
# nothing in this image dials out yet.
CA_CERT=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/ca-cert/raw
mkdir -p /etc/ssl/certs
[ -f "$CA_CERT" ] && cp "$CA_CERT" /etc/ssl/certs/ca-certificates.crt
exec /sbin/init
-10
View File
@@ -1,10 +0,0 @@
# The power button loads here, not in /init: busybox init has its SIGUSR2
# (poweroff) handler installed before sysinit runs, so a press can never reach
# the pre-exec shell PID 1. A press before this line is lost; the host re-sends.
::sysinit:/sbin/modprobe tiny_power_button power_signal=12
::sysinit:/usr/local/lib/sqs-store/sqs-store-prepare
::respawn:/usr/local/lib/sqs-store/sqs-store-run
::ctrlaltdel:/sbin/reboot
# One action: the store stops and its disk unmounts first, and the generic
# umount -a -r runs only if the store is gone.
::shutdown:/usr/local/lib/sqs-store/sqs-store-stop
-215
View File
@@ -1,215 +0,0 @@
# sqs-store-lib.sh — shared by the SQS store guest's prepare, run and stop
# steps. Sourced, never executed. Every path and command is overridable so the
# host test can drive the same code against a fake fw_cfg, sysfs and tools.
#
# Guest contract, read from fw_cfg under opt/spinifex/sqs-store/:
# data-serial virtio-blk serial of the already-formatted ext4 data disk
# listen-addr IPv4 address on a configured guest NIC; the only listener
# client-nkey NATS user NKey public key allowed to connect; never a seed
# Anything absent, malformed or ambiguous refuses the store. Nothing formats.
SQS_FWCFG=${SQS_FWCFG:-/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/sqs-store}
SQS_SYSBLOCK=${SQS_SYSBLOCK:-/sys/block}
SQS_DEVDIR=${SQS_DEVDIR:-/dev}
# The host test cannot create block nodes, so it relaxes this to -e.
SQS_NODE_TEST=${SQS_NODE_TEST:--b}
SQS_RUN=${SQS_RUN:-/run/sqs-store}
SQS_DATA=${SQS_DATA:-/data}
SQS_CONSOLE=${SQS_CONSOLE:-/dev/console}
SQS_MOUNTS=${SQS_MOUNTS:-/proc/mounts}
SQS_PORT=${SQS_PORT:-4222}
SQS_STOP_BUDGET_TENTHS=${SQS_STOP_BUDGET_TENTHS:-120}
NATS_BIN=${NATS_BIN:-/usr/local/bin/nats-server}
E2FSCK=${E2FSCK:-e2fsck}
MOUNT=${MOUNT:-mount}
UMOUNT=${UMOUNT:-umount}
IP=${IP:-ip}
sqs_log() { echo "[sqs-store] $*" >> "$SQS_CONSOLE"; }
sqs_fatal() { sqs_log "FATAL: $*"; return 1; }
# sqs_fwcfg <name>: print one fw_cfg value, refusing a missing, empty, oversized
# or multi-token file. The value is not echoed on failure.
sqs_fwcfg() {
f="$SQS_FWCFG/$1/raw"
[ -f "$f" ] || { sqs_fatal "fw_cfg $1 not supplied"; return 1; }
[ "$(wc -c < "$f")" -le 128 ] || { sqs_fatal "fw_cfg $1 is oversized"; return 1; }
v=$(cat "$f")
case "$v" in
"" | *[[:space:]]*) sqs_fatal "fw_cfg $1 must be exactly one value"; return 1 ;;
esac
printf '%s' "$v"
}
# A volume serial is the volume ID with dashes stripped: vol + 17 hex.
sqs_valid_serial() {
[ "${#1}" -eq 20 ] || return 1
case "$1" in vol*) ;; *) return 1 ;; esac
case "${1#vol}" in *[!0-9a-f]*) return 1 ;; esac
}
# A NATS user public NKey: "U" then 55 base32 characters. The checksum is
# left to the pinned server's own config test.
sqs_valid_user_nkey() {
[ "${#1}" -eq 56 ] || return 1
case "$1" in U*) ;; *) return 1 ;; esac
case "$1" in *[!A-Z2-7]*) return 1 ;; esac
}
sqs_valid_ipv4() {
case "$1" in *[!0-9.]* | .* | *. | *..*) return 1 ;; esac
old_ifs=$IFS; IFS=.
# shellcheck disable=SC2086 # split on dots on purpose
set -- $1
IFS=$old_ifs
[ "$#" -eq 4 ] || return 1
for o in "$@"; do
[ "${#o}" -le 3 ] && [ "$o" -le 255 ] || return 1
case "$o" in 0?*) return 1 ;; esac
done
}
# sqs_find_disk <serial>: print the /dev node of the one block device whose
# sysfs serial matches exactly. Zero or several matches refuse.
sqs_find_disk() {
found="" n=0
for b in "$SQS_SYSBLOCK"/*; do
[ -r "$b/serial" ] || continue
[ "$(cat "$b/serial")" = "$1" ] || continue
found=${b##*/}
n=$((n + 1))
done
[ "$n" -eq 1 ] || { sqs_fatal "expected one disk with serial $1, found $n"; return 1; }
test "$SQS_NODE_TEST" "$SQS_DEVDIR/$found" || { sqs_fatal "disk $found has no block device node"; return 1; }
printf '%s' "$SQS_DEVDIR/$found"
}
# sqs_has_addr <ipv4>: the address is assigned to a non-loopback interface.
sqs_has_addr() {
"$IP" -o -4 addr show 2>/dev/null \
| awk -v a="$1" '$2 != "lo" { split($4, p, "/"); if (p[1] == a) f = 1 } END { exit !f }'
}
# sqs_alive <pid>: running or stopped, not exited (a zombie counts as exited).
sqs_alive() { [ -r "/proc/$1/status" ] && ! grep -q '^State:[[:space:]]*Z' "/proc/$1/status" 2>/dev/null; }
# sqs_starttime <pid>: kernel start time (stat field 22). It survives exec, and
# pairing it with the pid reduces PID-reuse risk; without a pidfd the check and
# the signal stay separate steps, so it is not an absolute guarantee.
sqs_starttime() { sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f20; }
# sqs_same <pid> <starttime>: the recorded process is still alive.
sqs_same() { [ -n "$2" ] && sqs_alive "$1" && [ "$(sqs_starttime "$1")" = "$2" ]; }
# sqs_prepare: validate the contract, check and mount the data disk, write the
# server config and mark the store ready. Returns non-zero, unready, on any refusal.
sqs_prepare() {
rm -f "$SQS_RUN/ready"
mkdir -p "$SQS_RUN"
serial=$(sqs_fwcfg data-serial) || return 1
sqs_valid_serial "$serial" || { sqs_fatal "data-serial is not a volume serial"; return 1; }
addr=$(sqs_fwcfg listen-addr) || return 1
sqs_valid_ipv4 "$addr" || { sqs_fatal "listen-addr is not an IPv4 address"; return 1; }
nkey=$(sqs_fwcfg client-nkey) || return 1
sqs_valid_user_nkey "$nkey" || { sqs_fatal "client-nkey is not a user public NKey"; return 1; }
sqs_has_addr "$addr" || { sqs_fatal "listen-addr $addr is not on any guest NIC"; return 1; }
# The server checks the config, including the NKey checksum, before the
# disk is touched.
umask 077
cat > "$SQS_RUN/nats.conf" <<EOF
listen: "$addr:$SQS_PORT"
server_name: "sqs-store"
jetstream {
store_dir: "$SQS_DATA"
sync_interval: always
}
authorization {
users = [ { nkey: "$nkey" } ]
}
EOF
if ! "$NATS_BIN" -t -c "$SQS_RUN/nats.conf" > "$SQS_RUN/config-test.out" 2>&1; then
sqs_fatal "server config test failed: $(sed 's/U[A-Z2-7]\{55\}/<nkey>/g' "$SQS_RUN/config-test.out" | tr '\n' ' ')"
return 1
fi
dev=$(sqs_find_disk "$serial") || return 1
rc=0
"$E2FSCK" -p "$dev" > "$SQS_RUN/fsck.out" 2>&1 || rc=$?
sqs_log "e2fsck -p $dev rc=$rc"
# 0 clean, 1 corrected: proceed. Anything higher, including a disk that is
# not ext4 at all, refuses; the guest never formats.
[ "$rc" -le 1 ] || { sqs_fatal "e2fsck refused $dev: $(tr '\n' ' ' < "$SQS_RUN/fsck.out")"; return 1; }
mkdir -p "$SQS_DATA"
"$MOUNT" -t ext4 -o nodev,nosuid,noexec "$dev" "$SQS_DATA" || { sqs_fatal "mount $dev failed"; return 1; }
: > "$SQS_RUN/ready"
sqs_log "prepared: $dev mounted at $SQS_DATA, config written for $addr:$SQS_PORT; server not started yet"
}
# sqs_run: the respawned supervisor body. Its identity is recorded before the
# stopping check: a stop that misses it set stopping first, so this parks; a
# stop that reads it signals this process whether or not it has exec'd yet.
sqs_run() {
mkdir -p "$SQS_RUN"
echo "$$ $(sqs_starttime $$)" > "$SQS_RUN/pid"
if [ -e "$SQS_RUN/stopping" ]; then
sqs_log "run: shutdown in progress, not starting"
return 2
fi
if [ ! -e "$SQS_RUN/ready" ]; then
sqs_log "run: store not prepared, not starting"
return 2
fi
sqs_log "run: starting pid=$$"
exec "$NATS_BIN" -c "$SQS_RUN/nats.conf"
}
# sqs_stop: first shutdown action. Block any respawn, SIGTERM the recorded run
# process (a server, or a start not yet exec'd), SIGKILL only after the budget,
# then sync and unmount. Each signal rechecks the identity; a survivor keeps the disk mounted.
sqs_stop() {
mkdir -p "$SQS_RUN"
: > "$SQS_RUN/stopping"
sqs_log "stop: begin"
rec=$(cat "$SQS_RUN/pid" 2>/dev/null || true)
pid=${rec%% *} st=${rec#* }
[ "$st" != "$rec" ] || st=""
if [ -n "$pid" ] && sqs_same "$pid" "$st"; then
kill -TERM "$pid" 2>/dev/null || true
sqs_log "stop: SIGTERM sent to run pid=$pid"
i=0
while sqs_same "$pid" "$st" && [ "$i" -lt "$SQS_STOP_BUDGET_TENTHS" ]; do sleep 0.1; i=$((i + 1)); done
if sqs_same "$pid" "$st"; then
sqs_log "stop: run pid=$pid still running after $((SQS_STOP_BUDGET_TENTHS / 10))s, SIGKILL"
kill -KILL "$pid" 2>/dev/null || true
j=0
while sqs_same "$pid" "$st" && [ "$j" -lt 30 ]; do sleep 0.1; j=$((j + 1)); done
fi
if sqs_same "$pid" "$st"; then
sqs_log "stop: run pid=$pid STILL ALIVE, leaving $SQS_DATA mounted"
return 1
fi
sqs_log "stop: store exited after $((i / 10)).$((i % 10))s"
else
sqs_log "stop: no running store"
fi
sync
if grep -q " $SQS_DATA " "$SQS_MOUNTS" 2>/dev/null; then
rc=0
"$UMOUNT" "$SQS_DATA" || rc=$?
sqs_log "stop: umount $SQS_DATA rc=$rc"
fi
}
# sqs_shutdown: the only shutdown action. The generic read-only remount runs
# only after the store stopped; a surviving writer keeps every mount as it is.
sqs_shutdown() {
if ! sqs_stop; then
sync
sqs_log "shutdown: store survived, skipping umount -a -r"
return 1
fi
rc=0
"$UMOUNT" -a -r || rc=$?
echo "[init] shutdown: umount -a -r rc=$rc" >> "$SQS_CONSOLE"
}
@@ -1,9 +0,0 @@
#!/bin/sh
# sysinit: load the disk drivers, then validate the guest contract, check and
# mount the data disk and write the server config. A refusal leaves the store
# unprepared, so the run step never starts it.
. /usr/local/lib/sqs-store/sqs-store-lib.sh
modprobe virtio_blk 2>/dev/null || true
modprobe ext4 2>/dev/null || true
sqs_prepare || sqs_log "store refused; it will not start"
exit 0
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
# Respawned by init. A store that must not run parks here instead of exiting,
# so init does not respawn it in a tight loop.
. /usr/local/lib/sqs-store/sqs-store-lib.sh
sqs_run
exec sleep 2147483647
-5
View File
@@ -1,5 +0,0 @@
#!/bin/sh
# The only shutdown action: stop the store and unmount its disk, then run the
# generic read-only remount only if the store is gone.
. /usr/local/lib/sqs-store/sqs-store-lib.sh
sqs_shutdown
-10
View File
@@ -10,13 +10,8 @@ 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"
@@ -61,11 +56,6 @@ func compareAll() ([]awsmodel.OperationCoverage, error) {
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
route53awsapi.ServiceName: route53awsapi.OperationInventory(),
elbv2awsapi.InventoryName: elbv2awsapi.OperationInventory(),
rdsawsapi.ServiceName: rdsawsapi.OperationInventory(),
ecsawsapi.ServiceName: ecsawsapi.OperationInventory(),
eksawsapi.ServiceName: eksawsapi.OperationInventory(),
iamawsapi.ServiceName: iamawsapi.OperationInventory(),
stsawsapi.ServiceName: stsawsapi.OperationInventory(),
})
// 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 -35
View File
@@ -290,7 +290,7 @@ storage-provider contract.
`domains/sqs/message` defines the store-neutral interface the SQS domain reaches its message store through (ADR-0011:S6), with a JetStream adapter in `domains/sqs/message/jetstream` and a shared conformance suite in `domains/sqs/message/conformance`.
It is an internal primitive: nothing outside the SQS domain uses it, no daemon or service is wired to it, and it does not itself deliver an SQS API.
The dedicated JetStream server ADR-0011 describes is not deployed by this code, and Spinifex ships no host service, unit, user or firewall rule for it; the guest that could run it is described under *SQS store system instance (not wired)*.
The dedicated JetStream server ADR-0011 describes is not deployed by this code, and Spinifex ships no host service, unit, user or firewall rule for it.
The interface covers durable and idempotent append, body and metadata reads, conditional state writes and deletes, per-namespace counts, namespace enumeration, an ordered change feed, a bounded paginated scan, namespace deletion and a sweep of tombstoned bodies.
A namespace carries an immutable message-shard and state-shard allocation chosen when the queue is created, so a later change in shard counts never moves an existing queue.
@@ -309,40 +309,6 @@ If that consumer delivers nothing for about two seconds while the stream holds a
Coverage is unit and conformance tests on in-process R1 and R3 servers; there is no end-to-end or multi-host validation of this code, and earlier multi-host evidence predates the final feed.
### SQS store system instance (not wired)
Spinifex can build, install, launch, restart and give storage to a dedicated SQS store microVM, but nothing calls any of it.
No SQS API, domain service, admin command or daemon start-up path creates a store, so none of this is in production use.
The guest image is built by `make build-sqs-store-microvm-image`, separate from the load balancer image, and bundles the `nats-server` version pinned in `go.mod` plus the ext4 tools.
Release tarballs carry it as `microvm-sqs-store/`, and `setup.sh` installs it to `/usr/share/spinifex/microvm/sqs-store/`.
A release without a complete image removes any previously installed one, so a launch is then refused rather than booting a stale image.
At boot the guest reads three firmware config values: the data disk's serial, an IPv4 address on one of its interfaces, and one NATS user NKey public key.
It mounts exactly one matching disk at `/data` after `e2fsck -p`, refuses a missing, duplicate, unformatted or unrepairable disk, and never formats.
JetStream keeps its data on `/data` with `sync_interval: always` (ADR-0011:S3), and the server listens only on that address and accepts only that key.
On shutdown the store is stopped before `/data` is unmounted; a store that survives SIGKILL leaves every mount in place.
A launch uses the versioned subject `system.LaunchSQSStore.v1`, which a daemon without this code does not serve, so it fails with no responder instead of booting another guest.
The legacy `system.LaunchInstance` path refuses the store kind on both send and receive.
Every input check runs before capacity is reserved, and the data volume must be an available system-account volume.
It is mounted through the normal EBS path and detached, never deleted, on terminate, which is the protection standard EBS gives today and nothing stronger.
CPU mitigations stay enabled for the store, unlike the load balancer.
A successful launch means the VM is running, not that NATS is ready.
The instance record keeps the store's non-secret guest settings so a restart on the node that launched it rewrites the firmware config files.
A relaunch on any other node is refused, and cross-node recovery never claims a store.
Both checks exist only in daemons with this code: an older daemon can still claim and relaunch a store elsewhere, and an explicit start served by another node claims the instance before its relaunch is refused.
`domains/sqs/storevolume` prepares a fresh data volume, because the guest never formats.
It checks for `mkfs.ext4` and `qemu-img` before allocating, creates the volume in `creating`, writes an empty ext4 image to it over NBD on the local node, unpublishes it, and only then makes it `available`.
While a volume is `creating`, attach, delete, modify, force-detach and snapshot are refused.
A volume is deleted only after a confirmed unpublish; any publish error, an unconfirmed unpublish or a failed release keeps it and reports its ID, with its state reported as unknown after a failed release.
Evidence is local only: unit tests, real QEMU boots against disposable scratch disks, and one run of the preparer against the `qemunbdd` provider whose volume kept acknowledged writes across a reboot.
Viperblock, volume encryption, the EC2 volume record path, multiple nodes and host reachability of the guest address are not tested.
Credential issuance and rotation, store placement and NATS readiness checks do not exist, and there is no failover: a lost node leaves its store unavailable.
## Configuration
Cluster configuration (`spinifex/bootstrap/config/config.go`):
+8 -14
View File
@@ -353,17 +353,11 @@ while the branch is in flight; they do not describe the umbrella repository's
| `spinifex#1273` | `spinifex/gateway/{ecs,elbv2}` | `spinifex/domains/{ecs,elbv2}/awsapi` (`awsapi`, imported as `ecsawsapi`, `elbv2awsapi`) | A mechanical relocation of the ECS and ELBv2 AWS action adapters to their ADR-0001 domain homes, as `spinifex#1265` did for IAM, STS, EKS and RDS; APIs, NATS subjects, KV buckets and behaviour are unchanged. The root `gateway` dispatch still imports each package, recorded as `gateway` (service registration) debt, and `domains/admission/quota` keeps its live ELBv2 counter import, now of `domains/elbv2/awsapi`. |
| `spinifex#1336` | `spinifex/gateway/tagging.go` (`Tagging_Request`, `taggingActions`, `taggingActionFromTarget`) and the `GatewayConfig.TaggingResources` field | `spinifex/domains/tagging/awsapi` (`NewRegistration`, `Deps`, `Actions`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | Resource Groups Tagging registers through the ADR-0002 seam like ACM and Route 53. The check order is kept: action, scope, authorization, account, body, aggregator, so a denied caller still gets `AccessDenied` before any body error and an unwired aggregator answers `ServerInternal`. The `checkPolicyResources` stand-in of `*` for every action is unchanged. The legacy switch case, the `supportedServices` entry and the `jsonErrorService` case are removed, and the registration declares the JSON 1.1 envelope. No allowlist row existed for this path. The nil-NATS check moves to the gateway-wide gate, which answers `ServiceUnavailableException` before dispatch as it did for every service. `cmd/aws-model-coverage` output is byte-identical because there is no embedded Tagging model. The characterization tests that pin the pipeline behaviour were committed first and pass unchanged against the registration; only the `withTagging` test helper changed. |
| `spinifex#1336` | `spinifex/gateway/elbv2.go` (`ELBv2_Request`, `elbv2Actions`, `elbv2Handler`, `accountLoadBalancerLimit`) | `spinifex/domains/elbv2/awsapi` (`NewRegistration`, `Deps`, `Quota`, `ActionNames`, `OperationInventory`), `spinifex/domains/admission/quota` (`Service.LoadBalancerLimit`), `spinifex/ingress/aws/query` (`ParseArgs`), composed by `spinifex/runtime/roles/awsgw` | ELBv2 registers through the ADR-0002 seam as the first AWS Query service. The check order is kept: query parse, action, account, input parse, scope, authorization, NATS, dispatch, so a denied caller still gets `AccessDenied` before the handler runs. The gateway members ELBv2 read are now supplied as `Deps`: `NATSConn` as `Deps.NATS`, `Quota` as the `Deps.Quota` interface declared in `awsapi` because `domains/admission/quota` imports `awsapi`, `Region` as `Invocation.Region`, and the policy gate as `Invocation.Authorize`. The `DescribeAccountLimits` cap resolution moves to `Service.LoadBalancerLimit` unchanged, and a nil `Deps.Quota` enforces nothing and reports the AWS default. AWSGW now wires the registry after it builds the quota service, because the registration captures it. The registration declares the XML envelope and the inventory name `elasticloadbalancingv2`, so `cmd/aws-model-coverage` keys it explicitly and its output is byte-identical. `gateway.ParseAWSQueryArgs` delegates to `query.ParseArgs`. The legacy switch case, the `supportedServices` entry, the inventory entry and the `gateway -> domains/elbv2/awsapi` allowlist row are removed. `cmd/aws-model-coverage` imports `domains/elbv2/awsapi` for the inventory, under a fourth named-exception allowlist row whose reason is now generic for all four. Finding, not fixed here: the XML for `DescribeAccountLimits` and `DescribeLoadBalancers` lists member fields in a different order from run to run because the marshal is map-ordered, so the characterization tests pin decoded content rather than bytes. The characterization tests that pin the pipeline behaviour were committed first and pass unchanged against the registration; only the `withELBv2` and `serveELBv2` test helpers and the setup of two older gateway tests changed. |
| `spinifex#1337` | `spinifex/gateway/spinifex.go` (`Spinifex_Request`, `spinifexAdminActions`) | `spinifex/operator/admin` (`NewRegistration`, `Deps`, `ModelAccess`, `ActionNames`), composed by `spinifex/runtime/roles/awsgw` | The `spinifex` Query service registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, authorization with the wildcard resource, account, the super-admin account gate, then the action, so a denied caller learns nothing about which actions exist. The gateway members the actions read are now `Deps`: `Version` and `Commit` strings, `NATSConn` as `Deps.NATS`, `RootCAs`, `DiscoverActiveNodes` as `Deps.ActiveNodes`, `BedrockAccessAdmin` as the `ModelAccess` interface, and the credential and access resolvers as `Deps.Credentials` and `Deps.Access`. The query actions need only the caller account, so the actor is not widened. The cluster-unavailable answer stays in the shared request path ahead of authorization. `/admin/{method}` moved onto the same registration in `mulga-3nbla.7`, recorded below. |
| `spinifex#1344` | `spinifex/gateway/rds.go` (`RDS_Request`, `rdsCaller`) | `spinifex/domains/rds/awsapi` (`NewRegistration`, `Deps`, `Quota`, `CallerFromActor`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | RDS registers through the ADR-0002 seam with the XML envelope it had. The check order is kept: query parse, action, account, the principal-class gate for the internal agent actions, scope, authorization, NATS, quota and dispatch, so a denied caller still gets `AccessDenied` before the handler runs and no customer grant is evaluated against an agent action. The gateway members RDS read are now supplied as `Deps`: `NATSConn` as `Deps.NATS`, `ExpectedNodes` as `Deps.ExpectedNodes`, `Quota.EnforceRDSInstances` as the `Deps.Quota` interface, `Region` as `Invocation.Region` and the policy gate as `Invocation.Authorize`. A nil `Deps.Quota` skips the DB-instance cap. The caller comes from `dispatch.Actor`: the session name for an assumed-role session and the user name otherwise, and the role name from `Actor.RoleName()`, which parses the underlying role ARN and never the session name. The account comes from `Invocation.AccountID`, so a caller with no composable ARN still reaches the policy check and is refused there as before. The legacy switch case, the `supportedServices` entry, the inventory entry and the `gateway -> domains/rds/awsapi` allowlist row are removed, and `cmd/aws-model-coverage` reads the inventory under a fifth named-exception allowlist row. The characterization tests that pin the pipeline behaviour were committed first with spinifex#1336 and pass unchanged against the registration; only the `withRDS` and `serveRDS` test helpers and the setup of the direct-call RDS tests changed. |
| `spinifex#1337` | `spinifex/gateway/spinifex.go` (`Spinifex_Request`, `spinifexAdminActions`) | `spinifex/operator/admin` (`NewRegistration`, `Deps`, `ModelAccess`, `ActionNames`), composed by `spinifex/runtime/roles/awsgw` | The `spinifex` Query service registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, authorization with the wildcard resource, account, the super-admin account gate, then the action, so a denied caller learns nothing about which actions exist. The gateway members the actions read are now `Deps`: `Version` and `Commit` strings, `NATSConn` as `Deps.NATS`, `RootCAs`, `DiscoverActiveNodes` as `Deps.ActiveNodes`, `BedrockAccessAdmin` as the `ModelAccess` interface, and the credential and access resolvers as `Deps.Credentials` and `Deps.Access`. The query actions need only the caller account, so the actor is not widened. The cluster-unavailable answer stays in the shared request path ahead of authorization. `/admin/{method}` stays on its chi route in `gateway/admin*.go` until the actor carries `UserName`. |
| `#1277` | `spinifex/accountteardown` | `spinifex/operator/accountlifecycle` (package `accountlifecycle`, imported as `accountteardown`) | A mechanical whole-package relocation under the 2026-10-27 ADR-0001 amendment: the account deletion coordinator keeps its APIs, NATS subjects, KV access and behaviour; importers keep the `accountteardown` name as an alias, so only their import paths change. Replacing its direct domain and adapter cleanup calls with owner capabilities stays deferred debt. |
| `#1285` | `spinifex/gateway/requestaudit.go` and `slogRequestLogger` from `spinifex/gateway/gateway.go` | `spinifex/ingress/aws/audit` (package `audit`) | The first generic-ingress extraction from the root gateway: the request-audit middleware and access-log middleware move unchanged in behaviour. The gateway's authentication flow and dispatch still populate the record, now through the exported `audit.From` and `Record` setters at the same call sites; the package imports only `foundation/telemetry` and `ingress/http`. Its unit tests moved with it; the tests that run it behind SigV4 stay in `gateway`. |
| `spinifex#1334` | `spinifex/gateway/spx` (`spx.go`, `storage.go`, `promote_image.go` and their tests) | `spinifex/operator/admin` (package `admin`) | The version, node, VM and storage-status gather helpers and the image-promote NATS request move unchanged with no shim; the `spinifex` service dispatch that calls them stays in `gateway/spinifex.go` until the `spinifex` registration slice. `operator/admin` already imports `domains/ec2/image` and `contracts/operator/v1`, so the move adds no edge and the layering ratchet needed no allowlist row. The `gateway/spx` frozen-legacy entry is removed. |
| `spinifex#1334` | `spinifex/gateway` (`CreateAccountRequest`/`Response`, `DeleteAccountRequest`, `DescribeAccountDeletionRequest`, `DeletionState*`, `AccountSummary`, `ListAccountsResponse`, `AdminMethodNames`) | `contracts/operator/v1` (`admin.go`) | The `/admin` bodies the CLI shares with the gateway move unchanged in field names, JSON tags and omitempty, pinned by golden JSON written against the gateway types first and moved with them. `AdminMethodNames` is now owned by the contract and the gateway builds its `adminMethods` accept set from it, with a test that the router accepts exactly that list. `AccountQuotaRequest`/`AccountQuotaResponse` (embed `quota.Overrides` from `domains/admission/quota`) and `DeleteAccountResponse` (embeds `accountlifecycle.Result`) stay in `gateway`: `contracts` may import only `foundation` and `contracts`, so they are settled when the `/admin` handlers move to `operator/admin`. `accountlifecycle.Stage` serialises as an integer, not its name; the golden test pins that. |
| `spinifex#1345` | `spinifex/gateway/admin*.go` (the `/admin/{method}` handlers, gates and body cap) and the `AccountQuotaRequest`/`AccountQuotaResponse`/`DeleteAccountResponse` wire types | `spinifex/operator/admin` (`rest.go`, `account_*.go`), served by the same `spinifex` registration | `/admin/{method}` stays a chi route in `gateway`, ahead of the service selector, but `Admin_Request` now only mints the request ID and header and hands the request to the registered entry through `dispatchRegistered`, the path every Query request takes. `dispatch.Invocation` gains `Service`, the credential-scope service, so the scope gate is the first identity gate in the dispatcher and a key signed for another service is denied exactly as before. The order is unchanged: verb and method, then the five fail-closed gates (scope, super-admin account, IAM user, identity, `spinifex:<Method>` policy), then the cluster check, then the 8 KiB body cap. The dispatcher returns errors and the route renders them as the JSON envelope with `requestId`, then logs once from the real result, so a denial or a 5xx is never logged as a completed request; the registration's `ResolveAction` labels the method, which removes the `auth.go` special case. The caller is read from `dispatch.Actor` (`UserName` for the gate and `updatedBy`). `BucketStore` and `SignupMaxAccounts` leave `GatewayConfig` for `operator/admin.Deps`, composed in `awsgw` beside `IAM`, `Quota`, `ExpectedNodes` and `NodeIDs`. The three wire types that could not enter `contracts` move to `operator/admin` with their handlers, `operator/cli` imports them from there, and the last `operator/cli` → `gateway` allowlist entry is deleted. |
| `spinifex#1346` | `spinifex/gateway/iam.go` (`IAM_Request`, the IAM action table, `getUserForCaller`, the PassRole check) | `spinifex/domains/iam/awsapi` (`NewRegistration`, `Deps`, `ActionNames`), composed by `spinifex/runtime/roles/awsgw` | IAM registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, service and account checks, then one parse of the input, the `GetUser` default for a missing `UserName`, scope resolution, authorization, the `iam:PassRole` check for `AddRoleToInstanceProfile`, then the handler. The caller is read from `dispatch.Actor` (`PrincipalType`, `UserName`), with no ARN parsing. The dependencies are `Deps.IAM` and `Deps.CountInstanceProfileAssociations`, composed in `awsgw`, and the gateway keeps no IAM fallback. The `cmd/aws-model-coverage` named exception gains an `iam` row. |
| `spinifex#1346` | `spinifex/gateway/sts.go` (`STS_Request`, the STS action table, the anonymous STS dispatch) | `spinifex/domains/sts/awsapi` (`NewRegistration`, `Deps`, `ActionNames`, `NewAnonymousAssumeRoleWithWebIdentity`), composed by `spinifex/runtime/roles/awsgw` | STS registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, service check, caller, the `AssumeRole` identity gate scoped to the stored role ARN, then the handler. The caller is read from `dispatch.Actor` (`CallerARN`, `UserName`, `SessionName`, `UserID`, `AccessKeyID`), with no ARN parsing. The gateway's authorization failure wraps a generic `dispatch.PolicyDenialError`, and STS matches it to answer with the caller, action and resource, so the gateway holds no STS-specific denial code. An unsigned `AssumeRoleWithWebIdentity` stays ahead of SigV4 in the gateway and reaches the handler through `GatewayConfig.AnonymousSTS`, a func composed in `awsgw`, never the STS service. The `cmd/aws-model-coverage` named exception gains an `sts` row. |
| `spinifex#1348` | `spinifex/gateway/ecs.go` (`ECS_Request`) and `spinifex/gateway/eks.go` (`EKS_Request`, the EKS route table) | `spinifex/domains/ecs/awsapi` and `spinifex/domains/eks/awsapi` (`NewRegistration`, `Deps`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | ECS (AWS JSON 1.1) and EKS (REST JSON) register through the ADR-0002 seam with the JSON error envelopes they had, both declared as AWS JSON 1.1 (see the envelope divergence below). The check order and every response are kept, pinned by the pipeline characterisation tests committed first. EKS and ECS resolve roles through `foundation/aws/passrole` `Check` with `domains/iam` `RoleResolver`: a malformed or foreign role is refused first, a missing role is authorized as written before it is reported, and a resolver outage is returned unchanged. Role handling now follows the AWS capture of 2026-10-30. ECS RegisterTaskDefinition refuses a foreign role with `ClientException` "Role is not valid" before PassRole and accepts a nonexistent, invented-path or non-ARN role from a caller who can pass roles. ECS RunTask refuses a foreign `taskRoleArn` or `executionRoleArn` override the same way and answers a nonexistent or invented-path one with "ECS was unable to assume the role". EKS CreateCluster and CreateNodegroup answer a non-ARN or foreign role with `AccessDeniedException` "Cross-account pass role is not allowed.". EKS public routes now report every parameter fault as `InvalidParameterException`, the code the EKS model declares, while the internal control-plane routes keep `InvalidParameterValue` because the guest agents match on it. Two deliberate EKS divergences remain. First, CreateCluster still refuses a nonexistent or invented-path role with `InvalidParameterException` "role ... does not exist", where AWS accepts it and the cluster later ends FAILED. Our EKS only stores, validates and echoes the cluster role and never assumes it, so accepting would leave an ACTIVE cluster bound to a role that does not exist. Second, CreateNodegroup keeps its existing refusal of a nonexistent role because AWS was not captured for it. A third divergence is the error envelope. EKS is restJson1 but registers `ErrorEnvelopeJSON`, so errors carry `application/x-amz-json-1.1` and a `__type` body where AWS sends `application/json` and a `message` body. The `X-Amzn-Errortype` header is correct and SDKs read the code from it, so impact is low. Switching to `ErrorEnvelopeRESTJSON` also changes the throttle code and the default internal-error code (`ServiceException` where the EKS model declares `ServerException`), so it is left for its own change. Verified live on env14: the ECS and EKS role cases above, and a valid CreateCluster reaching ACTIVE with the control-plane agent publishing through the gateway. The EKS route table and its tests move with the registration, and the gateway keeps no ECS or EKS fallback. The `cmd/aws-model-coverage` named exception gains an `ecs` and an `eks` row, and the two `gateway` allowlist rows are removed. |
| `spinifex#1350` | `spinifex/gateway` (`ecr.go`, `ecrauth_middleware.go`, the `/v2/token` exchange, `ecr_principal.go` with `ResolveECRPrincipal`, the per-operation `/v2` authorization, the registry host matcher, `ctxECRPrincipal`) | `spinifex/domains/ecr/registry` (`Bridge`, `NewBridge`, `BridgeConfig`, and its own `Principal`, `Rehydrator`, `Authorizer`, `Admission` types with `ErrInvalidCredential` and `ErrUnavailable`), `spinifex/ingress/http` (`HostMatcher`, `TargetAccount`, `RequestClientIP`; names no service), `spinifex/ingress/aws/credential` (`Rehydrator`, `Principal.CallerARN`), and under `spinifex/runtime/roles/awsgw`: `credverify` (`Rehydrate`, sharing the SigV4 key and session lookups), `policyauth`, `accountadmission`, `registryadapt` | ADR-0001 amendment 2026-10-29 and ADR-0005:S2: the OCI `/v2` bridge and its token auth move out of the gateway, and ECR resolves its principal through the credential capability instead of reading `IAMService` and `STSService` directly. ADR-0002:S5 keeps `/v2` outside the seam, so the gateway keeps only a `RegistryHandler` mount that answers 501 when unset. `domains/ecr/registry` imports no ingress package (`TestADR0001_Layering` unchanged): the principal, rehydrator and authorizer are registry-local types and `registryadapt` maps `credential.Failure` codes onto `ErrInvalidCredential` (401) and `ErrUnavailable` (503). No widening of `dispatch.Actor` or `dispatch.Invocation`. The SigV4 path and the bridge share one policy evaluator (`policyauth`) and one account-admission check. Deliberate change: the open-registry mode for a nil token verifier is deleted and `NewBridge` refuses a nil Verifier, Issuer, Rehydrator, Authorizer or Admission, so tests that want an unauthenticated registry mount `registry.Handler` directly. The pre-move pipeline characterisation (status codes, error bodies, the `WWW-Authenticate` challenge, referrers, token exchange, revoked and deleted principals) passes unchanged apart from setup, and the retired `ResolveECRPrincipal` parity rows live on as `credverify` `Rehydrate` rows and `Bridge` claim-check rows. Live on env14 (DHCP external IP): crane, skopeo and docker push and pull (`tests/e2e/ecr`), a cosign sign and verify in the legacy tag mode and in OCI 1.1 referrers mode with the signature returned by `GET /v2/{repo}/referrers/{digest}`, `/v2/token` refresh from fresh logins, and a deactivated access key answering 401 on `/v2/token`, manifest GET, crane pull and crane push, then 200 again once reactivated. The ECR signing-key deadlock bead (`mulga-6wqce`) is untouched. |
| `spinifex#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
@@ -399,9 +393,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
@@ -450,7 +444,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`) 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.
@@ -468,7 +462,7 @@ These residuals were recorded by individual slices and must be resolved once the
- `operator/installer/firstboot` (security): `Config.InstallCallback` is rendered into `spinifex-firstboot.sh`, which is world-readable (0755). The field is currently unused (`fireInstallCallback` reads `SPINIFEX_INSTALL_CALLBACK` from the environment instead), but any future wiring must not place secrets or tokenised callback URLs in the generated script; sensitive callback credentials should reach first boot through a protected channel such as a 0600 file or a credential store.
- `operator/cli` (known security risk): `spx admin join` contacts the formation server with `InsecureSkipVerify`, sends the join token as a bearer credential and receives the cluster CA certificate and key, master key, Viperblock key and shared credentials, so an on-path attacker during formation can capture the token and the cluster root secrets or substitute a CA. This predates the move; lint surfaced it when the tree left `cmd/`. It is accepted for now and carries a line-level gosec annotation stating it is not a false positive. Removal condition: authenticate formation TLS with a pinned key or certificate delivered with the join material, or an out-of-band CA, with tests proving a mismatched server certificate fails before credentials are sent.
- `domains/eks/cluster` (potential security risk): `ClusterMeta.ControlPlaneTemplate` (`domains/eks/cluster/service_impl.go`) clears the template's credentials but keeps `OIDCPrivateKeyPEM` and the k3s `JoinToken`, so both are persisted in the `eks-account-{acct}` cluster record, which the gateway also reads. Whether that record is encrypted at rest is unconfirmed; if it is not, a reader of the bucket can forge service-account tokens for the cluster's OIDC issuer and join nodes to it. This predates the refactor and is not fixed in it. Removal condition: the key and token are held by a dedicated secret store (a future KMS or Secrets Manager capability is the likely owner) and the cluster record keeps only a reference, with a test proving the persisted record contains neither secret.
- `operator/cli` (dependency): the command tree still imports domain and service implementations directly (`handlers/{iam,eks}`, `domains/admission/quota`, `domains/ochre`, `vpcd`, the Viperblock adapter and runtime roles). The ADR's `cmd/spinifex` current-to-target map row requires command packages to reach domains through public capabilities; splitting supported use cases into `operator/*` packages is later work.
- `operator/cli` (dependency): the command tree still imports domain and service implementations directly (`handlers/{iam,eks}`, `domains/admission/quota`, `gateway`, `domains/ochre`, `vpcd`, the Viperblock adapter and runtime roles). The ADR's `cmd/spinifex` current-to-target map row requires command packages to reach domains through public capabilities; splitting supported use cases into `operator/*` packages is later work.
- `providers/ebs/viperblock` → `runtime/compute/nbd` and `runtime/host/process` (dependency, moved unchanged from `services/viperblockd`): the adapter formats NBD endpoints and manages nbdkit processes through runtime packages. ADR-0001 does not list providers → runtime as forbidden and the layering test does not enforce it (same category as `providers/ebs/conformance` → `runtime/compute/nbd`), but a provider should not depend on node runtime. Clears when NBD endpoint naming has a provider-facing home (a candidate for `contracts/ebs/v1`) and process supervision is injected by the role. The adapter also imports `providers/objectstore`, a provider-to-provider edge that is allowed.
- `gateway/ec2.go` (registration): the 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.
@@ -688,7 +682,7 @@ series above; "needs decision" is open.
| `gateway/bedrock` | — | Done. The whole package moved into `domains/ochre` in `spinifex#1266` with its package-private core and tests intact and no behaviour change. Separating the action adapters into `domains/ochre/awsapi` remains deferred, recorded as `domains/ochre` (action adapters) debt above. |
| `gateway/{iam,sts,eks,rds}` | — | Done. The action adapters moved to `domains/{iam,sts,eks,rds}/awsapi` in `spinifex#1265` with no API, NATS subject, storage or behaviour change. ADR-0001 does not make ADR-0002 registration a prerequisite for relocating a domain's adapters, as the EC2 adapter moves already showed; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
| `gateway/{ecs,elbv2}` | — | Done. The action adapters moved to `domains/{ecs,elbv2}/awsapi` in `spinifex#1273` with no API, NATS subject, storage or behaviour change; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
| `gateway/spx` | — | Done. The gather helpers, storage-status config and image-promote request moved to `operator/admin` with no behaviour change and the package is removed. The `spinifex` Query dispatch and the `/admin/{method}` handlers that call them now live in `operator/admin` as one seam registration. |
| `gateway/spx` | — | Done. The gather helpers, storage-status config and image-promote request moved to `operator/admin` with no behaviour change and the package is removed. The `spinifex` Query dispatch that calls them now lives in `operator/admin` as a seam registration; `/admin/{method}` stays in the root `gateway` until the actor carries `UserName`. |
| `gateway/tagging` | — | Done. `domains/tagging` owns the read-only cross-resource `GetResources` aggregation and its API semantics (`0d830e901`, `aedd0b2c4`); AWSGW composes the owner listers, the EC2 tag index and ELBv2 (`f3545e3ea`); the dispatch moved to `domains/tagging/awsapi` and the root `gateway` no longer serves Tagging. The package is removed, and its two allowlist rows and frozen-legacy entry are gone. Owner tag mutation capabilities remain future direction, so the RDS and ECS tag-writing edges above stay open. |
| `services/viperblockd` | — | Done. The adapter is `providers/ebs/viperblock` (with the read-only `dirty` reader), the shared vocabulary is `contracts/viperblockd/legacy/v1`, and the role is `runtime/roles/viperblockd`. `services/` is empty. |
| `utils` | — | Done and deleted. `ec2_subject.go` went to `contracts/ec2/v1`, the vpcd protocol to `contracts/network/v1` with the typed client in `domains/network/projection`, and the master-key loader to `domains/ec2/ebs/encryption`. |
@@ -708,7 +702,7 @@ ADR-0001 completion is structural, so none of these is cleared by remediating se
| Class | Paths | Status |
|---|---|---|
| No missing boundary (candidate moves) | `handlers/ecs/bus` → `contracts/ecs/v1` (`a130f6b8e`); `admin/images_promote.go` → `domains/ec2/image` (`263c58167`); `admin` bootstrap account identifiers → `foundation/aws/identifiers` (`3fdaa54d9`); `admin` credential-format generators → `foundation/aws/credentials` `0368e861a`, retiring `admin` | Done. |
| One narrow missing boundary | Done: `handlers/sts` EKS JWKS lookup → `handlers/eks` `ClusterJWKSResolver` (`254d898a3`); `gateway/tagging` → `domains/tagging` with AWSGW-composed listers (`0d830e901`, `f3545e3ea`, `aedd0b2c4`); `gateway/spx` → `operator/admin`. Done: the `spinifex` service and `/admin` dispatch registration, owned by `operator/admin` | Each needs one decision or one capability, then a mechanical move. |
| One narrow missing boundary | Done: `handlers/sts` EKS JWKS lookup → `handlers/eks` `ClusterJWKSResolver` (`254d898a3`); `gateway/tagging` → `domains/tagging` with AWSGW-composed listers (`0d830e901`, `f3545e3ea`, `aedd0b2c4`); `gateway/spx` → `operator/admin`. Open: the owner of the `spinifex` service and `/admin` dispatch registration | Each needs one decision or one capability, then a mechanical move. |
| Broader boundary or follows another package | `gateway` root (ADR-0002 auth and request-context phase) | Recorded debt with removal conditions above. |
Per-consumer IAM capabilities are the widest unblocker: they gate STS's IAM reads, Ochre's IAM reads, the ELBv2 role path and the ECS, EKS and RDS ensurers.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -33,7 +33,6 @@ require (
github.com/mulgadc/viperblock v1.23.0
github.com/nats-io/nats-server/v2 v2.15.1
github.com/nats-io/nats.go v1.54.0
github.com/nats-io/nkeys v0.4.16
github.com/opencontainers/runtime-spec v1.3.0
github.com/oracle/oci-go-sdk/v65 v65.128.0
github.com/ovn-kubernetes/libovsdb v0.9.0
@@ -162,6 +161,7 @@ require (
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/jwt/v2 v2.8.2 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
+1 -1
View File
@@ -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. 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. 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-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. |
-475
View File
@@ -1,475 +0,0 @@
#!/bin/bash
# build-sqs-store-microvm-image.sh — build the SQS store microVM kernel +
# initramfs: the dedicated nats-server, ext4 support, the store's guest steps
# and a clean power-down. The guest never formats a disk; build/microvm/
# sqs-store/sqs-store-lib.sh states its fw_cfg contract. The LB image
# (build-microvm-image.sh) is separate and unchanged.
#
# Inputs:
# $REPO_ROOT/build/microvm/sqs-store/init.sh, inittab
# $REPO_ROOT/build/microvm/sqs-store/sqs-store-{lib.sh,prepare,run,stop}
# $REPO_ROOT/bin/nats-server (make bin/nats-server, the go.mod-pinned version)
#
# Outputs:
# $REPO_ROOT/build/microvm/sqs-store/vmlinuz
# $REPO_ROOT/build/microvm/sqs-store/initramfs.cpio.gz
#
# Rootfs strategy (first match wins):
# 1. apk available on host → install directly into a temp chroot (Alpine CI/CD)
# 2. docker available → run alpine container, export filesystem
# 3. podman available → same via podman
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$REPO_ROOT/build/microvm/sqs-store"
INIT_SH="$BUILD_DIR/init.sh"
INITTAB="$BUILD_DIR/inittab"
NATS_SERVER_BIN="$REPO_ROOT/bin/nats-server"
ALPINE_VERSION="${ALPINE_VERSION:-3.24}"
ALPINE_MIRROR="${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}"
# e2fsprogs: mkfs.ext4/e2fsck for a guest that mounts an ext4 data volume.
# kmod: the image's own depmod/modprobe rebuild module metadata below.
ALPINE_PACKAGES="busybox linux-virt iproute2 ca-certificates e2fsprogs kmod"
# Modules kept by name on top of the virtio/fw_cfg/failover globs, each with its
# full depends= and softdep closure. tiny_power_button turns the ACPI power
# button (QMP system_powerdown) into a signal busybox init handles as poweroff.
MICROVM_EXTRA_MODULES="${MICROVM_EXTRA_MODULES:-ext4 tiny_power_button}"
echo "[build-sqs-store-microvm-image] repo root: $REPO_ROOT"
echo "[build-sqs-store-microvm-image] build dir: $BUILD_DIR"
mkdir -p "$BUILD_DIR"
# --- Verify non-negotiable tools ---
# fakeroot is mandatory: device nodes are written into the cpio archive inside a
# faked-root environment so no real device node is ever created on the host
# filesystem (which previously required sudo mknod and risked clobbering host
# /dev/null on cleanup).
for tool in cpio gzip find fakeroot; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "ERROR: required tool not found: $tool" >&2
exit 1
fi
done
# --- Verify the dedicated nats-server is the go.mod-pinned build ---
# A stale or host-installed binary would ship an unreviewed server version.
NATS_SERVER_PIN=$(awk '$1 == "github.com/nats-io/nats-server/v2" { print $2; exit }' "$REPO_ROOT/go.mod")
if [ -z "$NATS_SERVER_PIN" ]; then
echo "ERROR: no github.com/nats-io/nats-server/v2 requirement in $REPO_ROOT/go.mod" >&2
exit 1
fi
if [ ! -x "$NATS_SERVER_BIN" ]; then
echo "ERROR: $NATS_SERVER_BIN not found — run 'make bin/nats-server'" >&2
exit 1
fi
if ! file -b "$NATS_SERVER_BIN" | grep -q "statically linked"; then
echo "ERROR: $NATS_SERVER_BIN is not statically linked — it cannot run on the musl guest" >&2
exit 1
fi
nats_server_version=$("$NATS_SERVER_BIN" --version 2>&1 | awk '{ print $NF; exit }')
if [ "$nats_server_version" != "$NATS_SERVER_PIN" ]; then
echo "ERROR: $NATS_SERVER_BIN reports $nats_server_version, go.mod pins $NATS_SERVER_PIN" >&2
exit 1
fi
echo "[build-sqs-store-microvm-image] nats-server: $nats_server_version (matches go.mod)"
# --- Create chroot ---
CHROOT_DIR=$(mktemp -d)
# Guard: every destructive op below targets "$CHROOT_DIR/...". An empty or
# unexpected value would aim rm -rf at the host (e.g. /dev). Refuse anything that
# is not a freshly-created temp dir.
case "${CHROOT_DIR:?mktemp -d returned empty}" in
/tmp/*|/var/tmp/*|"${TMPDIR:-/nonexistent}"/*) ;;
*) echo "ERROR: refusing to use unexpected CHROOT_DIR: $CHROOT_DIR" >&2; exit 1 ;;
esac
# Outputs are written here first, beside $BUILD_DIR so publishing is a rename.
STAGE_DIR=$(mktemp -d "$BUILD_DIR/.stage.XXXXXX")
CONTAINER_TOOL=""
CONTAINER_CID=""
cleanup() {
echo "[build-sqs-store-microvm-image] cleaning up chroot: $CHROOT_DIR"
rm -rf "$CHROOT_DIR"
rm -rf "${STAGE_DIR:?}"
if [ -n "$CONTAINER_CID" ] && [ -n "$CONTAINER_TOOL" ]; then
echo "[build-sqs-store-microvm-image] cleaning up container: $CONTAINER_CID"
$CONTAINER_TOOL rm -f "$CONTAINER_CID" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
# --- Populate rootfs ---
# Strategy 1: native apk (Alpine hosts, CI)
if command -v apk >/dev/null 2>&1; then
echo "[build-sqs-store-microvm-image] rootfs: native apk"
mkdir -p \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/lib/apk/db" \
"$CHROOT_DIR/var/cache/apk"
cat > "$CHROOT_DIR/etc/apk/repositories" <<EOF
${ALPINE_MIRROR}/v${ALPINE_VERSION}/main
${ALPINE_MIRROR}/v${ALPINE_VERSION}/community
EOF
apk add \
--root "$CHROOT_DIR" \
--arch x86_64 \
--no-cache \
--allow-untrusted \
--initdb \
$ALPINE_PACKAGES
# Strategy 2: docker -> BuildKit local export.
# Do NOT use `docker export` here. On Docker's overlay2 driver it leaks the
# container's overlay mount (the mount survives `docker rm`), orphaning layers
# under /var/lib/docker that only a daemon restart reclaims; on a busy CI host
# this grows without bound. `buildx --output type=local` writes the image
# filesystem straight to a directory and releases every mount, so nothing leaks.
elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
echo "[build-sqs-store-microvm-image] rootfs: docker buildx --output type=local (alpine:${ALPINE_VERSION})"
BUILD_CTX=$(mktemp -d)
cat > "$BUILD_CTX/Dockerfile" <<DOCKERFILE
FROM alpine:${ALPINE_VERSION}
RUN apk add --no-cache ${ALPINE_PACKAGES}
DOCKERFILE
docker buildx build \
--platform linux/amd64 \
--output "type=local,dest=${CHROOT_DIR}" \
"$BUILD_CTX"
rm -rf "$BUILD_CTX"
# Strategy 3: podman -> run + export. Podman's store does not exhibit the docker
# overlay2 export-mount leak, and podman has no buildx.
elif command -v podman >/dev/null 2>&1; then
echo "[build-sqs-store-microvm-image] rootfs: podman export (alpine:${ALPINE_VERSION})"
CONTAINER_TOOL=podman
CONTAINER_CID=$(podman run -d \
"alpine:${ALPINE_VERSION}" \
sh -c "apk add --no-cache ${ALPINE_PACKAGES}")
exit_code=$(podman wait "$CONTAINER_CID")
if [ "$exit_code" != "0" ]; then
echo "ERROR: package installation failed in container (exit $exit_code)" >&2
exit 1
fi
podman export "$CONTAINER_CID" | tar -x -C "$CHROOT_DIR"
else
echo "ERROR: no rootfs build tool available." >&2
echo " Install one of: apk (Alpine), docker, podman" >&2
exit 1
fi
# --- /dev: empty mountpoint only ---
# tar extraction without root silently creates 0-byte regular files for device
# nodes. Drop them and leave an empty /dev mountpoint. The two static nodes the
# kernel needs (/dev/console to wire init's stdio, /dev/null) are written into
# the cpio archive under fakeroot at pack time — NOT created on the host. init
# mounts devtmpfs over /dev as its first action for everything else at runtime.
rm -rf "${CHROOT_DIR:?}/dev"
mkdir "$CHROOT_DIR/dev"
# --- Place init script ---
echo "[build-sqs-store-microvm-image] installing init.sh as /init..."
if [ ! -f "$INIT_SH" ]; then
echo "ERROR: $INIT_SH not found — cannot build initramfs" >&2
exit 1
fi
install -m 0755 "$INIT_SH" "$CHROOT_DIR/init"
# --- Place inittab ---
# Required: without it busybox init runs its default table, not the power-down.
if [ ! -f "$INITTAB" ]; then
echo "ERROR: $INITTAB not found — cannot build initramfs" >&2
exit 1
fi
echo "[build-sqs-store-microvm-image] installing inittab..."
install -m 0644 "$INITTAB" "$CHROOT_DIR/etc/inittab"
# --- Place the store's guest steps ---
echo "[build-sqs-store-microvm-image] installing store guest steps..."
mkdir -p "$CHROOT_DIR/usr/local/lib/sqs-store"
for step in sqs-store-lib.sh sqs-store-prepare sqs-store-run sqs-store-stop; do
if [ ! -f "$BUILD_DIR/$step" ]; then
echo "ERROR: $BUILD_DIR/$step not found — cannot build initramfs" >&2
exit 1
fi
done
install -m 0644 "$BUILD_DIR/sqs-store-lib.sh" "$CHROOT_DIR/usr/local/lib/sqs-store/"
install -m 0755 "$BUILD_DIR/sqs-store-prepare" "$BUILD_DIR/sqs-store-run" "$BUILD_DIR/sqs-store-stop" \
"$CHROOT_DIR/usr/local/lib/sqs-store/"
# --- Copy nats-server binary ---
echo "[build-sqs-store-microvm-image] copying nats-server binary..."
mkdir -p "$CHROOT_DIR/usr/local/bin"
install -m 0755 "$NATS_SERVER_BIN" "$CHROOT_DIR/usr/local/bin/nats-server"
# --- Create required directories for init ---
mkdir -p \
"$CHROOT_DIR/proc" \
"$CHROOT_DIR/sys" \
"$CHROOT_DIR/dev" \
"$CHROOT_DIR/etc/ssl/certs"
# --- Locate kernel ---
KERNEL_IMG=$(find "$CHROOT_DIR/boot" -name "vmlinuz*" | sort | tail -1)
if [ -z "$KERNEL_IMG" ]; then
echo "ERROR: no vmlinuz found in $CHROOT_DIR/boot" >&2
exit 1
fi
echo "[build-sqs-store-microvm-image] kernel image: $KERNEL_IMG"
KERNEL_VER=$(basename "$KERNEL_IMG" | sed 's/vmlinuz-//')
KERNEL_CONFIG="$CHROOT_DIR/boot/config-${KERNEL_VER}"
# --- Assert fw_cfg module ---
if ! find "$CHROOT_DIR/lib/modules" \
-name "qemu_fw_cfg.ko*" \
-o -name "fw_cfg_sysfs.ko*" \
| grep -q .; then
if ! grep -q "CONFIG_FW_CFG_SYSFS=y" "$KERNEL_CONFIG" 2>/dev/null; then
echo "ERROR: qemu_fw_cfg module missing from initramfs — fw_cfg reads will fail at boot" >&2
exit 1
fi
echo "[build-sqs-store-microvm-image] fw_cfg: built-in (CONFIG_FW_CFG_SYSFS=y)"
else
echo "[build-sqs-store-microvm-image] fw_cfg: module found in lib/modules"
fi
# --- Assert ext4 userspace tools (data volume) ---
for fs_tool in mkfs.ext4 e2fsck; do
if [ ! -x "$CHROOT_DIR/sbin/$fs_tool" ] && [ ! -x "$CHROOT_DIR/usr/sbin/$fs_tool" ]; then
echo "ERROR: $fs_tool missing from image — guest cannot check or create an ext4 data volume" >&2
exit 1
fi
done
echo "[build-sqs-store-microvm-image] e2fsprogs: mkfs.ext4 + e2fsck present"
# --- Strip kernel modules (Phase 2 keep-list approach) ---
# Only modules needed by a virtio-mmio microvm guest are kept:
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
# *fw_cfg*.ko* — QEMU fw_cfg sysfs driver (delivers boot blobs)
# *failover*.ko* — net_failover + failover (virtio_net deps for live migration)
# $MICROVM_EXTRA_MODULES — ext4 by default, plus dependency closure
# All other loadable modules are removed. Drivers compiled into the kernel
# (CONFIG_VIRTIO_MMIO=y etc.) are unaffected — they live in vmlinuz, not here.
# module_path prints a module's kernel/ path from the stock modules.dep, by
# name or modules.alias; it fails when the module is not loadable.
module_path() {
local kdir="$1" name="${2//-/_}" path alias_target
path=$(awk -F: -v n="$name" '{ f = $1; sub(/.*\//, "", f); sub(/\.ko(\.gz|\.xz|\.zst)?$/, "", f);
gsub(/-/, "_", f); if (f == n) { print $1; exit } }' "$kdir/modules.dep")
if [ -z "$path" ] && [ -f "$kdir/modules.alias" ]; then
alias_target=$(awk -v n="$2" '$1 == "alias" && $2 == n { print $3; exit }' "$kdir/modules.alias")
[ -n "$alias_target" ] && [ "$alias_target" != "$2" ] && path=$(module_path "$kdir" "$alias_target")
fi
[ -n "$path" ] && printf '%s\n' "$path"
}
# module_builtin reports whether a module name is compiled into the kernel.
module_builtin() {
local kdir="$1" name="${2//-/_}"
[ -f "$kdir/modules.builtin" ] || return 1
awk -v n="$name" '{ f = $0; sub(/.*\//, "", f); sub(/\.ko$/, "", f); gsub(/-/, "_", f);
if (f == n) { found = 1; exit } } END { exit !found }' "$kdir/modules.builtin"
}
# image_kmod runs the image's own kmod (depmod, modprobe) through its musl
# loader, so module metadata matches the guest's modprobe without a host kmod.
image_kmod() {
"$CHROOT_DIR/lib/ld-musl-x86_64.so.1" --library-path "$CHROOT_DIR/lib:$CHROOT_DIR/usr/lib" \
--argv0 "$1" "$CHROOT_DIR/bin/kmod" "${@:2}"
}
echo "[build-sqs-store-microvm-image] stripping kernel modules to virtio+fw_cfg+${MICROVM_EXTRA_MODULES} only..."
for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
kver="$(basename "$kver_dir")"
kernel_dir="$kver_dir/kernel"
[ -d "$kernel_dir" ] || continue
if [ ! -s "$kver_dir/modules.dep" ]; then
echo "ERROR: stock ${kver_dir}modules.dep missing — cannot resolve module dependencies" >&2
exit 1
fi
# Extra modules start the closure; the glob families are kept as found.
keep_list=$(mktemp)
: > "$keep_list"
for name in $MICROVM_EXTRA_MODULES; do
if ! path=$(module_path "$kver_dir" "$name"); then
if module_builtin "$kver_dir" "$name"; then continue; fi
echo "ERROR: module $name is neither loadable nor built into $kver" >&2
exit 1
fi
echo "$path" >> "$keep_list"
done
# Close over modules.dep hard dependencies and modules.softdep pre:/post:
# names until the set stops growing. A softdep must be loadable or built in.
while :; do
before=$(sort -u "$keep_list" | wc -l)
for path in $(sort -u "$keep_list"); do
awk -F': ' -v p="$path" '$1 == p { n = split($2, d, " "); for (i = 1; i <= n; i++) print d[i] }' \
"$kver_dir/modules.dep" >> "$keep_list"
mod=$(basename "$path"); mod="${mod%%.ko*}"; mod="${mod//-/_}"
[ -f "$kver_dir/modules.softdep" ] || continue
for soft in $(awk -v n="$mod" '{ m = $2; gsub(/-/, "_", m) }
$1 == "softdep" && m == n { for (i = 3; i <= NF; i++) if ($i != "pre:" && $i != "post:") print $i }' \
"$kver_dir/modules.softdep"); do
if soft_path=$(module_path "$kver_dir" "$soft"); then
echo "$soft_path" >> "$keep_list"
elif ! module_builtin "$kver_dir" "$soft"; then
echo "ERROR: softdep $soft of $mod is neither loadable nor built into $kver" >&2
exit 1
fi
done
done
after=$(sort -u "$keep_list" | wc -l)
[ "$before" -eq "$after" ] && break
done
extra_names=$(sort -u "$keep_list" | while read -r p; do m=$(basename "$p"); m="${m%%.ko*}"; echo "${m//-/_}"; done)
(cd "$kver_dir" && find kernel -name "virtio*.ko*" -o -name "*fw_cfg*.ko*" \
-o -name "*failover*.ko*") >> "$keep_list"
# Copy the keeper set out, preserving each kernel/ relative path.
tmp_keep=$(mktemp -d)
while read -r rel; do
rel="${rel#kernel/}"
mkdir -p "$tmp_keep/$(dirname "$rel")"
cp "$kernel_dir/$rel" "$tmp_keep/$rel"
done < <(sort -u "$keep_list")
rm -f "$keep_list"
# Replace kernel module tree with the keeper set.
rm -rf "$kernel_dir"
mkdir -p "$kernel_dir"
# Restore each kept file into the same relative path.
(cd "$tmp_keep" && find . -name "*.ko*" | while read -r f; do
dest_dir="$kernel_dir/$(dirname "${f#./}")"
mkdir -p "$dest_dir"
cp "$f" "$dest_dir/"
done)
rm -rf "$tmp_keep"
# Decompress kept modules to plain .ko. Alpine ships .ko.gz, but a host
# kmod that lacks gzip support (e.g. Debian trixie's kmod 34) silently
# produces an EMPTY modules.dep from compressed modules — the guest's
# modprobe then no-ops and qemu_fw_cfg never loads. Plain .ko sidesteps the
# host-kmod dependency entirely and the guest needs no decompressor either.
find "$kernel_dir" -name "*.ko.gz" -exec gunzip -f {} +
# Independent closure gate: each extra-closure module's own .modinfo depends=
# must name a kept module or a built-in, so a resolver bug fails here.
kept_names=$(find "$kernel_dir" -name "*.ko" -exec basename {} .ko \; | tr '-' '_' | sort -u)
while read -r ko; do
ko_name=$(basename "$ko" .ko); ko_name="${ko_name//-/_}"
grep -qx "$ko_name" <<< "$extra_names" || continue
deps=$(tr '\0' '\n' < "$ko" | grep -a -m1 '^depends=' | cut -d= -f2 | tr ',' ' ' || true)
for dep in $deps; do
dep="${dep//-/_}"
if ! grep -qx "$dep" <<< "$kept_names" && ! module_builtin "$kver_dir" "$dep"; then
echo "ERROR: $(basename "$ko") depends on $dep, which is neither kept nor built in" >&2
exit 1
fi
done
done < <(find "$kernel_dir" -name "*.ko")
for name in $MICROVM_EXTRA_MODULES; do
if ! grep -qx "${name//-/_}" <<< "$kept_names" && ! module_builtin "$kver_dir" "$name"; then
echo "ERROR: required module $name missing from the stripped module tree" >&2
exit 1
fi
done
echo "[build-sqs-store-microvm-image] extra module closure: $(echo "$extra_names" | tr '\n' ' ')"
echo "[build-sqs-store-microvm-image] modules kept: $(echo "$kept_names" | tr '\n' ' ')"
# Rebuild module metadata with the image's own kmod, the binary the guest's
# modprobe runs, so no host depmod is needed. Stock indexes still name .ko.gz
# files, so they are removed first and a failed rebuild cannot ship them.
for f in dep dep.bin alias alias.bin softdep weakdep symbols symbols.bin \
builtin.bin builtin.alias.bin devname; do
rm -f "$kver_dir/modules.$f"
done
if ! image_kmod depmod -b "$CHROOT_DIR" "$kver"; then
echo "ERROR: image depmod failed for $kver" >&2
exit 1
fi
if [ ! -s "$kver_dir/modules.dep" ] || [ ! -s "$kver_dir/modules.dep.bin" ]; then
echo "ERROR: ${kver_dir}modules.dep or modules.dep.bin missing or empty after depmod" >&2
exit 1
fi
# Resolution gate: the image's modprobe must resolve every required module
# to plain .ko files present in the stripped tree.
for name in $MICROVM_EXTRA_MODULES qemu_fw_cfg virtio_net; do
module_builtin "$kver_dir" "$name" && continue
if ! plan=$(image_kmod modprobe -d "$CHROOT_DIR" -S "$kver" --show-depends "$name"); then
echo "ERROR: image modprobe cannot resolve $name for $kver" >&2
exit 1
fi
while read -r verb path _; do
[ "$verb" = "insmod" ] || continue
case "$path" in
"$CHROOT_DIR/lib/modules/$kver/kernel/"*.ko) [ -f "$path" ] && continue ;;
esac
echo "ERROR: modprobe plan for $name names $path, not a kept plain .ko" >&2
exit 1
done <<< "$plan"
echo "[build-sqs-store-microvm-image] modprobe $name: $(awk '{ n = split($2, a, "/"); printf "%s ", a[n] }' <<< "$plan")"
done
done
# --- Strip package-manager artifacts (not needed at runtime) ---
echo "[build-sqs-store-microvm-image] stripping package manager artifacts..."
rm -rf \
"$CHROOT_DIR/lib/apk" \
"$CHROOT_DIR/var/cache/apk" \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/usr/share/man" \
"$CHROOT_DIR/usr/share/doc" \
"$CHROOT_DIR/usr/share/apk" \
"$CHROOT_DIR/usr/include" \
"$CHROOT_DIR/usr/lib/pkgconfig"
# --- Strip debug symbols from binaries ---
# nats-server in /usr/local/bin is left as built (-s -w), so the shipped bytes
# are the ones whose version was checked above.
echo "[build-sqs-store-microvm-image] stripping debug symbols from binaries..."
find "$CHROOT_DIR/usr/sbin" "$CHROOT_DIR/usr/bin" \
"$CHROOT_DIR/sbin" "$CHROOT_DIR/bin" \
-type f 2>/dev/null | while read -r bin; do
strip --strip-all "$bin" 2>/dev/null || true
done
# --- Copy vmlinuz out before stripping /boot from chroot ---
VMLINUZ_OUT="$STAGE_DIR/vmlinuz"
echo "[build-sqs-store-microvm-image] copying kernel: $VMLINUZ_OUT"
cp "$KERNEL_IMG" "$VMLINUZ_OUT"
# Drop /boot from the chroot — vmlinuz, Alpine's stock initramfs-virt, System.map
# and kernel config all live here (~25 MiB). None are needed at runtime: the
# guest already has the kernel loaded by QEMU's -kernel, and Alpine's initramfs
# is unused because we replace it with our own /init. Leaving /boot in the cpio
# nearly doubles uncompressed initramfs size and forces a 512 MiB guest just to
# survive decompression.
rm -rf "${CHROOT_DIR:?}/boot"
# --- Build initramfs ---
INITRAMFS_OUT="$STAGE_DIR/initramfs.cpio.gz"
echo "[build-sqs-store-microvm-image] building initramfs: $INITRAMFS_OUT"
# Create the static device nodes and pack the archive inside a single fakeroot
# session: mknod/cpio see faked device entries and write real char-device
# records into the cpio, while the host filesystem never gets an actual node.
fakeroot sh -c '
cd "$1" || exit 1
mknod -m 600 dev/console c 5 1
mknod -m 666 dev/null c 1 3
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
# --- Gate the staged pair, then publish it ---
# Nothing reaches $BUILD_DIR until every gate passes, so a failed build cannot
# leave a pair the grouped make target would take as up to date.
"$REPO_ROOT/scripts/lib/sqs-store-microvm-publish.sh" "$STAGE_DIR" "$BUILD_DIR"
echo ""
echo "[build-sqs-store-microvm-image] done."
-288
View File
@@ -1,288 +0,0 @@
#!/bin/sh
# sqs-store-guest_test.sh — the SQS store guest steps start the store only on
# a valid contract and one checked, mounted disk, never format, and stop the
# store before unmounting it.
#
# Drives build/microvm/sqs-store/sqs-store-lib.sh against a fake fw_cfg, sysfs
# and stub tools. The stop cases signal only test-owned children.
# The case settings below are read by the sourced library, not by this file.
# shellcheck disable=SC2034
set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)
LIB=${SQS_GUEST_LIB:-"$ROOT/build/microvm/sqs-store/sqs-store-lib.sh"}
TMP=$(mktemp -d)
CHILDREN=""
cleanup() {
for p in $CHILDREN; do kill -KILL "$p" 2>/dev/null || true; done
rm -rf "$TMP"
}
trap cleanup EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
check() { if eval "$2"; then pass "$1"; else fail "$1"; fi; }
echo "-- build/microvm/sqs-store/sqs-store-lib.sh"
SERIAL=vol0123456789abcdef0
NKEY=UAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB
ADDR=10.11.12.13
# Stub tools record each call to calls.log; their exit codes come from files.
mkdir -p "$TMP/bin"
cat > "$TMP/bin/e2fsck" <<'EOF'
#!/bin/sh
echo "e2fsck $*" >> "$T/calls.log"
exit "$(cat "$T/fsck.rc")"
EOF
cat > "$TMP/bin/mount" <<'EOF'
#!/bin/sh
echo "mount $*" >> "$T/calls.log"
exit "$(cat "$T/mount.rc")"
EOF
cat > "$TMP/bin/umount" <<'EOF'
#!/bin/sh
echo "umount $*" >> "$T/calls.log"
EOF
for f in mkfs mkfs.ext4 mke2fs; do
printf '#!/bin/sh\necho "mkfs %s $*" >> "$T/calls.log"\n' "$f" > "$TMP/bin/$f"
done
cat > "$TMP/bin/ip" <<'EOF'
#!/bin/sh
cat "$T/ip.out"
EOF
cat > "$TMP/bin/nats-server" <<'EOF'
#!/bin/sh
echo "nats-server $*" >> "$T/calls.log"
[ "$1" = -t ] && exit "$(cat "$T/configtest.rc")"
exit 0
EOF
chmod +x "$TMP/bin"/*
# fresh <name>: a case directory with a valid contract and one matching disk
# among others. Callers then break one thing.
fresh() {
T="$TMP/$1"
export T
mkdir -p "$T/fwcfg" "$T/sys" "$T/dev" "$T/run" "$T/data"
for k in data-serial listen-addr client-nkey; do mkdir -p "$T/fwcfg/$k"; done
printf '%s\n' "$SERIAL" > "$T/fwcfg/data-serial/raw"
printf '%s\n' "$ADDR" > "$T/fwcfg/listen-addr/raw"
printf '%s\n' "$NKEY" > "$T/fwcfg/client-nkey/raw"
disk vda vol00000000000000000
disk vdb "$SERIAL"
echo 0 > "$T/fsck.rc"
echo 0 > "$T/mount.rc"
echo 0 > "$T/configtest.rc"
: > "$T/calls.log"
: > "$T/console"
printf '1: lo inet 127.0.0.1/8 scope host lo\n2: eth0 inet %s/24 brd 10.11.12.255 scope global eth0\n' "$ADDR" > "$T/ip.out"
}
disk() { mkdir -p "$T/sys/$1"; printf '%s\n' "$2" > "$T/sys/$1/serial"; : > "$T/dev/$1"; }
# prepare: run sqs_prepare in a subshell with every path pointed at the case.
prepare() {
(
SQS_FWCFG="$T/fwcfg" SQS_SYSBLOCK="$T/sys" SQS_DEVDIR="$T/dev" SQS_NODE_TEST=-e
SQS_RUN="$T/run" SQS_DATA="$T/data" SQS_CONSOLE="$T/console"
E2FSCK="$TMP/bin/e2fsck" MOUNT="$TMP/bin/mount" IP="$TMP/bin/ip" NATS_BIN="$TMP/bin/nats-server"
PATH="$TMP/bin:$PATH"
export PATH
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
. "$LIB"
sqs_prepare
) > /dev/null 2>&1
}
refused() { [ ! -e "$T/run/ready" ] && grep -q FATAL "$T/console"; }
never_mounted() { ! grep -q '^mount ' "$T/calls.log"; }
never_formatted() { ! grep -q '^mkfs ' "$T/calls.log"; }
never_checked() { ! grep -q '^e2fsck ' "$T/calls.log"; }
# --- A valid contract prepares the store ---
fresh valid
check "valid contract is prepared" 'prepare && [ -e "$T/run/ready" ]'
check "the matching disk is checked, then mounted at the data dir" \
'grep -qx "e2fsck -p $T/dev/vdb" "$T/calls.log" && grep -qx "mount -t ext4 -o nodev,nosuid,noexec $T/dev/vdb $T/data" "$T/calls.log"'
check "config listens only on the supplied address" \
'grep -c "listen" "$T/run/nats.conf" | grep -qx 1 && grep -qx "listen: \"$ADDR:4222\"" "$T/run/nats.conf"'
check "config admits only the supplied user NKey" 'grep -q "users = \[ { nkey: \"$NKEY\" } \]" "$T/run/nats.conf"'
check "config syncs JetStream on the data disk" \
'grep -q "store_dir: \"$T/data\"" "$T/run/nats.conf" && grep -q "sync_interval: always" "$T/run/nats.conf"'
check "config is tested by the server before ready" 'grep -qx "nats-server -t -c $T/run/nats.conf" "$T/calls.log"'
check "no auth material on the console" '! grep -q "$NKEY" "$T/console"'
check "prepare reports the disk and config, not a listener" 'grep -q "prepared: $T/dev/vdb mounted" "$T/console" && ! grep -qi "listening" "$T/console"'
fresh fsck-corrected; echo 1 > "$T/fsck.rc"
check "e2fsck exit 1 (corrected) proceeds" 'prepare && [ -e "$T/run/ready" ]'
# --- Disk refusals: nothing mounted, nothing formatted ---
fresh no-disk; rm -rf "$T/sys/vdb"
check "missing disk is refused before any check or mount" '! prepare && refused && never_checked && never_mounted'
fresh wrong-disk; printf 'vol0fffffffffffffff0\n' > "$T/fwcfg/data-serial/raw"
check "disk with another serial is refused" '! prepare && refused && never_checked && never_mounted'
fresh two-disks; disk vdc "$SERIAL"
check "two disks with the serial are refused" '! prepare && refused && never_checked && never_mounted'
fresh bad-serial; printf 'vol-0123456789abcdef\n' > "$T/fwcfg/data-serial/raw"
check "malformed serial is refused" '! prepare && refused && never_mounted'
fresh no-node; rm -f "$T/dev/vdb"
check "disk without a device node is refused" '! prepare && refused && never_mounted'
for rc in 2 4 8; do
fresh "fsck-$rc"; echo "$rc" > "$T/fsck.rc"
check "e2fsck exit $rc is refused without mount or format" '! prepare && refused && never_mounted && never_formatted'
done
fresh mount-fail; echo 32 > "$T/mount.rc"
check "failed mount is refused" '! prepare && refused'
# --- Auth and listener refusals happen before the disk is touched ---
fresh no-nkey; rm -rf "$T/fwcfg/client-nkey"
check "missing NKey is refused before the disk" '! prepare && refused && never_checked && never_mounted'
for bad in seed lower short two empty; do
fresh "nkey-$bad"
case $bad in
seed) v=SUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ;;
lower) v=uaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab ;;
short) v=UAAAA ;;
two) v="$NKEY
$NKEY" ;;
empty) v="" ;;
esac
printf '%s\n' "$v" > "$T/fwcfg/client-nkey/raw"
check "NKey ($bad) is refused before the disk, without echoing it" \
'! prepare && refused && never_checked && { [ -z "$v" ] || ! grep -qF "$v" "$T/console"; }'
done
fresh nkey-checksum; echo 1 > "$T/configtest.rc"
check "NKey the server rejects is refused before the disk" '! prepare && refused && never_checked && never_mounted'
fresh no-addr; rm -rf "$T/fwcfg/listen-addr"
check "missing listen address is refused" '! prepare && refused && never_mounted'
fresh addr-absent; printf '1: lo inet 127.0.0.1/8 scope host lo\n2: eth0 inet 10.11.12.99/24 scope global eth0\n' > "$T/ip.out"
check "listen address not on a guest NIC is refused" '! prepare && refused && never_mounted'
fresh addr-loopback; printf '127.0.0.1\n' > "$T/fwcfg/listen-addr/raw"
check "loopback listen address is refused" '! prepare && refused && never_mounted'
for bad in 0.0.0.0x 1.2.3 1.2.3.256 01.2.3.4; do
fresh "addr-$bad"; printf '%s\n' "$bad" > "$T/fwcfg/listen-addr/raw"
check "malformed listen address $bad is refused" '! prepare && refused && never_mounted'
done
# --- Run: the store starts only when prepared and not stopping ---
run_step() {
(
SQS_RUN="$T/run" SQS_CONSOLE="$T/console" NATS_BIN="$TMP/bin/nats-server"
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
. "$LIB"
sqs_run
) > /dev/null 2>&1
}
fresh run-ready; prepare; : > "$T/calls.log"
check "a prepared store starts the server" 'run_step && grep -qx "nats-server -c $T/run/nats.conf" "$T/calls.log"'
fresh run-unready
check "an unprepared store never starts the server" '! run_step && ! grep -q "^nats-server" "$T/calls.log"'
fresh run-stopping; prepare; : > "$T/calls.log"; : > "$T/run/stopping"
check "a stopping store never starts the server" '! run_step && ! grep -q "^nats-server" "$T/calls.log"'
# --- Stop: SIGTERM the verified store, then unmount ---
STORE="$TMP/bin/fake-store"
cp "$(command -v sleep)" "$STORE"
stop_step() {
(
SQS_RUN="$T/run" SQS_DATA="$T/data" SQS_CONSOLE="$T/console" SQS_MOUNTS="$T/mounts"
UMOUNT="$TMP/bin/umount" NATS_BIN="$STORE" SQS_STOP_BUDGET_TENTHS=10
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
. "$LIB"
sqs_stop
) > /dev/null 2>&1
}
gone() { [ ! -r "/proc/$1/status" ] || grep -q '^State:[[:space:]]*Z' "/proc/$1/status"; }
# record <pid>: write the identity sqs_run would record for that process.
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
record() { echo "$1 $( . "$LIB"; sqs_starttime "$1" )" > "$T/run/pid"; }
fresh stop-orderly; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
stop_step
check "stop sends SIGTERM and the store exits" 'gone "$pid" && grep -q "SIGTERM sent" "$T/console" && ! grep -q SIGKILL "$T/console"'
check "stop blocks respawn and unmounts after the store exits" \
'[ -e "$T/run/stopping" ] && grep -qx "umount $T/data" "$T/calls.log"'
fresh stop-hung; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
(trap '' TERM; exec "$STORE" 60) & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
stop_step
check "store ignoring SIGTERM is killed after the budget, then unmounted" \
'gone "$pid" && grep -q SIGKILL "$T/console" && grep -qx "umount $T/data" "$T/calls.log"'
fresh stop-reused; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; echo "$pid 1" > "$T/run/pid"
stop_step
check "a recorded PID with another start time is never signalled" '! gone "$pid" && grep -q "no running store" "$T/console"'
kill -KILL "$pid"
# A start held after its stopping check and before its exec: the run step
# blocks writing "run: starting" to a FIFO console until the test reads it.
fresh stop-pending-start; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
: > "$T/run/ready"
mkfifo "$T/run-console"
printf '#!/bin/sh\n: > "%s/started"\nexec "%s" 60\n' "$T" "$STORE" > "$TMP/bin/pending-nats"
chmod +x "$TMP/bin/pending-nats"
SQS_RUN="$T/run" SQS_CONSOLE="$T/run-console" NATS_BIN="$TMP/bin/pending-nats" \
sh -c '. "$1"; sqs_run' sh "$LIB" & pid=$!; CHILDREN="$CHILDREN $pid"
i=0
while [ "$i" -lt 100 ] && ! grep -q "^$pid " "$T/run/pid" 2>/dev/null; do i=$((i + 1)); sleep 0.05; done
check "pending start is held before its exec" 'grep -q "^$pid " "$T/run/pid" && ! gone "$pid" && [ ! -e "$T/started" ]'
stop_step
# Release the FIFO: a start that survived the stop would exec the server now.
cat "$T/run-console" > /dev/null 2>&1 &
reader=$!; CHILDREN="$CHILDREN $reader"
i=0
while [ "$i" -lt 20 ] && ! gone "$pid"; do i=$((i + 1)); sleep 0.05; done
check "stop ends a pending start before it can exec the server" 'gone "$pid" && [ ! -e "$T/started" ]'
check "stop unmounts only after the pending start is gone" \
'grep -q "store exited" "$T/console" && grep -qx "umount $T/data" "$T/calls.log"'
kill -KILL "$reader" 2>/dev/null || true
# --- Shutdown: the generic remount runs only once the store is gone ---
# shutdown_step [survivor-pid]: with a pid, that process is reported alive
# whatever happens to it, as a writer stuck past SIGKILL would be.
shutdown_step() {
(
SQS_RUN="$T/run" SQS_DATA="$T/data" SQS_CONSOLE="$T/console" SQS_MOUNTS="$T/mounts"
UMOUNT="$TMP/bin/umount" SQS_STOP_BUDGET_TENTHS=5
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
. "$LIB"
if [ -n "${1:-}" ]; then sqs_same() { [ "$1" = "$survivor" ]; }; survivor=$1; fi
sqs_shutdown
) > /dev/null 2>&1
}
fresh shutdown-orderly; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
check "orderly shutdown succeeds" 'shutdown_step'
check "orderly shutdown unmounts the data disk, then runs umount -a -r" \
'[ "$(grep "^umount" "$T/calls.log" | tr "\n" "|")" = "umount $T/data|umount -a -r|" ]'
fresh shutdown-survivor; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
check "shutdown with a surviving store reports failure" '! shutdown_step "$pid"'
check "a surviving store leaves the data disk and every other mount alone" \
'! grep -q "^umount" "$T/calls.log" && grep -q "STILL ALIVE" "$T/console" && grep -q "skipping umount -a -r" "$T/console"'
fresh stop-unmounted; : > "$T/mounts"
stop_step
check "stop with no data mount does not unmount" '! grep -q "^umount" "$T/calls.log"'
if [ "$fails" -ne 0 ]; then
echo " $fails failure(s)"
exit 1
fi
-97
View File
@@ -1,97 +0,0 @@
#!/bin/bash
# sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR — gate a staged SQS store
# microVM kernel + initramfs and move them into OUT_DIR only if every gate passes.
#
# A failed gate leaves OUT_DIR as it was: a previous valid pair stays, and an
# older pair stays older than the build inputs, so make rebuilds next time.
set -euo pipefail
STAGE_DIR="${1:?usage: sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR}"
OUT_DIR="${2:?usage: sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR}"
VMLINUZ="$STAGE_DIR/vmlinuz"
INITRAMFS="$STAGE_DIR/initramfs.cpio.gz"
tag="[build-sqs-store-microvm-image]"
for f in "$VMLINUZ" "$INITRAMFS"; do
if [ ! -s "$f" ]; then
echo "ERROR: staged artifact $f missing or empty" >&2
exit 1
fi
done
# --- Content gate: read the packed archive, not the chroot ---
if ! archive_list=$(zcat "$INITRAMFS" | cpio --quiet -t); then
echo "ERROR: $INITRAMFS is not a readable gzip cpio archive" >&2
exit 1
fi
for want in init etc/inittab usr/local/bin/nats-server dev/console dev/null \
usr/local/lib/sqs-store/sqs-store-lib.sh usr/local/lib/sqs-store/sqs-store-prepare \
usr/local/lib/sqs-store/sqs-store-run usr/local/lib/sqs-store/sqs-store-stop; do
if ! grep -qx "$want" <<< "$archive_list"; then
echo "ERROR: $want missing from $INITRAMFS" >&2
exit 1
fi
done
for fs_tool in mkfs.ext4 e2fsck; do
if ! grep -qxE "(usr/)?sbin/$fs_tool" <<< "$archive_list"; then
echo "ERROR: $fs_tool missing from $INITRAMFS" >&2
exit 1
fi
done
# Module files may spell a name with dashes (tiny-power-button.ko). Normalise
# once: piping into grep -q under pipefail fails when grep exits on a match.
module_list=${archive_list//-/_}
for want_ko in ext4 tiny_power_button; do
if ! grep -q "/$want_ko\.ko\$" <<< "$module_list"; then
echo "ERROR: $want_ko missing from $INITRAMFS" >&2
exit 1
fi
done
if grep -qE '(^|/)(lb-agent|haproxy|nginx)$' <<< "$archive_list"; then
echo "ERROR: LB data-plane binaries found in the SQS store image" >&2
exit 1
fi
echo "$tag content gate: PASS"
echo ""
echo "$tag staged artifacts:"
ls -lh "$VMLINUZ" "$INITRAMFS"
echo ""
# --- Artifact size gate (50 MiB total) ---
ARTIFACT_MAX_MiB="${MICROVM_ARTIFACT_MAX_MiB:-50}"
total_bytes=$(( $(stat -c %s "$VMLINUZ") + $(stat -c %s "$INITRAMFS") ))
max_bytes=$(( ARTIFACT_MAX_MiB * 1024 * 1024 ))
total_mib=$(( (total_bytes + 1048575) / 1048576 ))
if [ "$total_bytes" -gt "$max_bytes" ]; then
echo "ERROR: artifact size ${total_mib} MiB exceeds ${ARTIFACT_MAX_MiB} MiB gate" >&2
echo " Set MICROVM_ARTIFACT_MAX_MiB=N to override during development." >&2
exit 1
fi
echo "$tag artifact size gate: PASS (${total_mib} MiB / ${ARTIFACT_MAX_MiB} MiB)"
# --- Guest-RAM initramfs gate (uncompressed) ---
# The kernel unpacks the whole initramfs into RAM before init, so the
# UNCOMPRESSED size — not the gzip artifact — sets the guest memory floor.
UNCOMPRESSED_MAX_MiB="${MICROVM_INITRAMFS_UNCOMPRESSED_MAX_MiB:-96}"
uncompressed_bytes=$(zcat "$INITRAMFS" | wc -c)
uncompressed_mib=$(( (uncompressed_bytes + 1048575) / 1048576 ))
umax_bytes=$(( UNCOMPRESSED_MAX_MiB * 1024 * 1024 ))
if [ "$uncompressed_bytes" -gt "$umax_bytes" ]; then
echo "ERROR: uncompressed initramfs ${uncompressed_mib} MiB exceeds ${UNCOMPRESSED_MAX_MiB} MiB guest-RAM gate" >&2
echo " The kernel unpacks this into guest RAM before init; a larger" >&2
echo " footprint panics before userspace. Slim the initramfs, or raise the" >&2
echo " instance memory AND MICROVM_INITRAMFS_UNCOMPRESSED_MAX_MiB together." >&2
exit 1
fi
echo "$tag initramfs guest-RAM gate: PASS (${uncompressed_mib} MiB / ${UNCOMPRESSED_MAX_MiB} MiB uncompressed)"
# --- Publish ---
# Two renames are not atomic as a pair, so the old initramfs goes first and the
# new one lands last. Stopped in between, the initramfs is absent: no mixed pair
# exists and the grouped make target is missing, so make rebuilds.
mkdir -p "$OUT_DIR"
rm -f "$OUT_DIR/initramfs.cpio.gz"
mv -f "$VMLINUZ" "$OUT_DIR/vmlinuz"
mv -f "$INITRAMFS" "$OUT_DIR/initramfs.cpio.gz"
echo "$tag published: $OUT_DIR/vmlinuz $OUT_DIR/initramfs.cpio.gz"
@@ -1,196 +0,0 @@
#!/bin/sh
# sqs-store-microvm-publish_test.sh — a staged SQS store microVM pair reaches
# the output directory only when every gate passes, and a failed build cannot
# leave a pair that make then takes as up to date.
#
# Artifacts are small fake archives listing the gated paths; no image is built.
set -eu
LIB_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
PUBLISH="$LIB_DIR/sqs-store-microvm-publish.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- lib/sqs-store-microvm-publish.sh"
for tool in cpio gzip make; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo " FAIL - required tool not found: $tool"
exit 1
fi
done
# stage <dir> [omit-path] — a fake staged pair; omit-path is left out of the archive.
stage() {
dir=$1 omit=${2:-}
root="$TMP/root.$$.$(date +%s%N)"
for p in init etc/inittab usr/local/bin/nats-server sbin/mkfs.ext4 sbin/e2fsck \
usr/local/lib/sqs-store/sqs-store-lib.sh usr/local/lib/sqs-store/sqs-store-prepare \
usr/local/lib/sqs-store/sqs-store-run usr/local/lib/sqs-store/sqs-store-stop \
dev/console dev/null lib/modules/6.12-virt/kernel/fs/ext4/ext4.ko \
lib/modules/6.12-virt/kernel/drivers/acpi/tiny-power-button.ko; do
[ "$p" = "$omit" ] && continue
mkdir -p "$root/$(dirname "$p")"
echo "$p" > "$root/$p"
done
# pad=N appends N files after the gated ones, as a real image's listing has.
if [ "${pad:-0}" -gt 0 ]; then
mkdir -p "$root/zz"
(cd "$root/zz" && seq "$pad" | xargs touch)
fi
mkdir -p "$dir"
(cd "$root" && find . -mindepth 1 | sed 's|^\./||' | sort | cpio --quiet -o -H newc) | gzip > "$dir/initramfs.cpio.gz"
echo kernel > "$dir/vmlinuz"
rm -rf "$root"
}
# fingerprint <dir> — content and mtime of both outputs, or "absent".
fingerprint() {
for f in "$1/vmlinuz" "$1/initramfs.cpio.gz"; do
if [ -e "$f" ]; then
printf '%s %s ' "$(cksum < "$f")" "$(stat -c %Y "$f")"
else
printf 'absent '
fi
done
}
# --- A valid pair is published and leaves the stage ---
stage "$TMP/ok/stage"
if bash "$PUBLISH" "$TMP/ok/stage" "$TMP/ok/out" >/dev/null 2>&1 \
&& [ -s "$TMP/ok/out/vmlinuz" ] && [ -s "$TMP/ok/out/initramfs.cpio.gz" ] \
&& [ ! -e "$TMP/ok/stage/vmlinuz" ] && [ ! -e "$TMP/ok/stage/initramfs.cpio.gz" ]; then
pass "valid pair is published"
else
fail "valid pair is published"
fi
# --- A large listing with the modules early still passes the module gate ---
pad=20000 stage "$TMP/large/stage"
if bash "$PUBLISH" "$TMP/large/stage" "$TMP/large/out" >/dev/null 2>&1; then
pass "large archive with early modules is published"
else
fail "large archive with early modules is published"
fi
# --- Each failing gate keeps the previous valid pair exactly ---
for case in nats-server store-stop ext4 size; do
d="$TMP/keep-$case"
stage "$d/prev"
bash "$PUBLISH" "$d/prev" "$d/out" >/dev/null 2>&1
before=$(fingerprint "$d/out")
max=50
case $case in
nats-server) stage "$d/stage" usr/local/bin/nats-server ;;
store-stop) stage "$d/stage" usr/local/lib/sqs-store/sqs-store-stop ;;
ext4) stage "$d/stage" lib/modules/6.12-virt/kernel/fs/ext4/ext4.ko ;;
size) stage "$d/stage"; max=0 ;;
esac
if MICROVM_ARTIFACT_MAX_MiB=$max bash "$PUBLISH" "$d/stage" "$d/out" >/dev/null 2>&1; then
fail "$case gate failure is refused"
elif [ "$(fingerprint "$d/out")" = "$before" ]; then
pass "$case gate failure keeps the previous pair"
else
fail "$case gate failure keeps the previous pair"
fi
done
# --- With no previous pair, a failed gate publishes nothing ---
stage "$TMP/none/stage" usr/local/bin/nats-server
if bash "$PUBLISH" "$TMP/none/stage" "$TMP/none/out" >/dev/null 2>&1; then
fail "failed gate with no previous pair is refused"
elif [ "$(fingerprint "$TMP/none/out")" = "absent absent " ]; then
pass "failed gate with no previous pair publishes nothing"
else
fail "failed gate with no previous pair publishes nothing"
fi
# --- make cannot take a failed rebuild's pair as up to date ---
# The same grouped-target shape as the Makefile; the builder stages a pair
# missing nats-server once BAD exists, the way a failed real build would.
m="$TMP/make"
mkdir -p "$m/out"
cat > "$m/build.sh" <<EOF
#!/bin/sh
set -eu
rm -rf "$m/stage"
. "$m/build.sh.lib"
if [ -e "$m/BAD" ]; then
stage "$m/stage" usr/local/bin/nats-server
else
stage "$m/stage"
fi
bash "$PUBLISH" "$m/stage" "$m/out"
EOF
{
echo "TMP=\"$TMP\""
sed -n '/^stage() {/,/^}/p' "$0"
} > "$m/build.sh.lib"
printf 'out/vmlinuz out/initramfs.cpio.gz &: input\n\tsh build.sh\n' > "$m/Makefile"
echo v1 > "$m/input"
if (cd "$m" && make -s out/vmlinuz >/dev/null 2>&1) && [ -s "$m/out/vmlinuz" ]; then
pass "make builds the first valid pair"
else
fail "make builds the first valid pair"
echo " $fails failure(s)"; exit 1
fi
good=$(fingerprint "$m/out")
touch "$m/BAD"
# Age the published pair so the input is strictly newer, without a future mtime.
past="@$(( $(date +%s) - 10 ))"
touch -d "$past" "$m/out/vmlinuz" "$m/out/initramfs.cpio.gz"
touch "$m/input"
good=$(fingerprint "$m/out")
if (cd "$m" && make -s out/vmlinuz >/dev/null 2>&1); then
fail "make reports the failed rebuild"
else
pass "make reports the failed rebuild"
fi
if (cd "$m" && make -q out/vmlinuz out/initramfs.cpio.gz); then
fail "after a failed rebuild make still sees the pair as stale"
else
pass "after a failed rebuild make still sees the pair as stale"
fi
if [ "$(fingerprint "$m/out")" = "$good" ]; then
pass "failed rebuild leaves the previous valid pair"
else
fail "failed rebuild leaves the previous valid pair"
fi
# --- A publish stopped after the kernel lands leaves no mixed pair ---
# A stub mv lets the first rename through and fails the second: the point at
# which an interrupt would leave a new kernel beside an old initramfs.
i="$TMP/interrupt"
mkdir -p "$i/bin"
real_mv=$(command -v mv)
{
echo '#!/bin/sh'
echo "if [ -e '$i/moved' ]; then exit 1; fi"
echo ": > '$i/moved'"
echo "exec '$real_mv' \"\$@\""
} > "$i/bin/mv"
chmod +x "$i/bin/mv"
stage "$i/prev"
bash "$PUBLISH" "$i/prev" "$i/out" >/dev/null 2>&1
stage "$i/stage"
echo new-kernel > "$i/stage/vmlinuz"
if PATH="$i/bin:$PATH" bash "$PUBLISH" "$i/stage" "$i/out" >/dev/null 2>&1; then
fail "interrupted publish is reported"
elif [ "$(cat "$i/out/vmlinuz")" = new-kernel ] && [ ! -e "$i/out/initramfs.cpio.gz" ]; then
pass "interrupted publish leaves no initramfs beside the new kernel"
else
fail "interrupted publish leaves no initramfs beside the new kernel"
fi
printf 'out/vmlinuz out/initramfs.cpio.gz &: input\n\ttrue\n' > "$i/Makefile"
touch -d "@$(( $(date +%s) - 10 ))" "$i/input"
if (cd "$i" && make -q out/vmlinuz out/initramfs.cpio.gz); then
fail "make does not accept the interrupted publish"
else
pass "make does not accept the interrupted publish"
fi
[ "$fails" -eq 0 ] || { echo " $fails failure(s)"; exit 1; }
-18
View File
@@ -1,18 +0,0 @@
#!/bin/sh
# stage-sqs-store-microvm.sh <image-dir> <dist-root>: copy a built SQS store
# microVM image into a release tree as <dist-root>/microvm-sqs-store. Without a
# complete image it stages nothing and says so, as the load balancer image does.
set -eu
src=$1 dest=$2/microvm-sqs-store
# An image staged by an earlier build must not ship with this one. Only the
# two image files are removed; anything else in the directory is left alone.
rm -f "$dest/vmlinuz" "$dest/initramfs.cpio.gz"
if [ -s "$src/vmlinuz" ] && [ -s "$src/initramfs.cpio.gz" ]; then
echo "[distro] staging the SQS store microVM image into the tarball"
mkdir -p "$dest"
cp "$src/vmlinuz" "$src/initramfs.cpio.gz" "$dest/"
else
[ ! -d "$dest" ] || rmdir "$dest" 2> /dev/null || true
echo "[distro] WARNING: SQS store microVM image missing; the tarball will not include it"
echo "[distro] run 'make build-sqs-store-microvm-image' first"
fi
+25 -37
View File
@@ -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, modules, logrotate,
# udev, fixown, migrations
# directories, env, systemd, sysctl, needrestart, modules,
# logrotate, udev, fixown, migrations
# Unset = run every stage appropriate for the current mode.
set -e
@@ -1542,41 +1542,6 @@ install_files() {
$SUDO cp "$EXTRACT_DIR/microvm/"* /usr/share/spinifex/microvm/
info " /usr/share/spinifex/microvm/*"
fi
install_sqs_store_image
}
# The SQS store image ships beside the load balancer's, not inside it: an older
# installer copies microvm/* without -r and would stop at a subdirectory.
install_sqs_store_image() {
local src="$EXTRACT_DIR/microvm-sqs-store" f
local dest="${SPINIFEX_MICROVM_DIR:-/usr/share/spinifex/microvm}/sqs-store"
# A previously installed image belongs to an older release, and this
# release's daemon must not boot it. Only the two image files are removed.
if [ ! -d "$src" ]; then
remove_sqs_store_image "$dest"
warn "This release has no SQS store microVM image; SQS store launches will be refused"
return 0
fi
for f in vmlinuz initramfs.cpio.gz; do
if [ ! -s "$src/$f" ]; then
remove_sqs_store_image "$dest"
fatal "The SQS store microVM image in this release is incomplete: $f is missing"
fi
done
# The initramfs goes last, so an interrupted install leaves no pair the
# daemon would boot: it refuses a launch while either file is missing.
$SUDO install -d "$dest"
$SUDO rm -f "$dest/initramfs.cpio.gz"
$SUDO install -m 0644 "$src/vmlinuz" "$dest/vmlinuz"
$SUDO install -m 0644 "$src/initramfs.cpio.gz" "$dest/initramfs.cpio.gz"
info " $dest/vmlinuz, $dest/initramfs.cpio.gz"
}
# Running store VMs keep their already-loaded kernel; the files are read at boot.
remove_sqs_store_image() {
[ -e "$1/initramfs.cpio.gz" ] || [ -e "$1/vmlinuz" ] || return 0
$SUDO rm -f "$1/initramfs.cpio.gz" "$1/vmlinuz"
warn "Removed the SQS store microVM image left by an earlier install from $1"
}
# --- Create directories ---
@@ -1930,6 +1895,28 @@ 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"
@@ -2124,6 +2111,7 @@ 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
+47 -3
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# setup_host_test.sh — tests for setup.sh's DNS handover to systemd-resolved,
# its chrony drop-in and its nbdkit plugin directory.
# its chrony and needrestart drop-ins 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 and nbdkit"
echo "-- setup.sh host DNS, chrony, needrestart 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; do
for tool in bash awk sed grep readlink basename dirname cp ln mkdir cat head tail wc tee rm chmod install; do
ln -s "$(command -v "$tool")" "$TMP/sys/$tool"
done
@@ -249,6 +249,50 @@ 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.
# --------------------------------------------------------------------------
-190
View File
@@ -1,190 +0,0 @@
#!/bin/sh
# setup_sqs_store_image_test.sh — tests for shipping the SQS store microVM
# image: staging it into a release tree and installing it with setup.sh.
#
# setup.sh is sourced with INSTALL_SPINIFEX_LIB_ONLY=1 so main never runs, and
# installs into a temporary SPINIFEX_MICROVM_DIR with SUDO cleared.
set -eu
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
SETUP="$SCRIPT_DIR/setup.sh"
STAGE="$SCRIPT_DIR/lib/stage-sqs-store-microvm.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- SQS store microVM image release and install"
# image <dir> <tag>: a fake built image whose bytes name its tag.
image() {
mkdir -p "$1"
printf 'kernel-%s\n' "$2" > "$1/vmlinuz"
printf 'initramfs-%s\n' "$2" > "$1/initramfs.cpio.gz"
}
# release <dir>: a release tree with the load balancer image, as distro-amd64 builds it.
release() {
rm -rf "$1"
mkdir -p "$1/microvm"
printf 'lb-kernel\n' > "$1/microvm/vmlinuz"
printf 'lb-initramfs\n' > "$1/microvm/initramfs.cpio.gz"
printf 'qboot\n' > "$1/microvm/qboot.rom"
}
# install_image <extract-dir> <microvm-dir> [path-prefix]: runs
# install_sqs_store_image and sets RC and OUT.
install_image() {
RC=0
OUT=$(PATH="${3:-}$PATH" EXTRACT_DIR="$1" SPINIFEX_MICROVM_DIR="$2" INSTALL_SPINIFEX_LIB_ONLY=1 \
bash -c '. "$1"; SUDO=""; install_sqs_store_image' _ "$SETUP" 2>&1) || RC=$?
}
same() { cmp -s "$1" "$2"; }
# --- staging ---
image "$TMP/built" v1
release "$TMP/rel"
sh "$STAGE" "$TMP/built" "$TMP/rel" > "$TMP/stage.out"
if same "$TMP/built/vmlinuz" "$TMP/rel/microvm-sqs-store/vmlinuz" \
&& same "$TMP/built/initramfs.cpio.gz" "$TMP/rel/microvm-sqs-store/initramfs.cpio.gz"; then
pass "stages the built image beside the load balancer's"
else
fail "staged image differs from the build"
fi
if [ ! -e "$TMP/rel/microvm/sqs-store" ] && [ "$(ls "$TMP/rel/microvm" | wc -l)" -eq 3 ]; then
pass "leaves the load balancer's microvm directory as it was"
else
fail "load balancer directory changed: $(ls "$TMP/rel/microvm")"
fi
release "$TMP/rel-none"
mkdir -p "$TMP/half"; printf 'k\n' > "$TMP/half/vmlinuz"
OUT=$(sh "$STAGE" "$TMP/half" "$TMP/rel-none")
if [ ! -e "$TMP/rel-none/microvm-sqs-store" ] && echo "$OUT" | grep -q 'WARNING: SQS store microVM image missing'; then
pass "stages nothing from an incomplete build, and says so"
else
fail "incomplete build staged or unannounced: $OUT"
fi
# --- an older installer meets a release that carries the store image ---
# It copies microvm/* without -r; the sibling directory must not reach it.
mkdir -p "$TMP/old-dest"
if cp "$TMP/rel/microvm/"* "$TMP/old-dest/" 2> "$TMP/old.err" && [ ! -e "$TMP/old-dest/sqs-store" ]; then
pass "an older installer's copy still succeeds and ignores the store image"
else
fail "older installer copy failed: $(cat "$TMP/old.err")"
fi
mkdir -p "$TMP/nested/microvm/sqs-store" "$TMP/nested-dest"
printf 'x\n' > "$TMP/nested/microvm/vmlinuz"
if cp "$TMP/nested/microvm/"* "$TMP/nested-dest/" 2> /dev/null; then
fail "control: a nested directory did not break the older copy, so the test proves nothing"
else
pass "control: a nested directory would have broken the older copy"
fi
# --- install ---
install_image "$TMP/rel" "$TMP/mv"
if [ "$RC" = 0 ] && same "$TMP/built/vmlinuz" "$TMP/mv/sqs-store/vmlinuz" \
&& same "$TMP/built/initramfs.cpio.gz" "$TMP/mv/sqs-store/initramfs.cpio.gz"; then
pass "installs the staged image where the daemon looks"
else
fail "install rc=$RC: $OUT"
fi
mode=$(stat -c '%a' "$TMP/mv/sqs-store/initramfs.cpio.gz" 2> /dev/null || echo none)
if [ "$mode" = 644 ]; then pass "installs the image 0644"; else fail "initramfs mode $mode"; fi
image "$TMP/built2" v2
release "$TMP/rel2"
sh "$STAGE" "$TMP/built2" "$TMP/rel2" > /dev/null
install_image "$TMP/rel2" "$TMP/mv"
if [ "$RC" = 0 ] && same "$TMP/built2/vmlinuz" "$TMP/mv/sqs-store/vmlinuz" \
&& same "$TMP/built2/initramfs.cpio.gz" "$TMP/mv/sqs-store/initramfs.cpio.gz"; then
pass "an upgrade replaces both files"
else
fail "upgrade rc=$RC: $OUT"
fi
# A release without the store image: nothing installed, and a clear warning.
install_image "$TMP/rel-none" "$TMP/mv-none"
if [ "$RC" = 0 ] && [ ! -e "$TMP/mv-none/sqs-store" ] && echo "$OUT" | grep -q 'no SQS store microVM image; SQS store launches will be refused'; then
pass "a release without the image installs nothing and warns"
else
fail "release without image: rc=$RC exists=$([ -e "$TMP/mv-none/sqs-store" ] && echo yes || echo no) $OUT"
fi
# installed <dir>: a store image from an earlier install, plus an unrelated file.
installed() {
image "$1/sqs-store" old
printf 'keep\n' > "$1/sqs-store/operator-note"
}
gone() { [ ! -e "$1/sqs-store/vmlinuz" ] && [ ! -e "$1/sqs-store/initramfs.cpio.gz" ]; }
kept() { [ "$(cat "$1/sqs-store/operator-note" 2> /dev/null)" = keep ]; }
# Upgrading to a release without the image must not leave the old one usable.
installed "$TMP/mv-up"
install_image "$TMP/rel-none" "$TMP/mv-up"
if [ "$RC" = 0 ] && gone "$TMP/mv-up" && kept "$TMP/mv-up" \
&& echo "$OUT" | grep -q 'Removed the SQS store microVM image left by an earlier install' \
&& echo "$OUT" | grep -q 'SQS store launches will be refused'; then
pass "a release without the image removes the earlier one, keeps other files, and warns"
else
fail "release without image over an installed one: rc=$RC $OUT"
fi
# An incomplete store image refuses and removes the earlier pair, because the
# binaries this release installed before it would otherwise boot the old one.
installed "$TMP/mv-bad"
cp -r "$TMP/rel2" "$TMP/rel-bad"
rm "$TMP/rel-bad/microvm-sqs-store/initramfs.cpio.gz"
install_image "$TMP/rel-bad" "$TMP/mv-bad"
if [ "$RC" != 0 ] && echo "$OUT" | grep -q 'incomplete: initramfs.cpio.gz is missing' \
&& gone "$TMP/mv-bad" && kept "$TMP/mv-bad"; then
pass "an incomplete image is refused and the earlier pair is removed"
else
fail "incomplete image: rc=$RC $OUT"
fi
# Restaging from a missing or incomplete build drops an image staged earlier.
for case in missing incomplete; do
image "$TMP/built-$case" new
release "$TMP/rel-$case"
sh "$STAGE" "$TMP/built" "$TMP/rel-$case" > /dev/null
if [ "$case" = missing ]; then rm -r "$TMP/built-$case"; else rm "$TMP/built-$case/initramfs.cpio.gz"; fi
OUT=$(sh "$STAGE" "$TMP/built-$case" "$TMP/rel-$case")
if [ ! -e "$TMP/rel-$case/microvm-sqs-store" ] && echo "$OUT" | grep -q 'tarball will not include it'; then
pass "restaging from a $case build ships no earlier image"
else
fail "restaging from a $case build left: $(ls -R "$TMP/rel-$case/microvm-sqs-store" 2> /dev/null)"
fi
done
mkdir -p "$TMP/rel-other/microvm-sqs-store"
image "$TMP/rel-other/microvm-sqs-store" old
printf 'keep\n' > "$TMP/rel-other/microvm-sqs-store/note"
sh "$STAGE" "$TMP/no-such-build" "$TMP/rel-other" > /dev/null
if [ ! -e "$TMP/rel-other/microvm-sqs-store/vmlinuz" ] && [ "$(cat "$TMP/rel-other/microvm-sqs-store/note")" = keep ]; then
pass "restaging removes only the image files"
else
fail "restaging touched more than the image files"
fi
# An install that stops at the initramfs leaves no pair the daemon would boot.
mkdir -p "$TMP/bin"
cat > "$TMP/bin/install" << 'EOF'
#!/bin/sh
for a in "$@"; do last=$a; done
case "$last" in */initramfs.cpio.gz) echo "install: stopped" >&2; exit 1 ;; esac
exec /usr/bin/install "$@"
EOF
chmod +x "$TMP/bin/install"
install_image "$TMP/rel" "$TMP/mv" "$TMP/bin:"
if [ "$RC" != 0 ] && same "$TMP/built/vmlinuz" "$TMP/mv/sqs-store/vmlinuz" && [ ! -e "$TMP/mv/sqs-store/initramfs.cpio.gz" ]; then
pass "an interrupted install leaves the initramfs missing, not mismatched"
else
fail "interrupted install: rc=$RC initramfs=$([ -e "$TMP/mv/sqs-store/initramfs.cpio.gz" ] && echo present || echo missing)"
fi
[ "$fails" = 0 ] || { echo "FAIL: $fails"; exit 1; }
echo " setup_sqs_store_image_test ok"
+5 -2
View File
@@ -29,8 +29,9 @@
#
# /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/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,
# needrestart and 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.
@@ -457,6 +458,7 @@ 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* \
@@ -615,6 +617,7 @@ 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
@@ -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")
}
@@ -1,38 +0,0 @@
package snapshot
import (
"context"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// A volume still being prepared has no settled contents, so it cannot be
// captured; once available the same volume snapshots normally.
func TestCreateSnapshot_RefusesACreatingVolume(t *testing.T) {
svc, store := setupTestSnapshotService(t)
ctx := context.Background()
seedProviderVolume(t, svc, "vol-preparing", 4)
seedVolumeDocument(t, store, ebsmetadata.Volume{
VolumeID: "vol-preparing", TenantID: testAccountID, CapacityGiB: 4,
State: "creating", AvailabilityZone: "us-east-1a",
})
_, err := svc.CreateSnapshot(ctx, &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-preparing")}, testAccountID)
assert.EqualError(t, err, awserrors.ErrorIncorrectState)
snaps, err := svc.metadata.ListSnapshotsStrict(ctx, testAccountID)
require.NoError(t, err)
assert.Empty(t, snaps)
meta, err := svc.metadata.GetVolume(ctx, testAccountID, "vol-preparing")
require.NoError(t, err)
meta.State = "available"
require.NoError(t, svc.metadata.PutVolume(ctx, meta))
_, err = svc.CreateSnapshot(ctx, &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-preparing")}, testAccountID)
require.NoError(t, err)
}
@@ -204,10 +204,6 @@ func (s *SnapshotServiceImpl) CreateSnapshot(ctx context.Context, input *ec2.Cre
if volume.CapacityGiB == 0 {
return nil, errors.New(awserrors.ErrorServerInternal)
}
// A volume still being prepared has no settled contents to capture.
if volume.State == string(ebsprovider.VolumeStateCreating) {
return nil, errors.New(awserrors.ErrorIncorrectState)
}
// Flush the writes buffered by whichever node serves the volume, so the
// provider reads a current checkpoint. An attached volume that cannot be
@@ -86,11 +86,6 @@ type SystemInstanceInput struct {
// endpoint serves the profile's role credentials. Empty leaves the VM
// without a profile (the agent then needs static keys).
IamInstanceProfileArn string `json:"iam_instance_profile_arn,omitempty"`
// Kind selects a non-load-balancer direct-boot guest; empty is the load
// balancer. SQSStore carries the KindSQSStore guest's volume and key.
Kind Kind `json:"kind,omitempty"`
SQSStore *SQSStoreInput `json:"sqs_store,omitempty"`
}
// ExtraENIInput describes an additional pre-created ENI to attach to a system
@@ -1,76 +0,0 @@
package systeminstance
import (
"errors"
"fmt"
"regexp"
"github.com/nats-io/nkeys"
)
// Kind selects the guest a direct-boot system instance runs. The empty kind
// is the load balancer; every other kind is launched only on its own subject.
type Kind string
// KindSQSStore is the dedicated SQS store guest on a persistent data volume.
const KindSQSStore Kind = "sqs-store"
// SQSStoreLaunchSubjectRoot is the subject root for SQS store launches. It is
// versioned and separate from system.LaunchInstance so a daemon that predates
// it has no subscriber and the launch fails instead of booting another guest.
const SQSStoreLaunchSubjectRoot = "system.LaunchSQSStore.v1"
// SQSStoreLaunchSubject is the queue subject for an instance type; with a node
// ID it is that node's targeted subject.
func SQSStoreLaunchSubject(instanceType, nodeID string) string {
if nodeID == "" {
return fmt.Sprintf("%s.%s", SQSStoreLaunchSubjectRoot, instanceType)
}
return fmt.Sprintf("%s.%s.%s", SQSStoreLaunchSubjectRoot, instanceType, nodeID)
}
// SQSStoreInput is what an SQS store guest needs beyond its network: an
// existing, already-formatted system-account volume and the one NATS user
// public key the store accepts. It never carries a private key or seed.
type SQSStoreInput struct {
DataVolumeID string `json:"data_volume_id"`
ClientNKey string `json:"client_nkey"`
}
// ErrInvalidSQSStoreInput is returned for a malformed SQS store launch.
var ErrInvalidSQSStoreInput = errors.New("sysinstance: invalid SQS store launch")
// volumeIDPattern is the platform's volume ID shape. The guest finds its disk
// by the ID without its dash, which must fit the 20-byte virtio-blk serial.
var volumeIDPattern = regexp.MustCompile(`^vol-[0-9a-f]{17}$`)
// ValidateSQSStore checks an SQS store launch's shape. It reads nothing, so a
// valid result says only that the volume and key are well formed.
func (in *SystemInstanceInput) ValidateSQSStore() error {
switch {
case in.Kind != KindSQSStore:
return fmt.Errorf("%w: kind %q is not %q", ErrInvalidSQSStoreInput, in.Kind, KindSQSStore)
case in.BootMode != "" && in.BootMode != BootDirect:
return fmt.Errorf("%w: boot mode %q, the store boots direct only", ErrInvalidSQSStoreInput, in.BootMode)
case in.SQSStore == nil:
return fmt.Errorf("%w: no data volume or client key", ErrInvalidSQSStoreInput)
case !volumeIDPattern.MatchString(in.SQSStore.DataVolumeID):
return fmt.Errorf("%w: data volume ID %q is malformed", ErrInvalidSQSStoreInput, in.SQSStore.DataVolumeID)
case !nkeys.IsValidPublicUserKey(in.SQSStore.ClientNKey):
// The value is not echoed: a caller that sent a seed here must not
// have it written to a log.
return fmt.Errorf("%w: client key is not a NATS user public key", ErrInvalidSQSStoreInput)
case in.LBAgentEnv != "" || in.CACert != "" || in.Scheme != "":
return fmt.Errorf("%w: load balancer settings on an SQS store launch", ErrInvalidSQSStoreInput)
}
return nil
}
// RefuseNonLoadBalancerKind keeps every non-load-balancer guest off the
// legacy launch path, which would boot the load balancer image instead.
func (in *SystemInstanceInput) RefuseNonLoadBalancerKind() error {
if in.Kind != "" || in.SQSStore != nil {
return fmt.Errorf("%w: kind %q must use its own launch subject", ErrInvalidSQSStoreInput, in.Kind)
}
return nil
}
@@ -1,98 +0,0 @@
package systeminstance
import (
"strings"
"testing"
identifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/nats-io/nkeys"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validSQSStoreInput(t *testing.T) (*SystemInstanceInput, []byte) {
t.Helper()
kp, err := nkeys.CreateUser()
require.NoError(t, err)
pub, err := kp.PublicKey()
require.NoError(t, err)
seed, err := kp.Seed()
require.NoError(t, err)
return &SystemInstanceInput{
InstanceType: "sys.micro",
Kind: KindSQSStore,
SQSStore: &SQSStoreInput{DataVolumeID: "vol-0123456789abcdef0", ClientNKey: pub},
}, seed
}
func TestValidateSQSStore(t *testing.T) {
in, _ := validSQSStoreInput(t)
require.NoError(t, in.ValidateSQSStore())
_, seed := validSQSStoreInput(t)
account, err := nkeys.CreateAccount()
require.NoError(t, err)
accountPub, err := account.PublicKey()
require.NoError(t, err)
cases := map[string]func(*SystemInstanceInput){
"no kind": func(in *SystemInstanceInput) { in.Kind = "" },
"other kind": func(in *SystemInstanceInput) { in.Kind = "lambda" },
"ami boot": func(in *SystemInstanceInput) { in.BootMode = BootAMI },
"no store input": func(in *SystemInstanceInput) { in.SQSStore = nil },
"no volume": func(in *SystemInstanceInput) { in.SQSStore.DataVolumeID = "" },
"malformed volume": func(in *SystemInstanceInput) { in.SQSStore.DataVolumeID = "vol-XYZ" },
"no key": func(in *SystemInstanceInput) { in.SQSStore.ClientNKey = "" },
"seed as key": func(in *SystemInstanceInput) { in.SQSStore.ClientNKey = string(seed) },
"account key": func(in *SystemInstanceInput) { in.SQSStore.ClientNKey = accountPub },
"lb agent env": func(in *SystemInstanceInput) { in.LBAgentEnv = "LB_ID=x" },
"lb ca cert": func(in *SystemInstanceInput) { in.CACert = "cert" },
"lb scheme": func(in *SystemInstanceInput) { in.Scheme = "internal" },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
in, _ := validSQSStoreInput(t)
mutate(in)
err := in.ValidateSQSStore()
require.ErrorIs(t, err, ErrInvalidSQSStoreInput)
assert.NotContains(t, err.Error(), string(seed), "a seed must never be echoed")
})
}
}
func TestValidateSQSStore_VolumeIDMatchesTheGuestSerial(t *testing.T) {
accepted := identifiers.GenerateResourceID("vol")
in, _ := validSQSStoreInput(t)
in.SQSStore.DataVolumeID = accepted
require.NoError(t, in.ValidateSQSStore(), "a platform-generated volume ID is accepted")
// The guest accepts only vol + 17 lowercase hex, the full 20-byte serial.
assert.Len(t, strings.ReplaceAll(accepted, "-", ""), 20)
for _, id := range []string{
// 16 hex
"vol-0123456789abcdef",
// 18 hex: serial over 20 bytes
"vol-0123456789abcdef01",
// 8 hex
"vol-01234567",
// upper case
"vol-0123456789ABCDEF0",
// no dash
"vol0123456789abcdef0",
// not a volume
"snap-0123456789abcdef0",
// trailing newline
"vol-0123456789abcdef0\n",
} {
in, _ := validSQSStoreInput(t)
in.SQSStore.DataVolumeID = id
require.ErrorIs(t, in.ValidateSQSStore(), ErrInvalidSQSStoreInput, id)
}
}
func TestRefuseNonLoadBalancerKind(t *testing.T) {
require.NoError(t, (&SystemInstanceInput{InstanceType: "sys.micro"}).RefuseNonLoadBalancerKind())
in, _ := validSQSStoreInput(t)
require.ErrorIs(t, in.RefuseNonLoadBalancerKind(), ErrInvalidSQSStoreInput)
require.Error(t, (&SystemInstanceInput{SQSStore: &SQSStoreInput{}}).RefuseNonLoadBalancerKind())
}
@@ -1,158 +0,0 @@
package volume
//test:in-package — the creating state is asserted on the metadata document,
// and the metadata store is unexported.
import (
"context"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createPreparingVolume(t *testing.T, svc *VolumeServiceImpl) string {
t.Helper()
vol, err := svc.CreateVolumeForPreparation(context.Background(), &ec2.CreateVolumeInput{
Size: aws.Int64(4), AvailabilityZone: aws.String("ap-southeast-2a"),
}, testVolAccountID)
require.NoError(t, err)
assert.Equal(t, "creating", aws.StringValue(vol.State))
return aws.StringValue(vol.VolumeId)
}
func requireVolumeState(t *testing.T, svc *VolumeServiceImpl, volumeID, state string) {
t.Helper()
meta, err := svc.metadata.GetVolume(context.Background(), testVolAccountID, volumeID)
require.NoError(t, err)
assert.Equal(t, state, meta.State)
assert.Empty(t, meta.AttachedInstance)
assert.Equal(t, uint64(4), meta.CapacityGiB)
}
func TestCreateVolumeForPreparation_IsBornCreatingWhileCreateVolumeStaysAvailable(t *testing.T) {
svc := newTestVolumeService("ap-southeast-2a")
id := createPreparingVolume(t, svc)
requireVolumeState(t, svc, id, "creating")
described, err := svc.DescribeVolumes(context.Background(), &ec2.DescribeVolumesInput{VolumeIds: []*string{aws.String(id)}}, testVolAccountID)
require.NoError(t, err)
require.Len(t, described.Volumes, 1)
assert.Equal(t, "creating", aws.StringValue(described.Volumes[0].State))
normal, err := svc.CreateVolume(context.Background(), &ec2.CreateVolumeInput{
Size: aws.Int64(4), AvailabilityZone: aws.String("ap-southeast-2a"),
}, testVolAccountID)
require.NoError(t, err)
assert.Equal(t, "available", aws.StringValue(normal.State))
requireVolumeState(t, svc, aws.StringValue(normal.VolumeId), "available")
}
// Every public path that could take, change or capture the volume refuses it
// and leaves it exactly as it was until preparation completes.
func TestCreatingVolume_PublicMutationsAreRefused(t *testing.T) {
svc := newTestVolumeService("ap-southeast-2a")
svc.snapshotKV = setupTestVolumeKV(t)
id := createPreparingVolume(t, svc)
ctx := context.Background()
_, err := svc.DeleteVolume(ctx, &ec2.DeleteVolumeInput{VolumeId: aws.String(id)}, testVolAccountID)
assert.EqualError(t, err, awserrors.ErrorVolumeInUse)
requireVolumeState(t, svc, id, "creating")
_, err = svc.ModifyVolume(ctx, &ec2.ModifyVolumeInput{VolumeId: aws.String(id), Size: aws.Int64(8)}, testVolAccountID)
assert.EqualError(t, err, awserrors.ErrorIncorrectState)
requireVolumeState(t, svc, id, "creating")
_, err = svc.ForceDetachVolume(ctx, &ec2.DetachVolumeInput{VolumeId: aws.String(id), Force: aws.Bool(true)}, testVolAccountID)
assert.EqualError(t, err, awserrors.ErrorIncorrectState)
requireVolumeState(t, svc, id, "creating")
}
func TestCompleteVolumePreparation_ReleasesTheVolume(t *testing.T) {
svc := newTestVolumeService("ap-southeast-2a")
svc.snapshotKV = setupTestVolumeKV(t)
id := createPreparingVolume(t, svc)
ctx := context.Background()
require.NoError(t, svc.CompleteVolumePreparation(ctx, testVolAccountID, id))
requireVolumeState(t, svc, id, "available")
// Released means ordinary behavior again: modify and delete now proceed.
_, err := svc.ModifyVolume(ctx, &ec2.ModifyVolumeInput{VolumeId: aws.String(id), Size: aws.Int64(8)}, testVolAccountID)
require.NoError(t, err)
_, err = svc.DeleteVolume(ctx, &ec2.DeleteVolumeInput{VolumeId: aws.String(id)}, testVolAccountID)
require.NoError(t, err)
}
// Completion only releases an unattached creating volume, so it can never
// flip a volume some other path owns to available.
func TestCompleteVolumePreparation_RefusesAnythingButAnUnattachedCreatingVolume(t *testing.T) {
svc := newTestVolumeService("ap-southeast-2a")
ctx := context.Background()
normal, err := svc.CreateVolume(ctx, &ec2.CreateVolumeInput{
Size: aws.Int64(4), AvailabilityZone: aws.String("ap-southeast-2a"),
}, testVolAccountID)
require.NoError(t, err)
err = svc.CompleteVolumePreparation(ctx, testVolAccountID, aws.StringValue(normal.VolumeId))
require.ErrorContains(t, err, awserrors.ErrorIncorrectState)
require.NoError(t, svc.UpdateVolumeState(testVolAccountID, aws.StringValue(normal.VolumeId), "in-use", "i-0123456789abcdef0", "/dev/sdf"))
err = svc.CompleteVolumePreparation(ctx, testVolAccountID, aws.StringValue(normal.VolumeId))
require.ErrorContains(t, err, awserrors.ErrorIncorrectState)
meta, err := svc.metadata.GetVolume(ctx, testVolAccountID, aws.StringValue(normal.VolumeId))
require.NoError(t, err)
assert.Equal(t, "in-use", meta.State)
id := createPreparingVolume(t, svc)
err = svc.CompleteVolumePreparation(ctx, "000000000099", id)
assert.EqualError(t, err, awserrors.ErrorInvalidVolumeNotFound)
requireVolumeState(t, svc, id, "creating")
err = svc.CompleteVolumePreparation(ctx, testVolAccountID, "vol-0000000000000dead")
assert.EqualError(t, err, awserrors.ErrorInvalidVolumeNotFound)
}
func TestDiscardVolumePreparation_DeletesTheCreatingVolumeAndItsStorage(t *testing.T) {
svc := newTestVolumeService("ap-southeast-2a")
id := createPreparingVolume(t, svc)
ctx := context.Background()
require.NoError(t, svc.DiscardVolumePreparation(ctx, testVolAccountID, id))
_, err := svc.metadata.GetVolume(ctx, testVolAccountID, id)
require.Error(t, err, "the record must be gone")
_, err = svc.provider.GetVolume(ctx, ebsprovider.GetVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: id})
require.ErrorIs(t, err, ebsprovider.ErrNotFound, "the provider volume must be gone")
}
// Discard is only for a failed preparation, so an ordinary or attached volume,
// or one in another account, is never deleted through it.
func TestDiscardVolumePreparation_RefusesAnythingButAnUnattachedCreatingVolume(t *testing.T) {
svc := newTestVolumeService("ap-southeast-2a")
ctx := context.Background()
normal, err := svc.CreateVolume(ctx, &ec2.CreateVolumeInput{
Size: aws.Int64(4), AvailabilityZone: aws.String("ap-southeast-2a"),
}, testVolAccountID)
require.NoError(t, err)
normalID := aws.StringValue(normal.VolumeId)
err = svc.DiscardVolumePreparation(ctx, testVolAccountID, normalID)
require.ErrorContains(t, err, awserrors.ErrorIncorrectState)
requireVolumeState(t, svc, normalID, "available")
require.NoError(t, svc.UpdateVolumeState(testVolAccountID, normalID, "in-use", "i-0123456789abcdef0", "/dev/sdf"))
err = svc.DiscardVolumePreparation(ctx, testVolAccountID, normalID)
require.ErrorContains(t, err, awserrors.ErrorIncorrectState)
id := createPreparingVolume(t, svc)
err = svc.DiscardVolumePreparation(ctx, "000000000099", id)
assert.EqualError(t, err, awserrors.ErrorInvalidVolumeNotFound)
requireVolumeState(t, svc, id, "creating")
_, err = svc.provider.GetVolume(ctx, ebsprovider.GetVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: id})
require.NoError(t, err, "a refused discard must leave the storage")
}
+3 -72
View File
@@ -118,70 +118,6 @@ func NewVolumeServiceImplWithStore(cfg *config.Config, store objectstore.ObjectS
// CreateVolume allocates a new EBS volume through the EBS provider and records
// its control-plane metadata document.
func (s *VolumeServiceImpl) CreateVolume(ctx context.Context, input *ec2.CreateVolumeInput, accountID string) (*ec2.Volume, error) {
return s.createVolume(ctx, input, accountID, ebsprovider.VolumeStateAvailable)
}
// CreateVolumeForPreparation is CreateVolume for an internal caller that must
// write the volume's first contents before anyone else can use it. The record
// is born "creating", which attach, delete, modify, force-detach and snapshot
// all refuse, until CompleteVolumePreparation makes it available.
func (s *VolumeServiceImpl) CreateVolumeForPreparation(ctx context.Context, input *ec2.CreateVolumeInput, accountID string) (*ec2.Volume, error) {
return s.createVolume(ctx, input, accountID, ebsprovider.VolumeStateCreating)
}
// CompleteVolumePreparation moves a volume made by CreateVolumeForPreparation
// to available. Anything other than an unattached "creating" volume is
// refused, so it cannot release a volume some other path is using.
func (s *VolumeServiceImpl) CompleteVolumePreparation(ctx context.Context, accountID, volumeID string) error {
meta, err := s.getVolumeMetadata(ctx, accountID, volumeID)
if err != nil {
return err
}
if meta.TenantID != accountID {
return errors.New(awserrors.ErrorInvalidVolumeNotFound)
}
if meta.State != string(ebsprovider.VolumeStateCreating) || meta.AttachedInstance != "" {
return awserrors.Errorf(awserrors.ErrorIncorrectState, "volume %s is %q, not an unattached creating volume", volumeID, meta.State)
}
meta.State = string(ebsprovider.VolumeStateAvailable)
if err := s.metadata.PutVolume(ctx, meta); err != nil {
return awserrors.Errorf(awserrors.ErrorServerInternal, "write volume metadata %s: %v", volumeID, err)
}
slog.InfoContext(ctx, "Volume preparation completed", "volumeId", volumeID)
return nil
}
// DiscardVolumePreparation deletes a volume whose preparation failed. Only an
// unattached "creating" volume in the caller's account qualifies; the caller
// must already know no export of it can still be live.
func (s *VolumeServiceImpl) DiscardVolumePreparation(ctx context.Context, accountID, volumeID string) error {
if err := s.requireProvider(ctx, "DiscardVolumePreparation"); err != nil {
return err
}
meta, err := s.getVolumeMetadata(ctx, accountID, volumeID)
if err != nil {
return err
}
if meta.TenantID != accountID {
return errors.New(awserrors.ErrorInvalidVolumeNotFound)
}
if meta.State != string(ebsprovider.VolumeStateCreating) || meta.AttachedInstance != "" {
return awserrors.Errorf(awserrors.ErrorIncorrectState, "volume %s is %q, not an unattached creating volume", volumeID, meta.State)
}
// Snapshots refuse a creating volume, so none can depend on this one.
if err := s.provider.DeleteVolume(ctx, ebsprovider.DeleteVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, Handle: meta.ProviderHandle,
}); err != nil {
return awserrors.Errorf(awserrors.ErrorServerInternal, "delete volume %s: %v", volumeID, err)
}
if err := s.metadata.DeleteVolume(ctx, accountID, volumeID); err != nil && !objectstore.IsNoSuchKeyError(err) {
return awserrors.Errorf(awserrors.ErrorServerInternal, "delete volume metadata %s: %v", volumeID, err)
}
slog.InfoContext(ctx, "Volume preparation discarded", "volumeId", volumeID)
return nil
}
func (s *VolumeServiceImpl) createVolume(ctx context.Context, input *ec2.CreateVolumeInput, accountID string, initialState ebsprovider.VolumeState) (*ec2.Volume, error) {
if input == nil {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
@@ -305,7 +241,7 @@ func (s *VolumeServiceImpl) createVolume(ctx context.Context, input *ec2.CreateV
return nil, errors.New(awserrors.ErrorServerInternal)
}
if err := s.metadata.PutVolume(ctx, ebsmetadata.Volume{
VolumeID: volumeID, TenantID: accountID, CapacityGiB: uint64(size), State: string(initialState),
VolumeID: volumeID, TenantID: accountID, CapacityGiB: uint64(size), State: string(ebsprovider.VolumeStateAvailable),
CreatedAt: now, AvailabilityZone: *input.AvailabilityZone, VolumeType: volumeType,
IOPS: iops, Throughput: throughput, SnapshotID: snapshotID, Tags: tags, ProviderHandle: created.Handle,
Encrypted: encrypted,
@@ -318,7 +254,7 @@ func (s *VolumeServiceImpl) createVolume(ctx context.Context, input *ec2.CreateV
slog.InfoContext(ctx, "CreateVolume completed", "volumeId", volumeID, "size", size, "type", volumeType)
return &ec2.Volume{VolumeId: aws.String(volumeID), Size: aws.Int64(size), VolumeType: aws.String(volumeType),
State: aws.String(string(initialState)), AvailabilityZone: input.AvailabilityZone, CreateTime: aws.Time(now),
State: aws.String("available"), AvailabilityZone: input.AvailabilityZone, CreateTime: aws.Time(now),
Iops: aws.Int64(int64(iops)), Throughput: aws.Int64(int64(throughput)), Encrypted: aws.Bool(encrypted),
SnapshotId: snapshotIDOrNil(snapshotID), Tags: awstags.MapToEC2(tags)}, nil
}
@@ -1011,7 +947,7 @@ func (s *VolumeServiceImpl) ModifyVolume(ctx context.Context, input *ec2.ModifyV
if input.Size == nil || *input.Size <= originalSize {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
if (meta.AttachedInstance != "" && meta.State == "in-use") || meta.State == string(ebsprovider.VolumeStateCreating) {
if meta.AttachedInstance != "" && meta.State == "in-use" {
return nil, errors.New(awserrors.ErrorIncorrectState)
}
originalType := meta.VolumeType
@@ -1134,11 +1070,6 @@ func (s *VolumeServiceImpl) ForceDetachVolume(ctx context.Context, input *ec2.De
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
}
// Clearing would also make a volume still being prepared available.
if meta.State == string(ebsprovider.VolumeStateCreating) {
return nil, errors.New(awserrors.ErrorIncorrectState)
}
previous := meta.AttachedInstance
if err := s.UpdateVolumeState(accountID, volumeID, "available", "", ""); err != nil {
slog.ErrorContext(ctx, "ForceDetachVolume: failed to clear attachment", "volumeId", volumeID, "err", err)
-385
View File
@@ -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))
})
}
-5
View File
@@ -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)
}
-89
View File
@@ -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)
})
}
}
-136
View File
@@ -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(""))
}
-203
View File
@@ -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)
}
}
-186
View File
@@ -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)
-232
View File
@@ -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)
}
@@ -46,10 +46,6 @@ func (l *natsSystemInstanceLauncher) LaunchSystemInstance(input *SystemInstanceI
if input.InstanceType == "" {
return nil, fmt.Errorf("system instance input missing InstanceType")
}
// An older receiver would ignore the kind and boot a load balancer.
if err := input.RefuseNonLoadBalancerKind(); err != nil {
return nil, err
}
payload, err := json.Marshal(input)
if err != nil {
@@ -8,7 +8,6 @@ import (
"time"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/systeminstance"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -270,44 +269,3 @@ func TestNATSSystemInstanceLauncher_TerminatePropagatesRemoteError(t *testing.T)
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestNATSSystemInstanceLauncher_RefusesNonLoadBalancerKindBeforeSending(t *testing.T) {
t.Parallel()
_, nc, _ := testutil.StartTestJetStream(t)
defer nc.Close()
// A responder on the legacy subject stands in for an older daemon that
// would ignore the kind and boot a load balancer.
var mu sync.Mutex
received := 0
sub, err := nc.Subscribe("system.LaunchInstance.sys.micro", func(msg *nats.Msg) {
mu.Lock()
received++
mu.Unlock()
payload, _ := json.Marshal(struct {
Output *SystemInstanceOutput `json:"output,omitempty"`
}{Output: &SystemInstanceOutput{InstanceID: "i-0123456789abcdef0"}})
_ = msg.Respond(payload)
})
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
launcher := NewNATSSystemInstanceLauncher(nc, 2*time.Second)
for name, input := range map[string]*SystemInstanceInput{
"kind": {InstanceType: "sys.micro", Kind: systeminstance.KindSQSStore},
"sqs store input": {InstanceType: "sys.micro", SQSStore: &systeminstance.SQSStoreInput{DataVolumeID: "vol-0123456789abcdef0"}},
} {
out, err := launcher.LaunchSystemInstance(input)
require.Error(t, err, name)
assert.Nil(t, out, name)
}
// An ownerless load balancer launch still reaches the responder, so the
// zero count above is the refusal, not a missing subscriber.
out, err := launcher.LaunchSystemInstance(&SystemInstanceInput{InstanceType: "sys.micro"})
require.NoError(t, err)
require.NotNil(t, out)
mu.Lock()
defer mu.Unlock()
assert.Equal(t, 1, received, "only the load balancer launch may be sent")
}
@@ -1,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)
}
}
-529
View File
@@ -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 := &regService{}
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: &regService{}}).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: &regService{}}, 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 := &regService{}
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 := &regService{}
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 := &regService{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 := &regService{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 := &regService{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 := &regService{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 := &regService{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: &regService{}}).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)
})
}
}
-45
View File
@@ -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
}
-130
View File
@@ -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)
}
+3 -3
View File
@@ -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
-52
View File
@@ -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")
}
-164
View File
@@ -1,164 +0,0 @@
package awsapi
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net/http"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
"github.com/nats-io/nats.go"
)
// ServiceName is the SigV4 credential-scope service RDS clients sign for.
const ServiceName = "rds"
// Quota is the account DB-instance cap a create consults.
type Quota interface {
EnforceRDSInstances(ctx context.Context, nc *nats.Conn, accountID string, want int) error
}
// Deps are the composed capabilities the RDS control plane dispatches to. A
// nil NATS connection answers every action with ServerInternal and a nil Quota
// skips the DB-instance cap.
type Deps struct {
NATS *nats.Conn
ExpectedNodes int
Quota Quota
}
// NewRegistration returns the RDS AWS Query control-plane registration.
func NewRegistration(deps Deps) dispatch.Registration {
return dispatch.Registration{
Service: ServiceName,
ResolveAction: resolveAction,
Dispatch: deps.dispatch,
Errors: dispatch.ErrorEnvelopeXML,
Inventory: OperationInventory(),
}
}
// OperationInventory classifies the actions the registration serves. The
// recognised-but-unavailable ones are Unsupported, so they are not implemented.
func OperationInventory() dispatch.Inventory {
return dispatch.Inventory{
Registered: ActionNames(),
Unsupported: UnsupportedActionNames(),
}
}
// resolveAction is the one Query Action parser telemetry and dispatch share.
// It leaves the body readable for the dispatcher and answers "" on any failure.
func resolveAction(r *http.Request) string {
args, err := readQueryArgs(r)
if err != nil {
return ""
}
return args["Action"]
}
// readQueryArgs parses the Query body and rewinds it, so the same bytes can be
// read again. Any read or parse failure is a malformed query string.
func readQueryArgs(r *http.Request) (map[string]string, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, sigv4.MaxPayloadLen+1))
if err != nil {
return nil, err
}
r.Body = io.NopCloser(bytes.NewReader(body))
if int64(len(body)) > sigv4.MaxPayloadLen {
return nil, errors.New(awserrors.ErrorRequestEntityTooLarge)
}
return query.ParseArgs(string(body))
}
// CallerFromActor projects the verified actor onto the RDS caller. The role name
// comes from the underlying role ARN, never the session name, which the caller
// picks at AssumeRole time and could name its way past the agent gate.
func CallerFromActor(actor dispatch.Actor) Caller {
name := actor.UserName
if actor.PrincipalType == principalTypeAssumedRole {
name = actor.SessionName
}
return Caller{
AccountID: actor.AccountID,
PrincipalType: actor.PrincipalType,
SessionName: name,
RoleName: actor.RoleName(),
}
}
// dispatch parses, scopes and authorizes the request, then runs the action.
// The principal-class gate runs before the policy check so no customer grant is
// ever evaluated against an internal agent action. Errors are awserrors codes
// the gateway renders.
func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
r := inv.Request
queryArgs, err := readQueryArgs(r)
if err != nil {
slog.DebugContext(r.Context(), "RDS: malformed query string", "err", err)
return errors.New(awserrors.ErrorMalformedQueryString)
}
action := queryArgs["Action"]
if action == "" {
return errors.New(awserrors.ErrorMissingAction)
}
// Resolved before the policy check, so an unrecognised action is rejected as
// InvalidAction rather than evaluated as an rds:<garbage> permission.
if !HasAction(action) {
slog.DebugContext(r.Context(), "RDS: unknown action", "action", action)
return errors.New(awserrors.ErrorInvalidAction)
}
if inv.AccountID == "" {
slog.ErrorContext(r.Context(), "RDS dispatch: no account ID in auth context")
return errors.New(awserrors.ErrorServerInternal)
}
// An error means no caller ARN could be composed; the actor is then empty,
// which no internal action admits and the policy check below refuses.
actor, _ := inv.Actor()
caller := CallerFromActor(actor)
caller.AccountID = inv.AccountID
if err := AuthorizeCaller(r.Context(), action, caller); err != nil {
return err
}
// inv.Region is the gateway's own region, never the caller-supplied
// credential-scope region, which would let a caller sign for another region
// and slide out from under a Deny.
resources, err := ResourceARN(action, inv.Region, caller.AccountID, queryArgs)
if err != nil {
return err
}
if err := inv.Authorize(ServiceName, action, resources, nil); err != nil {
return err
}
if d.NATS == nil {
return errors.New(awserrors.ErrorServerInternal)
}
env := Env{ExpectedNodes: d.ExpectedNodes}
if d.Quota != nil {
env.QuotaCheck = func(ctx context.Context, accountID string, want int) error {
return d.Quota.EnforceRDSInstances(ctx, d.NATS, accountID, want)
}
}
xmlOutput, err := Dispatch(r.Context(), action, queryArgs, d.NATS, caller, env)
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/xml")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(xmlOutput); err != nil {
slog.ErrorContext(r.Context(), "Failed to write RDS response", "err", err)
}
return nil
}
@@ -1,108 +0,0 @@
package awsapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mulgadc/bluebottle/pkg/iampolicy"
rdsinstance "github.com/mulgadc/spinifex/spinifex/domains/rds/instance"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func invocation(body, accountID string, actor dispatch.Actor, actorErr error, authorize dispatch.AuthorizeFunc) dispatch.Invocation {
if authorize == nil {
authorize = func(string, string, []string, iampolicy.ConditionKeys) error { return nil }
}
return dispatch.Invocation{
Request: httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)),
AccountID: accountID,
Region: "ap-southeast-2",
Authorize: authorize,
Actor: func() (dispatch.Actor, error) { return actor, actorErr },
}
}
func TestCallerFromActor_NameFollowsPrincipalType(t *testing.T) {
roleARN := "arn:aws:iam::" + awsidentifiers.GlobalAccountID + ":role/" + rdsinstance.InstanceRoleName
session := dispatch.Actor{
AccountID: awsidentifiers.GlobalAccountID, PrincipalType: principalTypeAssumedRole,
PrincipalARN: roleARN, SessionName: "i-abc", UserName: "ignored",
}
got := CallerFromActor(session)
assert.Equal(t, "i-abc", got.SessionName)
assert.Equal(t, rdsinstance.InstanceRoleName, got.RoleName)
user := dispatch.Actor{AccountID: "000000000002", PrincipalType: "user", UserName: "alice", SessionName: "ignored"}
got = CallerFromActor(user)
assert.Equal(t, "alice", got.SessionName)
assert.Empty(t, got.RoleName, "a user has no role name")
}
func TestCallerFromActor_RoleNameNeverFromSessionName(t *testing.T) {
// The caller picks its own session name at AssumeRole time; naming it after
// the agent role must not make it the agent role.
got := CallerFromActor(dispatch.Actor{
AccountID: "000000000002", PrincipalType: principalTypeAssumedRole,
PrincipalARN: "arn:aws:iam::000000000002:role/tenant", SessionName: rdsinstance.InstanceRoleName,
})
assert.Equal(t, "tenant", got.RoleName)
assert.Error(t, requireAgentPrincipal(context.Background(), got))
}
func TestDispatch_RequestShapeErrors(t *testing.T) {
actor := dispatch.Actor{AccountID: "000000000002", PrincipalType: "user", UserName: "alice"}
tests := []struct {
name, body, account, code string
}{
{"malformed query", "Action=%zz", "000000000002", awserrors.ErrorMalformedQueryString},
{"missing action", "", "000000000002", awserrors.ErrorMissingAction},
{"unknown action", "Action=Nope", "000000000002", awserrors.ErrorInvalidAction},
{"no account", "Action=DescribeDBInstances", "", awserrors.ErrorServerInternal},
{"no NATS", "Action=DescribeDBInstances", "000000000002", awserrors.ErrorServerInternal},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Deps{}.dispatch(httptest.NewRecorder(), invocation(tt.body, tt.account, actor, nil, nil))
code, _, _ := awserrors.ResolveErrorDetail(err)
assert.Equal(t, tt.code, code)
})
}
}
func TestDispatch_InternalActionRefusedBeforePolicy(t *testing.T) {
consulted := false
authorize := func(string, string, []string, iampolicy.ConditionKeys) error { consulted = true; return nil }
actor := dispatch.Actor{AccountID: "000000000002", PrincipalType: "user", UserName: "alice"}
err := Deps{}.dispatch(httptest.NewRecorder(),
invocation("Action=GetDBBootstrapConfig", "000000000002", actor, nil, authorize))
code, _, _ := awserrors.ResolveErrorDetail(err)
assert.Equal(t, awserrors.ErrorAccessDenied, code)
assert.False(t, consulted, "no customer grant is evaluated against an internal action")
}
func TestDispatch_PolicyDenialStopsBeforeHandler(t *testing.T) {
deny := func(string, string, []string, iampolicy.ConditionKeys) error {
return errors.New(awserrors.ErrorAccessDenied)
}
// An actor error leaves the actor empty; the policy check still decides.
err := Deps{}.dispatch(httptest.NewRecorder(),
invocation("Action=DescribeDBInstances", "000000000002", dispatch.Actor{}, errors.New("no arn"), deny))
code, _, _ := awserrors.ResolveErrorDetail(err)
assert.Equal(t, awserrors.ErrorAccessDenied, code)
}
func TestOperationInventory_SplitsUnsupported(t *testing.T) {
inv := OperationInventory()
assert.Contains(t, inv.Registered, "CreateDBInstance")
assert.Contains(t, inv.Unsupported, "CreateDBCluster")
assert.NotContains(t, inv.Unsupported, "CreateDBInstance")
require.NoError(t, dispatch.NewBuilder().Register(NewRegistration(Deps{})))
}
-301
View File
@@ -1,301 +0,0 @@
// Package storevolume prepares the data volume an SQS store guest mounts. The
// guest never formats a disk, so a new volume gets its ext4 filesystem here,
// on the host, before anything else can attach it.
package storevolume
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
)
// VolumeService is the slice of the EC2 volume service preparation needs. A
// volume it creates stays "creating", which public callers cannot attach,
// delete, modify or snapshot, until it is completed or discarded.
type VolumeService interface {
CreateVolumeForPreparation(ctx context.Context, input *ec2.CreateVolumeInput, accountID string) (*ec2.Volume, error)
CompleteVolumePreparation(ctx context.Context, accountID, volumeID string) error
DiscardVolumePreparation(ctx context.Context, accountID, volumeID string) error
}
// Publisher is the provider pair used to reach the new volume's blocks.
type Publisher interface {
PublishVolume(context.Context, ebsprovider.PublishVolumeRequest) (*ebsprovider.PublishedVolume, error)
UnpublishVolume(context.Context, ebsprovider.UnpublishVolumeRequest) error
}
// Request names the volume to create.
type Request struct {
AccountID string
AvailabilityZone string
SizeGiB int64
}
// Preparer creates fresh SQS store volumes. NodeID must be the node this
// process runs on, because the copy reads the export it publishes locally.
type Preparer struct {
Volumes VolumeService
Provider Publisher
NodeID string
ScratchDir string
// WriteImage copies a raw image onto an NBD export. The composition root
// supplies it, since a domain does not import the runtime copy helper.
WriteImage func(ctx context.Context, sourcePath, nbdURI string, progress io.Writer) error
lookPath func(string) (string, error)
runMkfs func(ctx context.Context, mkfs, path string) error
toolPaths []string
// cleanupTimeout bounds unpublish and discard, which ignore caller
// cancellation so a cancelled caller cannot skip closing the export.
cleanupTimeout time.Duration
}
const defaultCleanupTimeout = 30 * time.Second
// RetainedVolumeError reports a volume kept because deleting it was not safe.
// StateUnknown means a failed release may still have made it available.
type RetainedVolumeError struct {
VolumeID string
StateUnknown bool
Err error
}
func (e *RetainedVolumeError) Error() string {
if e.StateUnknown {
return fmt.Sprintf("volume %s retained in unknown state (creating or available) for recovery: %v", e.VolumeID, e.Err)
}
return fmt.Sprintf("volume %s retained in creating for recovery: %v", e.VolumeID, e.Err)
}
func (e *RetainedVolumeError) Unwrap() error { return e.Err }
// ErrToolMissing is returned before anything is allocated when the host lacks
// a tool preparation needs.
var ErrToolMissing = errors.New("required host tool is missing")
// sbinDirs covers tools a root daemon finds on its PATH but a login shell
// may not, since mkfs lives in sbin.
var sbinDirs = []string{"/usr/sbin", "/sbin"}
// Prepare creates a new volume, writes an empty ext4 filesystem to it and
// releases it as available. It never writes any volume it did not just create.
func (p *Preparer) Prepare(ctx context.Context, req Request) (string, error) {
if p.Volumes == nil || p.Provider == nil || p.WriteImage == nil || p.NodeID == "" || p.ScratchDir == "" {
return "", errors.New("storevolume: volume service, provider, image writer, node ID and scratch dir are required")
}
if req.AccountID == "" || req.AvailabilityZone == "" || req.SizeGiB < 1 {
return "", fmt.Errorf("storevolume: account, availability zone and a size of at least 1 GiB are required")
}
mkfs, err := p.findTool("mkfs.ext4")
if err != nil {
return "", err
}
// The copy runs qemu-img by name, so only a PATH lookup proves it will run.
if _, err := p.findOnPath("qemu-img"); err != nil {
return "", err
}
scratch, err := os.MkdirTemp(p.ScratchDir, "sqs-store-volume-")
if err != nil {
return "", fmt.Errorf("storevolume: scratch dir: %w", err)
}
defer func() {
if err := os.RemoveAll(scratch); err != nil {
slog.WarnContext(ctx, "storevolume: scratch cleanup failed", "dir", scratch, "err", err)
}
}()
image := filepath.Join(scratch, "ext4.raw")
if err := buildImage(ctx, p.mkfsRunner(), mkfs, image, req.SizeGiB); err != nil {
return "", err
}
vol, err := p.Volumes.CreateVolumeForPreparation(ctx, &ec2.CreateVolumeInput{
AvailabilityZone: aws.String(req.AvailabilityZone),
Size: aws.Int64(req.SizeGiB),
}, req.AccountID)
if err != nil {
return "", fmt.Errorf("storevolume: create volume: %w", err)
}
volumeID := aws.StringValue(vol.VolumeId)
if volumeID == "" {
return "", errors.New("storevolume: create volume returned no volume ID")
}
// Only a volume held in creating is known to be this call's alone.
if state := aws.StringValue(vol.State); state != string(ebsprovider.VolumeStateCreating) {
return "", &RetainedVolumeError{VolumeID: volumeID, Err: fmt.Errorf("new volume is %q, not creating; not writing it", state)}
}
if err := p.fill(ctx, volumeID, image); err != nil {
return "", p.fail(ctx, req.AccountID, err)
}
if err := p.Volumes.CompleteVolumePreparation(ctx, req.AccountID, volumeID); err != nil {
// The release write may still land, so the volume may already be
// available to others and is never discarded here.
return "", &RetainedVolumeError{VolumeID: volumeID, StateUnknown: true, Err: fmt.Errorf("complete preparation failed: %w", err)}
}
slog.InfoContext(ctx, "storevolume: prepared SQS store volume", "volumeId", volumeID, "sizeGiB", req.SizeGiB)
return volumeID, nil
}
// prepError records whether the failed volume can still have a live export.
type prepError struct {
volumeID string
err error
exportClosed bool
}
func (e *prepError) Error() string { return e.err.Error() }
// fill publishes the volume, copies the image and unpublishes it. Its error
// says whether the export is known to be closed.
func (p *Preparer) fill(ctx context.Context, volumeID, image string) error {
published, err := p.Provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: p.NodeID,
})
if err != nil {
// These refusals may mean another export exists, which an unpublish
// by volume and node would tear down, so it is left alone.
if refusedForAnotherExport(err) {
return &prepError{volumeID: volumeID, err: fmt.Errorf("publish refused, export not touched: %w", err)}
}
// Otherwise the publish may still land. Unpublish names only volume
// and node, so it is best effort, and the volume is kept regardless.
if uerr := p.unpublish(ctx, volumeID); uerr != nil {
err = fmt.Errorf("%w (unpublish also failed: %w)", err, uerr)
}
return &prepError{volumeID: volumeID, err: fmt.Errorf("publish outcome unknown: %w", err)}
}
var writeErr error
if published == nil || published.Export.Kind != ebsprovider.ExportKindNBD || published.Export.NBD == nil {
writeErr = errors.New("volume was not published as an nbd export")
} else {
writeErr = p.WriteImage(ctx, image, published.Export.NBD.NBDURI, nil)
}
if err := p.unpublish(ctx, volumeID); err != nil {
if writeErr != nil {
err = fmt.Errorf("write: %w (unpublish also failed: %w)", writeErr, err)
} else {
err = fmt.Errorf("unpublish: %w", err)
}
return &prepError{volumeID: volumeID, err: err}
}
if writeErr != nil {
return &prepError{volumeID: volumeID, err: fmt.Errorf("write: %w", writeErr), exportClosed: true}
}
return nil
}
func (p *Preparer) unpublish(ctx context.Context, volumeID string) error {
cctx, cancel := p.cleanupContext(ctx)
defer cancel()
return p.Provider.UnpublishVolume(cctx, ebsprovider.UnpublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: p.NodeID,
})
}
// fail discards the volume when its export is known closed and otherwise
// keeps it in creating, reporting its ID.
func (p *Preparer) fail(ctx context.Context, accountID string, err error) error {
var pe *prepError
if !errors.As(err, &pe) {
return err
}
if !pe.exportClosed {
return &RetainedVolumeError{VolumeID: pe.volumeID, Err: pe.err}
}
cctx, cancel := p.cleanupContext(ctx)
defer cancel()
if derr := p.Volumes.DiscardVolumePreparation(cctx, accountID, pe.volumeID); derr != nil {
return &RetainedVolumeError{VolumeID: pe.volumeID, Err: fmt.Errorf("%w (discard also failed: %w)", pe.err, derr)}
}
return fmt.Errorf("storevolume: prepare %s failed and the volume was discarded: %w", pe.volumeID, pe.err)
}
func refusedForAnotherExport(err error) bool {
return errors.Is(err, ebsprovider.ErrVolumeInUse) || errors.Is(err, ebsprovider.ErrAlreadyExists)
}
// cleanupContext keeps the caller's values but not its cancellation, bounded
// so a stuck provider cannot hang the caller.
func (p *Preparer) cleanupContext(ctx context.Context) (context.Context, context.CancelFunc) {
limit := p.cleanupTimeout
if limit <= 0 {
limit = defaultCleanupTimeout
}
return context.WithTimeout(context.WithoutCancel(ctx), limit)
}
// buildImage writes a sparse raw file of the volume's size and formats it.
func buildImage(ctx context.Context, run func(context.Context, string, string) error, mkfs, path string, sizeGiB int64) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("storevolume: create image: %w", err)
}
if err := f.Truncate(sizeGiB << 30); err != nil {
_ = f.Close()
return fmt.Errorf("storevolume: size image: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("storevolume: close image: %w", err)
}
if err := run(ctx, mkfs, path); err != nil {
return fmt.Errorf("storevolume: format image: %w", err)
}
return nil
}
func runMkfsExt4(ctx context.Context, mkfs, path string) error {
out, err := exec.CommandContext(ctx, mkfs, "-q", "-F", "-L", "sqsstore", path).CombinedOutput()
if err != nil {
return fmt.Errorf("%s: %w: %s", mkfs, err, out)
}
return nil
}
func (p *Preparer) findOnPath(name string) (string, error) {
look := p.lookPath
if look == nil {
look = exec.LookPath
}
if path, err := look(name); err == nil {
return path, nil
}
return "", fmt.Errorf("storevolume: %w: %s", ErrToolMissing, name)
}
// findTool also checks sbin, for a tool the caller runs by its resolved path.
func (p *Preparer) findTool(name string) (string, error) {
if path, err := p.findOnPath(name); err == nil {
return path, nil
}
dirs := p.toolPaths
if dirs == nil {
dirs = sbinDirs
}
for _, dir := range dirs {
candidate := filepath.Join(dir, name)
if info, err := os.Stat(candidate); err == nil && info.Mode().IsRegular() && info.Mode()&0o111 != 0 {
return candidate, nil
}
}
return "", fmt.Errorf("storevolume: %w: %s", ErrToolMissing, name)
}
func (p *Preparer) mkfsRunner() func(context.Context, string, string) error {
if p.runMkfs != nil {
return p.runMkfs
}
return runMkfsExt4
}
@@ -1,436 +0,0 @@
package storevolume
//test:in-package — the host tools, mkfs and NBD copy are swapped for fakes
// through unexported fields so no test formats or writes a real device.
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/volume"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var _ VolumeService = (*volume.VolumeServiceImpl)(nil)
const (
testAccount = "000000000000"
testVolume = "vol-0123456789abcdef0"
testNode = "node-a"
testURI = "nbd://127.0.0.1:10809"
)
// harness records every call, in order, across the fakes.
type harness struct {
mu sync.Mutex
calls []string
createState string
createErr error
publishErr error
exportKind ebsprovider.ExportKind
unpublishErr error
writeErr error
completeErr error
discardErr error
mkfsErr error
missing map[string]bool
completeApplies bool
unpublishBlocks bool
state string
writtenImage string
unpublishCtxOK bool
}
func (h *harness) record(s string) {
h.mu.Lock()
defer h.mu.Unlock()
h.calls = append(h.calls, s)
}
func (h *harness) CreateVolumeForPreparation(_ context.Context, in *ec2.CreateVolumeInput, accountID string) (*ec2.Volume, error) {
h.record(fmt.Sprintf("create %s %dGiB", accountID, aws.Int64Value(in.Size)))
if h.createErr != nil {
return nil, h.createErr
}
state := h.createState
if state == "" {
state = "creating"
}
return &ec2.Volume{VolumeId: aws.String(testVolume), State: aws.String(state)}, nil
}
func (h *harness) CompleteVolumePreparation(_ context.Context, accountID, volumeID string) error {
h.record("complete " + volumeID)
if h.completeErr == nil || h.completeApplies {
h.state = "available"
}
return h.completeErr
}
func (h *harness) DiscardVolumePreparation(_ context.Context, accountID, volumeID string) error {
h.record("discard " + volumeID)
return h.discardErr
}
func (h *harness) PublishVolume(_ context.Context, req ebsprovider.PublishVolumeRequest) (*ebsprovider.PublishedVolume, error) {
h.record("publish " + req.VolumeID + " on " + req.NodeID)
if h.publishErr != nil {
return nil, h.publishErr
}
kind := h.exportKind
if kind == "" {
kind = ebsprovider.ExportKindNBD
}
export := ebsprovider.VolumeExport{Kind: kind}
if kind == ebsprovider.ExportKindNBD {
export.NBD = &ebsprovider.NBDExport{NBDURI: testURI}
}
return &ebsprovider.PublishedVolume{VolumeID: req.VolumeID, NodeID: req.NodeID, Export: export}, nil
}
func (h *harness) UnpublishVolume(ctx context.Context, req ebsprovider.UnpublishVolumeRequest) error {
h.record("unpublish " + req.VolumeID + " on " + req.NodeID)
h.unpublishCtxOK = ctx.Err() == nil
if h.unpublishBlocks {
<-ctx.Done()
return ctx.Err()
}
return h.unpublishErr
}
func (h *harness) preparer(t *testing.T) *Preparer {
t.Helper()
return &Preparer{
Volumes: h, Provider: h, NodeID: testNode, ScratchDir: t.TempDir(),
lookPath: func(name string) (string, error) {
if h.missing[name] {
return "", errors.New("not found")
}
return "/fake/" + name, nil
},
toolPaths: []string{},
runMkfs: func(_ context.Context, mkfs, path string) error {
h.record("mkfs " + filepath.Base(path))
return h.mkfsErr
},
WriteImage: func(_ context.Context, src, uri string, _ io.Writer) error {
h.record("write " + uri)
h.writtenImage = src
return h.writeErr
},
}
}
func prepare(t *testing.T, h *harness) (string, error) {
t.Helper()
return h.preparer(t).Prepare(context.Background(), Request{AccountID: testAccount, AvailabilityZone: "ap-southeast-2a", SizeGiB: 1})
}
func requireRetained(t *testing.T, err error) {
t.Helper()
var retained *RetainedVolumeError
require.ErrorAs(t, err, &retained)
assert.Equal(t, testVolume, retained.VolumeID)
}
func TestPrepare_FormatsBeforeAllocatingThenReleasesOnlyAfterUnpublish(t *testing.T) {
h := &harness{}
p := h.preparer(t)
id, err := p.Prepare(context.Background(), Request{AccountID: testAccount, AvailabilityZone: "ap-southeast-2a", SizeGiB: 1})
require.NoError(t, err)
assert.Equal(t, testVolume, id)
assert.Equal(t, []string{
"mkfs ext4.raw",
"create 000000000000 1GiB",
"publish " + testVolume + " on " + testNode,
"write " + testURI,
"unpublish " + testVolume + " on " + testNode,
"complete " + testVolume,
}, h.calls)
assert.True(t, strings.HasPrefix(h.writtenImage, p.ScratchDir), "the image must come from private scratch")
entries, err := os.ReadDir(p.ScratchDir)
require.NoError(t, err)
assert.Empty(t, entries, "the scratch image must be removed")
}
func TestPrepare_MissingToolAllocatesNothing(t *testing.T) {
for _, tool := range []string{"mkfs.ext4", "qemu-img"} {
t.Run(tool, func(t *testing.T) {
h := &harness{missing: map[string]bool{tool: true}}
_, err := prepare(t, h)
require.ErrorIs(t, err, ErrToolMissing)
assert.Contains(t, err.Error(), tool)
assert.Empty(t, h.calls)
})
}
}
func TestPrepare_MissingImageWriterAllocatesNothing(t *testing.T) {
h := &harness{}
p := h.preparer(t)
p.WriteImage = nil
_, err := p.Prepare(context.Background(), Request{AccountID: testAccount, AvailabilityZone: "ap-southeast-2a", SizeGiB: 1})
require.ErrorContains(t, err, "image writer")
assert.Empty(t, h.calls)
}
func TestPrepare_FindsSbinToolOffThePath(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "mkfs.ext4"), []byte("#!/bin/sh\n"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "qemu-img"), []byte("not executable"), 0o644))
p := &Preparer{lookPath: func(string) (string, error) { return "", errors.New("not found") }, toolPaths: []string{dir}}
got, err := p.findTool("mkfs.ext4")
require.NoError(t, err)
assert.Equal(t, filepath.Join(dir, "mkfs.ext4"), got)
_, err = p.findTool("qemu-img")
require.ErrorIs(t, err, ErrToolMissing, "a file that is not executable is not the tool")
}
// The copy runs qemu-img by name, so a qemu-img found only in sbin must not
// pass the check that runs before anything is allocated.
func TestPrepare_QemuImgOnlyInSbinAllocatesNothing(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "qemu-img"), []byte("#!/bin/sh\n"), 0o755))
h := &harness{missing: map[string]bool{"qemu-img": true}}
p := h.preparer(t)
p.toolPaths = []string{dir}
_, err := p.Prepare(context.Background(), Request{AccountID: testAccount, AvailabilityZone: "ap-southeast-2a", SizeGiB: 1})
require.ErrorIs(t, err, ErrToolMissing)
assert.Contains(t, err.Error(), "qemu-img")
assert.Empty(t, h.calls)
}
func TestPrepare_FailedFormatAllocatesNothing(t *testing.T) {
h := &harness{mkfsErr: errors.New("mkfs exploded")}
_, err := prepare(t, h)
require.ErrorContains(t, err, "mkfs exploded")
assert.Equal(t, []string{"mkfs ext4.raw"}, h.calls)
}
func TestPrepare_FailedCreateTouchesNoVolume(t *testing.T) {
h := &harness{createErr: errors.New("no capacity")}
_, err := prepare(t, h)
require.ErrorContains(t, err, "no capacity")
assert.Equal(t, []string{"mkfs ext4.raw", "create 000000000000 1GiB"}, h.calls)
}
// A volume that does not come back creating may not be this call's alone, so
// nothing is published, written or deleted.
func TestPrepare_VolumeNotBornCreatingIsNeverWritten(t *testing.T) {
h := &harness{createState: "available"}
_, err := prepare(t, h)
requireRetained(t, err)
assert.Equal(t, []string{"mkfs ext4.raw", "create 000000000000 1GiB"}, h.calls)
}
func TestPrepare_FailedCopyUnpublishesThenDiscards(t *testing.T) {
h := &harness{writeErr: errors.New("qemu-img convert: broken pipe")}
_, err := prepare(t, h)
require.ErrorContains(t, err, "broken pipe")
require.ErrorContains(t, err, "discarded")
var retained *RetainedVolumeError
assert.NotErrorAs(t, err, &retained)
assert.Equal(t, []string{
"mkfs ext4.raw", "create 000000000000 1GiB",
"publish " + testVolume + " on " + testNode, "write " + testURI,
"unpublish " + testVolume + " on " + testNode, "discard " + testVolume,
}, h.calls)
}
func TestPrepare_NonNBDExportIsNotWrittenAndIsDiscarded(t *testing.T) {
h := &harness{exportKind: "iscsi"}
_, err := prepare(t, h)
require.ErrorContains(t, err, "not published as an nbd export")
assert.NotContains(t, h.calls, "write "+testURI)
assert.Equal(t, "discard "+testVolume, h.calls[len(h.calls)-1])
}
// An in-use or already-exists refusal may mean another export of the volume
// is live, and unpublish names only volume and node, so it is never sent.
func TestPrepare_PublishRefusedForAnotherExportSendsNoUnpublish(t *testing.T) {
for name, perr := range map[string]error{
"volume in use": &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeVolumeInUse, Message: "in use"},
"already exists": &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeAlreadyExists, Message: "exists"},
"wrapped": fmt.Errorf("nats provider: %w", &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeVolumeInUse, Message: "in use"}),
} {
t.Run(name, func(t *testing.T) {
h := &harness{publishErr: perr}
_, err := prepare(t, h)
requireRetained(t, err)
require.ErrorContains(t, err, "export not touched")
assert.Equal(t, []string{
"mkfs ext4.raw", "create 000000000000 1GiB",
"publish " + testVolume + " on " + testNode,
}, h.calls, "no unpublish, write, discard or release")
})
}
}
// Any other publish error may still land, so the volume is kept with its ID
// reported even when the best-effort unpublish succeeds.
func TestPrepare_AmbiguousPublishRetainsTheVolume(t *testing.T) {
for name, perr := range map[string]error{
"deadline": context.DeadlineExceeded,
"unavailable": &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnavailable, Message: "lease busy"},
"internal": &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeInternal, Message: "mount half done"},
"invalid argument": &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeInvalidArgument, Message: "bad node"},
"not found": &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeNotFound, Message: "no volume"},
} {
t.Run(name, func(t *testing.T) {
h := &harness{publishErr: perr}
_, err := prepare(t, h)
requireRetained(t, err)
assert.Equal(t, []string{
"mkfs ext4.raw", "create 000000000000 1GiB",
"publish " + testVolume + " on " + testNode,
"unpublish " + testVolume + " on " + testNode,
}, h.calls, "no write, no discard, no release")
})
}
}
func TestPrepare_FailedUnpublishRetainsTheVolume(t *testing.T) {
for name, writeErr := range map[string]error{"after a good copy": nil, "after a failed copy": errors.New("short write")} {
t.Run(name, func(t *testing.T) {
h := &harness{writeErr: writeErr, unpublishErr: errors.New("nats: timeout")}
_, err := prepare(t, h)
requireRetained(t, err)
require.ErrorContains(t, err, "nats: timeout")
require.ErrorContains(t, err, "retained in creating", "the release never ran, so the state is known")
last := h.calls[len(h.calls)-1]
assert.Equal(t, "unpublish "+testVolume+" on "+testNode, last, "no discard or release after an unconfirmed unpublish")
})
}
}
// A failed release write may still have landed, leaving the volume available
// to others, so it is never discarded and its state is reported as unknown.
func TestPrepare_FailedReleaseRetainsWithUnknownState(t *testing.T) {
for name, applied := range map[string]bool{"write not applied": false, "write applied, then error": true} {
t.Run(name, func(t *testing.T) {
h := &harness{completeErr: errors.New("object store: request failed"), completeApplies: applied}
_, err := prepare(t, h)
requireRetained(t, err)
var retained *RetainedVolumeError
require.ErrorAs(t, err, &retained)
assert.True(t, retained.StateUnknown)
require.ErrorContains(t, err, "unknown state (creating or available)")
assert.NotContains(t, err.Error(), "retained in creating")
assert.Equal(t, "complete "+testVolume, h.calls[len(h.calls)-1], "no discard after an uncertain release")
if applied {
assert.Equal(t, "available", h.state, "the fake really did release it, which is why discard would be wrong")
}
})
}
}
// Cleanup ignores caller cancellation but is bounded, so a provider that
// never answers cannot hang the caller.
func TestPrepare_StuckUnpublishIsBounded(t *testing.T) {
h := &harness{unpublishBlocks: true}
p := h.preparer(t)
p.cleanupTimeout = 50 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
p.WriteImage = func(context.Context, string, string, io.Writer) error {
cancel()
return context.Canceled
}
done := make(chan error, 1)
go func() {
_, err := p.Prepare(ctx, Request{AccountID: testAccount, AvailabilityZone: "ap-southeast-2a", SizeGiB: 1})
done <- err
}()
select {
case err := <-done:
requireRetained(t, err)
require.ErrorIs(t, err, context.DeadlineExceeded, "unpublish ends on its own bound, not the caller's cancel")
assert.NotContains(t, h.calls, "discard "+testVolume)
case <-time.After(10 * time.Second):
t.Fatal("Prepare hung on a stuck unpublish")
}
}
func TestPrepare_FailedDiscardRetainsTheVolume(t *testing.T) {
h := &harness{writeErr: errors.New("short write"), discardErr: errors.New("provider unreachable")}
_, err := prepare(t, h)
requireRetained(t, err)
require.ErrorContains(t, err, "provider unreachable")
}
// Unpublish is what closes the export, so a caller cancelling mid-copy must
// not make it skip that step.
func TestPrepare_CancelledCopyStillUnpublishes(t *testing.T) {
h := &harness{}
ctx, cancel := context.WithCancel(context.Background())
p := h.preparer(t)
p.WriteImage = func(context.Context, string, string, io.Writer) error {
cancel()
return context.Canceled
}
_, err := p.Prepare(ctx, Request{AccountID: testAccount, AvailabilityZone: "ap-southeast-2a", SizeGiB: 1})
require.Error(t, err)
assert.Contains(t, h.calls, "unpublish "+testVolume+" on "+testNode)
assert.True(t, h.unpublishCtxOK, "unpublish must run with a live context")
assert.Equal(t, "discard "+testVolume, h.calls[len(h.calls)-1])
}
func TestPrepare_RejectsIncompleteRequestBeforeAnything(t *testing.T) {
for name, req := range map[string]Request{
"no account": {AvailabilityZone: "ap-southeast-2a", SizeGiB: 1},
"no zone": {AccountID: testAccount, SizeGiB: 1},
"zero size": {AccountID: testAccount, AvailabilityZone: "ap-southeast-2a"},
} {
t.Run(name, func(t *testing.T) {
h := &harness{}
_, err := h.preparer(t).Prepare(context.Background(), req)
require.Error(t, err)
assert.Empty(t, h.calls)
})
}
}
// The real formatter produces an ext4 superblock the guest's e2fsck accepts.
func TestBuildImage_RealMkfsWritesExt4(t *testing.T) {
mkfs, err := (&Preparer{}).findTool("mkfs.ext4")
if err != nil {
t.Skip("mkfs.ext4 not installed")
}
path := filepath.Join(t.TempDir(), "ext4.raw")
require.NoError(t, buildImage(context.Background(), runMkfsExt4, mkfs, path, 1))
info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, int64(1<<30), info.Size())
f, err := os.Open(path)
require.NoError(t, err)
defer f.Close()
magic := make([]byte, 2)
_, err = f.ReadAt(magic, 1024+0x38)
require.NoError(t, err)
assert.Equal(t, uint16(0xEF53), binary.LittleEndian.Uint16(magic))
if fsck, err := (&Preparer{}).findTool("e2fsck"); err == nil {
out, err := exec.Command(fsck, "-n", "-f", path).CombinedOutput()
require.NoError(t, err, "e2fsck must find the new filesystem clean: %s", out)
}
require.Error(t, buildImage(context.Background(), runMkfsExt4, mkfs, path, 1), "an existing file is never reused")
}
@@ -112,16 +112,6 @@ func (s *STSServiceImpl) AssumeRoleWithWebIdentity(input *sts.AssumeRoleWithWebI
audience: []string(claims.Audience),
}
if err := evalTrustPolicyForWebIdentity(aws.StringValue(role.AssumeRolePolicyDocument), identity); err != nil {
if denial, ok := errors.AsType[*trustPolicyDeniedError](err); ok {
slog.Warn("AssumeRoleWithWebIdentity: trust policy denied",
"role_arn", aws.StringValue(role.Arn),
"issuer", claims.Issuer,
"subject", claims.Subject,
"reason", denial.reason,
"condition_operator", denial.operator,
"condition_key", denial.key,
)
}
return nil, err
}
-299
View File
@@ -1,299 +0,0 @@
package awsapi
import (
"bytes"
"errors"
"io"
"log/slog"
"maps"
"net/http"
"slices"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/mulgadc/bluebottle/pkg/sigv4"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsxml "github.com/mulgadc/spinifex/spinifex/foundation/aws/xml"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
)
// ServiceName is the SigV4 credential-scope service STS clients sign for.
const ServiceName = "sts"
const principalTypeAssumedRole = stsdomain.PrincipalTypeAssumedRole
// Deps are the composed capabilities the STS control plane dispatches to. A
// nil STS answers every action with InternalError.
type Deps struct {
STS stsdomain.STSService
// IAM resolves the target role of AssumeRole and the caller of
// GetCallerIdentity.
IAM iamdomain.IAMService
}
// caller is the SigV4-derived identity an action may consume, read once from
// the dispatch actor.
type caller struct {
accountID string
arn string
identity string
principalType string
assumedRoleID string
accessKey string
}
// action parses the request into a typed input and runs its handler.
type action struct {
// anonymous actions authenticate by a token in the body, so no caller is
// resolved for them.
anonymous bool
// gated actions need a pass on the caller's identity policy.
gated bool
run func(name string, q map[string]string, c caller, d Deps) ([]byte, error)
}
// handler creates a type-safe action: it allocates the input struct, parses the
// query parameters, runs the handler and marshals its output to XML.
func handler[In any](anonymous, gated bool, h func(c caller, input *In, d Deps) (any, error)) action {
return action{
anonymous: anonymous,
gated: gated,
run: func(name string, q map[string]string, c caller, d Deps) ([]byte, 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, errors.New(awserrors.ErrorValidationError)
}
output, err := h(c, input, d)
if err != nil {
return nil, err
}
xmlOutput, err := awsxml.Marshal(awsxml.QueryResponsePayload(name, output))
if err != nil {
return nil, errors.New(awserrors.ErrorInternalError)
}
return xmlOutput, nil
},
}
}
var actions = map[string]action{
"AssumeRole": handler(false, true, func(c caller, input *sts.AssumeRoleInput, d Deps) (any, error) {
return AssumeRole(c.accountID, c.arn, c.identity, input, d.STS)
}),
"AssumeRoleWithWebIdentity": handler(true, false, func(_ caller, input *sts.AssumeRoleWithWebIdentityInput, d Deps) (any, error) {
return AssumeRoleWithWebIdentity(input, d.STS)
}),
"GetAccessKeyInfo": handler(false, false, func(_ caller, input *sts.GetAccessKeyInfoInput, d Deps) (any, error) {
return GetAccessKeyInfo(input, d.STS)
}),
"GetCallerIdentity": handler(false, false, func(c caller, input *sts.GetCallerIdentityInput, d Deps) (any, error) {
return GetCallerIdentity(c.accountID, c.arn, c.principalType, c.identity, c.assumedRoleID, input, d.IAM, d.STS)
}),
"GetSessionToken": handler(false, false, func(c caller, input *sts.GetSessionTokenInput, d Deps) (any, error) {
return GetSessionToken(c.accountID, c.identity, c.principalType, c.accessKey, input, d.STS)
}),
}
// NewRegistration returns the STS 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.
func OperationInventory() dispatch.Inventory {
return dispatch.Inventory{Registered: ActionNames()}
}
// AnonymousFunc runs one unsigned STS action from its parsed query arguments
// and answers the XML response body.
type AnonymousFunc func(queryArgs map[string]string) ([]byte, error)
// NewAnonymousAssumeRoleWithWebIdentity serves AssumeRoleWithWebIdentity for a
// request that carries no SigV4 envelope. The web-identity token in the body is
// the only authentication, so it runs no caller or identity-policy step.
func NewAnonymousAssumeRoleWithWebIdentity(deps Deps) AnonymousFunc {
return func(q map[string]string) ([]byte, error) {
if deps.STS == nil {
slog.Error("STS: service not initialized")
return nil, errors.New(awserrors.ErrorInternalError)
}
return actions["AssumeRoleWithWebIdentity"].run("AssumeRoleWithWebIdentity", q, caller{}, deps)
}
}
// 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 the request, resolves the caller, applies the identity-policy
// gate and 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.Debug("STS: 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.Debug("STS: unknown action", "action", name)
return errors.New(awserrors.ErrorInvalidAction)
}
if d.STS == nil {
slog.Error("STS: service not initialized")
return errors.New(awserrors.ErrorInternalError)
}
// An anonymous action carries no SigV4 envelope; its handler ignores the
// zero caller.
var c caller
var actor dispatch.Actor
if !h.anonymous {
if c, actor, err = resolveCaller(inv); err != nil {
return err
}
}
// The identity policy is the first of two gates; the handler still applies
// the role's trust policy. Scoping to the target role lets a policy grant
// assumption of one role without granting all of them.
if h.gated {
resource, err := d.policyResource(queryArgs)
if err != nil {
return err
}
if err := inv.Authorize(ServiceName, name, []string{resource}, nil); err != nil {
if denial, ok := errors.AsType[*dispatch.PolicyDenialError](err); ok {
return detailedDenial(actor, denial)
}
return err
}
}
xmlOutput, err := h.run(name, queryArgs, c, d)
if err != nil {
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 STS response", "err", err)
}
return nil
}
// resolveCaller assembles the caller fields from the dispatch actor. The actor
// is populated even when no caller ARN can be composed.
func resolveCaller(inv dispatch.Invocation) (caller, dispatch.Actor, error) {
actor, err := inv.Actor()
if actor.AccountID == "" {
slog.Error("STS_Request: no account ID in auth context")
return caller{}, actor, errors.New(awserrors.ErrorInternalError)
}
if err != nil {
return caller{}, actor, err
}
c := caller{
accountID: actor.AccountID,
arn: actor.CallerARN,
identity: actor.UserName,
principalType: actor.PrincipalType,
accessKey: actor.AccessKeyID,
}
if actor.PrincipalType == principalTypeAssumedRole {
c.identity = actor.SessionName
c.assumedRoleID = actor.UserID
}
return c, actor, nil
}
// detailedDenial names the caller, action and resource. The shared
// authorization failure is opaque, and STS opts into detail because its
// resource is a role ARN the caller supplied.
func detailedDenial(actor dispatch.Actor, denial *dispatch.PolicyDenialError) error {
callerARN := actor.CallerARN
if callerARN == "" {
callerARN = actor.UserName
if actor.PrincipalType == principalTypeAssumedRole {
callerARN = actor.SessionName
}
}
return awserrors.Errorf(awserrors.ErrorAccessDenied,
"User: %s is not authorized to perform: %s on resource: %s",
callerARN, denial.Action, denial.Resource)
}
// policyResource returns the ARN a gated action is evaluated against.
// AssumeRole resolves the target role so the resource is the ARN IAM stored,
// which a policy naming a pathed role matches and an invented path does not.
func (d Deps) policyResource(queryArgs map[string]string) (string, error) {
// An absent RoleArn is a malformed request, not an unauthorized one: saying
// so beats sending the caller to widen a policy that was never the cause.
roleARN := queryArgs["RoleArn"]
if roleARN == "" {
return "", errors.New(awserrors.ErrorMissingParameter)
}
if d.IAM == nil {
slog.Error("STS: IAM service not available", "action", "AssumeRole")
return "", errors.New(awserrors.ErrorInternalError)
}
_, role, err := stsdomain.ResolveRoleByARN(d.IAM, roleARN)
switch {
case err == nil:
return aws.StringValue(role.Arn), nil
case errors.Is(err, stsdomain.ErrRoleUnresolved):
// Echoing the supplied ARN keeps the denial identical whether or not the
// role exists. The handler refuses an ARN that is not the stored one, so
// a grant matched here on an invented path still ends in a denial there.
return roleARN, nil
}
if code, ok := awserrors.ResolveErrorCode(err); ok && code == awserrors.ErrorValidationError {
slog.Debug("STS: unparseable RoleArn", "roleArn", roleARN)
return "", err
}
slog.Error("STS: role lookup failed ahead of the identity gate", "roleArn", roleARN, "err", err)
return "", errors.New(awserrors.ErrorInternalError)
}
@@ -1,302 +0,0 @@
package awsapi
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/mulgadc/bluebottle/pkg/iampolicy"
"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 (
regAccount = "000000000001"
regRoleARN = "arn:aws:iam::000000000001:role/demo"
)
// roleIAM scripts the one IAM call the AssumeRole identity gate makes.
type roleIAM struct {
stubIAMService
getRole func(accountID string, in *iam.GetRoleInput) (*iam.GetRoleOutput, error)
}
func (r *roleIAM) GetRole(accountID string, in *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
return r.getRole(accountID, in)
}
// webIdentitySTS answers the one unsigned action.
type webIdentitySTS struct{ stubSTSService }
func (webIdentitySTS) AssumeRoleWithWebIdentity(*sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) {
return &sts.AssumeRoleWithWebIdentityOutput{}, nil
}
type regAuthCall struct {
service, action string
resources []string
}
func invoke(t *testing.T, d Deps, accountID string, actor dispatch.Actor, actorErr error, authorize func() error, body string) (*httptest.ResponseRecorder, []regAuthCall, error) {
t.Helper()
r := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(body))
var calls []regAuthCall
inv := dispatch.Invocation{
Request: r,
AccountID: accountID,
Service: ServiceName,
Authorize: func(service, action string, resources []string, _ iampolicy.ConditionKeys) error {
calls = append(calls, regAuthCall{service, action, resources})
if authorize != nil {
return authorize()
}
return nil
},
Actor: func() (dispatch.Actor, error) { return actor, actorErr },
}
w := httptest.NewRecorder()
err := NewRegistration(d).Dispatch(w, inv)
return w, calls, err
}
func assumeRoleBody(roleARN string) string {
v := url.Values{"Action": {"AssumeRole"}, "RoleSessionName": {"s1"}}
if roleARN != "" {
v.Set("RoleArn", roleARN)
}
return v.Encode()
}
func foundRole(string, *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
return &iam.GetRoleOutput{Role: &iam.Role{Arn: aws.String(regRoleARN)}}, nil
}
func userActor() dispatch.Actor {
return dispatch.Actor{
AccountID: regAccount, CallerARN: "arn:aws:iam::000000000001:user/alice",
PrincipalType: "user", UserName: "alice", AccessKeyID: "AKIA1",
}
}
func TestRegistration_RequestShapeErrors(t *testing.T) {
tests := []struct {
name string
deps Deps
body string
want string
}{
{"missing action", Deps{STS: &stubSTSService{}}, "", awserrors.ErrorMissingAction},
{"unknown action", Deps{STS: &stubSTSService{}}, "Action=Nope", awserrors.ErrorInvalidAction},
{"nil service", Deps{}, "Action=GetCallerIdentity", awserrors.ErrorInternalError},
{"unknown action beats nil service", Deps{}, "Action=Nope", awserrors.ErrorInvalidAction},
{"malformed body", Deps{STS: &stubSTSService{}}, "Action=%G", awserrors.ErrorMalformedQueryString},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
w, calls, err := invoke(t, tc.deps, regAccount, userActor(), nil, nil, tc.body)
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, tc.want), "got %v want %s", err, tc.want)
assert.Empty(t, calls)
assert.Zero(t, w.Body.Len(), "dispatcher writes nothing on error")
})
}
}
func TestRegistration_CallerErrors(t *testing.T) {
deps := Deps{STS: &stubSTSService{}}
t.Run("no account", func(t *testing.T) {
_, _, err := invoke(t, deps, regAccount, dispatch.Actor{}, errors.New("no caller"), nil, "Action=GetCallerIdentity")
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError))
})
t.Run("actor fault", func(t *testing.T) {
a := userActor()
_, _, err := invoke(t, deps, regAccount, a, errors.New("boom"), nil, "Action=GetCallerIdentity")
require.Error(t, err)
assert.False(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError))
})
}
func TestRegistration_GetCallerIdentityReadsActor(t *testing.T) {
var gotARN, gotUserID string
svc := &stubSTSService{getCallerIdentityFn: func(acct, arn, uid string, _ *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error) {
gotARN, gotUserID = arn, uid
return &sts.GetCallerIdentityOutput{Account: aws.String(acct), Arn: aws.String(arn), UserId: aws.String(uid)}, nil
}}
t.Run("assumed role", func(t *testing.T) {
a := dispatch.Actor{
AccountID: regAccount, CallerARN: "arn:aws:sts::000000000001:assumed-role/demo/s1",
PrincipalType: principalTypeAssumedRole, SessionName: "s1", UserID: "AROAX:s1",
}
w, calls, err := invoke(t, Deps{STS: svc, IAM: &stubIAMService{}}, regAccount, a, nil, nil, "Action=GetCallerIdentity")
require.NoError(t, err)
assert.Empty(t, calls, "GetCallerIdentity is not identity-gated")
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "text/xml", w.Header().Get("Content-Type"))
assert.Contains(t, w.Body.String(), "GetCallerIdentityResult")
assert.Equal(t, a.CallerARN, gotARN)
assert.Equal(t, "AROAX:s1", gotUserID)
})
}
func TestRegistration_AssumeRoleGate(t *testing.T) {
assumed := 0
svc := &stubSTSService{assumeRoleFn: func(string, string, string, *sts.AssumeRoleInput) (*sts.AssumeRoleOutput, error) {
assumed++
return &sts.AssumeRoleOutput{}, nil
}}
iamOK := &roleIAM{getRole: foundRole}
t.Run("allowed runs the handler against the stored role ARN", func(t *testing.T) {
assumed = 0
w, calls, err := invoke(t, Deps{STS: svc, IAM: iamOK}, regAccount, userActor(), nil, nil, assumeRoleBody(regRoleARN))
require.NoError(t, err)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, 1, assumed)
require.Len(t, calls, 1)
assert.Equal(t, regAuthCall{"sts", "AssumeRole", []string{regRoleARN}}, calls[0])
})
t.Run("denial names caller action and resource", func(t *testing.T) {
assumed = 0
deny := func() error { return &dispatch.PolicyDenialError{Action: "sts:AssumeRole", Resource: regRoleARN} }
_, _, err := invoke(t, Deps{STS: svc, IAM: iamOK}, regAccount, userActor(), nil, deny, assumeRoleBody(regRoleARN))
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorAccessDenied))
assert.Contains(t, err.Error(), "arn:aws:iam::000000000001:user/alice")
assert.Contains(t, err.Error(), "sts:AssumeRole")
assert.Contains(t, err.Error(), regRoleARN)
assert.Zero(t, assumed, "handler must not run after a denial")
})
t.Run("opaque denial passes through", func(t *testing.T) {
deny := func() error { return errors.New(awserrors.ErrorAccessDenied) }
_, _, err := invoke(t, Deps{STS: svc, IAM: iamOK}, regAccount, userActor(), nil, deny, assumeRoleBody(regRoleARN))
assert.EqualError(t, err, awserrors.ErrorAccessDenied)
})
t.Run("missing RoleArn is a missing parameter before authorization", func(t *testing.T) {
_, calls, err := invoke(t, Deps{STS: svc, IAM: iamOK}, regAccount, userActor(), nil, nil, assumeRoleBody(""))
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorMissingParameter))
assert.Empty(t, calls)
})
t.Run("nil IAM", func(t *testing.T) {
_, calls, err := invoke(t, Deps{STS: svc}, regAccount, userActor(), nil, nil, assumeRoleBody(regRoleARN))
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError))
assert.Empty(t, calls)
})
t.Run("unresolved role echoes the supplied ARN", func(t *testing.T) {
missing := &roleIAM{getRole: func(string, *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
}}
_, calls, err := invoke(t, Deps{STS: svc, IAM: missing}, regAccount, userActor(), nil, nil, assumeRoleBody(regRoleARN))
require.NoError(t, err)
require.Len(t, calls, 1)
assert.Equal(t, []string{regRoleARN}, calls[0].resources)
})
t.Run("unparseable RoleArn is a validation error", func(t *testing.T) {
_, calls, err := invoke(t, Deps{STS: svc, IAM: iamOK}, regAccount, userActor(), nil, nil, assumeRoleBody("not-an-arn"))
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorValidationError))
assert.Empty(t, calls)
})
t.Run("lookup fault is internal", func(t *testing.T) {
broken := &roleIAM{getRole: func(string, *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
return nil, errors.New("kv down")
}}
_, calls, err := invoke(t, Deps{STS: svc, IAM: broken}, regAccount, userActor(), nil, nil, assumeRoleBody(regRoleARN))
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError))
assert.Empty(t, calls)
})
}
func TestDetailedDenial_CallerFallback(t *testing.T) {
denial := &dispatch.PolicyDenialError{Action: "sts:AssumeRole", Resource: regRoleARN}
t.Run("user without ARN names the user", func(t *testing.T) {
err := detailedDenial(dispatch.Actor{UserName: "alice", PrincipalType: "user"}, denial)
assert.Contains(t, err.Error(), "User: alice is not")
})
t.Run("session without ARN names the session", func(t *testing.T) {
err := detailedDenial(dispatch.Actor{SessionName: "s1", PrincipalType: principalTypeAssumedRole}, denial)
assert.Contains(t, err.Error(), "User: s1 is not")
})
}
func TestRegistration_GetSessionTokenForwardsActor(t *testing.T) {
var got [4]string
svc := &stubSTSService{getSessionTokenFn: func(acct, name, pt, akid string, _ *sts.GetSessionTokenInput) (*sts.GetSessionTokenOutput, error) {
got = [4]string{acct, name, pt, akid}
return &sts.GetSessionTokenOutput{}, nil
}}
_, _, err := invoke(t, Deps{STS: svc}, regAccount, userActor(), nil, nil, "Action=GetSessionToken")
require.NoError(t, err)
assert.Equal(t, [4]string{regAccount, "alice", "user", "AKIA1"}, got)
}
func TestRegistration_UnparseableInputIsValidationError(t *testing.T) {
// A non-numeric DurationSeconds cannot be read into the typed input.
body := assumeRoleBody(regRoleARN) + "&DurationSeconds=abc"
_, calls, err := invoke(t, Deps{STS: &stubSTSService{}, IAM: &roleIAM{getRole: foundRole}}, regAccount, userActor(), nil, nil, body)
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorValidationError), "got %v", err)
require.Len(t, calls, 1, "authorization precedes input parsing, as before")
}
func TestRegistration_ResolveAction(t *testing.T) {
reg := NewRegistration(Deps{})
r := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader("Action=GetCallerIdentity&Version=2011-06-15"))
assert.Equal(t, "GetCallerIdentity", reg.ResolveAction(r))
w, _, err := invokeRequest(t, Deps{STS: &stubSTSService{}, IAM: &stubIAMService{}}, r)
require.NoError(t, err, "dispatcher still reads the body after resolution")
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, []regAuthCall, error) {
t.Helper()
inv := dispatch.Invocation{
Request: r,
AccountID: regAccount,
Authorize: func(string, string, []string, iampolicy.ConditionKeys) error { return nil },
Actor: func() (dispatch.Actor, error) { return userActor(), nil },
}
w := httptest.NewRecorder()
return w, nil, NewRegistration(d).Dispatch(w, inv)
}
func TestActionNames_AreTheServedSet(t *testing.T) {
assert.Equal(t,
[]string{"AssumeRole", "AssumeRoleWithWebIdentity", "GetAccessKeyInfo", "GetCallerIdentity", "GetSessionToken"},
ActionNames())
assert.Equal(t, ActionNames(), OperationInventory().Registered)
}
func TestAnonymousAssumeRoleWithWebIdentity(t *testing.T) {
q := map[string]string{
"Action": "AssumeRoleWithWebIdentity", "RoleArn": regRoleARN,
"RoleSessionName": "s1", "WebIdentityToken": "h.p.s",
}
t.Run("nil service fails closed", func(t *testing.T) {
_, err := NewAnonymousAssumeRoleWithWebIdentity(Deps{})(q)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError))
})
t.Run("runs the handler with no caller", func(t *testing.T) {
svc := &webIdentitySTS{}
out, err := NewAnonymousAssumeRoleWithWebIdentity(Deps{STS: svc})(q)
require.NoError(t, err)
assert.Contains(t, string(out), "AssumeRoleWithWebIdentityResult")
})
}
@@ -3,13 +3,13 @@ package sts
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
)
const (
@@ -25,7 +25,7 @@ type webIdentityContext struct {
// federatedPrincipalARN is the oidc-provider ARN Principal.Federated must equal.
federatedPrincipalARN string
// issuer is the literal `iss` claim; its host-path (or scheme-prefixed form) prefixes condition keys like `{issuer}:sub`.
// issuer is the literal `iss` claim; used to build condition-key prefixes like `{iss}:sub`.
issuer string
// subject is the literal `sub` claim.
@@ -48,56 +48,46 @@ func evalTrustPolicyForWebIdentity(docJSON string, identity webIdentityContext)
if stmt.Effect != iamdomain.PolicyEffectDeny {
continue
}
matched, _, err := matchWebIdentityStatement(stmt.Action, stmt.Principal, stmt.Condition, identity)
matched, err := matchWebIdentityStatement(stmt.Action, stmt.Principal, stmt.Condition, identity)
if err != nil {
return err
}
if matched {
return &trustPolicyDeniedError{reason: denyReasonExplicitDeny}
return errors.New(awserrors.ErrorAccessDenied)
}
}
denial := &trustPolicyDeniedError{reason: denyReasonNoStatement}
for _, stmt := range doc.Statement {
if stmt.Effect != iamdomain.PolicyEffectAllow {
continue
}
matched, cd, err := matchWebIdentityStatement(stmt.Action, stmt.Principal, stmt.Condition, identity)
matched, err := matchWebIdentityStatement(stmt.Action, stmt.Principal, stmt.Condition, identity)
if err != nil {
return err
}
if matched {
return nil
}
// Keep the first statement that matched action and principal but failed its condition.
if cd != nil && denial.reason == denyReasonNoStatement {
denial = &trustPolicyDeniedError{reason: denyReasonCondMismatch, operator: cd.operator, key: cd.key}
if cd.unknownKey {
denial.reason = denyReasonCondUnsupport
}
}
}
return denial
return errors.New(awserrors.ErrorAccessDenied)
}
// matchWebIdentityStatement reports whether a statement applies; when only its Condition failed, the denial says why.
func matchWebIdentityStatement(actions []string, principalRaw, conditionRaw json.RawMessage, identity webIdentityContext) (bool, *condDenial, error) {
func matchWebIdentityStatement(actions []string, principalRaw, conditionRaw json.RawMessage, identity webIdentityContext) (bool, error) {
if !matchWebIdentityAction(actions) {
return false, nil, nil
return false, nil
}
principalMatch, err := matchFederatedPrincipal(principalRaw, identity.federatedPrincipalARN)
if err != nil {
return false, nil, err
return false, err
}
if !principalMatch {
return false, nil, nil
return false, nil
}
if held, cd := conditionsHold(conditionRaw, identity); !held {
return false, &cd, nil
if !conditionsHold(conditionRaw, identity) {
return false, nil
}
return true, nil, nil
return true, nil
}
func matchWebIdentityAction(actions []string) bool {
@@ -142,82 +132,43 @@ func matchFederatedPrincipal(raw json.RawMessage, expectedARN string) (bool, err
return false, nil
}
// Reasons a trust policy denied the assumption, logged by the caller.
const (
denyReasonNoStatement = "no statement matched"
denyReasonExplicitDeny = "explicit deny"
denyReasonCondMismatch = "condition value mismatch"
denyReasonCondUnsupport = "condition operator or key unsupported"
)
// trustPolicyDeniedError is the AccessDenied error carrying why, for logs only; the API response is unchanged.
type trustPolicyDeniedError struct {
reason string
operator string
key string
}
func (d *trustPolicyDeniedError) Error() string { return awserrors.ErrorAccessDenied }
// condDenial says why a Condition block did not hold, for the caller to log.
type condDenial struct {
operator string
key string
// unknownKey is true when the key or operator is unsupported, false when the value did not match.
unknownKey bool
}
// webIdentityHostPath is the issuer without scheme or trailing slash; it prefixes condition keys only.
func webIdentityHostPath(issuer string) string {
return strings.TrimSuffix(strings.TrimPrefix(issuer, "https://"), "/")
}
// conditionsHold evaluates a Condition block: StringEquals and StringLike on {issuer}:sub and {issuer}:aud.
// The issuer is the host-path (AWS form) or the scheme-prefixed legacy form; no Condition = grant.
// Values under one key are OR'd; keys and operators are AND'd. Anything unrecognised fails closed.
func conditionsHold(raw json.RawMessage, identity webIdentityContext) (bool, condDenial) {
// conditionsHold evaluates a Condition block. Only StringEquals is supported;
// keys are {iss}:sub (equality) and {iss}:aud (set-membership). No Condition = grant.
func conditionsHold(raw json.RawMessage, identity webIdentityContext) bool {
if !isCondRawNonEmpty(raw) {
return true, condDenial{}
return true
}
var ops map[string]map[string]json.RawMessage
if err := json.Unmarshal(raw, &ops); err != nil {
return false, condDenial{unknownKey: true}
return false
}
hostPath := webIdentityHostPath(identity.issuer)
subKeys := []string{hostPath + ":sub", "https://" + hostPath + ":sub"}
audKeys := []string{hostPath + ":aud", "https://" + hostPath + ":aud"}
for op, kv := range ops {
var match func(want, got []string) bool
switch op {
case "StringEquals":
match = anyEquals
case "StringLike":
match = anyLike
default:
// Validator rejects other operators at write time; one here means tampering.
return false, condDenial{operator: op, unknownKey: true}
if op != "StringEquals" {
// Validator rejects other operators at write time; a non-StringEquals here means tampering.
return false
}
issPrefix := strings.TrimSuffix(identity.issuer, "/")
for key, expectedRaw := range kv {
expected, ok := unmarshalStringOrArray(expectedRaw)
if !ok {
return false, condDenial{operator: op, key: key, unknownKey: true}
return false
}
switch {
case slices.Contains(subKeys, key):
if !match(expected, []string{identity.subject}) {
return false, condDenial{operator: op, key: key}
switch key {
case issPrefix + ":sub":
if !anyEquals(expected, []string{identity.subject}) {
return false
}
case slices.Contains(audKeys, key):
if !match(expected, identity.audience) {
return false, condDenial{operator: op, key: key}
case issPrefix + ":aud":
if !anyEquals(expected, identity.audience) {
return false
}
default:
// Unknown condition key — fail closed rather than silently ignore a stricter check.
return false, condDenial{operator: op, key: key, unknownKey: true}
return false
}
}
}
return true, condDenial{}
return true
}
// isCondRawNonEmpty reports whether a Condition RawMessage has meaningful content.
@@ -255,15 +206,3 @@ func anyEquals(want, got []string) bool {
}
return false
}
// anyLike reports whether any pattern in want matches any value in got, with IAM StringLike * and ? wildcards.
func anyLike(want, got []string) bool {
for _, w := range want {
for _, g := range got {
if filters.MatchWildcard(w, g) {
return true
}
}
}
return false
}
@@ -1,255 +0,0 @@
package sts
import (
"bytes"
"fmt"
"log/slog"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/sts"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testIRSAIssuer = "https://oidc.eks.au-mel-1.amazonaws.com/id/ABCDEF0123456789"
testIRSAFedARN = "arn:aws:iam::000000000001:oidc-provider/oidc.eks.au-mel-1.amazonaws.com/id/ABCDEF0123456789"
testIRSASubject = "system:serviceaccount:kube-system:aws-node"
)
func irsaIdentity() webIdentityContext {
return webIdentityContext{
federatedPrincipalARN: testIRSAFedARN,
issuer: testIRSAIssuer,
subject: testIRSASubject,
audience: []string{irsaExpectedAudience},
}
}
// irsaPolicy builds an Allow trust policy with one Condition block.
func irsaPolicy(condition string) string {
return fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity","Condition":%s}]}`,
testIRSAFedARN, condition)
}
func TestEvalWebIdentity_ConditionKeys(t *testing.T) {
awsKey := strings.TrimPrefix(testIRSAIssuer, "https://")
legacy := testIRSAIssuer
tests := []struct {
name string
condition string
grant bool
}{
{
name: "AWS-form sub+aud grants",
condition: fmt.Sprintf(`{"StringEquals":{%q:%q,%q:%q}}`, awsKey+":sub", testIRSASubject, awsKey+":aud", irsaExpectedAudience),
grant: true,
},
{
name: "AWS-form mismatched sub denies",
condition: fmt.Sprintf(`{"StringEquals":{%q:%q,%q:%q}}`, awsKey+":sub", "system:serviceaccount:default:other", awsKey+":aud", irsaExpectedAudience),
},
{
name: "AWS-form mismatched aud denies",
condition: fmt.Sprintf(`{"StringEquals":{%q:%q,%q:%q}}`, awsKey+":sub", testIRSASubject, awsKey+":aud", "other.example.com"),
},
{
name: "AWS-form aud array grants when any entry matches",
condition: fmt.Sprintf(`{"StringEquals":{%q:[%q,%q]}}`, awsKey+":aud", "other.example.com", irsaExpectedAudience),
grant: true,
},
{
name: "legacy https:// form still grants",
condition: fmt.Sprintf(`{"StringEquals":{%q:%q,%q:%q}}`, legacy+":sub", testIRSASubject, legacy+":aud", irsaExpectedAudience),
grant: true,
},
{
name: "StringLike sub wildcard grants",
condition: fmt.Sprintf(`{"StringLike":{%q:%q}}`, awsKey+":sub", "system:serviceaccount:kube-system:*"),
grant: true,
},
{
name: "StringLike sub single-char wildcard grants",
condition: fmt.Sprintf(`{"StringLike":{%q:%q}}`, awsKey+":sub", "system:serviceaccount:kube-system:aws-nod?"),
grant: true,
},
{
name: "StringLike sub non-matching pattern denies",
condition: fmt.Sprintf(`{"StringLike":{%q:%q}}`, awsKey+":sub", "system:serviceaccount:default:*"),
},
{
name: "StringLike is case-sensitive",
condition: fmt.Sprintf(`{"StringLike":{%q:%q}}`, awsKey+":sub", "SYSTEM:serviceaccount:kube-system:*"),
},
{
name: "StringLike on legacy-form key grants",
condition: fmt.Sprintf(`{"StringLike":{%q:%q}}`, legacy+":sub", "system:serviceaccount:kube-system:*"),
grant: true,
},
{
name: "StringEquals aud + StringLike sub grants when both hold",
condition: fmt.Sprintf(`{"StringEquals":{%q:%q},"StringLike":{%q:%q}}`,
awsKey+":aud", irsaExpectedAudience, awsKey+":sub", "system:serviceaccount:kube-system:*"),
grant: true,
},
{
name: "StringEquals aud + StringLike sub denies when aud fails",
condition: fmt.Sprintf(`{"StringEquals":{%q:%q},"StringLike":{%q:%q}}`,
awsKey+":aud", "other.example.com", awsKey+":sub", "system:serviceaccount:kube-system:*"),
},
{
name: "StringEquals aud + StringLike sub denies when sub fails",
condition: fmt.Sprintf(`{"StringEquals":{%q:%q},"StringLike":{%q:%q}}`,
awsKey+":aud", irsaExpectedAudience, awsKey+":sub", "system:serviceaccount:default:*"),
},
{
name: "unknown key denies",
condition: fmt.Sprintf(`{"StringEquals":{%q:%q}}`, awsKey+":email", "a@example.com"),
},
{
name: "key for a different issuer denies",
condition: fmt.Sprintf(`{"StringEquals":{%q:%q}}`, "oidc.eks.au-mel-1.amazonaws.com/id/OTHER:sub", testIRSASubject),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := evalTrustPolicyForWebIdentity(irsaPolicy(tt.condition), irsaIdentity())
if tt.grant {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
})
}
}
// An explicit Deny with a StringLike condition on the AWS-form key must still win over an Allow.
func TestEvalWebIdentity_ExplicitDenyAWSFormStringLike(t *testing.T) {
awsKey := strings.TrimPrefix(testIRSAIssuer, "https://")
policy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity"},
{"Effect":"Deny","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringLike":{%q:%q}}}
]}`, testIRSAFedARN, testIRSAFedARN, awsKey+":sub", "system:serviceaccount:kube-system:*")
err := evalTrustPolicyForWebIdentity(policy, irsaIdentity())
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
}
// The stock Terraform IRSA trust policy keys on the issuer without its scheme.
func TestAssumeRoleWithWebIdentity_AWSFormConditionKeys(t *testing.T) {
svc, _ := newTestSetup(t)
f := newWebIdentityFixture(t, svc, testCallerAccountID)
prefix := strings.TrimPrefix(f.issuer, "https://")
policyFor := func(sub string) string {
return fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringEquals":{%q:%q,%q:%q}}}]}`,
f.federatedARN, prefix+":sub", sub, prefix+":aud", irsaExpectedAudience)
}
assume := func(roleArn *string) error {
_, err := svc.AssumeRoleWithWebIdentity(&sts.AssumeRoleWithWebIdentityInput{
RoleArn: roleArn,
RoleSessionName: aws.String("sess"),
WebIdentityToken: aws.String(f.signToken(f.defaultClaims())),
})
return err
}
granted := createRoleForFixture(t, svc, testCallerAccountID, "irsa-aws-form-ok", policyFor("system:serviceaccount:default:my-sa"))
require.NoError(t, assume(granted.Arn))
denied := createRoleForFixture(t, svc, testCallerAccountID, "irsa-aws-form-bad", policyFor("system:serviceaccount:default:other-sa"))
err := assume(denied.Arn)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
}
// A provider registered with a trailing-slash issuer keeps matching Principal.Federated.
func TestAssumeRoleWithWebIdentity_TrailingSlashIssuerProviderStillGrants(t *testing.T) {
svc, _ := newTestSetup(t)
f := newWebIdentityFixture(t, svc, testCallerAccountID)
f.issuer += "/"
hostPath := strings.TrimPrefix(f.issuer, "https://")
f.federatedARN = iamdomain.OIDCProviderARN(testCallerAccountID, hostPath)
iamKV, err := kvutil.GetOrCreateBucket(t.Context(), svc.js,
iamdomain.IAMAccountBucketName(testCallerAccountID), iamdomain.KVBucketIAMAccountVersion)
require.NoError(t, err)
_, err = iamKV.Put(t.Context(), iamdomain.OIDCProviderKey(f.issuer), []byte(`{"registered":true}`))
require.NoError(t, err)
role := createRoleForFixture(t, svc, testCallerAccountID, "irsa-trailing-slash",
webIdentityTrustPolicyNoCondition(f.federatedARN))
out, err := svc.AssumeRoleWithWebIdentity(&sts.AssumeRoleWithWebIdentityInput{
RoleArn: role.Arn,
RoleSessionName: aws.String("sess"),
WebIdentityToken: aws.String(f.signToken(f.defaultClaims())),
})
require.NoError(t, err)
assert.Equal(t, hostPath, aws.StringValue(out.Provider))
}
// A trust-policy denial logs the role, issuer, subject and failing condition key, never the token.
func TestAssumeRoleWithWebIdentity_DenyLogsReasonWithoutToken(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
t.Cleanup(func() { slog.SetDefault(prev) })
svc, _ := newTestSetup(t)
f := newWebIdentityFixture(t, svc, testCallerAccountID)
prefix := strings.TrimPrefix(f.issuer, "https://")
policy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity","Condition":{"StringLike":{%q:%q}}}]}`,
f.federatedARN, prefix+":sub", "system:serviceaccount:kube-system:*")
role := createRoleForFixture(t, svc, testCallerAccountID, "irsa-deny-log", policy)
token := f.signToken(f.defaultClaims())
_, err := svc.AssumeRoleWithWebIdentity(&sts.AssumeRoleWithWebIdentityInput{
RoleArn: role.Arn,
RoleSessionName: aws.String("sess"),
WebIdentityToken: aws.String(token),
})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
logged := buf.String()
assert.Contains(t, logged, "trust policy denied")
assert.Contains(t, logged, aws.StringValue(role.Arn))
assert.Contains(t, logged, "system:serviceaccount:default:my-sa")
assert.Contains(t, logged, "condition_operator=StringLike")
assert.Contains(t, logged, "condition_key="+prefix+":sub")
assert.Contains(t, logged, "condition value mismatch")
assert.NotContains(t, logged, token)
}
func TestEvalWebIdentity_DenialReasons(t *testing.T) {
awsKey := strings.TrimPrefix(testIRSAIssuer, "https://")
otherPrincipal := strings.Replace(irsaPolicy(`{"StringEquals":{}}`), testIRSAFedARN, "arn:aws:iam::000000000001:oidc-provider/other", 1)
tests := []struct {
name string
policy string
reason string
}{
{"principal mismatch", otherPrincipal, denyReasonNoStatement},
{"value mismatch", irsaPolicy(fmt.Sprintf(`{"StringEquals":{%q:"x"}}`, awsKey+":sub")), denyReasonCondMismatch},
{"unknown key", irsaPolicy(fmt.Sprintf(`{"StringEquals":{%q:"x"}}`, awsKey+":email")), denyReasonCondUnsupport},
{"explicit deny", fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, testIRSAFedARN), denyReasonExplicitDeny},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := evalTrustPolicyForWebIdentity(tt.policy, irsaIdentity())
var denial *trustPolicyDeniedError
require.ErrorAs(t, err, &denial)
assert.Equal(t, tt.reason, denial.reason)
assert.Equal(t, awserrors.ErrorAccessDenied, err.Error())
})
}
}
@@ -1,67 +0,0 @@
// Package passrole is the shared iam:PassRole flow a service adapter runs
// before it hands a role to a managed service. It classifies the role
// reference and orders the checks so a caller who may not pass a role cannot
// learn whether it exists; it knows no service and writes no wire error.
package passrole
import (
"errors"
"github.com/mulgadc/bluebottle/pkg/iampolicy"
)
// Classifications a Resolver reports. Any other error is a dependency
// outage and is never mapped to a caller-facing refusal.
var (
ErrMalformed = errors.New("role reference is malformed")
ErrForeignAccount = errors.New("role is not in the caller's account")
ErrNotFound = errors.New("role does not exist")
)
// Role is the canonical identity of a same-account role. It carries no
// record, trust policy or attached policy.
type Role struct {
ARN string
Name string
}
// Resolver turns a caller-supplied role ARN into a canonical same-account role.
// It returns ErrMalformed, ErrForeignAccount or ErrNotFound (a spelling that
// is not the stored ARN is ErrNotFound) and any other error for an outage.
type Resolver interface {
ResolveRole(accountID, roleARN string) (Role, error)
}
// AuthorizeFunc asks for an IAM decision on the authenticated caller. A nil
// error permits the operation.
type AuthorizeFunc func(service, action string, resources []string, keys iampolicy.ConditionKeys) error
// Check resolves roleARN for accountID and authorizes iam:PassRole on it with
// iam:PassedToService set to passedToService, which only the calling adapter
// may supply.
// A malformed or foreign reference is refused before any authorization.
// A missing role is authorized as written first, so only a caller allowed to
// pass it is told the role is absent.
// An outage is returned unchanged and nothing is authorized.
func Check(r Resolver, authorize AuthorizeFunc, accountID, roleARN, passedToService string) (Role, error) {
role, err := r.ResolveRole(accountID, roleARN)
switch {
case err == nil:
if err := passRole(authorize, role.ARN, passedToService); err != nil {
return Role{}, err
}
return role, nil
case errors.Is(err, ErrNotFound):
if authErr := passRole(authorize, roleARN, passedToService); authErr != nil {
return Role{}, authErr
}
return Role{}, err
default:
return Role{}, err
}
}
func passRole(authorize AuthorizeFunc, roleARN, passedToService string) error {
return authorize("iam", "PassRole", []string{roleARN},
iampolicy.ConditionKeys{iampolicy.KeyPassedToService: passedToService})
}
@@ -1,90 +0,0 @@
package passrole
import (
"errors"
"testing"
"github.com/mulgadc/bluebottle/pkg/iampolicy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
acct = "111122223333"
canonical = "arn:aws:iam::" + acct + ":role/svc/app"
asWritten = "arn:aws:iam::" + acct + ":role/app"
target = "svc.example.amazonaws.com"
)
type fakeResolver struct {
role Role
err error
}
func (f fakeResolver) ResolveRole(string, string) (Role, error) { return f.role, f.err }
type call struct {
service, action string
resources []string
keys iampolicy.ConditionKeys
}
func recorder(result error, calls *[]call) AuthorizeFunc {
return func(service, action string, resources []string, keys iampolicy.ConditionKeys) error {
*calls = append(*calls, call{service, action, resources, keys})
return result
}
}
var errDenied = errors.New("AccessDenied")
func TestCheck_FoundRoleAuthorizesCanonicalARN(t *testing.T) {
var calls []call
role, err := Check(fakeResolver{role: Role{ARN: canonical, Name: "app"}}, recorder(nil, &calls), acct, asWritten, target)
require.NoError(t, err)
assert.Equal(t, Role{ARN: canonical, Name: "app"}, role)
require.Len(t, calls, 1)
assert.Equal(t, "iam", calls[0].service)
assert.Equal(t, "PassRole", calls[0].action)
assert.Equal(t, []string{canonical}, calls[0].resources, "the canonical ARN is authorized, not the spelling the caller gave")
assert.Equal(t, iampolicy.ConditionKeys{iampolicy.KeyPassedToService: target}, calls[0].keys)
}
func TestCheck_DeniedFoundRole(t *testing.T) {
var calls []call
_, err := Check(fakeResolver{role: Role{ARN: canonical, Name: "app"}}, recorder(errDenied, &calls), acct, asWritten, target)
require.ErrorIs(t, err, errDenied)
}
func TestCheck_MalformedAndForeignAreRefusedBeforeAuthorization(t *testing.T) {
for name, resolveErr := range map[string]error{"malformed": ErrMalformed, "foreign": ErrForeignAccount} {
t.Run(name, func(t *testing.T) {
var calls []call
_, err := Check(fakeResolver{err: resolveErr}, recorder(nil, &calls), acct, "x", target)
require.ErrorIs(t, err, resolveErr)
assert.Empty(t, calls, "nothing is authorized, so nothing about the role is probed")
})
}
}
func TestCheck_MissingRoleIsReportedOnlyToACallerAllowedToPassIt(t *testing.T) {
var calls []call
_, err := Check(fakeResolver{err: ErrNotFound}, recorder(errDenied, &calls), acct, asWritten, target)
require.ErrorIs(t, err, errDenied)
require.NotErrorIs(t, err, ErrNotFound, "a denied caller must not learn the role is absent")
require.Len(t, calls, 1)
assert.Equal(t, []string{asWritten}, calls[0].resources, "an absent role is authorized as written")
calls = nil
_, err = Check(fakeResolver{err: ErrNotFound}, recorder(nil, &calls), acct, asWritten, target)
require.ErrorIs(t, err, ErrNotFound)
}
func TestCheck_OutageIsReturnedUnchangedWithoutAuthorizing(t *testing.T) {
outage := errors.New("get role: nats: timeout")
var calls []call
_, err := Check(fakeResolver{err: outage}, recorder(nil, &calls), acct, asWritten, target)
require.ErrorIs(t, err, outage)
require.NotErrorIs(t, err, ErrNotFound)
assert.Empty(t, calls)
}
+1 -5
View File
@@ -36,10 +36,6 @@ const (
// vllm-serving AMI, and the shared Bedrock system VPC.
ManagedByBedrock = "bedrock"
// ManagedBySQS identifies the dedicated SQS store VMs, which live in the
// system account on a persistent data volume.
ManagedBySQS = "sqs"
// LBARNKey stores the parent LB ARN on ELBv2-managed ENIs.
LBARNKey = "spinifex:lb-arn"
@@ -68,7 +64,7 @@ const (
// VMs bind a system.TerminateInstance.{id} subject so a cluster-wide teardown
// invoked on any node can route a terminate to the owning node.
func IsSystemManaged(managedBy string) bool {
return managedBy == ManagedByELBv2 || managedBy == ManagedByEKS || managedBy == ManagedByRDS || managedBy == ManagedByBedrock || managedBy == ManagedBySQS
return managedBy == ManagedByELBv2 || managedBy == ManagedByEKS || managedBy == ManagedByRDS || managedBy == ManagedByBedrock
}
// MapToEC2 converts a tag map to a slice of EC2 Tag pointers. It returns nil
@@ -17,7 +17,6 @@ func TestIsSystemManaged(t *testing.T) {
"elbv2": {ManagedByELBv2, true, "HAProxy VMs are platform-owned"},
"eks": {ManagedByEKS, true, "K3s control-plane VMs are platform-owned"},
"rds": {ManagedByRDS, true, "DB engine VMs are platform-owned and live in the system account"},
"sqs": {ManagedBySQS, true, "SQS store VMs are platform-owned and live in the system account"},
// Container instances launched from the ECS node AMI stay customer-owned,
// so only the AMI carries the tag — never a VM.
"ecs": {ManagedByECS, false, "ECS container instances are customer-owned"},
@@ -8,9 +8,6 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
eksawsapi "github.com/mulgadc/spinifex/spinifex/domains/eks/awsapi"
rdsawsapi "github.com/mulgadc/spinifex/spinifex/domains/rds/awsapi"
stsawsapi "github.com/mulgadc/spinifex/spinifex/domains/sts/awsapi"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"net/http"
"strings"
@@ -74,8 +71,11 @@ func actorProbeGateway(t *testing.T, e *cbEnv) (http.Handler, *[]actorObservatio
return err
}
r := inv.Request
rds := rdsawsapi.CallerFromActor(actor)
eks := eksawsapi.CallerFromActor(actor)
rds, err := rdsCaller(r)
if err != nil {
return err
}
eks := eksCaller(r)
seen = append(seen, actorObservation{
Actor: actor,
Identity: callerPrincipalOf(r).identity,
@@ -89,7 +89,7 @@ func actorProbeGateway(t *testing.T, e *cbEnv) (http.Handler, *[]actorObservatio
RDSSessionName: rds.SessionName, RDSRoleName: rds.RoleName,
EKSAccountID: eks.AccountID, EKSPrincipalType: eks.PrincipalType,
EKSSessionName: eks.SessionName, EKSRoleName: eks.RoleName,
EKSPrincipalARN: actor.CallerARN,
EKSPrincipalARN: eksCallerPrincipalARN(r),
})
w.WriteHeader(http.StatusOK)
return nil
@@ -103,10 +103,8 @@ func actorProbeGateway(t *testing.T, e *cbEnv) (http.Handler, *[]actorObservatio
IAMService: e.counter,
Credentials: credverify.New(e.counter, e.sts),
STSService: e.sts,
Services: b.Build(),
}
gw.AnonymousSTS = stsawsapi.NewAnonymousAssumeRoleWithWebIdentity(stsawsapi.Deps{STS: gw.STSService, IAM: gw.IAMService})
require.NoError(t, b.Register(stsRegistration(gw)))
gw.Services = b.Build()
return gw.SetupRoutes(), &seen
}
+169 -22
View File
@@ -1,41 +1,65 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"uuid"
"github.com/go-chi/chi/v5"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
operatoradmin "github.com/mulgadc/spinifex/spinifex/operator/admin"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
ingresshttp "github.com/mulgadc/spinifex/spinifex/ingress/http"
)
// Admin_Request serves the private /admin/<Method> surface. The route stays
// here so it keeps its place ahead of the service selector; the work is the
// spinifex registration's, dispatched exactly as a Query request is. Only the
// error rendering differs: this surface answers in JSON with a request ID.
func (gw *GatewayConfig) Admin_Request(w http.ResponseWriter, r *http.Request) {
requestID := uuid.NewV4().String()
w.Header().Set("X-Amzn-Requestid", requestID)
r = r.WithContext(operatoradmin.WithRequestID(r.Context(), requestID))
entry, ok := gw.registered(operatoradmin.ServiceName)
if !ok {
slog.Error("Admin: spinifex service is not registered", "requestId", requestID)
gw.writeAdminError(w, requestID, awserrors.ErrorInternalError, "")
return
// adminMethods is the callable /admin/<Method> set, built from the contract's
// list. A method absent here is rejected before any parsing, so adding a
// handler is a deliberate act rather than a side effect of adding a function.
var adminMethods = func() map[string]bool {
m := make(map[string]bool)
for _, name := range operatorv1.AdminMethodNames() {
m[name] = true
}
return m
}()
action, _ := r.Context().Value(ctxAction).(string)
if err := gw.dispatchRegistered(entry, w, r); err != nil {
slog.Log(r.Context(), serviceErrorLevel(operatoradmin.ServiceName, err), "Service request error",
"service", operatoradmin.ServiceName, "action", action, "requestId", requestID, "error", err)
gw.writeAdminErrorFor(w, requestID, err)
return
// adminPathPrefix is the private admin surface's URL prefix. It is matched
// before routing so the auth middleware can label the request by method.
const adminPathPrefix = "/admin/"
// adminPathMethod extracts the method from an /admin/<Method> path. It reports
// false for anything else, including a nested path, so only the exact shape the
// router serves is labelled.
func adminPathMethod(path string) (string, bool) {
rest, ok := strings.CutPrefix(path, adminPathPrefix)
if !ok || rest == "" || strings.Contains(rest, "/") {
return "", false
}
slog.Info("Service request completed", "service", operatoradmin.ServiceName, "action", action, "requestId", requestID)
if !adminMethods[rest] {
return "", false
}
return rest, true
}
// ctxAdminRequestID carries the /admin request ID to handlers that log under it.
// It correlates log records only and authorises nothing.
const ctxAdminRequestID contextKey = "admin.requestID"
func adminRequestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(ctxAdminRequestID).(string)
return id
}
// adminMaxBodyBytes caps the JSON request body. The largest legitimate request
// is a few hundred bytes; the cap exists so an unauthenticated-shaped mistake
// cannot make the gateway buffer megabytes.
const adminMaxBodyBytes = 8 << 10
// adminErrorBody is the JSON error envelope for /admin. This surface is not an
// AWS API, so it does not use the EC2 or IAM XML envelopes.
type adminErrorBody struct {
@@ -48,6 +72,129 @@ type adminErrorDetail struct {
Message string `json:"message"`
}
// Admin_Request dispatches the private /admin/<Method> surface: super-admin
// only, JSON in and JSON out, SigV4-authenticated by the shared middleware.
//
// Authorization is fail-closed at every step and every denial returns the same
// AccessDenied, so a caller cannot probe which gate rejected it.
func (gw *GatewayConfig) Admin_Request(w http.ResponseWriter, r *http.Request) {
requestID := uuid.NewV4().String()
w.Header().Set("X-Amzn-Requestid", requestID)
method := chi.URLParam(r, "method")
ctx := context.WithValue(r.Context(), ctxAdminRequestID, requestID)
// One handler for every verb rather than a POST route plus a catch-all:
// chi resolves the two to the same pattern, and the catch-all wins.
if r.Method != http.MethodPost {
gw.writeAdminError(w, requestID, awserrors.ErrorMethodNotAllowed, "")
return
}
if !adminMethods[method] {
slog.Debug("Admin: unknown method", "method", method)
gw.writeAdminError(w, requestID, awserrors.ErrorInvalidAction, "")
return
}
if err := gw.authorizeAdmin(r, method); err != nil {
gw.writeAdminErrorFor(w, requestID, err)
return
}
if gw.NATSConn == nil || !gw.NATSConn.IsConnected() {
gw.writeAdminError(w, requestID, awserrors.ErrorServiceUnavailable, clusterUnavailableMsg)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, adminMaxBodyBytes+1))
if err != nil {
slog.Error("Admin: failed to read request body", "method", method, "err", err)
gw.writeAdminError(w, requestID, awserrors.ErrorInternalError, "")
return
}
if len(body) > adminMaxBodyBytes {
gw.writeAdminError(w, requestID, awserrors.ErrorInvalidRequest, "request body exceeds 8 KiB")
return
}
var output any
switch method {
case "CreateAccount":
output, err = gw.adminCreateAccount(ctx, body)
case "DeleteAccount":
output, err = gw.adminDeleteAccount(ctx, body)
case "DescribeAccountDeletion":
output, err = gw.adminDescribeAccountDeletion(ctx, body)
case "ListAccounts":
output, err = gw.adminListAccounts(ctx, body)
case "GetAccountQuota":
output, err = gw.adminGetAccountQuota(ctx, body)
case "PutAccountQuota":
output, err = gw.adminPutAccountQuota(ctx, body)
default:
// Unreachable: adminMethods gates the switch. Fail closed anyway so
// adding a name to the map without a case cannot silently return 200.
err = errors.New(awserrors.ErrorInvalidAction)
}
if err != nil {
gw.writeAdminErrorFor(w, requestID, err)
return
}
jsonOutput, err := json.Marshal(output)
if err != nil {
slog.Error("Admin: failed to marshal response", "method", method, "err", err)
gw.writeAdminError(w, requestID, awserrors.ErrorInternalError, "")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(jsonOutput); err != nil {
slog.Error("Admin: failed to write response", "method", method, "err", err)
}
}
// authorizeAdmin runs the five fail-closed gates. Each returns the same
// AccessDenied so the response cannot be used to enumerate which condition the
// caller failed; the log line names it for operators.
func (gw *GatewayConfig) authorizeAdmin(r *http.Request, method string) error {
ctx := r.Context()
service, _ := ctx.Value(ctxService).(string)
actor, _ := callerActor(ctx)
accountID, identity, principalType := actor.AccountID, actorName(actor), actor.PrincipalType
deny := func(reason string) error {
slog.Warn("Admin: access denied", "method", method, "reason", reason,
"service", service, "accountID", accountID, "identity", identity,
"principalType", principalType, "sourceIP", ingresshttp.ClientIP(r.RemoteAddr))
return errors.New(awserrors.ErrorAccessDenied)
}
// The credential scope must be spinifex: a key signed for ec2 reaching this
// path means the caller is probing, not calling.
if service != "spinifex" {
return deny("credential scope is not spinifex")
}
if accountID != awsidentifiers.DefaultAccountID() {
return deny("not the super-admin account")
}
// Long-lived IAM users only. This excludes assumed-role sessions outright,
// so a role session cannot reach account creation even from this account.
if principalType != principalTypeUser {
return deny("principal is not an IAM user")
}
if identity == "" {
return deny("no identity in auth context")
}
// Defence in depth and the actual grant: the principal must hold
// spinifex:<Method>. checkPolicy fails closed on a nil IAM service, an
// unauthenticated request and an unresolvable principal.
return gw.checkPolicy(r, "spinifex", method)
}
// writeAdminErrorFor resolves an error to its code and any message its call
// site attached, so a wrapped error renders as its own code instead of falling
// through to InternalError.
@@ -1,4 +1,4 @@
package admin
package gateway
import (
"context"
@@ -8,7 +8,6 @@ import (
"github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
)
// AccountQuotaRequest addresses one account. PutAccountQuota also carries the
@@ -34,15 +33,15 @@ const (
sourceOverride = "override"
)
// getAccountQuota reports one account's effective limits, its stored
// adminGetAccountQuota reports one account's effective limits, its stored
// overrides, and which layer each dimension resolved from.
func (d Deps) getAccountQuota(ctx context.Context, body []byte) (any, error) {
accountID, _, err := d.parseAccountQuotaRequest(body)
func (gw *GatewayConfig) adminGetAccountQuota(ctx context.Context, body []byte) (any, error) {
accountID, _, err := gw.parseAccountQuotaRequest(body)
if err != nil {
return nil, err
}
over, limits, err := d.Quota.GetAccountQuota(ctx, accountID)
over, limits, err := gw.Quota.GetAccountQuota(ctx, accountID)
if err != nil {
if awserrors.IsErrorCode(err, awserrors.ErrorBlocked) {
return nil, err
@@ -53,18 +52,19 @@ func (d Deps) getAccountQuota(ctx context.Context, body []byte) (any, error) {
return accountQuotaResponse(accountID, over, limits), nil
}
// putAccountQuota stores one account's overrides and returns the limits
// adminPutAccountQuota stores one account's overrides and returns the limits
// they resolve to, so a caller never has to guess what it just applied. An
// empty override set clears the record.
func (d Deps) putAccountQuota(ctx context.Context, caller dispatch.Actor, body []byte) (any, error) {
accountID, over, err := d.parseAccountQuotaRequest(body)
func (gw *GatewayConfig) adminPutAccountQuota(ctx context.Context, body []byte) (any, error) {
accountID, over, err := gw.parseAccountQuotaRequest(body)
if err != nil {
return nil, err
}
// The caller's own identity, so a quota change names who made it.
caller, _ := callerActor(ctx)
updatedBy := actorName(caller)
if err := d.Quota.PutAccountQuota(ctx, accountID, over, updatedBy); err != nil {
if err := gw.Quota.PutAccountQuota(ctx, accountID, over, updatedBy); err != nil {
if awserrors.IsErrorCode(err, awserrors.ErrorBlocked) {
return nil, err
}
@@ -74,7 +74,7 @@ func (d Deps) putAccountQuota(ctx context.Context, caller dispatch.Actor, body [
slog.InfoContext(ctx, "Account quota override updated",
"accountID", accountID, "updatedBy", updatedBy, "cleared", over.Empty())
stored, limits, err := d.Quota.GetAccountQuota(ctx, accountID)
stored, limits, err := gw.Quota.GetAccountQuota(ctx, accountID)
if err != nil {
if awserrors.IsErrorCode(err, awserrors.ErrorBlocked) {
return nil, err
@@ -88,8 +88,8 @@ func (d Deps) putAccountQuota(ctx context.Context, caller dispatch.Actor, body [
// parseAccountQuotaRequest decodes and validates a quota request. The account
// must exist: silently accepting an override for a typo'd ID would store a
// record that never applies to anything.
func (d Deps) parseAccountQuotaRequest(body []byte) (string, quota.Overrides, error) {
if d.Quota == nil || d.IAM == nil {
func (gw *GatewayConfig) parseAccountQuotaRequest(body []byte) (string, quota.Overrides, error) {
if gw.Quota == nil || gw.IAMService == nil {
slog.Error("AccountQuota: quota or IAM service not available")
return "", quota.Overrides{}, errors.New(awserrors.ErrorInternalError)
}
@@ -101,7 +101,7 @@ func (d Deps) parseAccountQuotaRequest(body []byte) (string, quota.Overrides, er
if !accountIDRE.MatchString(req.AccountID) {
return "", quota.Overrides{}, errors.New(awserrors.ErrorInvalidRequest)
}
if _, err := d.IAM.GetAccount(req.AccountID); err != nil {
if _, err := gw.IAMService.GetAccount(req.AccountID); err != nil {
return "", quota.Overrides{}, errors.New(awserrors.ErrorInvalidRequest)
}
return req.AccountID, req.Overrides, nil
@@ -1,11 +1,11 @@
//test:in-package — drives the unexported admin handlers and the request identity they read from the context.
package admin
package gateway
import (
"context"
"encoding/json"
"errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
"testing"
"github.com/mulgadc/spinifex/internal/testkit"
@@ -21,7 +21,7 @@ func quotaPtr(v int) *int { return &v }
// quotaIAMService resolves exactly one account, so a request naming any other
// exercises the "account must exist" gate.
type quotaIAMService struct {
iamdomain.IAMService
policyMockIAMService
known string
}
@@ -35,7 +35,7 @@ func (m *quotaIAMService) GetAccount(accountID string) (*iamdomain.Account, erro
// quotaTestGateway wires a real quota Service over a real JetStream KV bucket,
// so the admin handlers are exercised against the store they use in production.
func quotaTestGateway(t *testing.T) (Deps, string) {
func quotaTestGateway(t *testing.T) (*GatewayConfig, string) {
t.Helper()
const account = "000000000042"
@@ -52,9 +52,10 @@ func quotaTestGateway(t *testing.T) (Deps, string) {
}, nil)
quota.SetOverrides(kv)
return (Deps{
IAM: &quotaIAMService{known: account},
Quota: quota,
return withVerifier(&GatewayConfig{
DisableLogging: true,
IAMService: &quotaIAMService{known: account},
Quota: quota,
}), account
}
@@ -70,7 +71,7 @@ func quotaRequestBody(t *testing.T, req AccountQuotaRequest) []byte {
func TestAdminGetAccountQuotaInherits(t *testing.T) {
gw, account := quotaTestGateway(t)
output, err := gw.getAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
output, err := gw.adminGetAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
require.NoError(t, err)
resp, ok := output.(*AccountQuotaResponse)
@@ -83,9 +84,9 @@ func TestAdminGetAccountQuotaInherits(t *testing.T) {
// named changed.
func TestAdminPutThenGetAccountQuota(t *testing.T) {
gw, account := quotaTestGateway(t)
ctx := t.Context()
ctx := withPrincipalField(t.Context(), fieldIdentity, "operator")
output, err := gw.putAccountQuota(ctx, operatorActor, quotaRequestBody(t, AccountQuotaRequest{
output, err := gw.adminPutAccountQuota(ctx, quotaRequestBody(t, AccountQuotaRequest{
AccountID: account,
Overrides: admissionquota.Overrides{VCPUs: quotaPtr(32)},
}))
@@ -98,7 +99,7 @@ func TestAdminPutThenGetAccountQuota(t *testing.T) {
require.Equal(t, "operator", put.Overrides.UpdatedBy)
require.Equal(t, 4, put.Limits["vpcs"], "an unnamed dimension must keep inheriting")
output, err = gw.getAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
output, err = gw.adminGetAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
require.NoError(t, err)
got, ok := output.(*AccountQuotaResponse)
require.True(t, ok)
@@ -109,15 +110,15 @@ func TestAdminPutThenGetAccountQuota(t *testing.T) {
// Clearing must return the account to the configured limits.
func TestAdminPutAccountQuotaClears(t *testing.T) {
gw, account := quotaTestGateway(t)
ctx := t.Context()
ctx := withPrincipalField(t.Context(), fieldIdentity, "operator")
_, err := gw.putAccountQuota(ctx, operatorActor, quotaRequestBody(t, AccountQuotaRequest{
_, err := gw.adminPutAccountQuota(ctx, quotaRequestBody(t, AccountQuotaRequest{
AccountID: account,
Overrides: admissionquota.Overrides{VCPUs: quotaPtr(32)},
}))
require.NoError(t, err)
output, err := gw.putAccountQuota(ctx, operatorActor, quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
output, err := gw.adminPutAccountQuota(ctx, quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
require.NoError(t, err)
resp, ok := output.(*AccountQuotaResponse)
@@ -142,11 +143,11 @@ func TestAdminAccountQuotaRejectsBadRequests(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := gw.getAccountQuota(t.Context(), tt.body)
_, err := gw.adminGetAccountQuota(t.Context(), tt.body)
require.Error(t, err)
require.Equal(t, awserrors.ErrorInvalidRequest, err.Error())
_, err = gw.putAccountQuota(t.Context(), operatorActor, tt.body)
_, err = gw.adminPutAccountQuota(t.Context(), tt.body)
require.Error(t, err)
require.Equal(t, awserrors.ErrorInvalidRequest, err.Error())
})
@@ -156,9 +157,9 @@ func TestAdminAccountQuotaRejectsBadRequests(t *testing.T) {
// A gateway with quotas disabled has no override bucket; the admin surface must
// report that rather than pretend the write landed.
func TestAdminAccountQuotaWithoutQuotaService(t *testing.T) {
gw := Deps{IAM: &quotaIAMService{known: "000000000042"}}
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: &quotaIAMService{known: "000000000042"}})
_, err := gw.getAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: "000000000042"}))
_, err := gw.adminGetAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: "000000000042"}))
require.Error(t, err)
require.Equal(t, awserrors.ErrorInternalError, err.Error())
}
@@ -197,6 +198,15 @@ func TestAccountQuotaResponseExplicitZeroIsAnOverride(t *testing.T) {
require.Equal(t, sourceConfig, resp.Source["vcpus"])
}
// The two quota methods must be routable, or they return InvalidAction on a
// cluster that otherwise looks correctly configured.
func TestAccountQuotaMethodsAreRegistered(t *testing.T) {
require.True(t, adminMethods["GetAccountQuota"])
require.True(t, adminMethods["PutAccountQuota"])
require.Contains(t, operatorv1.AdminMethodNames(), "GetAccountQuota")
require.Contains(t, operatorv1.AdminMethodNames(), "PutAccountQuota")
}
// quotaStubChecker answers AccountTerminating from a fixed set, so these
// tests can seal a record without a real IAM bucket.
type quotaStubChecker struct {
@@ -212,10 +222,10 @@ func (c *quotaStubChecker) AccountTerminating(_ context.Context, accountID strin
// type, the stored record stays active, and the response never shows state.
func TestAdminRequestCannotCarryState(t *testing.T) {
gw, account := quotaTestGateway(t)
ctx := t.Context()
ctx := withPrincipalField(t.Context(), fieldIdentity, "operator")
body := []byte(`{"accountId":"` + account + `","overrides":{"vcpus":32,"state":"sealed"}}`)
output, err := gw.putAccountQuota(ctx, operatorActor, body)
output, err := gw.adminPutAccountQuota(ctx, body)
require.NoError(t, err)
resp, ok := output.(*AccountQuotaResponse)
@@ -238,17 +248,14 @@ func TestAdminQuotaReadsMapBlocked(t *testing.T) {
_, err := gw.Quota.SealAccountOverrides(t.Context(), account)
require.NoError(t, err)
_, err = gw.getAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
_, err = gw.adminGetAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
require.Error(t, err)
require.Equal(t, awserrors.ErrorBlocked, err.Error())
ctx := t.Context()
_, err = gw.putAccountQuota(ctx, operatorActor, quotaRequestBody(t, AccountQuotaRequest{
ctx := withPrincipalField(t.Context(), fieldIdentity, "operator")
_, err = gw.adminPutAccountQuota(ctx, quotaRequestBody(t, AccountQuotaRequest{
AccountID: account, Overrides: admissionquota.Overrides{VCPUs: quotaPtr(8)},
}))
require.Error(t, err)
require.Equal(t, awserrors.ErrorBlocked, err.Error())
}
// operatorActor is the caller a quota change is attributed to.
var operatorActor = dispatch.Actor{UserName: "operator", PrincipalType: "user"}
@@ -1,4 +1,4 @@
package admin
package gateway
import (
"context"
@@ -59,14 +59,14 @@ const (
adminIdemDone = "done"
)
// createAccount provisions a tenant account and returns its first
// adminCreateAccount provisions a tenant account and returns its first
// credentials.
//
// Correctness rests on two CAS creates rather than a transaction: the client
// token admits exactly one in-flight attempt, and the name reservation makes
// duplicate names impossible. Provisioning itself is resumable, so a failure
// anywhere is recovered by retrying with the same token.
func (d Deps) createAccount(ctx context.Context, body []byte) (any, error) {
func (gw *GatewayConfig) adminCreateAccount(ctx context.Context, body []byte) (any, error) {
var req operatorv1.CreateAccountRequest
if err := json.Unmarshal(body, &req); err != nil {
slog.Debug("CreateAccount: malformed JSON body", "err", err)
@@ -75,12 +75,12 @@ func (d Deps) createAccount(ctx context.Context, body []byte) (any, error) {
if err := validateCreateAccountRequest(&req); err != nil {
return nil, err
}
if d.IAM == nil {
if gw.IAMService == nil {
slog.Error("CreateAccount: IAM service not available")
return nil, errors.New(awserrors.ErrorInternalError)
}
names, idem, err := d.stores(ctx)
names, idem, err := gw.adminStores(ctx)
if err != nil {
return nil, err
}
@@ -92,7 +92,7 @@ func (d Deps) createAccount(ctx context.Context, body []byte) (any, error) {
return nil, err
}
if replay != nil {
secret, err := d.IAM.DecryptSecret(replay.SecretAccessKey)
secret, err := gw.IAMService.DecryptSecret(replay.SecretAccessKey)
if err != nil {
slog.Error("CreateAccount: cannot decrypt replayed secret", "accountID", replay.AccountID, "err", err)
return nil, errors.New(awserrors.ErrorInternalError)
@@ -103,7 +103,7 @@ func (d Deps) createAccount(ctx context.Context, body []byte) (any, error) {
return &out, nil
}
response, err := d.provisionNewAccount(ctx, names, &req)
response, err := gw.provisionNewAccount(ctx, names, &req)
if err != nil {
// Drop the token claim so the caller can retry rather than being told
// OperationInProgress until the TTL expires.
@@ -115,7 +115,7 @@ func (d Deps) createAccount(ctx context.Context, body []byte) (any, error) {
}
stored := *response
ciphertext, err := d.IAM.EncryptSecret(response.SecretAccessKey)
ciphertext, err := gw.IAMService.EncryptSecret(response.SecretAccessKey)
if err != nil {
slog.Error("CreateAccount: failed to encrypt secret for replay", "accountID", response.AccountID, "err", err)
return nil, errors.New(awserrors.ErrorInternalError)
@@ -144,14 +144,14 @@ func (d Deps) createAccount(ctx context.Context, body []byte) (any, error) {
// provisionNewAccount reserves the name, creates the account, provisions its
// admin identity and waits for the default VPC.
func (d Deps) provisionNewAccount(
func (gw *GatewayConfig) provisionNewAccount(
ctx context.Context,
names *iamdomain.AccountNameIndex,
req *operatorv1.CreateAccountRequest,
) (*operatorv1.CreateAccountResponse, error) {
// Accounts that predate the reservation index have no entry to collide
// with, so they are found by name scan. The same listing bounds the cap.
existing, err := iamdomain.FindAccountByName(d.IAM, req.Name)
existing, err := iamdomain.FindAccountByName(gw.IAMService, req.Name)
if err != nil {
slog.Error("CreateAccount: failed to list accounts", "err", err)
return nil, errors.New(awserrors.ErrorInternalError)
@@ -159,7 +159,7 @@ func (d Deps) provisionNewAccount(
if existing != nil {
return nil, errors.New(awserrors.ErrorAccountAlreadyExists)
}
if err := d.checkAccountCap(); err != nil {
if err := gw.checkAccountCap(); err != nil {
return nil, err
}
@@ -173,7 +173,7 @@ func (d Deps) provisionNewAccount(
return nil, errors.New(awserrors.ErrorInternalError)
}
account, err := d.IAM.CreateAccount(iamdomain.NormalizeAccountName(req.Name))
account, err := gw.IAMService.CreateAccount(iamdomain.NormalizeAccountName(req.Name))
if err != nil {
if relErr := names.Release(ctx, req.Name, req.ClientToken); relErr != nil {
slog.Error("CreateAccount: failed to release name reservation", "err", relErr)
@@ -191,14 +191,14 @@ func (d Deps) provisionNewAccount(
return nil, errors.New(awserrors.ErrorInternalError)
}
provisioned, err := iamdomain.ProvisionAccount(d.IAM, account.AccountID, account.AccountName)
provisioned, err := iamdomain.ProvisionAccount(gw.IAMService, account.AccountID, account.AccountName)
if err != nil {
slog.Error("CreateAccount: failed to provision account",
"accountID", account.AccountID, "err", err)
return nil, errors.New(awserrors.ErrorInternalError)
}
vpcID, err := d.ensureDefaultVPC(ctx, account.AccountID)
vpcID, err := gw.ensureDefaultVPC(ctx, account.AccountID)
if err != nil {
slog.Error("CreateAccount: default VPC not ready",
"accountID", account.AccountID, "err", err)
@@ -268,18 +268,18 @@ func claimClientToken(ctx context.Context, idem jetstream.KeyValue, clientToken,
// checkAccountCap enforces [signup] max_accounts. Zero or absent means no cap,
// which is the pre-existing behaviour for a cluster that never opted in.
func (d Deps) checkAccountCap() error {
if d.SignupMaxAccounts <= 0 {
func (gw *GatewayConfig) checkAccountCap() error {
if gw.SignupMaxAccounts <= 0 {
return nil
}
accounts, err := d.IAM.ListAccounts()
accounts, err := gw.IAMService.ListAccounts()
if err != nil {
slog.Error("CreateAccount: failed to count accounts for cap", "err", err)
return errors.New(awserrors.ErrorInternalError)
}
if len(accounts) >= d.SignupMaxAccounts {
if len(accounts) >= gw.SignupMaxAccounts {
slog.Warn("CreateAccount: account cap reached",
"accounts", len(accounts), "maxAccounts", d.SignupMaxAccounts)
"accounts", len(accounts), "maxAccounts", gw.SignupMaxAccounts)
return errors.New(awserrors.ErrorIAMLimitExceeded)
}
return nil
@@ -288,7 +288,7 @@ func (d Deps) checkAccountCap() error {
// ensureDefaultVPC asks the daemon to build the account's default VPC and waits
// for the acknowledgement, so the response never promises an account that
// cannot launch anything.
func (d Deps) ensureDefaultVPC(ctx context.Context, accountID string) (string, error) {
func (gw *GatewayConfig) ensureDefaultVPC(ctx context.Context, accountID string) (string, error) {
payload, err := json.Marshal(struct {
AccountID string `json:"account_id"`
}{AccountID: accountID})
@@ -299,7 +299,7 @@ func (d Deps) ensureDefaultVPC(ctx context.Context, accountID string) (string, e
reqCtx, cancel := context.WithTimeout(ctx, ensureDefaultVpcTimeout)
defer cancel()
msg, err := d.NATS.RequestWithContext(reqCtx, ec2v1.EnsureDefaultVpcSubject, payload)
msg, err := gw.NATSConn.RequestWithContext(reqCtx, ec2v1.EnsureDefaultVpcSubject, payload)
if err != nil {
return "", fmt.Errorf("request default VPC: %w", err)
}
@@ -320,11 +320,11 @@ func (d Deps) ensureDefaultVPC(ctx context.Context, accountID string) (string, e
return reply.VpcID, nil
}
// stores opens the name index and the idempotency bucket. Both are opened
// adminStores opens the name index and the idempotency bucket. Both are opened
// per request rather than cached on the gateway so a bucket created after
// startup (a cluster upgraded into this feature) is picked up without a restart.
func (d Deps) stores(ctx context.Context) (*iamdomain.AccountNameIndex, jetstream.KeyValue, error) {
js, err := jetstream.New(d.NATS)
func (gw *GatewayConfig) adminStores(ctx context.Context) (*iamdomain.AccountNameIndex, jetstream.KeyValue, error) {
js, err := jetstream.New(gw.NATSConn)
if err != nil {
slog.Error("CreateAccount: failed to get JetStream context", "err", err)
return nil, nil, errors.New(awserrors.ErrorServiceUnavailable)
@@ -1,4 +1,4 @@
package admin
package gateway
//test:in-package — exercises the unexported CreateAccount internals
// (claimClientToken, checkAccountCap, request validation) directly, since the
@@ -8,6 +8,7 @@ import (
"bytes"
"encoding/json"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"log/slog"
"strings"
"testing"
@@ -205,8 +206,10 @@ func TestCheckAccountCap(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gw := Deps{
IAM: &capMockIAMService{accounts: tc.accounts},
gw := &GatewayConfig{
DisableLogging: true,
IAMService: &capMockIAMService{accounts: tc.accounts},
Credentials: credverify.New(&capMockIAMService{accounts: tc.accounts}, nil),
SignupMaxAccounts: tc.max,
}
err := gw.checkAccountCap()
@@ -222,8 +225,10 @@ func TestCheckAccountCap(t *testing.T) {
// A cap that cannot be evaluated must not be treated as "not reached".
func TestCheckAccountCapFailsClosedOnListError(t *testing.T) {
gw := Deps{
IAM: &capMockIAMService{err: assert.AnError},
gw := &GatewayConfig{
DisableLogging: true,
IAMService: &capMockIAMService{err: assert.AnError},
Credentials: credverify.New(&capMockIAMService{err: assert.AnError}, nil),
SignupMaxAccounts: 128,
}
@@ -234,7 +239,7 @@ func TestCheckAccountCapFailsClosedOnListError(t *testing.T) {
// newCreateAccountGateway wires a gateway against a real IAM service, embedded
// JetStream, and a stub daemon answering the default-VPC request.
func newCreateAccountGateway(t *testing.T) Deps {
func newCreateAccountGateway(t *testing.T) *GatewayConfig {
t.Helper()
_, nc, _ := testutil.StartTestJetStream(t)
@@ -249,9 +254,11 @@ func newCreateAccountGateway(t *testing.T) Deps {
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
return Deps{
IAM: svc,
NATS: nc,
return &GatewayConfig{
DisableLogging: true,
IAMService: svc,
Credentials: credverify.New(svc, nil),
NATSConn: nc,
SignupMaxAccounts: 128,
}
}
@@ -268,7 +275,7 @@ func TestAdminCreateAccountProvisionsAndReplays(t *testing.T) {
ctx := t.Context()
body := createAccountBody(t, "ben@example.com", testClientToken)
first, err := gw.createAccount(ctx, body)
first, err := gw.adminCreateAccount(ctx, body)
require.NoError(t, err)
created := first.(*operatorv1.CreateAccountResponse)
@@ -280,11 +287,11 @@ func TestAdminCreateAccountProvisionsAndReplays(t *testing.T) {
assert.Equal(t, "vpc-0a1b2c3d", created.DefaultVpcID)
// The replay is the only way to re-obtain the secret, so it must match.
second, err := gw.createAccount(ctx, body)
second, err := gw.adminCreateAccount(ctx, body)
require.NoError(t, err)
assert.Equal(t, created, second.(*operatorv1.CreateAccountResponse))
accounts, err := gw.IAM.ListAccounts()
accounts, err := gw.IAMService.ListAccounts()
require.NoError(t, err)
assert.Len(t, accounts, 1, "replay must not provision a second account")
}
@@ -293,12 +300,12 @@ func TestAdminCreateAccountRejectsDuplicateName(t *testing.T) {
gw := newCreateAccountGateway(t)
ctx := t.Context()
_, err := gw.createAccount(ctx, createAccountBody(t, "ben@example.com", testClientToken))
_, err := gw.adminCreateAccount(ctx, createAccountBody(t, "ben@example.com", testClientToken))
require.NoError(t, err)
// Different token, same name: a second person cannot take an existing name,
// and the error discloses nothing about the account that holds it.
_, err = gw.createAccount(ctx, createAccountBody(t, "BEN@example.com", strings.Repeat("b", 32)))
_, err = gw.adminCreateAccount(ctx, createAccountBody(t, "BEN@example.com", strings.Repeat("b", 32)))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorAccountAlreadyExists, err.Error())
}
@@ -308,10 +315,10 @@ func TestAdminCreateAccountEnforcesCap(t *testing.T) {
gw.SignupMaxAccounts = 1
ctx := t.Context()
_, err := gw.createAccount(ctx, createAccountBody(t, "ben@example.com", testClientToken))
_, err := gw.adminCreateAccount(ctx, createAccountBody(t, "ben@example.com", testClientToken))
require.NoError(t, err)
_, err = gw.createAccount(ctx, createAccountBody(t, "second@example.com", strings.Repeat("c", 32)))
_, err = gw.adminCreateAccount(ctx, createAccountBody(t, "second@example.com", strings.Repeat("c", 32)))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorIAMLimitExceeded, err.Error())
}
@@ -323,22 +330,22 @@ func TestAdminCreateAccountReleasesTokenAfterFailure(t *testing.T) {
gw.SignupMaxAccounts = 1
ctx := t.Context()
_, err := gw.createAccount(ctx, createAccountBody(t, "ben@example.com", testClientToken))
_, err := gw.adminCreateAccount(ctx, createAccountBody(t, "ben@example.com", testClientToken))
require.NoError(t, err)
capped := createAccountBody(t, "second@example.com", strings.Repeat("c", 32))
_, err = gw.createAccount(ctx, capped)
_, err = gw.adminCreateAccount(ctx, capped)
require.Error(t, err)
gw.SignupMaxAccounts = 128
_, err = gw.createAccount(ctx, capped)
_, err = gw.adminCreateAccount(ctx, capped)
assert.NoError(t, err)
}
func TestAdminCreateAccountMalformedBody(t *testing.T) {
gw := newCreateAccountGateway(t)
_, err := gw.createAccount(t.Context(), []byte("not json"))
_, err := gw.adminCreateAccount(t.Context(), []byte("not json"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidRequest, err.Error())
}
@@ -352,7 +359,7 @@ func TestAdminCreateAccountNeverLogsTheSecret(t *testing.T) {
t.Cleanup(func() { slog.SetDefault(previous) })
gw := newCreateAccountGateway(t)
out, err := gw.createAccount(t.Context(), createAccountBody(t, "ben@example.com", testClientToken))
out, err := gw.adminCreateAccount(t.Context(), createAccountBody(t, "ben@example.com", testClientToken))
require.NoError(t, err)
created := out.(*operatorv1.CreateAccountResponse)
@@ -1,4 +1,4 @@
package admin
package gateway
import (
"context"
@@ -76,8 +76,8 @@ type accountDeletionJob struct {
Error string `json:"error,omitempty"`
}
// deleteAccount starts a teardown, or answers a dry run inline.
func (d Deps) deleteAccount(ctx context.Context, body []byte) (any, error) {
// adminDeleteAccount starts a teardown, or answers a dry run inline.
func (gw *GatewayConfig) adminDeleteAccount(ctx context.Context, body []byte) (any, error) {
var req operatorv1.DeleteAccountRequest
if err := json.Unmarshal(body, &req); err != nil {
slog.Debug("DeleteAccount: malformed JSON body", "err", err)
@@ -86,12 +86,12 @@ func (d Deps) deleteAccount(ctx context.Context, body []byte) (any, error) {
if err := validateDeleteAccountRequest(&req); err != nil {
return nil, err
}
if d.IAM == nil {
if gw.IAMService == nil {
slog.Error("DeleteAccount: IAM service not available")
return nil, errors.New(awserrors.ErrorInternalError)
}
engine, err := accountteardown.NewClusterEngine(ctx, d.NATS, d.ExpectedNodes, d.NodeIDs, d.IAM, d.BucketStore)
engine, err := accountteardown.NewClusterEngine(ctx, gw.NATSConn, gw.ExpectedNodes, gw.NodeIDs, gw.IAMService, gw.BucketStore)
if err != nil {
slog.Error("DeleteAccount: failed to build teardown engine", "err", err)
return nil, errors.New(awserrors.ErrorServiceUnavailable)
@@ -101,7 +101,7 @@ func (d Deps) deleteAccount(ctx context.Context, body []byte) (any, error) {
inventoryCtx, cancel := context.WithTimeout(ctx, accountDeletionInventoryTimeout)
defer cancel()
inventory, err := engine.DryRun(inventoryCtx, req.AccountID, requestIDFrom(ctx))
inventory, err := engine.DryRun(inventoryCtx, req.AccountID, adminRequestIDFrom(ctx))
if err != nil {
return nil, teardownError("DeleteAccount", req.AccountID, err)
}
@@ -110,7 +110,7 @@ func (d Deps) deleteAccount(ctx context.Context, body []byte) (any, error) {
}, nil
}
jobs, err := d.accountDeletionStore(ctx)
jobs, err := gw.accountDeletionStore(ctx)
if err != nil {
return nil, err
}
@@ -141,16 +141,16 @@ func (d Deps) deleteAccount(ctx context.Context, body []byte) (any, error) {
return deletionJobResponse(replay), nil
}
d.runAccountDeletion(engine, jobs, job)
gw.runAccountDeletion(engine, jobs, job)
return &DeleteAccountResponse{
DeletionID: job.DeletionID, AccountID: job.AccountID, State: job.State,
}, nil
}
// describeAccountDeletion returns the stored job for an account, including
// adminDescribeAccountDeletion returns the stored job for an account, including
// one that finished — the record outlives the account it describes.
func (d Deps) describeAccountDeletion(ctx context.Context, body []byte) (any, error) {
func (gw *GatewayConfig) adminDescribeAccountDeletion(ctx context.Context, body []byte) (any, error) {
var req operatorv1.DescribeAccountDeletionRequest
if err := json.Unmarshal(body, &req); err != nil {
slog.Debug("DescribeAccountDeletion: malformed JSON body", "err", err)
@@ -164,7 +164,7 @@ func (d Deps) describeAccountDeletion(ctx context.Context, body []byte) (any, er
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
jobs, err := d.accountDeletionStore(ctx)
jobs, err := gw.accountDeletionStore(ctx)
if err != nil {
return nil, err
}
@@ -179,15 +179,15 @@ func (d Deps) describeAccountDeletion(ctx context.Context, body []byte) (any, er
return job, nil
}
// listAccounts enumerates every account. It takes no parameters: the
// adminListAccounts enumerates every account. It takes no parameters: the
// account cap bounds the listing, so paging would be complexity with no payer.
func (d Deps) listAccounts(_ context.Context, _ []byte) (any, error) {
if d.IAM == nil {
func (gw *GatewayConfig) adminListAccounts(_ context.Context, _ []byte) (any, error) {
if gw.IAMService == nil {
slog.Error("ListAccounts: IAM service not available")
return nil, errors.New(awserrors.ErrorInternalError)
}
accounts, err := d.IAM.ListAccounts()
accounts, err := gw.IAMService.ListAccounts()
if err != nil {
slog.Error("ListAccounts: failed to list accounts", "err", err)
return nil, errors.New(awserrors.ErrorInternalError)
@@ -212,7 +212,7 @@ func (d Deps) listAccounts(_ context.Context, _ []byte) (any, error) {
// runAccountDeletion runs the teardown in the background on its own context.
// The request's context is cancelled the moment the response is written, and a
// teardown cancelled halfway leaves the account half-emptied.
func (d Deps) runAccountDeletion(
func (gw *GatewayConfig) runAccountDeletion(
engine *accountteardown.Engine,
jobs jetstream.KeyValue,
job *accountDeletionJob,
@@ -451,8 +451,8 @@ func readDeletionJob(ctx context.Context, jobs jetstream.KeyValue, accountID str
// accountDeletionStore opens the job bucket. Opened per request, like the
// CreateAccount stores, so a cluster upgraded into this feature picks it up
// without a gateway restart.
func (d Deps) accountDeletionStore(ctx context.Context) (jetstream.KeyValue, error) {
js, err := jetstream.New(d.NATS)
func (gw *GatewayConfig) accountDeletionStore(ctx context.Context) (jetstream.KeyValue, error) {
js, err := jetstream.New(gw.NATSConn)
if err != nil {
slog.Error("DeleteAccount: failed to get JetStream context", "err", err)
return nil, errors.New(awserrors.ErrorServiceUnavailable)
@@ -1,16 +1,18 @@
package admin
package gateway
//test:in-package — the deletion handler and its request-ID context are
// unexported.
//test:in-package — the admin dispatcher's request-ID context and the
// deletion handler it reaches are unexported.
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"strings"
"sync"
"testing"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
accountteardown "github.com/mulgadc/spinifex/spinifex/operator/accountlifecycle"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -18,6 +20,21 @@ import (
const dryRunShadowMessage = "Account teardown dry-run EC2 platform shadow comparison"
// deleteAccountGrant is the real IAM service with a policy granting
// spinifex:DeleteAccount, so a request clears the admin gates.
type deleteAccountGrant struct {
iamdomain.IAMService
}
func (deleteAccountGrant) GetUserPolicies(string, string) ([]iamdomain.PolicyDocument, error) {
return []iamdomain.PolicyDocument{{
Version: "2012-10-17",
Statement: []iamdomain.Statement{{
Effect: "Allow", Action: iamdomain.StringOrArr{"spinifex:DeleteAccount"}, Resource: iamdomain.StringOrArr{"*"},
}},
}}, nil
}
// syncBuffer is a log sink safe for the gateway's concurrent writers.
type syncBuffer struct {
mu sync.Mutex
@@ -49,23 +66,22 @@ func captureDefaultLog(t *testing.T) *syncBuffer {
// shadow comparison is logged under the request ID the response carries.
func TestAdminDeleteAccountDryRunLogsTheShadowUnderTheRequestID(t *testing.T) {
gw := newDeleteAccountGateway(t)
gw.IAMService = deleteAccountGrant{IAMService: gw.IAMService}
account := newTenantAccount(t, gw, "tenant@example.com")
logs := captureDefaultLog(t)
const requestID = "req-dryrun-1"
got, err := gw.deleteAccount(WithRequestID(t.Context(), requestID),
[]byte(`{"accountId":"`+account.AccountID+`","dryRun":true}`))
require.NoError(t, err)
gotJSON, err := json.Marshal(got)
require.NoError(t, err)
rec := authorizedAdminRequestForMethod(gw, "DeleteAccount", `{"accountId":"`+account.AccountID+`","dryRun":true}`)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
requestID := rec.Header().Get("X-Amzn-Requestid")
require.NotEmpty(t, requestID)
engine, err := accountteardown.NewClusterEngine(t.Context(), gw.NATS, gw.ExpectedNodes, gw.NodeIDs, gw.IAM, gw.BucketStore)
engine, err := accountteardown.NewClusterEngine(t.Context(), gw.NATSConn, gw.ExpectedNodes, gw.NodeIDs, gw.IAMService, gw.BucketStore)
require.NoError(t, err)
inventory, err := engine.Inventory(t.Context(), account.AccountID)
require.NoError(t, err)
want, err := json.Marshal(&DeleteAccountResponse{AccountID: account.AccountID, State: "DRY_RUN", DryRun: true, Inventory: inventory})
require.NoError(t, err)
assert.JSONEq(t, string(want), string(gotJSON))
assert.JSONEq(t, string(want), rec.Body.String())
var records []map[string]any
for line := range strings.SplitSeq(strings.TrimSpace(logs.String()), "\n") {
@@ -1,4 +1,4 @@
package admin
package gateway
//test:in-package — the deletion job record, its claim rules and the progress
// tracker are unexported, and they are what make a teardown survive a retry or
@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"strings"
"testing"
"time"
@@ -41,7 +42,7 @@ const sealCapabilitySubjectPrefix = "ec2.v1.AccountSettingsSealCapability."
// embedded JetStream, with a stub cluster answering the subjects the reapers
// drive. The tenant has no resources, so every stage drains empty and the test
// exercises the job record rather than the reapers.
func newDeleteAccountGateway(t *testing.T) Deps {
func newDeleteAccountGateway(t *testing.T) *GatewayConfig {
t.Helper()
_, nc, _ := testutil.StartTestJetStream(t)
@@ -100,12 +101,14 @@ func newDeleteAccountGateway(t *testing.T) Deps {
}
require.NoError(t, nc.Flush())
return Deps{
IAM: svc,
NATS: nc,
ExpectedNodes: 1,
NodeIDs: []string{"node-1"},
BucketStore: emptyBucketStore{},
return &GatewayConfig{
DisableLogging: true,
IAMService: svc,
Credentials: credverify.New(svc, nil),
NATSConn: nc,
ExpectedNodes: 1,
NodeIDs: []string{"node-1"},
BucketStore: emptyBucketStore{},
}
}
@@ -139,13 +142,13 @@ func (emptyBucketStore) DeleteBucket(context.Context, string) error { return nil
// newTenantAccount creates an account that is safe to delete. Account ids are
// sequential and the first belongs to the super admin, which is protected, so
// the tenant under test can never be the first one created.
func newTenantAccount(t *testing.T, gw Deps, name string) *iamdomain.Account {
func newTenantAccount(t *testing.T, gw *GatewayConfig, name string) *iamdomain.Account {
t.Helper()
if accounts, err := gw.IAM.ListAccounts(); err == nil && len(accounts) == 0 {
_, err := gw.IAM.CreateAccount("super-admin@example.com")
if accounts, err := gw.IAMService.ListAccounts(); err == nil && len(accounts) == 0 {
_, err := gw.IAMService.CreateAccount("super-admin@example.com")
require.NoError(t, err)
}
account, err := gw.IAM.CreateAccount(name)
account, err := gw.IAMService.CreateAccount(name)
require.NoError(t, err)
return account
}
@@ -161,7 +164,7 @@ func deleteAccountBody(t *testing.T, accountID, name, token string) []byte {
// storeDeletionJob seeds a job record directly, which is how a job left behind
// by another gateway is reproduced without racing a live teardown.
func storeDeletionJob(t *testing.T, gw Deps, job *accountDeletionJob) jetstream.KeyValue {
func storeDeletionJob(t *testing.T, gw *GatewayConfig, job *accountDeletionJob) jetstream.KeyValue {
t.Helper()
jobs, err := gw.accountDeletionStore(t.Context())
require.NoError(t, err)
@@ -173,12 +176,12 @@ func storeDeletionJob(t *testing.T, gw Deps, job *accountDeletionJob) jetstream.
return jobs
}
func describeDeletion(t *testing.T, gw Deps, accountID string) *accountDeletionJob {
func describeDeletion(t *testing.T, gw *GatewayConfig, accountID string) *accountDeletionJob {
t.Helper()
body, err := json.Marshal(operatorv1.DescribeAccountDeletionRequest{AccountID: accountID})
require.NoError(t, err)
output, err := gw.describeAccountDeletion(t.Context(), body)
output, err := gw.adminDescribeAccountDeletion(t.Context(), body)
require.NoError(t, err)
job, ok := output.(*accountDeletionJob)
require.True(t, ok)
@@ -195,7 +198,7 @@ func TestAdminDeleteAccountDryRunChangesNothing(t *testing.T) {
body, err := json.Marshal(operatorv1.DeleteAccountRequest{AccountID: account.AccountID, DryRun: true})
require.NoError(t, err)
output, err := gw.deleteAccount(t.Context(), body)
output, err := gw.adminDeleteAccount(t.Context(), body)
require.NoError(t, err)
response, ok := output.(*DeleteAccountResponse)
@@ -205,12 +208,12 @@ func TestAdminDeleteAccountDryRunChangesNothing(t *testing.T) {
assert.Empty(t, response.DeletionID, "a dry run must not claim a job")
require.NotNil(t, response.Inventory)
stored, err := gw.IAM.GetAccount(account.AccountID)
stored, err := gw.IAMService.GetAccount(account.AccountID)
require.NoError(t, err)
assert.Equal(t, iamdomain.AccountStatusActive, stored.Status)
// No job record means a later real delete is not answered as a replay.
_, err = gw.describeAccountDeletion(t.Context(),
_, err = gw.adminDescribeAccountDeletion(t.Context(),
[]byte(`{"accountId":"`+account.AccountID+`"}`))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
@@ -224,7 +227,7 @@ func TestAdminDeleteAccountReturnsBeforeTheTeardownFinishes(t *testing.T) {
gw := newDeleteAccountGateway(t)
account := newTenantAccount(t, gw, "tenant@example.com")
output, err := gw.deleteAccount(t.Context(),
output, err := gw.adminDeleteAccount(t.Context(),
deleteAccountBody(t, account.AccountID, "tenant@example.com", deleteClientToken))
require.NoError(t, err)
@@ -245,7 +248,7 @@ func TestAdminDeleteAccountReturnsBeforeTheTeardownFinishes(t *testing.T) {
assert.Empty(t, job.Error)
assert.NotEmpty(t, job.Stages, "the stage report is the only view into a finished teardown")
_, err = gw.IAM.GetAccount(account.AccountID)
_, err = gw.IAMService.GetAccount(account.AccountID)
assert.Error(t, err, "the account record must be gone once the job completes")
}
@@ -257,7 +260,7 @@ func TestAdminDeleteAccountReplaysAfterTheAccountIsGone(t *testing.T) {
account := newTenantAccount(t, gw, "tenant@example.com")
body := deleteAccountBody(t, account.AccountID, "tenant@example.com", deleteClientToken)
first, err := gw.deleteAccount(t.Context(), body)
first, err := gw.adminDeleteAccount(t.Context(), body)
require.NoError(t, err)
started := first.(*DeleteAccountResponse)
@@ -265,7 +268,7 @@ func TestAdminDeleteAccountReplaysAfterTheAccountIsGone(t *testing.T) {
return describeDeletion(t, gw, account.AccountID).State == operatorv1.DeletionStateCompleted
}, 60*time.Second, 10*time.Millisecond)
second, err := gw.deleteAccount(t.Context(), body)
second, err := gw.adminDeleteAccount(t.Context(), body)
require.NoError(t, err)
replayed, ok := second.(*DeleteAccountResponse)
@@ -275,7 +278,7 @@ func TestAdminDeleteAccountReplaysAfterTheAccountIsGone(t *testing.T) {
// A caller that lost its token and generated a fresh one is told the same
// thing, rather than that the account it just deleted does not exist.
third, err := gw.deleteAccount(t.Context(),
third, err := gw.adminDeleteAccount(t.Context(),
deleteAccountBody(t, account.AccountID, "tenant@example.com", strings.Repeat("f", 32)))
require.NoError(t, err)
assert.Equal(t, operatorv1.DeletionStateCompleted, third.(*DeleteAccountResponse).State)
@@ -295,7 +298,7 @@ func TestAdminDeleteAccountRejectsATokenBoundToAnotherName(t *testing.T) {
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
})
_, err := gw.deleteAccount(t.Context(),
_, err := gw.adminDeleteAccount(t.Context(),
deleteAccountBody(t, account.AccountID, "someone-else@example.com", deleteClientToken))
require.Error(t, err)
@@ -316,7 +319,7 @@ func TestAdminDeleteAccountRefusesAConcurrentTeardown(t *testing.T) {
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
})
_, err := gw.deleteAccount(t.Context(),
_, err := gw.adminDeleteAccount(t.Context(),
deleteAccountBody(t, account.AccountID, "tenant@example.com", strings.Repeat("b", 32)))
require.Error(t, err)
@@ -337,7 +340,7 @@ func TestAdminDeleteAccountTakesOverAStaleJob(t *testing.T) {
UpdatedAt: time.Now().UTC().Add(-2 * accountDeletionStale).Format(time.RFC3339),
})
output, err := gw.deleteAccount(t.Context(),
output, err := gw.adminDeleteAccount(t.Context(),
deleteAccountBody(t, account.AccountID, "tenant@example.com", strings.Repeat("c", 32)))
require.NoError(t, err)
@@ -362,7 +365,7 @@ func TestAdminDeleteAccountRetriesAFailedJob(t *testing.T) {
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
})
output, err := gw.deleteAccount(t.Context(),
output, err := gw.adminDeleteAccount(t.Context(),
deleteAccountBody(t, account.AccountID, "tenant@example.com", strings.Repeat("d", 32)))
require.NoError(t, err)
@@ -388,7 +391,7 @@ func TestAdminDeleteAccountReportsACompletedJobToANewToken(t *testing.T) {
FinishedAt: time.Now().UTC().Format(time.RFC3339),
})
output, err := gw.deleteAccount(t.Context(),
output, err := gw.adminDeleteAccount(t.Context(),
deleteAccountBody(t, account.AccountID, "tenant@example.com", strings.Repeat("e", 32)))
require.NoError(t, err)
@@ -407,13 +410,11 @@ func TestAdminDeleteAccountRefusesProtectedAccounts(t *testing.T) {
// The system account and the super admin, whichever names they carry.
for _, accountID := range []string{"000000000000", "000000000001"} {
t.Run(accountID, func(t *testing.T) {
_, err := gw.deleteAccount(t.Context(),
_, err := gw.adminDeleteAccount(t.Context(),
deleteAccountBody(t, accountID, "super-admin@example.com", deleteClientToken))
require.Error(t, err)
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok)
assert.Equal(t, awserrors.ErrorAccessDenied, code)
assertDenied(t, err)
})
}
}
@@ -424,13 +425,13 @@ func TestAdminDeleteAccountRefusesAMismatchedName(t *testing.T) {
gw := newDeleteAccountGateway(t)
account := newTenantAccount(t, gw, "tenant@example.com")
_, err := gw.deleteAccount(t.Context(),
_, err := gw.adminDeleteAccount(t.Context(),
deleteAccountBody(t, account.AccountID, "wrong@example.com", deleteClientToken))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorIdempotentParameterMismatch, err.Error())
stored, err := gw.IAM.GetAccount(account.AccountID)
stored, err := gw.IAMService.GetAccount(account.AccountID)
require.NoError(t, err)
assert.Equal(t, iamdomain.AccountStatusActive, stored.Status)
}
@@ -439,7 +440,7 @@ func TestAdminDeleteAccountRejectsAnUnknownAccount(t *testing.T) {
gw := newDeleteAccountGateway(t)
newTenantAccount(t, gw, "tenant@example.com")
_, err := gw.deleteAccount(t.Context(),
_, err := gw.adminDeleteAccount(t.Context(),
deleteAccountBody(t, "000000009999", "ghost@example.com", deleteClientToken))
require.Error(t, err)
@@ -449,7 +450,7 @@ func TestAdminDeleteAccountRejectsAnUnknownAccount(t *testing.T) {
func TestAdminDeleteAccountRejectsAMalformedBody(t *testing.T) {
gw := newDeleteAccountGateway(t)
_, err := gw.deleteAccount(t.Context(), []byte(`{`))
_, err := gw.adminDeleteAccount(t.Context(), []byte(`{`))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidRequest, err.Error())
@@ -458,9 +459,9 @@ func TestAdminDeleteAccountRejectsAMalformedBody(t *testing.T) {
// Without an IAM service there is no way to check what the account owns, and
// deleting on a guess is worse than refusing.
func TestAdminDeleteAccountRefusesWithoutAnIAMService(t *testing.T) {
gw := Deps{}
gw := &GatewayConfig{DisableLogging: true}
_, err := gw.deleteAccount(context.Background(),
_, err := gw.adminDeleteAccount(context.Background(),
deleteAccountBody(t, "000000000042", "tenant@example.com", deleteClientToken))
require.Error(t, err)
@@ -470,7 +471,7 @@ func TestAdminDeleteAccountRefusesWithoutAnIAMService(t *testing.T) {
func TestAdminDescribeAccountDeletionRejectsAMalformedBody(t *testing.T) {
gw := newDeleteAccountGateway(t)
_, err := gw.describeAccountDeletion(t.Context(), []byte(`{`))
_, err := gw.adminDescribeAccountDeletion(t.Context(), []byte(`{`))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidRequest, err.Error())
@@ -1,4 +1,4 @@
package admin
package gateway
//test:in-package — the request validators, the job staleness rule and the
// engine-error mapping are unexported, and each is a safety rail that would go
@@ -22,7 +22,7 @@ import (
// listAccountsIAMService answers the one call /admin/ListAccounts makes.
type listAccountsIAMService struct {
iamdomain.IAMService
policyMockIAMService
accounts []*iamdomain.Account
err error
@@ -177,7 +177,7 @@ func TestTeardownErrorMapping(t *testing.T) {
// The listing is an operator's index of tenants. It must carry the status —
// that is how a stuck TERMINATING account is noticed — and no key material.
func TestAdminListAccountsReportsStatus(t *testing.T) {
gw := Deps{IAM: &listAccountsIAMService{
gw := &GatewayConfig{DisableLogging: true, IAMService: &listAccountsIAMService{
accounts: []*iamdomain.Account{
{AccountID: "000000000042", AccountName: "t@example.com", Status: iamdomain.AccountStatusActive},
nil,
@@ -185,7 +185,7 @@ func TestAdminListAccountsReportsStatus(t *testing.T) {
},
}}
output, err := gw.listAccounts(context.Background(), nil)
output, err := gw.adminListAccounts(context.Background(), nil)
require.NoError(t, err)
response, ok := output.(*operatorv1.ListAccountsResponse)
@@ -198,26 +198,40 @@ func TestAdminListAccountsReportsStatus(t *testing.T) {
// Failing closed matters here: an empty listing reads as "no accounts", which
// is exactly what an operator would act on.
func TestAdminListAccountsFailsClosed(t *testing.T) {
gw := Deps{IAM: &listAccountsIAMService{
gw := &GatewayConfig{DisableLogging: true, IAMService: &listAccountsIAMService{
err: errors.New("kv unavailable"),
}}
_, err := gw.listAccounts(context.Background(), nil)
_, err := gw.adminListAccounts(context.Background(), nil)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
}
// Each admin method is a separate grant. The signup Worker holds
// spinifex:CreateAccount and must not reach deletion with it.
func TestCreateAccountGrantDoesNotAllowDeleteAccount(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()})
for _, method := range []string{"DeleteAccount", "DescribeAccountDeletion", "ListAccounts"} {
t.Run(method, func(t *testing.T) {
rec := authorizedAdminRequestForMethod(gw, method, `{}`)
assert.Equal(t, awserrors.ErrorAccessDenied, decodeAdminError(t, rec).Error.Code)
})
}
}
// A missing account id must be refused before anything reads the cluster, so
// DescribeAccountDeletion cannot be used to probe with junk.
func TestDescribeAccountDeletionValidatesTheAccountID(t *testing.T) {
gw := Deps{}
gw := &GatewayConfig{DisableLogging: true}
_, err := gw.describeAccountDeletion(context.Background(), []byte(`{}`))
_, err := gw.adminDescribeAccountDeletion(context.Background(), []byte(`{}`))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
_, err = gw.describeAccountDeletion(context.Background(), []byte(`{"accountId":"nope"}`))
_, err = gw.adminDescribeAccountDeletion(context.Background(), []byte(`{"accountId":"nope"}`))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
}
-87
View File
@@ -1,87 +0,0 @@
package gateway
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"strings"
"testing"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type loggedRecord struct {
Level string `json:"level"`
Msg string `json:"msg"`
Status float64 `json:"status"`
}
// logRecords parses the JSON log lines captured while a request ran.
func logRecords(t *testing.T, logs *bytes.Buffer) []loggedRecord {
t.Helper()
var out []loggedRecord
for line := range strings.SplitSeq(logs.String(), "\n") {
var rec loggedRecord
if json.Unmarshal([]byte(line), &rec) == nil && rec.Msg != "" {
out = append(out, rec)
}
}
return out
}
func findRecord(recs []loggedRecord, msg string) (loggedRecord, bool) {
for _, rec := range recs {
if rec.Msg == msg {
return rec, true
}
}
return loggedRecord{}, false
}
// The outcome a /admin request is logged with must be the one the caller got:
// a denial or a fault is never recorded as a completed request.
func TestAdminOutcome_LogFollowsTheRealResult(t *testing.T) {
cases := []struct {
name string
accountID string
mutate func(*GatewayConfig)
wantStatus int
wantLevel string
}{
{"success", awsidentifiers.DefaultAccountID(), nil, http.StatusOK, ""},
{"denied caller", authzAccountID, nil, http.StatusForbidden, "INFO"},
{"cluster fault", awsidentifiers.DefaultAccountID(), func(gw *GatewayConfig) { gw.NATSConn = nil }, http.StatusServiceUnavailable, "ERROR"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, app := operatorPipelineApp(t, tc.accountID, allowAllOperatorDocs(), tc.mutate)
logs := &bytes.Buffer{}
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(logs, &slog.HandlerOptions{Level: slog.LevelInfo})))
t.Cleanup(func() { slog.SetDefault(prev) })
resp := adminCall(t, audit.RequestLogger(app), http.MethodPost, "ListAccounts", "spinifex", `{}`)
require.Equal(t, tc.wantStatus, resp.StatusCode)
recs := logRecords(t, logs)
access, ok := findRecord(recs, "request")
require.True(t, ok, "no access record in:\n%s", logs.String())
assert.EqualValues(t, tc.wantStatus, access.Status)
_, completed := findRecord(recs, "Service request completed")
failure, failed := findRecord(recs, "Service request error")
if tc.wantLevel == "" {
assert.True(t, completed, "a success must be logged as completed")
assert.False(t, failed, "a success must not be logged as an error")
return
}
assert.False(t, completed, "a failed request must never be logged as completed")
require.True(t, failed, "a failed request must be logged as an error")
assert.Equal(t, tc.wantLevel, failure.Level)
})
}
}
+151 -15
View File
@@ -7,8 +7,10 @@ package gateway
import (
"context"
"encoding/json"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
@@ -20,6 +22,30 @@ import (
"github.com/stretchr/testify/require"
)
func TestAdminPathMethod(t *testing.T) {
tests := []struct {
name string
path string
want string
wantOK bool
}{
{"known method", "/admin/CreateAccount", "CreateAccount", true},
{"deletion method", "/admin/DeleteAccount", "DeleteAccount", true},
{"unknown method", "/admin/PurgeEverything", "", false},
{"nested path", "/admin/CreateAccount/extra", "", false},
{"bare prefix", "/admin/", "", false},
{"not admin", "/", "", false},
{"case mismatch", "/admin/createaccount", "", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := adminPathMethod(tc.path)
assert.Equal(t, tc.wantOK, ok)
assert.Equal(t, tc.want, got)
})
}
}
// createAccountPolicy grants exactly the action the signup principal holds.
func createAccountPolicy() *policyMockIAMService {
allow := []iamdomain.PolicyDocument{{
@@ -38,6 +64,105 @@ func createAccountPolicy() *policyMockIAMService {
}
}
// Every gate must deny on its own, and every denial must be indistinguishable
// from the others so a caller cannot learn which one it failed.
func TestAuthorizeAdminGates(t *testing.T) {
tests := []struct {
name string
service string
accountID string
identity string
principalType string
policy *policyMockIAMService
wantErr string
}{
{
name: "permitted signup principal",
service: "spinifex",
accountID: awsidentifiers.DefaultAccountID(),
identity: "signup",
principalType: principalTypeUser,
policy: createAccountPolicy(),
},
{
name: "credential scope is not spinifex",
service: "ec2",
accountID: awsidentifiers.DefaultAccountID(),
identity: "signup",
principalType: principalTypeUser,
policy: createAccountPolicy(),
wantErr: awserrors.ErrorAccessDenied,
},
{
name: "not the super-admin account",
service: "spinifex",
accountID: "123456789012",
identity: "signup",
principalType: principalTypeUser,
policy: createAccountPolicy(),
wantErr: awserrors.ErrorAccessDenied,
},
{
name: "assumed-role session",
service: "spinifex",
accountID: awsidentifiers.DefaultAccountID(),
identity: "signup",
principalType: principalTypeAssumedRole,
policy: createAccountPolicy(),
wantErr: awserrors.ErrorAccessDenied,
},
{
name: "no identity",
service: "spinifex",
accountID: awsidentifiers.DefaultAccountID(),
identity: "",
principalType: principalTypeUser,
policy: createAccountPolicy(),
wantErr: awserrors.ErrorAccessDenied,
},
{
name: "policy does not grant CreateAccount",
service: "spinifex",
accountID: awsidentifiers.DefaultAccountID(),
identity: "signup",
principalType: principalTypeUser,
policy: &policyMockIAMService{},
wantErr: awserrors.ErrorAccessDenied,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: tc.policy})
req := httptest.NewRequest(http.MethodPost, "/admin/CreateAccount", nil)
ctx := context.WithValue(req.Context(), ctxService, tc.service)
ctx = withPrincipalField(ctx, fieldAccountID, tc.accountID)
ctx = withPrincipalField(ctx, fieldIdentity, tc.identity)
ctx = withPrincipalField(ctx, fieldPrincipalType, tc.principalType)
err := gw.authorizeAdmin(req.WithContext(ctx), "CreateAccount")
if tc.wantErr == "" {
assert.NoError(t, err)
return
}
require.Error(t, err)
code, _ := awserrors.ResolveErrorCode(err)
assert.Equal(t, tc.wantErr, code)
})
}
}
// An unauthenticated request carries no auth context at all, which must not
// read as a permitted principal.
func TestAuthorizeAdminDeniesUnauthenticated(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
req := httptest.NewRequest(http.MethodPost, "/admin/CreateAccount", nil)
err := gw.authorizeAdmin(req, "CreateAccount")
require.Error(t, err)
assertDenied(t, err)
}
// adminRequest drives Admin_Request with the chi route param the router sets.
func adminRequest(gw *GatewayConfig, method string, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/admin/"+method, strings.NewReader(body))
@@ -60,7 +185,7 @@ func decodeAdminError(t *testing.T, rec *httptest.ResponseRecorder) adminErrorBo
// An unknown method is rejected before authorization, so it never discloses
// whether the caller would have been permitted.
func TestAdminRequestUnknownMethod(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
rec := adminRequest(gw, "PurgeEverything", `{}`)
@@ -69,7 +194,7 @@ func TestAdminRequestUnknownMethod(t *testing.T) {
}
func TestAdminRequestDeniesUnauthorizedCaller(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
rec := adminRequest(gw, "CreateAccount", `{}`)
@@ -81,7 +206,7 @@ func TestAdminRequestDeniesUnauthorizedCaller(t *testing.T) {
// The request ID ties a client failure to the gateway log line, so it must be
// present on the header and in the body even for a denial.
func TestAdminRequestAlwaysCarriesRequestID(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
rec := adminRequest(gw, "CreateAccount", `{}`)
@@ -104,7 +229,7 @@ func TestWriteAdminErrorFallsBackOnUnknownCode(t *testing.T) {
// The router serves every verb from one pattern, so the handler is what
// refuses a GET — a second route would shadow the POST one.
func TestAdminRequestRejectsNonPost(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
req := httptest.NewRequest(http.MethodGet, "/admin/CreateAccount", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("method", "CreateAccount")
@@ -144,23 +269,34 @@ func authorizedAdminRequestForMethod(gw *GatewayConfig, method, body string) *ht
// An authorized caller must still get a clear answer when the cluster is not
// reachable, rather than a panic or a silent success.
func TestAdminRequestReportsClusterUnavailable(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()}))
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()})
rec := authorizedAdminRequest(gw, `{}`)
assert.Equal(t, awserrors.ErrorServiceUnavailable, decodeAdminError(t, rec).Error.Code)
}
// Each admin method is a separate grant. The signup Worker holds
// spinifex:CreateAccount and must not reach deletion with it.
func TestCreateAccountGrantDoesNotAllowDeleteAccount(t *testing.T) {
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()}))
// The credential-minting CLI grants these by name. A method served but absent
// from the list would be uncallable by every key an operator issues.
func TestAdminMethodNamesCoverTheRoutedMethods(t *testing.T) {
names := operatorv1.AdminMethodNames()
for _, method := range []string{"DeleteAccount", "DescribeAccountDeletion", "ListAccounts"} {
t.Run(method, func(t *testing.T) {
rec := authorizedAdminRequestForMethod(gw, method, `{}`)
assert.Equal(t, awserrors.ErrorAccessDenied, decodeAdminError(t, rec).Error.Code)
})
require.Len(t, names, len(adminMethods))
assert.True(t, sort.StringsAreSorted(names), "names must be sorted for stable help text")
for _, name := range names {
method, ok := adminPathMethod(adminPathPrefix + name)
assert.True(t, ok, "%s is granted but not routed", name)
assert.Equal(t, name, method)
}
}
func TestAdminPathMethod_AcceptsExactlyTheContractList(t *testing.T) {
for _, name := range operatorv1.AdminMethodNames() {
got, ok := adminPathMethod("/admin/" + name)
require.True(t, ok, name)
require.Equal(t, name, got)
}
_, ok := adminPathMethod("/admin/NotInTheContractList")
require.False(t, ok)
require.Len(t, adminMethods, len(operatorv1.AdminMethodNames()))
}
@@ -1,4 +1,4 @@
package admin
package gateway
import (
"encoding/json"
+13 -3
View File
@@ -5,7 +5,6 @@ import (
"context"
"errors"
authlimit "github.com/mulgadc/spinifex/spinifex/ingress/aws/ratelimit"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"io"
"log/slog"
"net/http"
@@ -36,8 +35,8 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
if gw.RateLimiter == nil {
gw.RateLimiter = authlimit.NewAuthRateLimiter()
}
if gw.Admission == nil {
gw.Admission = accountadmission.New(gw.IAMService)
if gw.accountStatus == nil {
gw.accountStatus = newAccountStatusCache()
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -230,6 +229,13 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
}
}
// /admin carries its method in the path and a JSON body, so the
// query-arg parse above finds no Action. Without this every admin
// method would share one "unknown" throttle bucket and trace label.
if method, ok := adminPathMethod(r.URL.Path); ok {
ctx = context.WithValue(ctx, ctxAction, method)
}
// A registered service names its own action; everything else keeps
// resolving JSON-1.1/path-routed REST-JSON actions through the legacy
// resolver, since those carry no query-protocol Action to parse above.
@@ -263,6 +269,10 @@ func resolveNonQueryAction(r *http.Request, service string) string {
}
method, path := r.Method, r.URL.EscapedPath()
switch service {
case "eks":
if action, _, _, ok := eksRouter.Lookup(method, path); ok {
return action
}
case "bedrock":
if action, _, _, ok := bedrockRouter.Lookup(method, path); ok {
return action
+80 -7
View File
@@ -1,24 +1,97 @@
package gateway
import (
"errors"
"log/slog"
"sync"
"time"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
)
// accountActiveTTL bounds how long an account is trusted to still be ACTIVE
// without re-reading it. Only the ACTIVE answer is cached, so suspending or
// terminating an account takes effect within this window rather than being
// held open by a warm cache entry.
const accountActiveTTL = 5 * time.Second
// accountStatusCache remembers which accounts were ACTIVE and when. Every
// authenticated request needs the answer and the account record changes
// perhaps twice in its lifetime, so an uncached read would double the
// auth path's KV traffic to learn nothing new.
type accountStatusCache struct {
mu sync.RWMutex
active map[string]time.Time
}
func newAccountStatusCache() *accountStatusCache {
return &accountStatusCache{active: make(map[string]time.Time)}
}
// activeUntil reports whether accountID was seen ACTIVE recently enough.
func (c *accountStatusCache) activeUntil(accountID string, now time.Time) bool {
if c == nil {
return false
}
c.mu.RLock()
defer c.mu.RUnlock()
expiry, ok := c.active[accountID]
return ok && now.Before(expiry)
}
func (c *accountStatusCache) markActive(accountID string, now time.Time) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.active[accountID] = now.Add(accountActiveTTL)
}
// forget drops an entry so a status change takes effect immediately on the
// node that made it, rather than after the TTL.
func (c *accountStatusCache) forget(accountID string) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.active, accountID)
}
// checkAccountActive rejects a request whose account is suspended or being
// torn down. It returns an empty string when the request may proceed.
//
// This is the only enforcement point for account status on the AWS surface:
// until it existed, suspending an account blocked ECR and nothing else.
func (gw *GatewayConfig) checkAccountActive(accountID string, src authSource) string {
switch err := gw.Admission.Admit(accountID, src.logIP); {
case err == nil:
if accountID == "" {
return ""
case errors.Is(err, accountadmission.ErrNotActive):
return awserrors.ErrorAccessDenied
default:
}
now := time.Now()
if gw.accountStatus.activeUntil(accountID, now) {
return ""
}
if gw.IAMService == nil {
slog.Error("Auth: IAM service not initialized for account status check")
return awserrors.ErrorInternalError
}
account, err := gw.IAMService.GetAccount(accountID)
if err != nil {
// Fail closed. An account we cannot read is not evidence of an account
// in good standing, and this gate is what stops a terminating tenant
// from creating resources the teardown has already walked past.
slog.Error("Auth: failed to read account for status check", "accountID", accountID, "err", err)
return awserrors.ErrorInternalError
}
if account.Status != iamdomain.AccountStatusActive {
slog.Warn("Auth failure: account is not active",
"accountID", accountID, "status", account.Status, "sourceIP", src.logIP)
return awserrors.ErrorAccessDenied
}
gw.accountStatus.markActive(accountID, now)
return ""
}
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
@@ -101,3 +102,26 @@ func TestAuthFailsClosedWhenTheAccountCannotBeRead(t *testing.T) {
t.Fatal("expected an unreadable account to be denied, got 200")
}
}
func TestAccountStatusCacheOnlyRemembersActive(t *testing.T) {
cache := newAccountStatusCache()
now := time.Now()
if cache.activeUntil("123456789012", now) {
t.Fatal("an unseen account must not read as active")
}
cache.markActive("123456789012", now)
if !cache.activeUntil("123456789012", now) {
t.Fatal("a just-marked account must read as active")
}
if cache.activeUntil("123456789012", now.Add(accountActiveTTL+time.Second)) {
t.Fatal("the entry must expire with the TTL")
}
cache.markActive("123456789012", now)
cache.forget("123456789012")
if cache.activeUntil("123456789012", now) {
t.Fatal("forget must drop the entry so a status change takes effect at once")
}
}
@@ -10,7 +10,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"net/http"
"net/http/httptest"
@@ -141,7 +140,6 @@ func (e *cbEnv) gateway() http.Handler {
Credentials: credverify.New(e.counter, e.sts),
STSService: e.sts,
}
withIAMAndSTS(gw)
return gw.SetupRoutes()
}
@@ -858,9 +856,9 @@ func TestCurrentBehaviour_AntiEnumeration_RejectionShapes(t *testing.T) {
}
// Pins CURRENT behaviour: the door caches only an account's ACTIVE status, per
// gateway, for accountadmission.ActiveTTL; keys and sessions are re-read on every request.
// gateway, for accountActiveTTL; keys and sessions are re-read on every request.
func TestCurrentBehaviour_Cache_AccountStatusIsTheOnlyAuthCache(t *testing.T) {
require.Equal(t, 5*time.Second, accountadmission.ActiveTTL)
require.Equal(t, 5*time.Second, accountActiveTTL)
for _, status := range []string{iamdomain.AccountStatusSuspended, iamdomain.AccountStatusTerminating} {
t.Run(status, func(t *testing.T) {
@@ -873,7 +871,7 @@ func TestCurrentBehaviour_Cache_AccountStatusIsTheOnlyAuthCache(t *testing.T) {
// CURRENT: an out-of-band status write is not seen by a gateway
// holding a warm ACTIVE entry until that entry expires.
warm := e.callerIdentity(t, e.alice)
if time.Since(start) >= accountadmission.ActiveTTL {
if time.Since(start) >= accountActiveTTL {
t.Skip("cache window elapsed before the warm request; outcome not attributable")
}
assert.Equal(t, http.StatusOK, warm.Code)
-6
View File
@@ -10,8 +10,6 @@ import (
"fmt"
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"
taggingawsapi "github.com/mulgadc/spinifex/spinifex/domains/tagging/awsapi"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
@@ -2695,8 +2693,6 @@ func TestWriteSigV4Error_JSONErrorServicesUnaffectedByFallback(t *testing.T) {
require.NoError(t, b.Register(awsapi.NewRegistration(awsapi.Deps{})))
require.NoError(t, b.Register(acmawsapi.NewRegistration(acmawsapi.Deps{})))
require.NoError(t, b.Register(taggingawsapi.NewRegistration(taggingawsapi.Deps{})))
require.NoError(t, b.Register(ecsawsapi.NewRegistration(ecsawsapi.Deps{})))
require.NoError(t, b.Register(eksawsapi.NewRegistration(eksawsapi.Deps{})))
gw.Services = b.Build()
for _, svc := range []string{"eks", "ecr", "acm", "ecs", "tagging",
@@ -2773,8 +2769,6 @@ func TestSigV4Auth_ResolvesCtxActionForNonQueryServices(t *testing.T) {
require.NoError(t, b.Register(awsapi.NewRegistration(awsapi.Deps{})))
require.NoError(t, b.Register(acmawsapi.NewRegistration(acmawsapi.Deps{})))
require.NoError(t, b.Register(taggingawsapi.NewRegistration(taggingawsapi.Deps{})))
require.NoError(t, b.Register(ecsawsapi.NewRegistration(ecsawsapi.Deps{})))
require.NoError(t, b.Register(eksawsapi.NewRegistration(eksawsapi.Deps{})))
gw.Services = b.Build()
var gotAction, gotThrottleKey string
-41
View File
@@ -1,41 +0,0 @@
package gateway
import (
"errors"
"log/slog"
spxarn "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"
)
// buildCallerARN composes the caller ARN: assumed-role uses the sealed assumedRoleARN,
// root uses arn:aws:iam::{aid}:root, user uses its stored userARN, path included,
// or arn:aws:iam::{aid}:user/{identity} when there is no record to read it from.
func buildCallerARN(accountID, identity, principalType, assumedRoleARN, userARN string) (string, error) {
switch principalType {
case principalTypeAssumedRole:
if assumedRoleARN == "" {
slog.Error("buildCallerARN: assumed-role principal without ARN")
return "", errors.New(awserrors.ErrorInternalError)
}
return assumedRoleARN, nil
case principalTypeRoot:
return spxarn.FormatIAMRoot(accountID), nil
case principalTypeUser:
if identity == "root" && accountID == awsidentifiers.GlobalAccountID {
return spxarn.FormatIAMRoot(accountID), nil
}
if identity == "" {
slog.Error("buildCallerARN: user principal without identity")
return "", errors.New(awserrors.ErrorInternalError)
}
if userARN != "" {
return userARN, nil
}
return spxarn.FormatIAMPath(spxarn.IAMUser, accountID, "/", identity), nil
default:
slog.Error("buildCallerARN: unknown principal type", "principalType", principalType)
return "", errors.New(awserrors.ErrorInternalError)
}
}
@@ -1,68 +0,0 @@
// Tests that a dispatch failure is logged at the level the status the caller
// receives implies, so a client's own 4xx does not read as a fault of ours.
package gateway
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"testing"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// loggedLevelOf returns the level of the first record in logs whose msg is
// msg, failing the test when no record carries it.
func loggedLevelOf(t *testing.T, logs *bytes.Buffer, msg string) string {
t.Helper()
for line := range strings.SplitSeq(logs.String(), "\n") {
var rec struct {
Level string `json:"level"`
Msg string `json:"msg"`
}
if err := json.Unmarshal([]byte(line), &rec); err != nil {
continue
}
if rec.Msg == msg {
return rec.Level
}
}
t.Fatalf("no %q record in:\n%s", msg, logs.String())
return ""
}
func TestDispatchSeam_ErrorLevelFollowsTheStatusTheCallerGets(t *testing.T) {
cases := []struct {
name string
code string
status int
level string
}{
{"a dry run is the answer AWS documents for one", awserrors.ErrorDryRunOperation, http.StatusPreconditionFailed, "INFO"},
{"a volume the caller named does not exist", awserrors.ErrorInvalidVolumeNotFound, http.StatusNotFound, "INFO"},
{"a malformed parameter is the caller's request", awserrors.ErrorInvalidParameterValue, http.StatusBadRequest, "INFO"},
{"an internal failure is ours", awserrors.ErrorInternalError, http.StatusInternalServerError, "ERROR"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
logs := &bytes.Buffer{}
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(logs, &slog.HandlerOptions{Level: slog.LevelInfo})))
t.Cleanup(func() { slog.SetDefault(prev) })
capt := &dispatchCapture{dispatchErr: errors.New(tc.code)}
_, app := dispatchSeamApp(t, allowAllECRDocs(), fakeJSONRegistration(capt))
resp := doRequest(app, signDispatchRequest(t, fakeJSONService, []byte("{}")))
require.Equal(t, tc.status, resp.StatusCode)
assert.Equal(t, tc.level, loggedLevelOf(t, logs, "Service request error"))
})
}
}
+1 -2
View File
@@ -4,7 +4,6 @@ package gateway
import (
"context"
"errors"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
"log/slog"
"net"
"net/http"
@@ -738,7 +737,7 @@ func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) err
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[*policyauth.DenialError](err); denied {
if _, denied := errors.AsType[*identityPolicyDenialError](err); denied {
return errors.New(awserrors.ErrorUnauthorizedOperation)
}
return err
+45
View File
@@ -0,0 +1,45 @@
package gateway
import (
"github.com/go-chi/chi/v5"
ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry"
)
// mountOCIRegistry registers the OCI Distribution Spec v2 surface (/v2/*) on r.
// These endpoints are host- and token-authenticated rather than
// SigV4-credential-scoped, so they mount outside the SigV4 group behind the
// hostMatch host-routing middleware.
//
// OCI repository names may contain slashes (e.g. team/app), so the blob,
// manifest and tag paths can't be expressed as fixed chi segments. The
// Registry parses the path manually. GET /v2/ is always live so the registry
// version-check probe succeeds even before any repository exists. When no
// Registry is wired (e.g. unit tests of unrelated routes), the surface falls
// back to the 501 stub.
//
// Requests flow hostMatch -> ecrAuthBridge -> ecrOperationAuthorization ->
// Registry: the bridge authenticates the token and rehydrates its principal
// from current IAM/STS state, then authorization classifies the OCI
// operation and evaluates it against that principal's policies before the
// request ever reaches Registry's object/metadata stores.
func (gw *GatewayConfig) mountOCIRegistry(r chi.Router) {
r.Route("/v2", func(v2 chi.Router) {
v2.Use(gw.hostMatch)
// The token endpoint authenticates the presented credential in-band and
// issues a Bearer token, so it mounts outside the bearer auth bridge.
v2.Get("/token", gw.handleECRToken)
v2.Group(func(reg chi.Router) {
reg.Use(gw.ecrAuthBridge)
reg.Use(gw.ecrOperationAuthorization)
reg.Get("/", ecrregistry.APIVersion)
if gw.ECRRegistry != nil {
reg.HandleFunc("/*", gw.ECRRegistry.ServeHTTP)
} else {
reg.HandleFunc("/*", ecrregistry.NotImplemented)
}
})
})
}
+89
View File
@@ -0,0 +1,89 @@
package gateway
import (
"fmt"
"net/http"
ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/policy"
)
// ecrOperationAuthorization runs after ecrAuthBridge has rehydrated and
// stashed the current principal: it classifies the request's OCI operation,
// builds the repository ARN(s) the route requires, and evaluates every
// required ecr: action through the same identity-policy evaluator SigV4
// requests use. The request only reaches Registry once every requirement
// evaluates allow — a token that authenticates a real but under-permissioned
// principal (e.g. AmazonEC2ContainerRegistryPullOnly) must not push, delete,
// or overwrite despite carrying a validly signed token.
//
// A nil ECRTokenVerifier disables ecrAuthBridge upstream (registry mounts
// open in unit tests of unrelated routes); this middleware mirrors that
// bypass so it never blocks those tests.
func (gw *GatewayConfig) ecrOperationAuthorization(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if gw.ECRTokenVerifier == nil {
next.ServeHTTP(w, r)
return
}
principal, ok := r.Context().Value(ctxECRPrincipal).(principalContext)
if !ok {
// ecrAuthBridge did not stash a principal: it either already rejected
// the request (this handler is never reached) or is disabled in a way
// that skipped rehydration. Fail closed rather than dispatch with no
// principal to evaluate.
ecrregistry.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required")
return
}
op, ok := ecrregistry.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.
ecrregistry.WriteError(w, http.StatusNotFound, "NAME_UNKNOWN", "unrecognized registry operation")
return
}
// Hoisted: the keys depend on the request and the principal, neither of
// which changes across requirements.
keys := requestConditionKeys(r, principal)
for _, req := range op.Requirements {
resource, err := ecrResourceARN(gw.Region, principal.accountID, req.Scope, op)
if err != nil {
ecrregistry.WriteError(w, http.StatusServiceUnavailable, "UNKNOWN", "authorization unavailable")
return
}
if err := gw.evaluatePrincipalPolicyResources(
principal, policy.IAMAction("ecr", req.Action), []string{resource}, keys,
); err != nil {
if err.Error() == awserrors.ErrorInternalError {
// IAM/STS/NATS dependency failure: fail closed, never dispatch.
ecrregistry.WriteError(w, http.StatusServiceUnavailable, "UNKNOWN", "authorization unavailable")
return
}
ecrregistry.WriteError(w, http.StatusForbidden, "DENIED", "insufficient permissions")
return
}
}
next.ServeHTTP(w, r)
})
}
// ecrResourceARN builds the repository ARN an ActionRequirement's scope
// resolves to, from the gateway's configured region and the token's account.
func ecrResourceARN(region, accountID string, scope ecrregistry.ResourceScope, op ecrregistry.ClassifiedOperation) (string, error) {
switch scope {
case ecrregistry.ScopeAccountWildcard:
return fmt.Sprintf("arn:aws:ecr:%s:%s:repository/*", region, accountID), nil
case ecrregistry.ScopeDestination:
return fmt.Sprintf("arn:aws:ecr:%s:%s:repository/%s", region, accountID, op.Repo), nil
case ecrregistry.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)
}
}
+311
View File
@@ -1,7 +1,21 @@
package gateway
import (
"context"
"errors"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"net/http"
"net/http/httptest"
"testing"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
)
const (
ecrAuthzTestAccount = "000000000077"
ecrAuthzTestUser = "svc"
)
// ecrAllowPolicy and ecrDenyPolicy build single-statement policy documents for
@@ -26,3 +40,300 @@ func ecrDenyPolicy(actions, resources []string) iamdomain.PolicyDocument {
},
}
}
// ecrAuthzTestGateway builds a GatewayConfig wired for ecrOperationAuthorization
// tests: a non-nil ECRTokenVerifier (so the middleware is active) and an
// ecrMockIAMService seeded with ecrAuthzTestUser's policy documents.
func ecrAuthzTestGateway(t *testing.T, policies []iamdomain.PolicyDocument) (*GatewayConfig, *ecrMockIAMService) {
t.Helper()
_, verify := newECRAuth(t)
iamSvc := newECRMockIAMService()
iamSvc.userPolicies[ecrAuthzTestAccount+"|"+ecrAuthzTestUser] = policies
return &GatewayConfig{
Region: ecrTestRegion,
ECRTokenVerifier: verify,
IAMService: iamSvc,
Credentials: credverify.New(iamSvc, nil),
}, iamSvc
}
// ecrAuthzRequest builds a request carrying a resolved principal, as
// ecrAuthBridge would stash it, for method/path/query.
func ecrAuthzRequest(method, path string, principal principalContext) *http.Request {
req := httptest.NewRequest(method, path, nil)
ctx := context.WithValue(req.Context(), ctxECRPrincipal, principal)
return req.WithContext(ctx)
}
func ecrTestUserPrincipal() principalContext {
return principalContext{identity: ecrAuthzTestUser, accountID: ecrAuthzTestAccount, principalType: principalTypeUser}
}
// TestECROperationAuthorization_PullOnlyMatrix pins the release-gate positive
// and negative matrix for a PullOnly-shaped policy: pull operations succeed,
// every push/tag-overwrite/delete operation is denied.
func TestECROperationAuthorization_PullOnlyMatrix(t *testing.T) {
repoARN := "arn:aws:ecr:" + ecrTestRegion + ":" + ecrAuthzTestAccount + ":repository/team/app"
pullOnly := ecrAllowPolicy(
[]string{"ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:BatchCheckLayerAvailability"},
[]string{repoARN},
)
gw, _ := ecrAuthzTestGateway(t, []iamdomain.PolicyDocument{pullOnly})
allowed := []struct{ method, path string }{
{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"},
}
for _, c := range allowed {
t.Run("allow "+c.method+" "+c.path, func(t *testing.T) {
next, called := okHandler()
w := httptest.NewRecorder()
gw.ecrOperationAuthorization(next).ServeHTTP(w, ecrAuthzRequest(c.method, c.path, ecrTestUserPrincipal()))
assert.True(t, *called)
assert.Equal(t, http.StatusOK, w.Code)
})
}
denied := []struct{ method, path string }{
{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"},
}
for _, c := range denied {
t.Run("deny "+c.method+" "+c.path, func(t *testing.T) {
next, called := okHandler()
w := httptest.NewRecorder()
gw.ecrOperationAuthorization(next).ServeHTTP(w, ecrAuthzRequest(c.method, c.path, ecrTestUserPrincipal()))
assert.False(t, *called, "denied operation must never dispatch")
assert.Equal(t, http.StatusForbidden, w.Code)
})
}
}
// TestECROperationAuthorization_ReadOnlyMatrix pins the ReadOnly release-gate
// matrix: catalog, tags, blob and manifest reads succeed; every write/delete
// is denied.
func TestECROperationAuthorization_ReadOnlyMatrix(t *testing.T) {
repoARN := "arn:aws:ecr:" + ecrTestRegion + ":" + ecrAuthzTestAccount + ":repository/team/app"
wildcardARN := "arn:aws:ecr:" + ecrTestRegion + ":" + ecrAuthzTestAccount + ":repository/*"
readOnly := ecrAllowPolicy(
[]string{
"ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:BatchCheckLayerAvailability",
"ecr:ListImages", "ecr:DescribeRepositories",
},
[]string{repoARN, wildcardARN},
)
gw, _ := ecrAuthzTestGateway(t, []iamdomain.PolicyDocument{readOnly})
allowed := []struct{ method, path string }{
{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"},
}
for _, c := range allowed {
t.Run("allow "+c.method+" "+c.path, func(t *testing.T) {
next, called := okHandler()
w := httptest.NewRecorder()
gw.ecrOperationAuthorization(next).ServeHTTP(w, ecrAuthzRequest(c.method, c.path, ecrTestUserPrincipal()))
assert.True(t, *called)
assert.Equal(t, http.StatusOK, w.Code)
})
}
denied := []struct{ method, path string }{
{http.MethodPost, "/v2/team/app/blobs/uploads/"},
{http.MethodPut, "/v2/team/app/manifests/latest"},
{http.MethodDelete, "/v2/team/app/manifests/sha256:abc"},
}
for _, c := range denied {
t.Run("deny "+c.method+" "+c.path, func(t *testing.T) {
next, called := okHandler()
w := httptest.NewRecorder()
gw.ecrOperationAuthorization(next).ServeHTTP(w, ecrAuthzRequest(c.method, c.path, ecrTestUserPrincipal()))
assert.False(t, *called)
assert.Equal(t, http.StatusForbidden, w.Code)
})
}
}
// TestECROperationAuthorization_RepoScopedAllowDoesNotCrossRepos verifies an
// allow scoped to repository A's ARN does not authorize the identical action
// against repository B.
func TestECROperationAuthorization_RepoScopedAllowDoesNotCrossRepos(t *testing.T) {
repoAARN := "arn:aws:ecr:" + ecrTestRegion + ":" + ecrAuthzTestAccount + ":repository/team/a"
policy := ecrAllowPolicy([]string{"ecr:BatchGetImage"}, []string{repoAARN})
gw, _ := ecrAuthzTestGateway(t, []iamdomain.PolicyDocument{policy})
t.Run("repo A allowed", func(t *testing.T) {
next, called := okHandler()
w := httptest.NewRecorder()
gw.ecrOperationAuthorization(next).ServeHTTP(w, ecrAuthzRequest(http.MethodGet, "/v2/team/a/manifests/latest", ecrTestUserPrincipal()))
assert.True(t, *called)
assert.Equal(t, http.StatusOK, w.Code)
})
t.Run("repo B denied", func(t *testing.T) {
next, called := okHandler()
w := httptest.NewRecorder()
gw.ecrOperationAuthorization(next).ServeHTTP(w, ecrAuthzRequest(http.MethodGet, "/v2/team/b/manifests/latest", ecrTestUserPrincipal()))
assert.False(t, *called)
assert.Equal(t, http.StatusForbidden, w.Code)
})
t.Run("repo A referrers allowed", func(t *testing.T) {
next, called := okHandler()
w := httptest.NewRecorder()
gw.ecrOperationAuthorization(next).ServeHTTP(w, ecrAuthzRequest(http.MethodGet, "/v2/team/a/referrers/sha256:abc", ecrTestUserPrincipal()))
assert.True(t, *called)
assert.Equal(t, http.StatusOK, w.Code)
})
t.Run("repo B referrers denied", func(t *testing.T) {
next, called := okHandler()
w := httptest.NewRecorder()
gw.ecrOperationAuthorization(next).ServeHTTP(w, ecrAuthzRequest(http.MethodGet, "/v2/team/b/referrers/sha256:abc", ecrTestUserPrincipal()))
assert.False(t, *called)
assert.Equal(t, http.StatusForbidden, w.Code)
})
}
// TestECROperationAuthorization_ReferrersNeedBatchGetImage verifies the
// referrers listing is gated on BatchGetImage and on nothing a push grants.
func TestECROperationAuthorization_ReferrersNeedBatchGetImage(t *testing.T) {
repoARN := "arn:aws:ecr:" + ecrTestRegion + ":" + ecrAuthzTestAccount + ":repository/team/app"
pushOnly := ecrAllowPolicy(
[]string{"ecr:PutImage", "ecr:InitiateLayerUpload", "ecr:ListImages", "ecr:BatchCheckLayerAvailability"},
[]string{repoARN},
)
gw, _ := ecrAuthzTestGateway(t, []iamdomain.PolicyDocument{pushOnly})
next, called := okHandler()
w := httptest.NewRecorder()
gw.ecrOperationAuthorization(next).ServeHTTP(w, ecrAuthzRequest(http.MethodGet, "/v2/team/app/referrers/sha256:abc", ecrTestUserPrincipal()))
assert.False(t, *called, "a caller without BatchGetImage must not reach the handler")
assert.Equal(t, http.StatusForbidden, w.Code)
}
// TestECROperationAuthorization_ExplicitDenyWins verifies an explicit Deny
// statement overrides a broader Allow for the same action/resource.
func TestECROperationAuthorization_ExplicitDenyWins(t *testing.T) {
repoARN := "arn:aws:ecr:" + ecrTestRegion + ":" + ecrAuthzTestAccount + ":repository/team/app"
allow := ecrAllowPolicy([]string{"ecr:BatchGetImage"}, []string{"arn:aws:ecr:" + ecrTestRegion + ":" + ecrAuthzTestAccount + ":repository/*"})
deny := ecrDenyPolicy([]string{"ecr:BatchGetImage"}, []string{repoARN})
gw, _ := ecrAuthzTestGateway(t, []iamdomain.PolicyDocument{allow, deny})
next, called := okHandler()
w := httptest.NewRecorder()
gw.ecrOperationAuthorization(next).ServeHTTP(w, ecrAuthzRequest(http.MethodGet, "/v2/team/app/manifests/latest", ecrTestUserPrincipal()))
assert.False(t, *called)
assert.Equal(t, http.StatusForbidden, w.Code)
next, called = okHandler()
w = httptest.NewRecorder()
gw.ecrOperationAuthorization(next).ServeHTTP(w, ecrAuthzRequest(http.MethodGet, "/v2/team/app/referrers/sha256:abc", ecrTestUserPrincipal()))
assert.False(t, *called)
assert.Equal(t, http.StatusForbidden, w.Code)
}
// TestECROperationAuthorization_CrossRepoMount verifies a mount requires both
// destination write and source read permission.
func TestECROperationAuthorization_CrossRepoMount(t *testing.T) {
destARN := "arn:aws:ecr:" + ecrTestRegion + ":" + ecrAuthzTestAccount + ":repository/team/app"
sourceARN := "arn:aws:ecr:" + ecrTestRegion + ":" + ecrAuthzTestAccount + ":repository/team/base"
t.Run("destination write only: denied, never dispatches", func(t *testing.T) {
gw, _ := ecrAuthzTestGateway(t, []iamdomain.PolicyDocument{
ecrAllowPolicy([]string{"ecr:InitiateLayerUpload"}, []string{destARN}),
})
next, called := okHandler()
w := httptest.NewRecorder()
req := ecrAuthzRequest(http.MethodPost, "/v2/team/app/blobs/uploads/?mount=sha256:abc&from=team/base", ecrTestUserPrincipal())
gw.ecrOperationAuthorization(next).ServeHTTP(w, req)
assert.False(t, *called)
assert.Equal(t, http.StatusForbidden, w.Code)
})
t.Run("destination write + source read: allowed", func(t *testing.T) {
gw, _ := ecrAuthzTestGateway(t, []iamdomain.PolicyDocument{
ecrAllowPolicy([]string{"ecr:InitiateLayerUpload"}, []string{destARN}),
ecrAllowPolicy([]string{"ecr:BatchCheckLayerAvailability"}, []string{sourceARN}),
})
next, called := okHandler()
w := httptest.NewRecorder()
req := ecrAuthzRequest(http.MethodPost, "/v2/team/app/blobs/uploads/?mount=sha256:abc&from=team/base", ecrTestUserPrincipal())
gw.ecrOperationAuthorization(next).ServeHTTP(w, req)
assert.True(t, *called)
assert.Equal(t, http.StatusOK, w.Code)
})
}
// TestECROperationAuthorization_UnclassifiedOperationNeverDispatches verifies
// a method/path combination Registry does not implement is refused before
// dispatch rather than silently forwarded.
func TestECROperationAuthorization_UnclassifiedOperationNeverDispatches(t *testing.T) {
gw, _ := ecrAuthzTestGateway(t, nil)
next, called := okHandler()
w := httptest.NewRecorder()
gw.ecrOperationAuthorization(next).ServeHTTP(w, ecrAuthzRequest(http.MethodPost, "/v2/", ecrTestUserPrincipal()))
assert.False(t, *called)
assert.Equal(t, http.StatusNotFound, w.Code)
}
// TestECROperationAuthorization_MissingPrincipalUnauthorized verifies the
// middleware fails closed if no principal was stashed on the context (the
// bridge is disabled or was bypassed), rather than dispatching unauthenticated.
func TestECROperationAuthorization_MissingPrincipalUnauthorized(t *testing.T) {
gw, _ := ecrAuthzTestGateway(t, nil)
next, called := okHandler()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v2/team/app/manifests/latest", nil)
gw.ecrOperationAuthorization(next).ServeHTTP(w, req)
assert.False(t, *called)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
// TestECROperationAuthorization_DependencyFailureNeverDispatches verifies an
// IAM backend outage fails closed with 503 rather than allowing or denying
// based on incomplete information.
func TestECROperationAuthorization_DependencyFailureNeverDispatches(t *testing.T) {
gw, iamSvc := ecrAuthzTestGateway(t, nil)
iamSvc.genericErr = errors.New(awserrors.ErrorServiceUnavailable)
next, called := okHandler()
w := httptest.NewRecorder()
req := ecrAuthzRequest(http.MethodGet, "/v2/team/app/manifests/latest", ecrTestUserPrincipal())
gw.ecrOperationAuthorization(next).ServeHTTP(w, req)
assert.False(t, *called)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
// TestECROperationAuthorization_NilVerifierPassesThrough mirrors the bridge's
// own nil-verifier bypass, used by unit tests of unrelated routes.
func TestECROperationAuthorization_NilVerifierPassesThrough(t *testing.T) {
gw := &GatewayConfig{}
next, called := okHandler()
w := httptest.NewRecorder()
gw.ecrOperationAuthorization(next).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v2/team/app/manifests/latest", nil))
assert.True(t, *called)
assert.Equal(t, http.StatusOK, w.Code)
}
// TestECROperationAuthorization_BasePathNoActionRequired verifies GET /v2/
// dispatches once a principal is resolved, with no resource-specific decision.
func TestECROperationAuthorization_BasePathNoActionRequired(t *testing.T) {
gw, _ := ecrAuthzTestGateway(t, nil)
next, called := okHandler()
w := httptest.NewRecorder()
req := ecrAuthzRequest(http.MethodGet, "/v2/", ecrTestUserPrincipal())
gw.ecrOperationAuthorization(next).ServeHTTP(w, req)
assert.True(t, *called)
assert.Equal(t, http.StatusOK, w.Code)
}
@@ -170,8 +170,9 @@ func TestECRPipeline_StubbedAction_501NotImplementedException(t *testing.T) {
// successful ECR action answers 200 with the AWS JSON 1.1 content type
// through the full pipeline.
func TestECRPipeline_GetAuthorizationToken_200JSONContentType(t *testing.T) {
iss, _ := newECRAuth(t)
iss, verify := newECRAuth(t)
gw, app := ecrPipelineApp(t, allowAllECRDocs())
gw.ECRTokenIssuer, gw.ECRTokenVerifier = iss, verify
withECR(gw, awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
@@ -396,8 +397,8 @@ func TestGetAuthorizationToken_IgnoresRequestSuppliedIdentity(t *testing.T) {
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
gw := withECR(withVerifier(&GatewayConfig{
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
ECRTokenIssuer: iss, ECRTokenVerifier: verify, DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, endpoint)})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"registryIds":["999999999999"]}`))
@@ -10,7 +10,6 @@ import (
"github.com/aws/aws-sdk-go/aws"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -20,8 +19,8 @@ func TestGetAuthorizationToken_MintsUsableToken(t *testing.T) {
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
gw := withECR(withVerifier(&GatewayConfig{
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
ECRTokenIssuer: iss, ECRTokenVerifier: verify, DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, endpoint)})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}"))
@@ -74,8 +73,8 @@ func TestGetAuthorizationToken_PathedUserRoundTrips(t *testing.T) {
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
gw := withECR(withVerifier(&GatewayConfig{
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: iamSvc,
ECRTokenIssuer: iss, ECRTokenVerifier: verify, DisableLogging: true,
IAMService: iamSvc,
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, endpoint)})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}"))
@@ -103,18 +102,18 @@ func TestGetAuthorizationToken_PathedUserRoundTrips(t *testing.T) {
claims, err := verify.Verify(jwtStr)
require.NoError(t, err)
assert.Equal(t, ecrPathedUserARN, claims.Subject)
got, failure := credverify.New(gw.IAMService, gw.STSService).Rehydrate(claims.AccessKeyID, claims.AccountID)
require.Nil(t, failure)
assert.Equal(t, ecrPathedUserARN, got.CallerARN)
got, err := gw.resolveECRPrincipal(claims)
require.NoError(t, err)
assert.Equal(t, ecrPathedUserARN, got.userARN)
}
func TestGetAuthorizationToken_ProxyEndpointCarriesPort(t *testing.T) {
iss, _ := newECRAuth(t)
iss, verify := newECRAuth(t)
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix, RegistryPort: "9999"}
gw := withECR(withVerifier(&GatewayConfig{
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix, RegistryPort: "9999",
DisableLogging: true,
IAMService: allowAllIAMService(),
ECRTokenIssuer: iss, ECRTokenVerifier: verify, DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, endpoint)})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}"))
@@ -157,11 +156,11 @@ func TestGetAuthorizationToken_MissingComposition(t *testing.T) {
}
func TestECRRequest_GetAuthorizationTokenDispatched(t *testing.T) {
iss, _ := newECRAuth(t)
iss, verify := newECRAuth(t)
gw := withECR(withVerifier(&GatewayConfig{
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
ECRTokenIssuer: iss, ECRTokenVerifier: verify, DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, awsapi.RepositoryEndpoint{
Region: ecrTestRegion, ServicesDomain: ecrTestSuffix,
})})
+4 -14
View File
@@ -7,7 +7,6 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/aws/aws-sdk-go/aws"
@@ -27,29 +26,20 @@ func newImageGateway(t *testing.T) *GatewayConfig {
t.Helper()
meta := ecrdomain.NewMemoryMetaStore()
reg := ecrregistry.NewRegistry(objectstore.NewMemoryObjectStore(), meta, ecrTestAccount)
gw := withECR(withVerifier(&GatewayConfig{
return withECR(withVerifier(&GatewayConfig{
ECRRegistry: reg,
Region: ecrTestRegion,
InternalSuffix: ecrTestSuffix,
DisableLogging: true,
IAMService: allowAllIAMService(),
}), awsapi.Deps{Registry: awsapi.NewRegistryActionService(reg, reg, reg, reg), LifecyclePreview: awsapi.NewLifecyclePreviewActionService(meta, reg)})
imageRegistries.Store(gw, reg)
return gw
}
// imageRegistries maps a newImageGateway config to its registry so seeds can reach it.
var imageRegistries sync.Map
func registryOf(gw *GatewayConfig) *ecrregistry.Registry {
reg, _ := imageRegistries.Load(gw)
return reg.(*ecrregistry.Registry)
}
// seedGatewayRepo creates the repository metadata so push/PutImage handlers —
// which require an existing repository — accept writes. Idempotent.
func seedGatewayRepo(t *testing.T, gw *GatewayConfig, repo string) {
t.Helper()
require.NoError(t, registryOf(gw).Meta.PutRepo(context.Background(), ecrTestAccount, ecrdomain.RepoMeta{Name: repo}))
require.NoError(t, gw.ECRRegistry.Meta.PutRepo(context.Background(), ecrTestAccount, ecrdomain.RepoMeta{Name: repo}))
}
// seedTaggedImage stores a layerless manifest under repo:tag, returning its
@@ -59,7 +49,7 @@ func seedTaggedImage(t *testing.T, gw *GatewayConfig, repo, tag string) string {
seedGatewayRepo(t, gw, repo)
manifest := fmt.Appendf(nil,
`{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","layers":[],"annotations":{"tag":"%s"}}`, tag)
digest, err := registryOf(gw).StoreManifest(context.Background(), ecrTestAccount, repo, tag, "application/vnd.docker.distribution.manifest.v2+json", manifest)
digest, err := gw.ECRRegistry.StoreManifest(context.Background(), ecrTestAccount, repo, tag, "application/vnd.docker.distribution.manifest.v2+json", manifest)
require.NoError(t, err)
return digest
}
+245
View File
@@ -0,0 +1,245 @@
package gateway
import (
"errors"
"fmt"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
)
// An ECR registry token is a signed pointer to an IAM/STS record, not a
// permission grant: possession of a verified, unexpired JWT only proves the
// bearer once authenticated as the access key named in its claims. Every
// /v2/* request must resolve that access key against the *current* IAM/STS
// state before dispatch, so a key deactivation, session expiry/deletion, user
// or role deletion, account suspension, or policy change takes effect on the
// very next request — including one carrying an already-issued 12h token.
//
// resolveECRPrincipal performs that rehydration and returns the same
// principalContext the SigV4 middleware builds, so ECR requests are
// authorized by the identical policy-evaluation path as every other AWS
// action.
// ecrPrincipalError classifies a principal-rehydration failure so the HTTP
// layer can fail closed with the right status: an invalid/revoked/mismatched
// identity is client-visible (401, with the registry's re-auth challenge),
// while a lookup that could not complete is an operator-visible dependency
// failure (503) that must never be treated as an allow.
type ecrPrincipalError struct {
dependency bool
err error
}
func (e *ecrPrincipalError) Error() string { return e.err.Error() }
func (e *ecrPrincipalError) Unwrap() error { return e.err }
// ecrInvalidPrincipal wraps a reason the presented identity pointer is no
// longer valid (revoked, expired, deleted, or inconsistent with the signed claims).
func ecrInvalidPrincipal(format string, args ...any) error {
return &ecrPrincipalError{err: fmt.Errorf(format, args...)}
}
// ecrDependencyFailure wraps a reason the identity pointer could not be
// checked at all (IAM/STS/NATS unavailable). Never treat this as an allow.
func ecrDependencyFailure(format string, args ...any) error {
return &ecrPrincipalError{dependency: true, err: fmt.Errorf(format, args...)}
}
// isECRDependencyFailure reports whether err (from resolveECRPrincipal or a
// caller further down the ECR chain) represents a backend outage rather than
// an invalid identity, so the HTTP layer can choose 503 over 401.
func isECRDependencyFailure(err error) bool {
var pe *ecrPrincipalError
return errors.As(err, &pe) && pe.dependency
}
// classifyIAMLookupErr maps an IAMService lookup error to the right
// ecrPrincipalError kind: a NoSuchEntity-shaped error means the referenced
// record is gone (invalid, 401); anything else is a dependency failure (503),
// since it says nothing about whether the identity is still valid.
func classifyIAMLookupErr(err error, what string) error {
if awserrors.IsErrorCode(err, awserrors.ErrorIAMNoSuchEntity) {
return ecrInvalidPrincipal("%s not found: %w", what, err)
}
return ecrDependencyFailure("%s lookup failed: %w", what, err)
}
// resolveECRPrincipal rehydrates claims (already ES256/expiry/issuer/audience
// verified by ecrauth.Verifier) against current IAM/STS state and
// returns the equivalent of a fresh SigV4 authentication for the same
// identity. It never trusts claims.PrincipalType or claims.Subject on their
// own — both are cross-checked against the freshly resolved record before
// being accepted, so a forged or stale claim cannot widen access beyond what
// the current IAM/STS state actually grants.
func (gw *GatewayConfig) resolveECRPrincipal(claims *ecrauth.Claims) (principalContext, error) {
switch {
case strings.HasPrefix(claims.AccessKeyID, longLivedAKIDPrefix):
return gw.resolveECRLongLivedPrincipal(claims)
case strings.HasPrefix(claims.AccessKeyID, sessionAKIDPrefix):
return gw.resolveECRSessionPrincipal(claims)
default:
return principalContext{}, ecrInvalidPrincipal("unrecognized access key prefix")
}
}
// resolveECRLongLivedPrincipal rehydrates an AKIA (IAM user) claim: the
// access key must still be active and belong to the claimed account, the
// user it names must still exist, the account must still be active, and the
// canonical ARN/principalType computed from that current state must match
// what was signed into the token.
func (gw *GatewayConfig) resolveECRLongLivedPrincipal(claims *ecrauth.Claims) (principalContext, error) {
if gw.IAMService == nil {
return principalContext{}, ecrDependencyFailure("IAM service not available")
}
ak, err := gw.IAMService.LookupAccessKey(claims.AccessKeyID)
if err != nil {
return principalContext{}, classifyIAMLookupErr(err, "access key")
}
if ak.Status != iamdomain.AccessKeyStatusActive {
return principalContext{}, ecrInvalidPrincipal("access key %q is inactive", claims.AccessKeyID)
}
if ak.AccountID != claims.AccountID {
return principalContext{}, ecrInvalidPrincipal("access key account does not match token account")
}
if err := gw.requireActiveECRAccount(ak.AccountID); err != nil {
return principalContext{}, err
}
userOut, err := gw.IAMService.GetUser(ak.AccountID, &iam.GetUserInput{UserName: aws.String(ak.UserName)})
if err != nil {
return principalContext{}, classifyIAMLookupErr(err, "user")
}
identity := aws.StringValue(userOut.User.UserName)
userARN := aws.StringValue(userOut.User.Arn)
if claims.PrincipalType != principalTypeUser {
return principalContext{}, ecrInvalidPrincipal("principalType claim %q does not match resolved user", claims.PrincipalType)
}
canonicalARN, err := buildCallerARN(ak.AccountID, identity, principalTypeUser, "", userARN)
if err != nil {
return principalContext{}, ecrInvalidPrincipal("cannot build canonical ARN: %w", err)
}
if canonicalARN != claims.Subject {
return principalContext{}, ecrInvalidPrincipal("subject claim does not match canonical principal ARN")
}
return principalContext{
identity: identity,
accountID: ak.AccountID,
principalType: principalTypeUser,
userID: aws.StringValue(userOut.User.UserId),
userARN: userARN,
}, nil
}
// resolveECRSessionPrincipal rehydrates an ASIA (STS session) claim. Both a
// GetSessionToken user session and an assumed-role session are re-resolved
// against their principal's live IAM record and rejected if it was deleted, or
// recreated under the same name with a different immutable ID.
func (gw *GatewayConfig) resolveECRSessionPrincipal(claims *ecrauth.Claims) (principalContext, error) {
if gw.STSService == nil {
return principalContext{}, ecrDependencyFailure("STS service not available")
}
if gw.IAMService == nil {
return principalContext{}, ecrDependencyFailure("IAM service not available")
}
cred, err := gw.STSService.LookupSessionCredential(claims.AccessKeyID)
if err != nil {
return principalContext{}, ecrDependencyFailure("session lookup failed: %w", err)
}
if cred == nil {
return principalContext{}, ecrInvalidPrincipal("session %q not found", claims.AccessKeyID)
}
if time.Now().UTC().After(cred.ExpiresAt) {
return principalContext{}, ecrInvalidPrincipal("session %q expired", claims.AccessKeyID)
}
if cred.AccountID != claims.AccountID {
return principalContext{}, ecrInvalidPrincipal("session account does not match token account")
}
if err := gw.requireActiveECRAccount(cred.AccountID); err != nil {
return principalContext{}, err
}
// One read of the live principal, shared by both branches: it both rejects a
// session whose principal was deleted or recreated under the same name and
// supplies the resolved record the branches below would otherwise re-read.
live, err := gw.STSService.VerifySessionPrincipal(cred)
if err != nil {
if stsdomain.IsSessionPrincipalVerdict(err) {
return principalContext{}, ecrInvalidPrincipal("session principal is no longer valid: %w", err)
}
return principalContext{}, ecrDependencyFailure("session principal check failed: %w", err)
}
if cred.PrincipalType == principalTypeUser {
// GetSessionToken: the session resolves to the same user identity as a
// long-lived key, so it is authorized as that user.
if claims.PrincipalType != principalTypeUser {
return principalContext{}, ecrInvalidPrincipal("principalType claim %q does not match resolved session", claims.PrincipalType)
}
canonicalARN, err := buildCallerARN(cred.AccountID, cred.SessionName, principalTypeUser, "", live.UserARN)
if err != nil {
return principalContext{}, ecrInvalidPrincipal("cannot build canonical ARN: %w", err)
}
if canonicalARN != claims.Subject {
return principalContext{}, ecrInvalidPrincipal("subject claim does not match canonical principal ARN")
}
return principalContext{
identity: cred.SessionName,
accountID: cred.AccountID,
principalType: principalTypeUser,
userID: live.UserID,
userARN: live.UserARN,
}, nil
}
// Assumed-role, or a legacy record whose empty PrincipalType predates the
// field — both mean "assumed-role" (mirrors resolveSessionAKID).
if claims.PrincipalType != principalTypeAssumedRole {
return principalContext{}, ecrInvalidPrincipal("principalType claim %q does not match resolved session", claims.PrincipalType)
}
canonicalARN, err := buildCallerARN(cred.AccountID, cred.SessionName, principalTypeAssumedRole, cred.AssumedRoleARN, "")
if err != nil {
return principalContext{}, ecrInvalidPrincipal("cannot build canonical ARN: %w", err)
}
if canonicalARN != claims.Subject {
return principalContext{}, ecrInvalidPrincipal("subject claim does not match canonical principal ARN")
}
return principalContext{
identity: cred.SessionName,
accountID: cred.AccountID,
principalType: principalTypeAssumedRole,
assumedRoleARN: cred.AssumedRoleARN,
assumedRoleID: cred.AssumedRoleID,
underlyingRoleARN: cred.UnderlyingRoleARN,
userID: cred.AssumedRoleID,
}, nil
}
// requireActiveECRAccount fails closed unless accountID resolves to an
// active account, so a suspended account's already-issued tokens stop
// working immediately rather than at their natural 12h expiry.
func (gw *GatewayConfig) requireActiveECRAccount(accountID string) error {
account, err := gw.IAMService.GetAccount(accountID)
if err != nil {
return classifyIAMLookupErr(err, "account")
}
if account.Status != iamdomain.AccountStatusActive {
return ecrInvalidPrincipal("account %q is not active", accountID)
}
return nil
}
+411 -7
View File
@@ -2,14 +2,19 @@ package gateway
import (
"errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ecrMockIAMService implements iamdomain.IAMService for ECR principal
@@ -31,10 +36,6 @@ type ecrMockIAMService struct {
// not-found error, simulating an IAM backend outage rather than a
// missing record.
genericErr error
// userPolicyErr fails only the policy read, so a test can let the principal
// rehydrate and then break authorization.
userPolicyErr error
}
func newECRMockIAMService() *ecrMockIAMService {
@@ -96,9 +97,6 @@ func (m *ecrMockIAMService) GetUserPolicies(accountID, userName string) ([]iamdo
if m.genericErr != nil {
return nil, m.genericErr
}
if m.userPolicyErr != nil {
return nil, m.userPolicyErr
}
return m.userPolicies[accountID+"|"+userName], nil
}
@@ -166,8 +164,176 @@ func seedECRTestUser(iamSvc *ecrMockIAMService, accountID, userName, akid string
iamSvc.accounts[accountID] = &iamdomain.Account{AccountID: accountID, Status: iamdomain.AccountStatusActive}
}
func TestResolveECRPrincipal_LongLivedUser(t *testing.T) {
iamSvc := newECRMockIAMService()
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
gw := &GatewayConfig{IAMService: iamSvc}
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestAKID,
}
claims.Subject = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/dev"
got, err := gw.resolveECRPrincipal(claims)
require.NoError(t, err)
assert.Equal(t, principalContext{identity: "dev", accountID: ecrPrincipalTestAccount,
principalType: principalTypeUser, userID: "AIDADEV", userARN: claims.Subject}, got)
}
const ecrPathedUserARN = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/eng/dev"
// A user at a non-root path verifies against its stored ARN, path included. A
// token whose subject drops the path, as one minted before the path was carried
// would, no longer names the principal and is rejected.
func TestResolveECRPrincipal_LongLivedUser_Pathed(t *testing.T) {
iamSvc := newECRMockIAMService()
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
iamSvc.users[ecrPrincipalTestAccount+"|dev"].Arn = aws.String(ecrPathedUserARN)
gw := &GatewayConfig{IAMService: iamSvc}
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestAKID,
}
claims.Subject = ecrPathedUserARN
got, err := gw.resolveECRPrincipal(claims)
require.NoError(t, err)
assert.Equal(t, ecrPathedUserARN, got.userARN)
claims.Subject = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/dev"
_, err = gw.resolveECRPrincipal(claims)
require.Error(t, err)
assert.False(t, isECRDependencyFailure(err))
}
func TestResolveECRPrincipal_GlobalRoot(t *testing.T) {
iamSvc := newECRMockIAMService()
seedECRTestUser(iamSvc, awsidentifiers.GlobalAccountID, "root", ecrPrincipalTestAKID)
gw := &GatewayConfig{IAMService: iamSvc}
claims := &ecrauth.Claims{
AccountID: awsidentifiers.GlobalAccountID,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestAKID,
}
claims.Subject = "arn:aws:iam::" + awsidentifiers.GlobalAccountID + ":root"
got, err := gw.resolveECRPrincipal(claims)
require.NoError(t, err)
assert.Equal(t, principalTypeUser, got.principalType)
assert.Equal(t, "root", got.identity)
}
func TestResolveECRPrincipal_LongLivedUser_Rejections(t *testing.T) {
validClaims := func() *ecrauth.Claims {
c := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestAKID,
}
c.Subject = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/dev"
return c
}
t.Run("unknown access key", func(t *testing.T) {
iamSvc := newECRMockIAMService()
gw := &GatewayConfig{IAMService: iamSvc}
_, err := gw.resolveECRPrincipal(validClaims())
require.Error(t, err)
assert.False(t, isECRDependencyFailure(err))
})
t.Run("inactive access key", func(t *testing.T) {
iamSvc := newECRMockIAMService()
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
iamSvc.accessKeys[ecrPrincipalTestAKID].Status = "Inactive"
gw := &GatewayConfig{IAMService: iamSvc}
_, err := gw.resolveECRPrincipal(validClaims())
require.Error(t, err)
assert.False(t, isECRDependencyFailure(err))
})
t.Run("access key account does not match claim account", func(t *testing.T) {
iamSvc := newECRMockIAMService()
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
iamSvc.accessKeys[ecrPrincipalTestAKID].AccountID = "000000000099"
gw := &GatewayConfig{IAMService: iamSvc}
_, err := gw.resolveECRPrincipal(validClaims())
require.Error(t, err)
})
t.Run("deleted user", func(t *testing.T) {
iamSvc := newECRMockIAMService()
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
delete(iamSvc.users, ecrPrincipalTestAccount+"|dev")
gw := &GatewayConfig{IAMService: iamSvc}
_, err := gw.resolveECRPrincipal(validClaims())
require.Error(t, err)
assert.False(t, isECRDependencyFailure(err))
})
t.Run("suspended account", func(t *testing.T) {
iamSvc := newECRMockIAMService()
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
iamSvc.accounts[ecrPrincipalTestAccount].Status = iamdomain.AccountStatusSuspended
gw := &GatewayConfig{IAMService: iamSvc}
_, err := gw.resolveECRPrincipal(validClaims())
require.Error(t, err)
assert.False(t, isECRDependencyFailure(err))
})
// CURRENT: a registry token is rehydrated against the account record, so a
// terminating account's already-issued token is rejected as invalid.
t.Run("terminating account", func(t *testing.T) {
iamSvc := newECRMockIAMService()
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
iamSvc.accounts[ecrPrincipalTestAccount].Status = iamdomain.AccountStatusTerminating
gw := &GatewayConfig{IAMService: iamSvc}
_, err := gw.resolveECRPrincipal(validClaims())
require.Error(t, err)
assert.False(t, isECRDependencyFailure(err))
})
t.Run("principalType claim mismatch", func(t *testing.T) {
iamSvc := newECRMockIAMService()
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
gw := &GatewayConfig{IAMService: iamSvc}
c := validClaims()
c.PrincipalType = principalTypeAssumedRole
_, err := gw.resolveECRPrincipal(c)
require.Error(t, err)
})
t.Run("subject does not match canonical ARN", func(t *testing.T) {
iamSvc := newECRMockIAMService()
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
gw := &GatewayConfig{IAMService: iamSvc}
c := validClaims()
c.Subject = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/someone-else"
_, err := gw.resolveECRPrincipal(c)
require.Error(t, err)
})
t.Run("nil IAM service is a dependency failure", func(t *testing.T) {
gw := &GatewayConfig{}
_, err := gw.resolveECRPrincipal(validClaims())
require.Error(t, err)
assert.True(t, isECRDependencyFailure(err))
})
t.Run("IAM backend outage is a dependency failure, not a denial", func(t *testing.T) {
iamSvc := newECRMockIAMService()
iamSvc.genericErr = errors.New("nats: no responders")
gw := &GatewayConfig{IAMService: iamSvc}
_, err := gw.resolveECRPrincipal(validClaims())
require.Error(t, err)
assert.True(t, isECRDependencyFailure(err))
})
}
func seedECRSessionUser(iamSvc *ecrMockIAMService, stsSvc *ecrMockSTSService, accountID, userName, asid string, expiresAt time.Time) {
iamSvc.users[accountID+"|"+userName] = &iam.User{
UserName: aws.String(userName),
@@ -185,6 +351,112 @@ func seedECRSessionUser(iamSvc *ecrMockIAMService, stsSvc *ecrMockSTSService, ac
}
}
func TestResolveECRPrincipal_SessionToken_User(t *testing.T) {
iamSvc := newECRMockIAMService()
stsSvc := newECRMockSTSService()
seedECRSessionUser(iamSvc, stsSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestASID, time.Now().Add(time.Hour))
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestASID,
}
claims.Subject = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/dev"
got, err := gw.resolveECRPrincipal(claims)
require.NoError(t, err)
assert.Equal(t, principalContext{identity: "dev", accountID: ecrPrincipalTestAccount,
principalType: principalTypeUser, userID: "AIDADEV"}, got)
}
func TestResolveECRPrincipal_SessionToken_User_Pathed(t *testing.T) {
iamSvc := newECRMockIAMService()
stsSvc := newECRMockSTSService()
seedECRSessionUser(iamSvc, stsSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestASID, time.Now().Add(time.Hour))
stsSvc.liveUserARN = ecrPathedUserARN
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestASID,
}
claims.Subject = ecrPathedUserARN
got, err := gw.resolveECRPrincipal(claims)
require.NoError(t, err)
assert.Equal(t, ecrPathedUserARN, got.userARN)
claims.Subject = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/dev"
_, err = gw.resolveECRPrincipal(claims)
require.Error(t, err)
assert.False(t, isECRDependencyFailure(err))
}
// The user branch had no continuity check at all before: a GetSessionToken
// session survived its user being deleted and recreated under the same name.
func TestResolveECRPrincipal_SessionToken_User_StalePrincipalRejected(t *testing.T) {
cases := []struct {
name string
err error
}{
{"user deleted", stsdomain.ErrSessionPrincipalGone},
{"user recreated same name", stsdomain.ErrSessionPrincipalReplaced},
{"legacy record with no immutable UserID", stsdomain.ErrSessionPrincipalLegacy},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
iamSvc := newECRMockIAMService()
stsSvc := newECRMockSTSService()
seedECRSessionUser(iamSvc, stsSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestASID, time.Now().Add(time.Hour))
stsSvc.principalErr = tc.err
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestASID,
}
claims.Subject = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/dev"
_, err := gw.resolveECRPrincipal(claims)
require.Error(t, err)
assert.False(t, isECRDependencyFailure(err))
})
}
}
func TestResolveECRPrincipal_SessionToken_Expired(t *testing.T) {
iamSvc := newECRMockIAMService()
stsSvc := newECRMockSTSService()
seedECRSessionUser(iamSvc, stsSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestASID, time.Now().Add(-time.Hour))
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: ecrPrincipalTestASID,
}
claims.Subject = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/dev"
_, err := gw.resolveECRPrincipal(claims)
require.Error(t, err)
assert.False(t, isECRDependencyFailure(err))
}
func TestResolveECRPrincipal_SessionToken_UnknownSession(t *testing.T) {
gw := &GatewayConfig{IAMService: newECRMockIAMService(), STSService: newECRMockSTSService()}
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeAssumedRole,
AccessKeyID: ecrPrincipalTestASID,
}
claims.Subject = "arn:aws:iam::" + ecrPrincipalTestAccount + ":assumed-role/deploy/session"
_, err := gw.resolveECRPrincipal(claims)
require.Error(t, err)
assert.False(t, isECRDependencyFailure(err))
}
func seedECRAssumedRole(iamSvc *ecrMockIAMService, stsSvc *ecrMockSTSService, accountID, roleName, roleID, asid, sessionName string, expiresAt time.Time) string {
roleARN := "arn:aws:iam::" + accountID + ":role/" + roleName
iamSvc.roles[accountID+"|"+roleName] = &iam.Role{
@@ -209,3 +481,135 @@ func seedECRAssumedRole(iamSvc *ecrMockIAMService, stsSvc *ecrMockSTSService, ac
}
return assumedRoleARN
}
func TestResolveECRPrincipal_AssumedRole(t *testing.T) {
iamSvc := newECRMockIAMService()
stsSvc := newECRMockSTSService()
assumedARN := seedECRAssumedRole(iamSvc, stsSvc, ecrPrincipalTestAccount, "deploy", "AROATESTROLE0001", ecrPrincipalTestASID, "session-1", time.Now().Add(time.Hour))
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeAssumedRole,
AccessKeyID: ecrPrincipalTestASID,
}
claims.Subject = assumedARN
got, err := gw.resolveECRPrincipal(claims)
require.NoError(t, err)
assert.Equal(t, principalTypeAssumedRole, got.principalType)
assert.Equal(t, "session-1", got.identity)
assert.Equal(t, assumedARN, got.assumedRoleARN)
assert.Equal(t, "arn:aws:iam::"+ecrPrincipalTestAccount+":role/deploy", got.underlyingRoleARN)
}
func TestResolveECRPrincipal_AssumedRole_LegacyEmptyPrincipalType(t *testing.T) {
iamSvc := newECRMockIAMService()
stsSvc := newECRMockSTSService()
assumedARN := seedECRAssumedRole(iamSvc, stsSvc, ecrPrincipalTestAccount, "deploy", "AROATESTROLE0001", ecrPrincipalTestASID, "session-1", time.Now().Add(time.Hour))
stsSvc.sessions[ecrPrincipalTestASID].PrincipalType = "" // pre-PrincipalType legacy record
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeAssumedRole,
AccessKeyID: ecrPrincipalTestASID,
}
claims.Subject = assumedARN
got, err := gw.resolveECRPrincipal(claims)
require.NoError(t, err)
assert.Equal(t, principalTypeAssumedRole, got.principalType)
}
func TestResolveECRPrincipal_AssumedRole_Rejections(t *testing.T) {
baseClaims := func(assumedARN string) *ecrauth.Claims {
c := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeAssumedRole,
AccessKeyID: ecrPrincipalTestASID,
}
c.Subject = assumedARN
return c
}
// Every continuity verdict is client-visible (401 + re-auth challenge), never
// a 503: the identity really is invalid, and answering 503 would tell a
// client to retry a credential that will never work again.
t.Run("continuity verdicts are invalid-principal, not dependency failures", func(t *testing.T) {
verdicts := map[string]error{
"role deleted": stsdomain.ErrSessionPrincipalGone,
"role replaced (RoleId changed)": stsdomain.ErrSessionPrincipalReplaced,
"legacy record with no immutable RoleID": stsdomain.ErrSessionPrincipalLegacy,
}
for name, verdict := range verdicts {
t.Run(name, func(t *testing.T) {
iamSvc := newECRMockIAMService()
stsSvc := newECRMockSTSService()
assumedARN := seedECRAssumedRole(iamSvc, stsSvc, ecrPrincipalTestAccount, "deploy", "AROATESTROLE0001", ecrPrincipalTestASID, "session-1", time.Now().Add(time.Hour))
stsSvc.principalErr = verdict
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
_, err := gw.resolveECRPrincipal(baseClaims(assumedARN))
require.Error(t, err)
assert.False(t, isECRDependencyFailure(err))
})
}
})
t.Run("continuity check fault is a dependency failure", func(t *testing.T) {
iamSvc := newECRMockIAMService()
stsSvc := newECRMockSTSService()
assumedARN := seedECRAssumedRole(iamSvc, stsSvc, ecrPrincipalTestAccount, "deploy", "AROATESTROLE0001", ecrPrincipalTestASID, "session-1", time.Now().Add(time.Hour))
stsSvc.principalErr = errors.New("nats: no responders")
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
_, err := gw.resolveECRPrincipal(baseClaims(assumedARN))
require.Error(t, err)
assert.True(t, isECRDependencyFailure(err))
})
t.Run("expired session", func(t *testing.T) {
iamSvc := newECRMockIAMService()
stsSvc := newECRMockSTSService()
assumedARN := seedECRAssumedRole(iamSvc, stsSvc, ecrPrincipalTestAccount, "deploy", "AROATESTROLE0001", ecrPrincipalTestASID, "session-1", time.Now().Add(-time.Minute))
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
_, err := gw.resolveECRPrincipal(baseClaims(assumedARN))
require.Error(t, err)
})
t.Run("attacker-controlled session name cannot rename into another role's ARN shape", func(t *testing.T) {
// resolveECRPrincipal must derive the ARN from the stored underlying
// role, never trust an attacker-influenced session name for anything
// beyond the ARN's trailing session-name segment.
iamSvc := newECRMockIAMService()
stsSvc := newECRMockSTSService()
assumedARN := seedECRAssumedRole(iamSvc, stsSvc, ecrPrincipalTestAccount, "deploy", "AROATESTROLE0001", ecrPrincipalTestASID, "session-1", time.Now().Add(time.Hour))
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
claims := baseClaims(assumedARN)
// Subject claims a completely different role than the session's stored
// underlying role — must be rejected even though PrincipalType matches.
claims.Subject = "arn:aws:sts::" + ecrPrincipalTestAccount + ":assumed-role/other-role/session-1"
_, err := gw.resolveECRPrincipal(claims)
require.Error(t, err)
})
t.Run("nil STS service is a dependency failure", func(t *testing.T) {
gw := &GatewayConfig{IAMService: newECRMockIAMService()}
_, err := gw.resolveECRPrincipal(baseClaims("arn:aws:sts::" + ecrPrincipalTestAccount + ":assumed-role/deploy/session-1"))
require.Error(t, err)
assert.True(t, isECRDependencyFailure(err))
})
}
func TestResolveECRPrincipal_UnrecognizedAccessKeyPrefix(t *testing.T) {
gw := &GatewayConfig{IAMService: newECRMockIAMService()}
claims := &ecrauth.Claims{
AccountID: ecrPrincipalTestAccount,
PrincipalType: principalTypeUser,
AccessKeyID: "NOTAVALIDPREFIX",
}
claims.Subject = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/dev"
_, err := gw.resolveECRPrincipal(claims)
require.Error(t, err)
assert.False(t, isECRDependencyFailure(err))
}
+1 -4
View File
@@ -1,7 +1,6 @@
package gateway
import (
"maps"
"net/http"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
@@ -36,7 +35,5 @@ func (gw *GatewayConfig) serveECR(w http.ResponseWriter, r *http.Request) error
// ecrRegistrationInventory is the inventory the ECR registration declares to
// a registry, keyed as AWSOperationInventory expects.
func ecrRegistrationInventory() map[string]dispatch.Inventory {
inventory := withECR(&GatewayConfig{}, awsapi.Deps{}).Services.Inventory()
maps.Copy(inventory, withContainers(&GatewayConfig{}).Services.Inventory())
return inventory
return withECR(&GatewayConfig{}, awsapi.Deps{}).Services.Inventory()
}
+13 -1
View File
@@ -59,10 +59,22 @@ func TestECRRequest_KnownActionNotImplemented(t *testing.T) {
assert.Equal(t, awserrors.ErrorNotImplemented, err.Error())
}
func TestOCIRegistry_VersionCheck(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
r := chi.NewRouter()
gw.mountOCIRegistry(r)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v2/", nil))
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "registry/2.0", w.Header().Get("Docker-Distribution-Api-Version"))
}
func TestOCIRegistry_StubReturns501(t *testing.T) {
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
r := chi.NewRouter()
gw.mountRegistry(r)
gw.mountOCIRegistry(r)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v2/team/app/tags/list", nil))
+120
View File
@@ -0,0 +1,120 @@
package gateway
import (
"encoding/json"
"log/slog"
"net/http"
"time"
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
ecrregistry "github.com/mulgadc/spinifex/spinifex/domains/ecr/registry"
)
// 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"`
}
// handleECRToken implements the OCI Bearer token endpoint advertised by the
// /v2 401 challenge. The caller presents its existing credential (Basic
// AWS:<jwt> from GetAuthorizationToken, or Bearer <jwt>); this verifies it,
// enforces the registry-host account match, and re-mints a fresh full-TTL
// token. Re-minting on each call is the token-refresh path: a client near
// expiry obtains a new token without re-running SigV4.
//
// The endpoint authenticates the presented credential itself, so it mounts
// outside the bearer auth bridge. A missing or invalid credential returns 401
// without a Bearer realm to avoid a challenge loop.
func (gw *GatewayConfig) handleECRToken(w http.ResponseWriter, r *http.Request) {
if gw.ECRTokenVerifier == nil || gw.ECRTokenIssuer == nil {
ecrregistry.WriteError(w, http.StatusNotImplemented, "UNSUPPORTED", "token endpoint not configured")
return
}
authz := r.Header.Values("Authorization")
if len(authz) > 1 {
ecrregistry.WriteError(w, http.StatusBadRequest, "UNAUTHORIZED", "multiple Authorization headers")
return
}
raw := ""
if len(authz) == 1 {
raw = authz[0]
}
token, ok := extractECRToken(raw)
if !ok {
ecrregistry.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required")
return
}
claims, err := gw.ECRTokenVerifier.Verify(token)
if err != nil {
slog.Debug("ECR token endpoint: verify failed", "err", err)
ecrregistry.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid credentials")
return
}
// Cross-account guard mirrors the auth bridge: a token is account-scoped and
// must match the account in the registry host it is presented against.
if target, _ := r.Context().Value(ctxTargetAccount).(string); target != "" && target != claims.AccountID {
ecrregistry.WriteError(w, http.StatusForbidden, "DENIED", "token account does not match registry host")
return
}
// A refresh must not re-issue a token for an identity that was revoked
// since the presented token was minted: rehydrate against current IAM/STS
// state and mint the replacement from that authoritative record, never
// blindly from the presented claims.
principal, err := gw.resolveECRPrincipal(claims)
if err != nil {
// No Bearer challenge here: this is already the challenge target, and a
// revoked identity retrying the challenge flow would only loop back.
if isECRDependencyFailure(err) {
slog.Error("ECR token endpoint: principal rehydration dependency failure", "err", err)
ecrregistry.WriteError(w, http.StatusServiceUnavailable, "UNKNOWN", "authorization unavailable")
return
}
slog.Warn("ECR token endpoint: refusing to refresh revoked identity", "err", err)
ecrregistry.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid credentials")
return
}
callerARN, err := buildCallerARN(principal.accountID, principal.identity, principal.principalType, principal.assumedRoleARN, principal.userARN)
if err != nil {
slog.Error("ECR token endpoint: cannot build canonical caller ARN", "err", err)
ecrregistry.WriteError(w, http.StatusInternalServerError, "SERVER_ERROR", "token mint failed")
return
}
fresh, expiresAt, err := gw.ECRTokenIssuer.Mint(ecrauth.Principal{
AccountID: principal.accountID,
ARN: callerARN,
Type: principal.principalType,
AccessKeyID: claims.AccessKeyID,
})
if err != nil {
slog.Error("ECR token endpoint: mint failed", "err", err)
ecrregistry.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)
ecrregistry.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)
}
}

Some files were not shown because too many files have changed in this diff Show More