mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-05 10:37:23 +00:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 006aaeb7d3 | |||
| f62a398cf8 | |||
| 93d14a4a24 | |||
| a4a481b6e0 | |||
| 3619745113 | |||
| e3b28c82dd | |||
| ad81431722 | |||
| 778382b73e | |||
| 4e633c7213 | |||
| 7dc6d08da0 | |||
| 05065b494e | |||
| cd0afa7c92 | |||
| 30bf544ce5 | |||
| 4143f9e332 | |||
| bc239fd324 | |||
| 84593071d4 | |||
| 12bf0c336c | |||
| 0d1f7b4efd | |||
| 506bf312c1 | |||
| 14ba324675 | |||
| 3cd935abca | |||
| e450adfdac | |||
| 3be055302a | |||
| 141d697e40 | |||
| 6315ec722c | |||
| 00dc4de390 | |||
| 75670419a5 | |||
| c18fc18dc0 | |||
| 51e5985fb9 | |||
| 3e10e3459d | |||
| d131cc8138 | |||
| 65b3816313 | |||
| af9b246445 | |||
| eb7d61eac2 | |||
| c8d54b35a0 | |||
| dee9ab8028 | |||
| f627052fad | |||
| ed15039fa7 | |||
| 3ec9becb3c | |||
| 03acc2711c | |||
| 276d893128 | |||
| d1f86e910b | |||
| bafcf7240a | |||
| 9a766abc09 | |||
| 7f1bf2aa4f | |||
| bc35613570 |
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# e2e-select.sh push <before> <sha> | suites "<e2e-x ...>"
|
||||
# Prints the e2e gate's single_legs, multi_suites and multi_timeout outputs.
|
||||
# Exits non-zero on anything it cannot read, which fails the e2e status.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPTS_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
|
||||
# shellcheck source=suite-sets.sh
|
||||
. "${SCRIPTS_DIR}/suite-sets.sh"
|
||||
|
||||
# Provision, install and teardown around the suites; the floor is what the
|
||||
# jobs have always had, so a budget can only be raised from it.
|
||||
SETUP_ALLOWANCE_MIN=15
|
||||
FLOOR_MIN=60
|
||||
|
||||
BASE_RUNNER=ci-single
|
||||
SERVICE_RUNNER=ci-pseudo
|
||||
|
||||
log() { printf '[e2e-select] %s\n' "$*" >&2; }
|
||||
die() { log "error: $*"; exit 1; }
|
||||
|
||||
# in_list <word> <list>
|
||||
in_list() {
|
||||
local w
|
||||
for w in $2; do [ "$w" = "$1" ] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
# keep <list> <eligible>: the words of <list> that are in <eligible>, deduplicated.
|
||||
keep() {
|
||||
local out="" s
|
||||
for s in $1; do
|
||||
in_list "$s" "$2" || continue
|
||||
in_list "$s" "$out" || out="${out:+$out }$s"
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# budget <list>: max over the suites' own timeouts plus the setup allowance.
|
||||
# A sum would demand hours for a set that shares one job and never takes them.
|
||||
budget() {
|
||||
local want="${FLOOR_MIN}" s t
|
||||
for s in $1; do
|
||||
t="$(e2e_suite_timeout "$s")"
|
||||
t=$((${t%m} + SETUP_ALLOWANCE_MIN))
|
||||
[ "$t" -gt "$want" ] && want="$t"
|
||||
done
|
||||
printf '%s' "$want"
|
||||
}
|
||||
|
||||
prefixed() {
|
||||
local out="" s
|
||||
for s in $1; do out="${out:+$out }e2e-$s"; done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# leg <name> <runner> <suites>: one matrix entry, or nothing for no suites.
|
||||
leg() {
|
||||
[ -n "$3" ] || return 0
|
||||
jq -nc --arg leg "$1" --arg runner "$2" --arg suites "$(prefixed "$3")" \
|
||||
--argjson timeout "$(budget "$3")" \
|
||||
'{leg: $leg, runner: $runner, suites: $suites, timeout: $timeout}'
|
||||
}
|
||||
|
||||
# emit <base-single> <service-single> <multi>
|
||||
emit() {
|
||||
local legs
|
||||
legs=$( { leg base "${BASE_RUNNER}" "$1"; leg service "${SERVICE_RUNNER}" "$2"; } | jq -sc .)
|
||||
log "base leg: ${1:-none}"
|
||||
log "service leg: ${2:-none}"
|
||||
log "multi: ${3:-none}"
|
||||
printf 'single_legs=%s\n' "$legs"
|
||||
printf 'multi_suites=%s\n' "$(prefixed "$3")"
|
||||
printf 'multi_timeout=%s\n' "$(budget "$3")"
|
||||
}
|
||||
|
||||
# select_for_paths <newline-separated paths> [all]: base set plus matches.
|
||||
# With "all", every mapped suite is selected, for a push with no baseline.
|
||||
select_for_paths() {
|
||||
local matched="" p s
|
||||
if [ "${2:-}" = all ]; then
|
||||
matched=$(e2e_path_suites_all | tr '\n' ' ')
|
||||
else
|
||||
while IFS= read -r p; do
|
||||
[ -n "$p" ] || continue
|
||||
for s in $(e2e_suites_for_path "$p"); do
|
||||
in_list "$s" "$matched" || { matched="${matched:+$matched }$s"; log "${p} -> ${s}"; }
|
||||
done
|
||||
done <<<"$1"
|
||||
fi
|
||||
# A matched suite runs where it is eligible, preferring one node, so route53
|
||||
# goes to the service leg and lb to the multi job.
|
||||
local extra_single="" extra_multi=""
|
||||
for s in $matched; do
|
||||
if in_list "$s" "${E2E_SUITES_SINGLE}"; then
|
||||
extra_single="${extra_single:+$extra_single }$s"
|
||||
elif in_list "$s" "${E2E_SUITES_MULTI}"; then
|
||||
extra_multi="${extra_multi:+$extra_multi }$s"
|
||||
else
|
||||
die "E2E_PATH_SUITES names '${s}', which no topology can run"
|
||||
fi
|
||||
done
|
||||
local base_single base_multi
|
||||
base_single=$(keep "${E2E_SUITES_PUSH_BASE}" "${E2E_SUITES_SINGLE}")
|
||||
base_multi=$(keep "${E2E_SUITES_PUSH_BASE}" "${E2E_SUITES_MULTI}")
|
||||
# A matched suite already in the base leg runs there, not twice.
|
||||
extra_single=$(for s in $extra_single; do in_list "$s" "$base_single" || printf '%s ' "$s"; done)
|
||||
emit "$base_single" "$(keep "$extra_single" "${E2E_SUITES_SINGLE}")" \
|
||||
"$(keep "$base_multi $extra_multi" "${E2E_SUITES_MULTI}")"
|
||||
}
|
||||
|
||||
mode="${1:-}"
|
||||
case "$mode" in
|
||||
push)
|
||||
[ $# -eq 3 ] || die "usage: $0 push <before> <sha>"
|
||||
before="$2" sha="$3"
|
||||
git cat-file -e "${sha}^{commit}" 2>/dev/null || die "commit ${sha} is not in this checkout"
|
||||
if [ -z "${before//0/}" ]; then
|
||||
log "no previous commit for this push; selecting every mapped suite"
|
||||
select_for_paths "" all
|
||||
exit 0
|
||||
fi
|
||||
if ! git cat-file -e "${before}^{commit}" 2>/dev/null; then
|
||||
git fetch -q --filter=blob:none origin "${before}" 2>/dev/null || true
|
||||
fi
|
||||
if ! git cat-file -e "${before}^{commit}" 2>/dev/null; then
|
||||
log "previous commit ${before} is gone; selecting every mapped suite"
|
||||
select_for_paths "" all
|
||||
exit 0
|
||||
fi
|
||||
# A rename counts as both paths, so moving code out of a service selects it.
|
||||
changed=$(git diff --no-renames --name-only "${before}" "${sha}") || die "git diff ${before} ${sha} failed"
|
||||
log "$(printf '%s\n' "$changed" | grep -c . || true) paths changed ${before:0:9}..${sha:0:9}"
|
||||
select_for_paths "$changed"
|
||||
;;
|
||||
suites)
|
||||
[ $# -eq 2 ] || die "usage: $0 suites \"<e2e-x ...>\""
|
||||
if [ -z "${2// /}" ]; then
|
||||
select_for_paths ""
|
||||
exit 0
|
||||
fi
|
||||
requested=$(printf '%s' "$2" | sed 's/e2e-//g')
|
||||
emit "$(keep "$requested" "${E2E_SUITES_SINGLE}")" "" "$(keep "$requested" "${E2E_SUITES_MULTI}")"
|
||||
;;
|
||||
*)
|
||||
die "usage: $0 push <before> <sha> | suites \"<list>\""
|
||||
;;
|
||||
esac
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# Trims GOCACHE to the entries one CI run used. Go refreshes an entry's mtime
|
||||
# on use only when it is over an hour old, so "mark" backdates every restored
|
||||
# entry first and "trim" drops whatever this run neither read nor wrote.
|
||||
set -euo pipefail
|
||||
|
||||
cache="$(go env GOCACHE)"
|
||||
mark="${RUNNER_TEMP:?}/go-build-cache.mark"
|
||||
|
||||
# stale prints each entry this run did not touch and no touched action entry
|
||||
# names. Go skips rewriting an output it already holds, so a fresh action
|
||||
# entry can point at a backdated output.
|
||||
stale() {
|
||||
local keep="${RUNNER_TEMP}/go-build-cache.keep"
|
||||
find "$cache" -mindepth 2 -maxdepth 2 -name '*-a' -newer "$mark" -exec cat {} + |
|
||||
awk '{ print "/" $3 "-d" }' >"$keep"
|
||||
find "$cache" -mindepth 2 -maxdepth 2 ! -newer "$mark" | { grep -vFf "$keep" || true; }
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
mark)
|
||||
mkdir -p "$cache"
|
||||
find "$cache" -mindepth 2 -maxdepth 2 -exec touch -m -d '2 hours ago' {} +
|
||||
touch "$mark"
|
||||
;;
|
||||
stale | trim)
|
||||
if [[ ! -f "$mark" ]]; then
|
||||
echo "go-build-cache: no mark, leaving the cache untrimmed" >&2
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$1" == stale ]]; then
|
||||
stale
|
||||
exit 0
|
||||
fi
|
||||
before=$(du -sm "$cache" | cut -f1)
|
||||
# An entry is a file, or a directory holding one cached executable whose
|
||||
# mtime Go refreshes in place of the binary's, so it goes as a unit.
|
||||
stale | xargs -r rm -rf --
|
||||
echo "trimmed ${before}MB -> $(du -sm "$cache" | cut -f1)MB"
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 mark|stale|trim" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Warn when a PR body has no filled "Mulga design" line. Reads PR_BODY and
|
||||
# never fails the job: the check is a prompt, not a gate.
|
||||
set -euo pipefail
|
||||
shopt -s nocasematch
|
||||
|
||||
section_re='^#{1,6}[[:space:]]*Mulga design[[:space:]]*$'
|
||||
line_re='^[[:space:]]*(Updated|Not needed)[[:space:]]*:[[:space:]]*(.*)$'
|
||||
placeholder_re='<[^<>]*>'
|
||||
|
||||
filled() {
|
||||
local body=$1 line value kept inside=0 pre rest
|
||||
body=${body//$'\r'/}
|
||||
while [[ $body == *'<!--'*'-->'* ]]; do
|
||||
pre=${body%%'<!--'*}
|
||||
rest=${body#*'<!--'}
|
||||
body=$pre${rest#*'-->'}
|
||||
done
|
||||
while IFS= read -r line; do
|
||||
if [[ $line =~ $section_re ]]; then
|
||||
inside=1
|
||||
elif ((inside)) && [[ ${line#"${line%%[![:space:]]*}"} == '#'* ]]; then
|
||||
return 1
|
||||
elif ((inside)) && [[ $line =~ $line_re ]]; then
|
||||
value=${BASH_REMATCH[2]} kept=''
|
||||
while [[ $value =~ $placeholder_re ]]; do
|
||||
kept+=${value%%"${BASH_REMATCH[0]}"*}
|
||||
value=${value#*"${BASH_REMATCH[0]}"}
|
||||
done
|
||||
[[ $kept$value == *[![:space:]]* ]] && return 0
|
||||
fi
|
||||
done <<<"$body"
|
||||
return 1
|
||||
}
|
||||
|
||||
if filled "${PR_BODY:-}"; then
|
||||
echo "The Mulga design line is filled in."
|
||||
else
|
||||
echo '::warning title=Mulga design::Add a "## Mulga design" section with one line: "Updated: <mulga PR>" or "Not needed: <reason>".'
|
||||
fi
|
||||
@@ -66,6 +66,67 @@ E2E_SUITES_MULTI="multinode lb cert quota route53 kvquorum lbrecovery instancere
|
||||
E2E_SUITES_SINGLE_RE="$(printf '%s' "${E2E_SUITES_SINGLE}" | tr ' ' '|')"
|
||||
E2E_SUITES_MULTI_RE="$(printf '%s' "${E2E_SUITES_MULTI}" | tr ' ' '|')"
|
||||
|
||||
# The push gate always runs this set. A push adds to it the suites whose code
|
||||
# it changed, by E2E_PATH_SUITES below; a dispatch names its own set instead.
|
||||
E2E_SUITES_PUSH_BASE="single multinode cert iam partialblock"
|
||||
|
||||
# "suite prefix" pairs: a push changing a path under prefix adds suite. The
|
||||
# recovery and fault suites stay nightly, and shared code adds nothing because
|
||||
# the base set covers it. Image directories carry each guest's own scripts.
|
||||
E2E_PATH_SUITES="
|
||||
rds cmd/rds-agent/
|
||||
rds internal/rdsgw/
|
||||
rds spinifex/agents/rds/
|
||||
rds spinifex/domains/rds/
|
||||
rds tests/e2e/rds/
|
||||
rds scripts/images/rds-mariadb/
|
||||
rds scripts/images/rds-postgres/
|
||||
ecs cmd/ecs-agent/
|
||||
ecs contracts/ecs/
|
||||
ecs internal/ecsgw/
|
||||
ecs spinifex/agents/ecs/
|
||||
ecs spinifex/domains/ecs/
|
||||
ecs tests/e2e/ecs/
|
||||
ecs scripts/images/ecs-agent/
|
||||
eks cmd/eks-
|
||||
eks contracts/eks/
|
||||
eks internal/eksgw/
|
||||
eks spinifex/agents/eks/
|
||||
eks spinifex/domains/eks/
|
||||
eks tests/e2e/eks/
|
||||
eks scripts/images/eks-node/
|
||||
rds scripts/images/common/
|
||||
ecs scripts/images/common/
|
||||
eks scripts/images/common/
|
||||
rds scripts/build-system-image.sh
|
||||
ecs scripts/build-system-image.sh
|
||||
eks scripts/build-system-image.sh
|
||||
route53 spinifex/domains/route53/
|
||||
route53 tests/e2e/route53/
|
||||
lb cmd/lb-agent/
|
||||
lb spinifex/agents/elbv2/
|
||||
lb spinifex/domains/elbv2/
|
||||
lb tests/e2e/lb/
|
||||
"
|
||||
|
||||
# Every suite E2E_PATH_SUITES can name, once each, in table order.
|
||||
# e2e_path_suites_all
|
||||
e2e_path_suites_all() {
|
||||
printf '%s\n' "${E2E_PATH_SUITES}" | awk 'NF == 2 && !seen[$1]++ { print $1 }'
|
||||
}
|
||||
|
||||
# The suites a change to <path> triggers, one per line.
|
||||
# e2e_suites_for_path <path>
|
||||
e2e_suites_for_path() {
|
||||
local path="$1" suite prefix
|
||||
while read -r suite prefix; do
|
||||
[ -n "${prefix}" ] || continue
|
||||
case "${path}" in
|
||||
"${prefix}"*) printf '%s\n' "${suite}" ;;
|
||||
esac
|
||||
done <<<"${E2E_PATH_SUITES}"
|
||||
}
|
||||
|
||||
# What every e2e-nightly permutation cell must prove, deliberately narrower
|
||||
# than the two sets above: those cells have a 35-minute budget and exist to show
|
||||
# that every install / network / host-OS combination boots and serves. The
|
||||
|
||||
@@ -50,8 +50,6 @@ workflows:
|
||||
'.github/workflows/image-scripts.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/setup-go@v7.0.0'
|
||||
'.github/workflows/mulga-design.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
'.github/workflows/perf-3node.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'actions/create-github-app-token@v3.3.0'
|
||||
|
||||
@@ -6,11 +6,32 @@ name: E2E Nightly
|
||||
# `main` and `dev` as separate nightlies, and it would always execute `main`'s
|
||||
# copy of this file — leaving a fix to the workflow itself untestable until it
|
||||
# had already shipped. A dispatch takes both the file and the source from its ref.
|
||||
#
|
||||
# Cells (matrix cells are defined in .github/e2e-cells.json):
|
||||
#
|
||||
# Single node Three nodes
|
||||
# 1 Debian 13, DHCP 8 Debian 13, DHCP
|
||||
# 2 Ubuntu 26.04, static 9 Ubuntu 26.04, static
|
||||
# 3 Ubuntu 26.04, DHCP 10 Ubuntu 26.04, DHCP
|
||||
# 17 tofu examples 29 storage fault injection
|
||||
# 18 reboot 30 routed NAT
|
||||
# 19 routed NAT 31 instance recovery
|
||||
# 25 eks 32 load balancer recovery
|
||||
# 26 ecs 33 KV bucket quorum under node loss
|
||||
# 27 rds 37 Oracle Linux 10, DHCP
|
||||
# 34 Oracle Linux 10, DHCP 38 Oracle Linux 10, routed NAT
|
||||
# 35 Oracle Linux 10, reboot
|
||||
# 36 Oracle Linux 10, routed NAT
|
||||
#
|
||||
# Standalone jobs
|
||||
# 20 bare metal (PXE) 23 predastore throughput
|
||||
# 21 ISO, single node 24 predastore fault injection
|
||||
# 22 ISO, three nodes 28 guest disk performance (bare metal)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cells:
|
||||
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 34-36 Oracle Linux single node (basic, reboot, routed NAT), 37-38 Oracle Linux on three nodes (multi-node subset with IPsec, routed NAT), 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes, 33 KV bucket quorum under node loss on three nodes"
|
||||
description: "Comma-separated cell numbers, no spaces, e.g. 1,25,27 (blank = all). The cell list is at the top of .github/workflows/e2e-nightly.yml."
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
|
||||
+125
-79
@@ -12,13 +12,15 @@ on:
|
||||
- go.sum
|
||||
- Makefile
|
||||
- tests/e2e/**
|
||||
- scripts/images/**
|
||||
- scripts/build-system-image.sh
|
||||
- .github/workflows/e2e.yml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
suites:
|
||||
description: "Space-separated Make targets to run (e.g. 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-eks e2e-iam e2e-rds')"
|
||||
required: false
|
||||
default: "e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam"
|
||||
default: "e2e-single e2e-multinode e2e-cert e2e-iam"
|
||||
test_run:
|
||||
description: "Optional regex for -test.run (e.g. '^TestMultinodeIPSec$'). Empty = run all tests in suite."
|
||||
required: false
|
||||
@@ -31,86 +33,75 @@ permissions:
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam e2e-partialblock' }}
|
||||
TEST_RUN: ${{ inputs.test_run || '' }}
|
||||
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
|
||||
|
||||
jobs:
|
||||
# The inner timeout must fire before the outer one. `go test` reports where it
|
||||
# stopped and the suite's own failure; a job cancelled on timeout reports only
|
||||
# "The operation was canceled" and takes the artifacts with it.
|
||||
#
|
||||
# A job budget cannot be a literal here because the suite set is a dispatch
|
||||
# input: storagefault asks for 90m and diskperf for 180m, both eligible on one
|
||||
# node, so any fixed number is either wrong for them or wasteful for everyone
|
||||
# else. Resolving it from suite-sets.sh keeps the one source of truth and
|
||||
# leaves the default push set at exactly the 60m it has always had.
|
||||
# A push runs the base set plus the suites its changed paths map to in
|
||||
# suite-sets.sh; a dispatch runs its suites input, or the base set if empty.
|
||||
# Budgets come from the suites' own timeouts there.
|
||||
plan:
|
||||
name: Resolve Job Budgets
|
||||
name: Select Suites
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
single_timeout: ${{ steps.budgets.outputs.single_timeout }}
|
||||
multi_timeout: ${{ steps.budgets.outputs.multi_timeout }}
|
||||
single_legs: ${{ steps.select.outputs.single_legs }}
|
||||
multi_suites: ${{ steps.select.outputs.multi_suites }}
|
||||
multi_timeout: ${{ steps.select.outputs.multi_timeout }}
|
||||
steps:
|
||||
# Full history without file contents: the diff needs both commits' trees.
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
path: spinifex
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
- name: Resolve Budgets
|
||||
id: budgets
|
||||
- name: Select suites
|
||||
id: select
|
||||
working-directory: spinifex
|
||||
env:
|
||||
# Provision, install and teardown around the suites, plus the headroom
|
||||
# a multi-suite job needs for whatever ran before the one that hangs.
|
||||
SETUP_ALLOWANCE_MIN: 15
|
||||
# What the job has always had. Kept as a floor so resolving the budget
|
||||
# can only ever raise it, never quietly shorten an existing run.
|
||||
FLOOR_MIN: 60
|
||||
EVENT: ${{ github.event_name }}
|
||||
BEFORE: ${{ github.event.before }}
|
||||
IN_SUITES: ${{ inputs.suites }}
|
||||
run: |
|
||||
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
|
||||
# Only max() is a sound bound: the suites share one job, so a sum would
|
||||
# demand hours for a set that has never taken them. Max is what makes
|
||||
# the hanging suite panic on its own timeout first, which is the point.
|
||||
budget() {
|
||||
local want="${FLOOR_MIN}" s t
|
||||
for s in $(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE "$1"); do
|
||||
t="$(e2e_suite_timeout "${s}")"
|
||||
t=$(( ${t%m} + SETUP_ALLOWANCE_MIN ))
|
||||
[ "${t}" -gt "${want}" ] && want="${t}"
|
||||
done
|
||||
echo "${want}"
|
||||
}
|
||||
single=$(budget "${E2E_SUITES_SINGLE_RE}")
|
||||
multi=$(budget "${E2E_SUITES_MULTI_RE}")
|
||||
echo "single_timeout=${single}" >> "$GITHUB_OUTPUT"
|
||||
echo "multi_timeout=${multi}" >> "$GITHUB_OUTPUT"
|
||||
echo "suites='${SUITES}' -> single ${single}m, multi ${multi}m"
|
||||
set -euo pipefail
|
||||
SELECT=.github/scripts/e2e-select.sh
|
||||
if [ "${EVENT}" = push ]; then
|
||||
bash "${SELECT}" push "${BEFORE}" "${GITHUB_SHA}" | tee -a "$GITHUB_OUTPUT"
|
||||
else
|
||||
bash "${SELECT}" suites "${IN_SUITES}" | tee -a "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
e2e_single:
|
||||
name: Single-Node E2E
|
||||
# The dispatch check gates on exactly the suites this job's Run step
|
||||
# allow-lists, i.e. E2E_SUITES_SINGLE in .github/scripts/suite-sets.sh —
|
||||
# an `if:` expression cannot source that file, so this one list is
|
||||
# hand-mirrored and must be updated with it. Gating on a suite the job
|
||||
# won't run — lb, which only executes in the multinode job — would
|
||||
# provision a tofu VM and pay the Spinifex install only to run nothing and
|
||||
# tear down. Push triggers keep running via the null-check on
|
||||
# inputs.suites.
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-iam') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-eks') || contains(github.event.inputs.suites, 'e2e-ecs') || contains(github.event.inputs.suites, 'e2e-storagegrowth') || contains(github.event.inputs.suites, 'e2e-partialblock') || contains(github.event.inputs.suites, 'e2e-rds')
|
||||
# The base leg keeps the job and artifact names it always had: mulga's
|
||||
# registry intake finds a run's results by them.
|
||||
name: ${{ matrix.leg == 'base' && 'Single-Node E2E' || format('Single-Node E2E ({0})', matrix.leg) }}
|
||||
# `base` runs on ci-single; `service`, on ci-pseudo, carries the matched
|
||||
# single-node suites so a long one stays off the base leg's critical path.
|
||||
if: needs.plan.outputs.single_legs != '[]'
|
||||
needs: plan
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: ${{ fromJSON(needs.plan.outputs.single_timeout) }}
|
||||
runs-on: [self-hosted, "${{ matrix.runner }}"]
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJSON(needs.plan.outputs.single_legs) }}
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-artifacts-${{ github.run_id }}
|
||||
SUITES: ${{ matrix.suites }}
|
||||
# Both legs can run on banksia at once, so every host path carries the leg.
|
||||
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}-${{ matrix.leg }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-artifacts-${{ github.run_id }}-${{ matrix.leg }}
|
||||
# The base leg keeps the cell name it always had, so its history in
|
||||
# Elastic and telemetry continues; any other leg appends its name.
|
||||
E2E_CELL: ${{ matrix.leg == 'base' && 'single' || format('single-{0}', matrix.leg) }}
|
||||
# Job-level env wins over the workflow-level default. This job and
|
||||
# e2e_multi share one run id and both name their hosts node1/node2/node3,
|
||||
# so without ci.cell their telemetry collapses into one bucket. This job
|
||||
# is where "Bootstrap via Binary Install" bakes SPINIFEX_OTEL_ATTRS into
|
||||
# the VM's telemetry.env, so the label has to be set here.
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=single
|
||||
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=${{ matrix.leg == 'base' && 'single' || format('single-{0}', matrix.leg) }}
|
||||
steps:
|
||||
- name: Reap Orphan Procs
|
||||
run: |
|
||||
@@ -180,7 +171,7 @@ jobs:
|
||||
- name: Resolve CI job id
|
||||
uses: $/.github/actions/otel-job-id
|
||||
with:
|
||||
job-name: Single-Node E2E
|
||||
job-name: ${{ matrix.leg == 'base' && 'Single-Node E2E' || format('Single-Node E2E ({0})', matrix.leg) }}
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -224,6 +215,12 @@ jobs:
|
||||
echo "wan_ip=${WAN_IP}" >> "$GITHUB_OUTPUT"
|
||||
echo "ssh_key=${SSH_KEY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# build-system-image.sh reuses a raw image under ten minutes old from
|
||||
# its build directory, and every runner on banksia shares the default
|
||||
# one in /tmp, so a job could test another commit's guest agent.
|
||||
- name: Use a per-job image build directory
|
||||
run: echo "SYSTEM_IMAGE_BUILD_DIR=${RUNNER_TEMP}/system-image-build" >> "$GITHUB_ENV"
|
||||
|
||||
# The eks suite's CreateCluster boots a control-plane VM from the
|
||||
# eks-node AMI, so that AMI must exist in the VM's image store before the
|
||||
# suite runs. build-system-image.sh customizes the image with libguestfs
|
||||
@@ -247,7 +244,7 @@ jobs:
|
||||
group_open "make build-eks-node-image"
|
||||
make build-eks-node-image
|
||||
group_close
|
||||
ok "raw: $(du -h /tmp/eks-node-image-build/eks-node-alpine.raw | cut -f1)"
|
||||
ok "raw: $(du -h "${SYSTEM_IMAGE_BUILD_DIR}/eks-node-image-build/eks-node-alpine.raw" | cut -f1)"
|
||||
|
||||
- name: Import eks-node AMI on VM
|
||||
if: contains(env.SUITES, 'e2e-eks')
|
||||
@@ -259,13 +256,16 @@ jobs:
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
|
||||
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
|
||||
banner "import eks-node AMI to VM image store"
|
||||
# The VM's /tmp is a tmpfs and scp writes each raw at its full size,
|
||||
# so every import removes its file before the next image arrives.
|
||||
scp "${SSH_OPTS[@]}" \
|
||||
/tmp/eks-node-image-build/eks-node-alpine.raw \
|
||||
"${SYSTEM_IMAGE_BUILD_DIR}/eks-node-image-build/eks-node-alpine.raw" \
|
||||
"$VM":/tmp/eks-node-alpine.raw
|
||||
ssh "${SSH_OPTS[@]}" "$VM" \
|
||||
"spx admin images import --file /tmp/eks-node-alpine.raw \
|
||||
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
|
||||
--boot-mode bios --ami-name ${AMI_NAME} --tag ${SYSTEM_TAG}" >/dev/null 2>&1
|
||||
--boot-mode bios --ami-name ${AMI_NAME} --tag ${SYSTEM_TAG}; \
|
||||
rc=\$?; rm -f /tmp/eks-node-alpine.raw; exit \$rc" >/dev/null 2>&1
|
||||
ok "eks-node AMI imported"
|
||||
|
||||
# The eks data-plane subtest (TestEKS/KubectlGetNodes) drives the full
|
||||
@@ -299,6 +299,40 @@ jobs:
|
||||
EOF
|
||||
ok "kubectl installed on VM"
|
||||
|
||||
# The ecs suite boots its container instance from the ecs-node AMI, found
|
||||
# by its managed-by tag, so the tag matters. The build directory is named
|
||||
# for the manifest's IMAGE_NAME (ecs-agent), not for the make target.
|
||||
- name: Build ecs-node AMI (libguestfs, runner)
|
||||
if: contains(env.SUITES, 'e2e-ecs')
|
||||
working-directory: spinifex
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# direct backend = no libvirt. Set here rather than in the script:
|
||||
# it has to be in the environment the build itself runs in.
|
||||
export LIBGUESTFS_BACKEND=direct
|
||||
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/ensure-libguestfs.sh"
|
||||
make build-ecs-node-image
|
||||
|
||||
- name: Import ecs-node AMI on VM
|
||||
if: contains(env.SUITES, 'e2e-ecs')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
eval "$(grep -E '^(ALPINE_VERSION|AMI_NAME|SYSTEM_TAG)=' \
|
||||
spinifex/scripts/images/ecs-agent/manifest.conf)"
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
|
||||
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
|
||||
banner "import ecs-node AMI to VM image store"
|
||||
scp "${SSH_OPTS[@]}" \
|
||||
"${SYSTEM_IMAGE_BUILD_DIR}/ecs-agent-image-build/ecs-agent-alpine.raw" \
|
||||
"$VM":/tmp/ecs-agent-alpine.raw
|
||||
ssh "${SSH_OPTS[@]}" "$VM" \
|
||||
"spx admin images import --file /tmp/ecs-agent-alpine.raw \
|
||||
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
|
||||
--boot-mode bios --ami-name ${AMI_NAME} --tag ${SYSTEM_TAG}; \
|
||||
rc=\$?; rm -f /tmp/ecs-agent-alpine.raw; exit \$rc" >/dev/null 2>&1
|
||||
ok "ecs-node AMI imported"
|
||||
|
||||
# CreateDBInstance boots the DB VM from an RDS AMI, so both engines' images
|
||||
# must be in the VM's image store before the suite runs — same split as the
|
||||
# eks-node pair above: build on the runner (needs source + network), import
|
||||
@@ -321,7 +355,7 @@ jobs:
|
||||
group_open "make build-${IMAGE}-image"
|
||||
make "build-${IMAGE}-image"
|
||||
group_close
|
||||
ok "raw: $(du -h "/tmp/${IMAGE}-image-build/${IMAGE}-alpine.raw" | cut -f1)"
|
||||
ok "raw: $(du -h "${SYSTEM_IMAGE_BUILD_DIR}/${IMAGE}-image-build/${IMAGE}-alpine.raw" | cut -f1)"
|
||||
done
|
||||
|
||||
- name: Import RDS AMIs on VM
|
||||
@@ -343,12 +377,13 @@ jobs:
|
||||
for tag in ${SYSTEM_TAG}; do TAG_ARGS="${TAG_ARGS} --tag ${tag}"; done
|
||||
banner "import ${IMAGE} AMI to VM image store"
|
||||
scp "${SSH_OPTS[@]}" \
|
||||
"/tmp/${IMAGE}-image-build/${IMAGE}-alpine.raw" \
|
||||
"${SYSTEM_IMAGE_BUILD_DIR}/${IMAGE}-image-build/${IMAGE}-alpine.raw" \
|
||||
"$VM":"/tmp/${IMAGE}-alpine.raw"
|
||||
ssh "${SSH_OPTS[@]}" "$VM" \
|
||||
"spx admin images import --file /tmp/${IMAGE}-alpine.raw \
|
||||
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
|
||||
--boot-mode bios --ami-name ${AMI_NAME}${TAG_ARGS}" >/dev/null 2>&1
|
||||
--boot-mode bios --ami-name ${AMI_NAME}${TAG_ARGS}; \
|
||||
rc=\$?; rm -f /tmp/${IMAGE}-alpine.raw; exit \$rc" >/dev/null 2>&1
|
||||
ok "${IMAGE} AMI imported"
|
||||
done
|
||||
|
||||
@@ -526,7 +561,7 @@ jobs:
|
||||
uses: $/.github/actions/junit-to-es
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
cell: single
|
||||
cell: ${{ env.E2E_CELL }}
|
||||
|
||||
- name: Analyze e2e failures
|
||||
if: always()
|
||||
@@ -546,7 +581,7 @@ jobs:
|
||||
- uses: actions/upload-artifact@v7.0.2
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-artifacts-${{ github.run_id }}
|
||||
name: ${{ matrix.leg == 'base' && format('e2e-artifacts-{0}', github.run_id) || format('e2e-artifacts-{0}-{1}', github.run_id, matrix.leg) }}
|
||||
path: ${{ env.ARTIFACT_DIR }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
@@ -589,7 +624,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: tofu-log-single-${{ github.run_id }}
|
||||
name: ${{ matrix.leg == 'base' && format('tofu-log-single-{0}', github.run_id) || format('tofu-log-single-{0}-{1}', github.run_id, matrix.leg) }}
|
||||
path: ${{ env.TOFU_LOG }}
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
@@ -605,15 +640,13 @@ jobs:
|
||||
|
||||
e2e_multi:
|
||||
name: Multi-Node E2E
|
||||
# The dispatch check gates on exactly the suites this job's Run step
|
||||
# allow-lists (multinode|lb|cert) so lb — which only runs here — still
|
||||
# provisions the 3-node env when requested on its own, and the env stays
|
||||
# unprovisioned when none of them are asked for.
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-multinode') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-cert')
|
||||
# Runs when the plan job selected any multi-node suite, and only then.
|
||||
if: needs.plan.outputs.multi_suites != ''
|
||||
needs: plan
|
||||
runs-on: [self-hosted, ci-multi]
|
||||
timeout-minutes: ${{ fromJSON(needs.plan.outputs.multi_timeout) }}
|
||||
env:
|
||||
SUITES: ${{ needs.plan.outputs.multi_suites }}
|
||||
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-multi-artifacts-${{ github.run_id }}
|
||||
# See the matching note on e2e_single: ci.cell is what separates this
|
||||
@@ -1070,7 +1103,10 @@ jobs:
|
||||
e2e_status:
|
||||
name: Report E2E Status
|
||||
if: always()
|
||||
needs: [e2e_single, e2e_multi]
|
||||
# plan is a dependency because a failed plan skips both e2e jobs, and a
|
||||
# skipped job counts as passed below: without it a broken selection would
|
||||
# report green having run nothing.
|
||||
needs: [plan, e2e_single, e2e_multi]
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -1079,15 +1115,16 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPORT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
single_ok=true; multi_ok=true
|
||||
plan_ok=true; single_ok=true; multi_ok=true
|
||||
[ "${{ needs.plan.result }}" = success ] || plan_ok=false
|
||||
case "${{ needs.e2e_single.result }}" in success|skipped) ;; *) single_ok=false ;; esac
|
||||
case "${{ needs.e2e_multi.result }}" in success|skipped) ;; *) multi_ok=false ;; esac
|
||||
if $single_ok && $multi_ok; then
|
||||
if $plan_ok && $single_ok && $multi_ok; then
|
||||
STATE="success"
|
||||
DESC="E2E suites passed"
|
||||
else
|
||||
STATE="failure"
|
||||
DESC="E2E failed: single=${{ needs.e2e_single.result }} multi=${{ needs.e2e_multi.result }}"
|
||||
DESC="E2E failed: plan=${{ needs.plan.result }} single=${{ needs.e2e_single.result }} multi=${{ needs.e2e_multi.result }}"
|
||||
fi
|
||||
gh api "repos/${{ github.repository }}/statuses/${REPORT_SHA}" \
|
||||
-f state="$STATE" \
|
||||
@@ -1104,7 +1141,7 @@ jobs:
|
||||
# status-report check.
|
||||
e2e_es_summary:
|
||||
name: ES CI Run Summary
|
||||
needs: [e2e_single, e2e_multi]
|
||||
needs: [plan, e2e_single, e2e_multi]
|
||||
if: always()
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 5
|
||||
@@ -1116,6 +1153,11 @@ jobs:
|
||||
# this host, so no credentials are needed for the POST.
|
||||
MULGA_ES_SINK: ${{ vars.MULGA_ES_SINK || '192.168.1.13:9200' }}
|
||||
steps:
|
||||
# The workspace outlives the run on a self-hosted runner, and the status
|
||||
# files are collected recursively, so an earlier run's would count here.
|
||||
- name: Clear previous summary downloads
|
||||
run: rm -rf "${GITHUB_WORKSPACE}/es-summary-single" "${GITHUB_WORKSPACE}/es-summary-multi"
|
||||
|
||||
# Either leg may not have run (e.g. a workflow_dispatch that only
|
||||
# requested single-node suites), so a missing artifact is tolerated —
|
||||
# the aggregation script below treats an absent suite-status.txt as
|
||||
@@ -1124,7 +1166,7 @@ jobs:
|
||||
uses: actions/download-artifact@v8.0.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: e2e-artifacts-${{ github.run_id }}
|
||||
pattern: e2e-artifacts-${{ github.run_id }}*
|
||||
path: ${{ github.workspace }}/es-summary-single
|
||||
|
||||
- name: Download multi-node artifacts
|
||||
@@ -1144,6 +1186,7 @@ jobs:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
PLAN_RESULT: ${{ needs.plan.result }}
|
||||
SINGLE_RESULT: ${{ needs.e2e_single.result }}
|
||||
MULTI_RESULT: ${{ needs.e2e_multi.result }}
|
||||
SINGLE_DIR: ${{ github.workspace }}/es-summary-single
|
||||
@@ -1151,7 +1194,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
SINGLE_STATUS="${SINGLE_DIR}/suite-status.txt"
|
||||
# One subdirectory per single-node leg's artifact.
|
||||
SINGLE_STATUS=$(find "${SINGLE_DIR}" -name suite-status.txt 2>/dev/null | sort || true)
|
||||
MULTI_STATUS="${MULTI_DIR}/suite-status.txt"
|
||||
|
||||
# Collect "<suite>: FAILED" lines from both legs' suite-status.txt
|
||||
@@ -1160,7 +1204,9 @@ jobs:
|
||||
# rather than a bash array so this stays correct under `set -u`
|
||||
# even when nothing failed.
|
||||
FAILED_SUITES=""
|
||||
for f in "$SINGLE_STATUS" "$MULTI_STATUS"; do
|
||||
# SINGLE_STATUS is a newline-separated list, so it is split here.
|
||||
# shellcheck disable=SC2086
|
||||
for f in ${SINGLE_STATUS} "$MULTI_STATUS"; do
|
||||
[ -f "$f" ] || continue
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
@@ -1180,7 +1226,7 @@ jobs:
|
||||
case "$MULTI_RESULT" in success | skipped) ;; *) multi_ok=false ;; esac
|
||||
|
||||
STATE=success
|
||||
if [ "$single_ok" != true ] || [ "$multi_ok" != true ] || [ "$FAILED_COUNT" -gt 0 ]; then
|
||||
if [ "$PLAN_RESULT" != success ] || [ "$single_ok" != true ] || [ "$multi_ok" != true ] || [ "$FAILED_COUNT" -gt 0 ]; then
|
||||
STATE=failure
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
name: Mulga design
|
||||
|
||||
# Reminds the author to say whether the Mulga design records need to change.
|
||||
# It reads only the pull request body, uses no secrets, and warns without ever
|
||||
# failing: the check is a prompt, not a gate. Fork PRs are skipped because their
|
||||
# authors cannot see the design records.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
types: [opened, edited, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
mulga_design:
|
||||
name: Mulga design line
|
||||
if: ${{ !endsWith(github.actor, '[bot]') && github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
- name: Check the Mulga design line
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
working-directory: .github/scripts
|
||||
run: bash mulga-design-check.sh
|
||||
@@ -105,6 +105,13 @@ jobs:
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
restore-keys: gobuild-${{ github.job }}-
|
||||
|
||||
# Go refreshes an entry's mtime on use only when it is over an hour old, so
|
||||
# restored entries are backdated past that before any Go command runs and
|
||||
# the trim below can tell this run's working set from the last run's.
|
||||
- name: Mark Go build cache
|
||||
if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
|
||||
run: spinifex/.github/scripts/go-build-cache.sh mark
|
||||
|
||||
- name: Setup Go Workspace
|
||||
run: go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
|
||||
|
||||
@@ -137,19 +144,12 @@ jobs:
|
||||
- name: Check Diff Coverage
|
||||
run: cd spinifex && scripts/diff-coverage.sh coverage.out --base HEAD~1
|
||||
|
||||
# Compiled output stays valid whatever the tests said, and skipping the
|
||||
# save on a red run would leave the next one rebuilding it, so this is
|
||||
# gated on the branch rather than on success.
|
||||
# Go bumps an entry's mtime when a run uses it, so this run's working set
|
||||
# survives and objects superseded by later commits do not. Runs after the
|
||||
# work, so it costs this job nothing and only shrinks what the next reads.
|
||||
# Keeps what this run read or wrote and drops the rest, so the saved entry
|
||||
# is one run's working set however often dev is pushed. A red run may have
|
||||
# stopped early and used only part of the set, so it saves untrimmed.
|
||||
- name: Trim Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: |
|
||||
C="$(go env GOCACHE)"
|
||||
before=$(du -sm "$C" | cut -f1)
|
||||
find "$C" -type f -mmin +240 -delete
|
||||
echo "trimmed ${before}MB -> $(du -sm "$C" | cut -f1)MB"
|
||||
if: ${{ success() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: spinifex/.github/scripts/go-build-cache.sh trim
|
||||
|
||||
- name: Save Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
@@ -160,6 +160,7 @@ jobs:
|
||||
|
||||
race_detection:
|
||||
name: Race Detection
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
steps:
|
||||
@@ -235,22 +236,25 @@ jobs:
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
restore-keys: gobuild-${{ github.job }}-
|
||||
|
||||
# Go refreshes an entry's mtime on use only when it is over an hour old, so
|
||||
# restored entries are backdated past that before any Go command runs and
|
||||
# the trim below can tell this run's working set from the last run's.
|
||||
- name: Mark Go build cache
|
||||
if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
|
||||
run: spinifex/.github/scripts/go-build-cache.sh mark
|
||||
|
||||
- name: Setup Go Workspace
|
||||
run: go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
|
||||
|
||||
- name: Run Tests with Race Detector
|
||||
run: make -C spinifex test-race GOTESTFLAGS=-count=1
|
||||
|
||||
# Go bumps an entry's mtime when a run uses it, so this run's working set
|
||||
# survives and objects superseded by later commits do not. Runs after the
|
||||
# work, so it costs this job nothing and only shrinks what the next reads.
|
||||
# Keeps what this run read or wrote and drops the rest, so the saved entry
|
||||
# is one run's working set however often dev is pushed. A red run may have
|
||||
# stopped early and used only part of the set, so it saves untrimmed.
|
||||
- name: Trim Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: |
|
||||
C="$(go env GOCACHE)"
|
||||
before=$(du -sm "$C" | cut -f1)
|
||||
find "$C" -type f -mmin +240 -delete
|
||||
echo "trimmed ${before}MB -> $(du -sm "$C" | cut -f1)MB"
|
||||
if: ${{ success() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: spinifex/.github/scripts/go-build-cache.sh trim
|
||||
|
||||
- name: Save Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
@@ -337,6 +341,13 @@ jobs:
|
||||
key: gobuild-${{ github.job }}-${{ github.run_id }}
|
||||
restore-keys: gobuild-${{ github.job }}-
|
||||
|
||||
# Go refreshes an entry's mtime on use only when it is over an hour old, so
|
||||
# restored entries are backdated past that before any Go command runs and
|
||||
# the trim below can tell this run's working set from the last run's.
|
||||
- name: Mark Go build cache
|
||||
if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
|
||||
run: spinifex/.github/scripts/go-build-cache.sh mark
|
||||
|
||||
- name: Setup Go Workspace
|
||||
run: go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
|
||||
|
||||
@@ -353,16 +364,16 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
run: make -C spinifex test-integration
|
||||
|
||||
# Go bumps an entry's mtime when a run uses it, so this run's working set
|
||||
# survives and objects superseded by later commits do not. Runs after the
|
||||
# work, so it costs this job nothing and only shrinks what the next reads.
|
||||
- name: Run Gated Suites
|
||||
if: ${{ !cancelled() }}
|
||||
run: make -C spinifex test-gated GOTESTFLAGS=-count=1
|
||||
|
||||
# Keeps what this run read or wrote and drops the rest, so the saved entry
|
||||
# is one run's working set however often dev is pushed. A red run may have
|
||||
# stopped early and used only part of the set, so it saves untrimmed.
|
||||
- name: Trim Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: |
|
||||
C="$(go env GOCACHE)"
|
||||
before=$(du -sm "$C" | cut -f1)
|
||||
find "$C" -type f -mmin +240 -delete
|
||||
echo "trimmed ${before}MB -> $(du -sm "$C" | cut -f1)MB"
|
||||
if: ${{ success() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
run: spinifex/.github/scripts/go-build-cache.sh trim
|
||||
|
||||
- name: Save Go build cache
|
||||
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
|
||||
|
||||
@@ -309,7 +309,8 @@ test-build-scripts:
|
||||
done
|
||||
$(_Q)shellcheck -S warning .github/scripts/hypervisor-disk-preflight.sh \
|
||||
.github/scripts/wait-for-memory.sh \
|
||||
.github/scripts/mulga-design-check.sh
|
||||
.github/scripts/go-build-cache.sh \
|
||||
.github/scripts/e2e-select.sh
|
||||
@$(_HDR) "\n....Running shellcheck over build/scripts/**/*.sh + the node scripts...."
|
||||
$(_Q)shellcheck -S warning $$(find build/scripts -name '*.sh' | sort) $(NODE_SCRIPTS)
|
||||
$(_Q)shellcheck -S warning -x scripts/build-oracle-appliance.sh $$(find scripts/appliance -name '*.sh' | sort)
|
||||
@@ -344,6 +345,16 @@ test-integration:
|
||||
@$(MAKE) --no-print-directory generate-aws-model-coverage
|
||||
@echo "AWS operation coverage: $(AWS_MODEL_OPERATION_COVERAGE_DIR)"
|
||||
|
||||
# Suites the unit tiers skip: the three-node SQS R3 scenarios behind
|
||||
# SPINIFEX_SQS_R3 and the lambdatestlink-tagged tests. CI runs these on every PR.
|
||||
test-gated:
|
||||
@echo -e "\n....Running env- and tag-gated suites...."
|
||||
$(_Q)LOG_IGNORE=1 SPINIFEX_SQS_R3=1 go test $(GOTESTFLAGS) -timeout 180s -run '^TestR3$$' ./spinifex/domains/sqs/message/jetstream/ $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -tags=lambdatestlink -timeout 120s ./cmd/lambda-agent/ ./spinifex/agents/lambda/hostlink/ ./spinifex/runtime/compute/lambda/guestconn/ ./spinifex/runtime/compute/lambda/testlink/ ./spinifex/domains/network/host/ $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -tags=lambdatestlink -timeout 120s -run '^TestLambdaTestLink(Executor|Reply)_' ./spinifex/runtime/roles/awsgw/ $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -tags=lambdatestlink,lambdatestlinkseam -timeout 120s -run '^TestLambdaTestLinkSeam_' ./spinifex/runtime/roles/awsgw/ $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -tags=lambdatestlink -timeout 120s -run '^TestLambdaTestLink' ./spinifex/runtime/daemon/ $(_RACEQ)
|
||||
|
||||
generate-aws-model-coverage:
|
||||
@go run ./cmd/aws-model-coverage -out $(AWS_MODEL_OPERATION_COVERAGE_DIR) -json $(AWS_MODEL_OPERATION_COVERAGE_DIR)/coverage.json
|
||||
|
||||
@@ -379,11 +390,7 @@ test-cover:
|
||||
# Run unit tests with race detector
|
||||
test-race:
|
||||
@echo -e "\n....Running tests with race detector for $(GO_PROJECT_NAME)...."
|
||||
$(_Q)LOG_IGNORE=1 SPINIFEX_SQS_R3=1 go test $(GOTESTFLAGS) -race -timeout 300s ./spinifex/... ./cmd/... ./internal/... ./contracts/... ./architecture/... $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -tags=lambdatestlink -timeout 120s ./cmd/lambda-agent/ ./spinifex/agents/lambda/hostlink/ ./spinifex/runtime/compute/lambda/testlink/ ./spinifex/domains/network/host/ $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -tags=lambdatestlink -timeout 120s -run '^TestLambdaTestLink(Executor|Reply)_' ./spinifex/runtime/roles/awsgw/ $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -tags=lambdatestlink,lambdatestlinkseam -timeout 120s -run '^TestLambdaTestLinkSeam_' ./spinifex/runtime/roles/awsgw/ $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -tags=lambdatestlink -timeout 120s -run '^TestLambdaTestLink' ./spinifex/runtime/daemon/ $(_RACEQ)
|
||||
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -timeout 300s ./spinifex/... ./cmd/... ./internal/... ./contracts/... ./architecture/... $(_RACEQ)
|
||||
|
||||
# Unit tests for in-repo GitHub Actions (e.g. .github/actions/e2e-analyze).
|
||||
# Kept out of `test-cover` so coverage % isn't diluted by CI-only tooling.
|
||||
@@ -594,7 +601,7 @@ distro-arm64:
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: test-package-check build build-lambda-testlink build-lambda-testlink-image build-ui test-ui-e2e ui-e2e-container ui-baselines 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-worker-microvm-image build-lambda-runtime-microvm-image install-microvm install-sqs-worker-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
|
||||
.PHONY: test-package-check build build-lambda-testlink build-lambda-testlink-image build-ui test-ui-e2e ui-e2e-container ui-baselines 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-worker-microvm-image build-lambda-runtime-microvm-image install-microvm install-sqs-worker-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration test-gated generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck nilaway \
|
||||
|
||||
@@ -79,6 +79,12 @@ func (t testlink) run(parent context.Context) (int, error) {
|
||||
if err := checkBinding(scope, grant, first, time.Now()); err != nil {
|
||||
return exitRefused, err
|
||||
}
|
||||
// A host that sent no credential is refused before anything is installed.
|
||||
if first.Credential == nil || !first.Credential.Complete() {
|
||||
return exitRefused, errors.New("first frame carries no execution credential")
|
||||
}
|
||||
cred := *first.Credential
|
||||
first.Credential = nil
|
||||
if err := t.pkg.install(spec); err != nil {
|
||||
return exitRefused, fmt.Errorf("install package: %w", err)
|
||||
}
|
||||
@@ -94,12 +100,7 @@ func (t testlink) run(parent context.Context) (int, error) {
|
||||
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
defer cancel(nil)
|
||||
cmd, err := t.startBootstrap(ctx, []string{
|
||||
"AWS_LAMBDA_RUNTIME_API=" + ln.Addr().String(),
|
||||
"_HANDLER=" + spec.handler,
|
||||
"LAMBDA_TASK_ROOT=" + taskRoot,
|
||||
"PATH=/bin:/sbin:/usr/bin",
|
||||
})
|
||||
cmd, err := t.startBootstrap(ctx, bootstrapEnv(ln.Addr().String(), spec.handler, cred))
|
||||
if err != nil {
|
||||
return exitLink, fmt.Errorf("start bootstrap: %w", err)
|
||||
}
|
||||
@@ -123,6 +124,20 @@ func (t testlink) run(parent context.Context) (int, error) {
|
||||
return exitClosed, nil
|
||||
}
|
||||
|
||||
// bootstrapEnv is the bootstrap's whole environment: the runtime variables and
|
||||
// the execution role's three credential variables.
|
||||
func bootstrapEnv(runtimeAPI, handler string, cred hostlink.Credential) []string {
|
||||
return []string{
|
||||
"AWS_LAMBDA_RUNTIME_API=" + runtimeAPI,
|
||||
"_HANDLER=" + handler,
|
||||
"LAMBDA_TASK_ROOT=" + taskRoot,
|
||||
"PATH=/bin:/sbin:/usr/bin",
|
||||
"AWS_ACCESS_KEY_ID=" + cred.AccessKeyID,
|
||||
"AWS_SECRET_ACCESS_KEY=" + cred.SecretAccessKey,
|
||||
"AWS_SESSION_TOKEN=" + cred.SessionToken,
|
||||
}
|
||||
}
|
||||
|
||||
// shutdownGrace is how long past the invocation deadline the agent waits for
|
||||
// the host's shutdown frame once its result is written.
|
||||
const shutdownGrace = 2 * time.Second
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -69,6 +70,8 @@ type bootstrapStarts struct {
|
||||
sealed bool
|
||||
// handlerOut receives the _HANDLER the bootstrap process itself saw.
|
||||
handlerOut string
|
||||
// env is the environment the agent gave the bootstrap.
|
||||
env []string
|
||||
}
|
||||
|
||||
// testlink builds a guest whose fw_cfg also carries a controlled package,
|
||||
@@ -102,6 +105,7 @@ func (b *bootstrapStarts) testlink(t *testing.T, entries map[string][]byte) test
|
||||
runtimeAddr: "127.0.0.1:0",
|
||||
startBootstrap: func(ctx context.Context, env []string) (*exec.Cmd, error) {
|
||||
b.n++
|
||||
b.env = env
|
||||
b.started <- struct{}{}
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestHelperBootstrap$")
|
||||
cmd.Env = append(env, helperEnv+"=1", helperHandlerOut+"="+b.handlerOut)
|
||||
@@ -140,7 +144,7 @@ func TestTestlinkServesOneInvocationThenStops(t *testing.T) {
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
hs := hostlink.NewTestStream(conn)
|
||||
req := hostlink.Request{GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: []byte(`{"a":40,"b":2}`)}
|
||||
req := hostlink.Request{Credential: &testCredential, GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: []byte(`{"a":40,"b":2}`)}
|
||||
require.NoError(t, hs.HostSend(req))
|
||||
got, err := hs.HostResult(ctx)
|
||||
require.NoError(t, err)
|
||||
@@ -164,6 +168,55 @@ func TestTestlinkServesOneInvocationThenStops(t *testing.T) {
|
||||
assert.Equal(t, testHandler, string(seen), "bootstrap process not given the configured handler")
|
||||
assert.True(t, b.sealed, "task dir not made read-only")
|
||||
assert.FileExists(t, filepath.Join(b.task, "lib", "data.txt"))
|
||||
assert.ElementsMatch(t, []string{
|
||||
"AWS_LAMBDA_RUNTIME_API=" + envValue(b.env, "AWS_LAMBDA_RUNTIME_API"),
|
||||
"_HANDLER=" + testHandler,
|
||||
"LAMBDA_TASK_ROOT=" + taskRoot,
|
||||
"PATH=/bin:/sbin:/usr/bin",
|
||||
"AWS_ACCESS_KEY_ID=" + testCredential.AccessKeyID,
|
||||
"AWS_SECRET_ACCESS_KEY=" + testCredential.SecretAccessKey,
|
||||
"AWS_SESSION_TOKEN=" + testCredential.SessionToken,
|
||||
}, b.env, "the bootstrap gets exactly the runtime and credential variables")
|
||||
assert.NotEmpty(t, envValue(b.env, "AWS_LAMBDA_RUNTIME_API"))
|
||||
}
|
||||
|
||||
var testCredential = hostlink.Credential{AccessKeyID: "ASIAGUESTKEY", SecretAccessKey: "guest-secret", SessionToken: "guest-token"}
|
||||
|
||||
func envValue(env []string, name string) string {
|
||||
for _, kv := range env {
|
||||
if v, ok := strings.CutPrefix(kv, name+"="); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// A first frame without a complete credential installs and runs nothing.
|
||||
func TestTestlinkFrameWithoutCredentialNeverStartsBootstrap(t *testing.T) {
|
||||
for name, cred := range map[string]*hostlink.Credential{
|
||||
"none": nil,
|
||||
"incomplete": {AccessKeyID: "ASIAGUESTKEY"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHost(t)
|
||||
h.accept(ctx)
|
||||
var b bootstrapStarts
|
||||
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
require.NoError(t, hostlink.NewTestStream(conn).HostSend(hostlink.Request{Credential: cred, GrantID: "g-1", AttemptID: "a-1",
|
||||
RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: []byte(`{}`)}))
|
||||
|
||||
res := wait(t, done, "agent to stop")
|
||||
assert.Equal(t, exitRefused, res.code)
|
||||
assert.ErrorContains(t, res.err, "no execution credential")
|
||||
assert.Zero(t, b.n)
|
||||
assert.False(t, b.sealed)
|
||||
assert.NoFileExists(t, filepath.Join(b.task, "bootstrap"), "package installed")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A bootstrap that posts its response and exits at once does not cost the
|
||||
@@ -185,7 +238,7 @@ func TestTestlinkBootstrapExitingAfterResponseStillDelivers(t *testing.T) {
|
||||
defer conn.Close()
|
||||
hs := hostlink.NewTestStream(conn)
|
||||
payload := bytes.Repeat([]byte("0123456789"), 300_000)
|
||||
req := hostlink.Request{GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: payload}
|
||||
req := hostlink.Request{Credential: &testCredential, GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: payload}
|
||||
require.NoError(t, hs.HostSend(req))
|
||||
got, err := hs.HostResult(ctx)
|
||||
require.NoError(t, err)
|
||||
@@ -340,7 +393,7 @@ func TestTestlinkBadPackageNeverStartsBootstrap(t *testing.T) {
|
||||
|
||||
conn := h.guest(t)
|
||||
defer conn.Close()
|
||||
require.NoError(t, hostlink.NewTestStream(conn).HostSend(hostlink.Request{GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1",
|
||||
require.NoError(t, hostlink.NewTestStream(conn).HostSend(hostlink.Request{Credential: &testCredential, GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1",
|
||||
DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli()}))
|
||||
res := wait(t, done, "agent to stop")
|
||||
assert.Equal(t, exitRefused, res.code)
|
||||
@@ -405,7 +458,7 @@ func TestTestlinkUnboundRequestNeverStartsBootstrap(t *testing.T) {
|
||||
case tc.deadlineMs > 0:
|
||||
deadline = tc.deadlineMs
|
||||
}
|
||||
req := hostlink.Request{GrantID: tc.grantID, AttemptID: tc.attempt, RequestID: "r-1",
|
||||
req := hostlink.Request{Credential: &testCredential, GrantID: tc.grantID, AttemptID: tc.attempt, RequestID: "r-1",
|
||||
DeadlineMs: deadline, Payload: []byte(`{}`)}
|
||||
require.NoError(t, hostlink.NewTestStream(conn).HostSend(req))
|
||||
|
||||
|
||||
@@ -159,6 +159,9 @@ func parseEntry(raw []byte, off int) (entry, int, error) {
|
||||
}
|
||||
nameEnd := off + newcHeader + int(nameSize)
|
||||
dataOff := align4(nameEnd)
|
||||
if dataOff > len(raw) || size > uint64(len(raw)-dataOff) {
|
||||
return entry{}, 0, errors.New("truncated entry")
|
||||
}
|
||||
dataEnd := dataOff + int(size)
|
||||
if dataEnd > len(raw) || align4(dataEnd) > len(raw) {
|
||||
return entry{}, 0, errors.New("truncated entry")
|
||||
|
||||
@@ -21,7 +21,7 @@ Tenant code may be able to reach the listener, but it cannot open the agent's co
|
||||
**Authentication:** mutual TLS 1.3, bound to the environment ID and realization generation, using material the user runtime cannot read (ADR-0009:S17).
|
||||
For each generation the executor creates ephemeral self-signed host and guest certificates whose single URI SAN is `spinifex-lambda://<Region>/<account>/<function ID>/<environment ID>/<generation>/<role>`, with role `host` or `guest`.
|
||||
Each end validates the peer certificate against the one pinned peer certificate and separately requires that URI SAN with the opposite role; hostname verification is not relied on.
|
||||
ALPN carries the L-GUEST major version, `spinifex-lambda-guest/1`, and an unsupported version fails the handshake.
|
||||
ALPN carries the L-GUEST major version, `spinifex-lambda-guest/1`, and an unsupported version fails the handshake. Builds with the `lambdatestlink` tag offer only `spinifex-lambda-testlink/2` on both host and guest, whose first frame carries the execution-role credential, so a test-link end and a release end fail the handshake; release builds are unchanged.
|
||||
Under TLS 1.3 the guest's handshake can complete before the host refuses its certificate, so the guest treats the connection as accepted only after the host's first message.
|
||||
|
||||
**Bootstrap material:** the executor passes these fw_cfg entries under `opt/spinifex/lambda/`, and the agent reads them as root and unloads `qemu_fw_cfg` before user code starts.
|
||||
|
||||
+5
-2
@@ -19,7 +19,7 @@ Every service is `PartOf=spinifex.target` and ordered by its own `After=`/`Wants
|
||||
| `spinifex-predastore.service` | `spx service predastore start` | S3-compatible object storage, serving that host's nodes and the S3 gate among them |
|
||||
| `spinifex-northstar.service` | `spx service northstar start` | Authoritative DNS for `*.spx3.net`, recursive via upstream forwarders, syncing zones from its S3 bucket |
|
||||
| `spinifex-viperblock.service` | `spx service viperblock start` | Block storage with the NBD plugin. `KillMode=process`: nbdkit serving a guest survives a restart of this unit |
|
||||
| `spinifex-daemon.service` | `spx service spinifex start` | VM orchestration, NATS subscriptions, health endpoint. `KillMode=process`: guest QEMU survives a restart and the new daemon reattaches. `SIGHUP` reloads config |
|
||||
| `spinifex-daemon.service` | `spx service spinifex start` | VM orchestration, NATS subscriptions, health endpoint. Two `ExecStartPre` steps run first: `spx admin hostnet check`, and `spx admin node-facts collect`, which runs as root and whose failure does not stop the daemon. `KillMode=process`: guest QEMU survives a restart and the new daemon reattaches. `SIGHUP` reloads config |
|
||||
| `spinifex-awsgw.service` | `spx service awsgw start` | AWS-compatible gateway: SigV4 auth, IAM policy enforcement, TLS. Also serves the Private Admin API |
|
||||
| `spinifex-vpcd.service` | `spx service vpcd start` | VPC daemon: translates `vpc.*` NATS events to OVN logical switches, ports and routers |
|
||||
| `spinifex-ui.service` | `spx service spinifex-ui start` | Web UI serving the React frontend |
|
||||
@@ -129,6 +129,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|---------|-------|-------------|
|
||||
| `spx admin preflight` | `--quiet` (print only non-OK rows) | Reports whether this host has the privileged helper scripts and sudoers grants the installed build depends on. `spx version` knows the build but not the host: `setup.sh` and `setup-ovn.sh` install those assets, and a binary-swap deploy (`make deploy`, or a manual copy) skips them. It also reports any running service still executing a replaced binary — overwriting `/usr/local/bin/spx` while `spinifex.target` is up does not move services onto it, so each keeps executing the replaced inode until it restarts and a node can serve a mixture of two builds indefinitely. Checks compare content, not existence, so a present-but-stale asset is caught. On x86_64 it also checks that the QEMU binary the daemon will run lists the `microvm` machine type and that the microvm firmware `/usr/share/spinifex/microvm/qboot.rom` is installed, since load balancers boot on both. It runs the host network check `spinifex-daemon` and `spinifex-vpcd` run before they start, and fails while firewalld or ufw is active, since the Spinifex nftables policy is the only supported node firewall. It reports a Warning, without failing, when systemd-resolved is active but `/etc/resolv.conf` is not its stub, since lookups then bypass the route that sends Spinifex names to northstar. Exits non-zero if any asset is Missing, Stale, Ungranted, Unsupported or Failed. |
|
||||
| `spx admin node js-probe` | `--timeout` (default: 10s) | Round-trips a canary key through the cluster-state KV bucket and exits non-zero if the write or the read-back fails. JetStream can latch a write error per message block and keep serving reads — nats-server's filestore records the failure on the block and returns it on every later write, and nothing clears it short of a restart. The HTTP health endpoint reports the server-wide JetStream state, which that latch never touches, so only an actual write observes it. |
|
||||
| `spx admin node-facts collect` | — | Runs `dmidecode`, `ethtool`, `udevadm`, `networkctl lldp` and `systemd-detect-virt`, and writes their raw output to `/etc/spinifex/node-facts/`, readable by the spinifex group. `spinifex-daemon.service` runs it as a root `ExecStartPre` because the daemon itself is unprivileged; the daemon parses the files when the operator console asks. A probe that fails writes no new file, so a file from an earlier successful run can remain and the console reads it as available; a source with no file is shown as unavailable. |
|
||||
| `spx admin banner` | `--boot-check` | Writes the node information banner to `/etc/issue` (shown before login on the physical/serial console) and `/etc/motd` (shown after SSH login). With `--boot-check` it also detects a management IP that has changed since last boot and updates `/etc/spinifex/node.conf` accordingly. |
|
||||
|
||||
### Storage Inspection
|
||||
@@ -136,6 +137,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin volumes orphans` | — | Lists volumes the storage provider holds that the control plane has no record of. Their blocks consume space but they have no API handle, so they can be neither described nor deleted through the EC2 API. **Reports only, never deletes**: the evidence that an orphan is still wanted is the record that went missing, so removal is an operator decision taken per volume. It also lists UEFI variable stores nothing owns: a `uefi/i-*/` prefix with no instance record and no store record, a `uefi/ami-*/` prefix with no AMI document, and a store record that is not `deleting` and has no instance record. These are reported and never deleted, because an absent instance record is not proof of a terminate. |
|
||||
| `spx admin volumes obligations` | — | Lists the open unpublish obligations, oldest first: a volume export that a force detach, or a terminate whose unmount did not complete, left on the node that held the attachment. Each row names the volume, the node, the instance and the age, plus attempts and the last error once the node has tried. The node settles each one once it answers; an obligation stays open while its node is down and closes without the node only once the node has been removed from the cluster. **Reports only.** |
|
||||
| `spx admin volumes unsealed` | — | Lists volumes a node has open, or had open, without a confirmed seal to the backend — the node named holds writes predastore may not have. Starting such a volume elsewhere is refused while that node is alive (its daemon heartbeat is under 60 seconds old, or cannot be read) and is otherwise allowed, in which case it opens from the last checkpoint that did reach the backend. This is the list of volumes where that would cost something. |
|
||||
| `spx admin storage status` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Sends the signed `GetStorageStatus` action to the AWS gateway with credentials from the standard AWS chain (e.g. `AWS_PROFILE=spinifex`) and prints what the console's Service Metrics page shows: predastore encoding, meta and blob node counts, cluster health, a meta node table (health, raft state, leader, term, commit and applied index) and the blob nodes. Cluster health is `Degraded` when any meta node is unreachable or the nodes reporting raft state do not agree on one leader. A meta node read only through its host's `/readyz` shows healthy with blank raft fields. |
|
||||
|
||||
@@ -155,8 +157,9 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
| `spx admin account list --remote` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Same listing read through `POST /admin/ListAccounts` over SigV4 instead of NATS, so an off-cluster operator or the load-test harness sees its tenants without SSH access to a node. Credentials come from the standard AWS credential chain: on a cluster node that means `AWS_PROFILE=spinifex` (there is no default profile, so without it the command fails to find credentials), or equivalent credentials; the caller must be an IAM user in the super-admin account holding `spinifex:ListAccounts`. |
|
||||
| `spx admin account quota get <account-id>` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Reads the limits in force for one account through `POST /admin/GetAccountQuota` over SigV4, and reports for each dimension whether it came from the `[quota]` block in `awsgw.toml` (`config`) or from an override stored against the account (`override`). Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:GetAccountQuota`. |
|
||||
| `spx admin account quota set <account-id>` | `--vcpus`, `--vpcs`, `--subnets`, `--eips`, `--volumes`, `--volumes-gib`, `--rds-instances`, `--load-balancers`, `--clear`, plus `--endpoint`, `--region`, `--ca-bundle` | Overrides individual limits for one account through `POST /admin/PutAccountQuota`, leaving every dimension not named inheriting the configured baseline. `-1` uncaps a dimension; `0` is a real limit that denies every request on it. `--clear` removes the whole override record and cannot be combined with a limit flag. The account must already exist. Prints the resulting limits, so a caller never has to guess what it applied. The caller must hold `spinifex:PutAccountQuota`. |
|
||||
| `spx admin account delete <account-id>` | `--dry-run`, `--force`, `--yes` (requires `--name`), `--name` (account name, must match the stored record), `--endpoint`, `--region`, `--ca-bundle`, `--client-token` | Always runs through `POST /admin/DeleteAccount` over SigV4 and follows `DescribeAccountDeletion` to completion; the CLI never tears an account down in its own process, so a CLI delete and an API delete cannot race. Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:DeleteAccount` and `spinifex:DescribeAccountDeletion`. Prints the inventory it is about to remove → confirms the account name (prompted unless `--name` is given) → marks the account `TERMINATING`, which blocks its credentials cluster-wide → deletes its resources in dependency order (compute, attachments, storage, network, platform, identity, account), waiting for each stage to actually drain before starting the next → seals the account's quota override record so no later write can recreate it, which needs every gateway node the cluster configures to answer a read-only probe first (otherwise nothing is sealed and the account stays `TERMINATING`) → releases the name reservation and quota counter → deletes the account record. Anything that will not delete is reported as **stuck** with the reason, and the account stays `TERMINATING` so the residue keeps an owner. `000000000000` and `000000000001` are refused. `--dry-run` lists and changes nothing. The compute stage reaps ECS clusters, EKS nodegroups and clusters, and RDS instances before it reaps plain instances, because their backing VMs belong to the system account and a tenant-scoped instance listing cannot see them. The storage stage reaps the account's S3 buckets: uploads in flight are aborted, then the objects are removed a page at a time, then the bucket. Only buckets the account owns are ever listed, so the shared platform buckets are out of reach rather than filtered out. Platform-managed serving endpoints are reaped ahead of instances, because only their own delete releases the ENI and attached data volume alongside the VM, and spot instance requests are cancelled first so a live request cannot launch a replacement for something already terminated. The network stage reaps load balancers, then target groups, then NAT gateways and egress-only internet gateways before the subnets, security groups and addresses they hold. The platform stage reaps ACM certificates. |
|
||||
| `spx admin account delete <account-id>` | `--dry-run`, `--force`, `--non-finalizing` (hidden), `--yes` (requires `--name`), `--name` (account name, must match the stored record), `--endpoint`, `--region`, `--ca-bundle`, `--client-token` | Always runs through `POST /admin/DeleteAccount` over SigV4 and follows `DescribeAccountDeletion` to completion; the CLI never tears an account down in its own process, so a CLI delete and an API delete cannot race. Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:DeleteAccount` and `spinifex:DescribeAccountDeletion`. Prints the inventory it is about to remove → confirms the account name (prompted unless `--name` is given) → marks the account `TERMINATING`, which blocks its credentials cluster-wide → deletes its resources in dependency order (compute, attachments, storage, network, platform, identity, account), waiting for each stage to actually drain before starting the next → seals the account's quota override record so no later write can recreate it, which needs every gateway node the cluster configures to answer a read-only probe first (otherwise nothing is sealed and the account stays `TERMINATING`) → releases the name reservation and quota counter → deletes the account record. Anything that will not delete is reported as **stuck** with the reason, and the account stays `TERMINATING` so the residue keeps an owner. `000000000000` and `000000000001` are refused. `--dry-run` lists and changes nothing. The compute stage reaps ECS clusters, EKS nodegroups and clusters, and RDS instances before it reaps plain instances, because their backing VMs belong to the system account and a tenant-scoped instance listing cannot see them. The storage stage reaps the account's S3 buckets: uploads in flight are aborted, then the objects are removed a page at a time, then the bucket. Only buckets the account owns are ever listed, so the shared platform buckets are out of reach rather than filtered out. Platform-managed serving endpoints are reaped ahead of instances, because only their own delete releases the ENI and attached data volume alongside the VM, and spot instance requests are cancelled first so a live request cannot launch a replacement for something already terminated. The network stage reaps load balancers, then target groups, then NAT gateways and egress-only internet gateways before the subnets, security groups and addresses they hold. The platform stage reaps ACM certificates. |
|
||||
| `spx admin account delete --force` | as above | Escalates only for resources the ordinary API refuses: it clears a volume attachment in the control plane without the guest's cooperation (`ec2.ForceDetachVolume`), hard-destroys after a graceful attempt times out, and treats an already-missing resource as deleted. It never reorders the stages and never widens scope beyond the account. Use it once the guest is being destroyed — this is the escape from a volume stuck attached to an instance that will not terminate, where neither can otherwise be deleted. |
|
||||
| `spx admin account delete --non-finalizing` | as above | An exercise run, not a finalising delete, but the cleanup is real. Marks the account `TERMINATING`, deletes the resources of each EC2 platform kind the evaluation found quiesced (for example key pairs, empty placement groups and orphaned tags), re-reads the owner inventory against that cleanup, and ends `EXERCISED` with the account still `TERMINATING`. No other stage runs and the account record, name reservation and quota are left in place. It refuses `--force` and `--dry-run`. |
|
||||
| `spx admin account delete --remote` | as above | Accepted for existing scripts and ignored: every delete already goes through the admin API. |
|
||||
| `spx admin principal create <name>` | `--grant` (repeatable or comma-separated; default: every admin method — `CreateAccount`, `DeleteAccount`, `DescribeAccountDeletion`, `GetAccountQuota`, `ListAccounts`, `PutAccountQuota`) | Creates an IAM user in the super-admin account with an inline `spx-admin-methods` policy allowing the requested methods, revokes any access key left from a previous run, and mints one new key. Each method is named rather than granted as `spinifex:*`, so a later addition to the admin surface is not authorised by an existing key. The principal's grants are exactly that one policy: re-running with fewer `--grant` values narrows it, and any other inline policy on the user is removed, so a rename cannot leave a wider grant behind. The secret is printed once; store it in a secret manager or an AWS profile, never in a config file. `admin` is refused as a name — it is the account's own administrator and rotating its key would revoke the bootstrap credential. |
|
||||
| `spx admin principal list` | — | Lists every principal in the super-admin account with its access-key count and what it may call. An attached policy is reported by name (`AdministratorAccess (attached)`) rather than expanded, which is how an unscoped principal is spotted. |
|
||||
|
||||
@@ -112,6 +112,8 @@ When a daemon handles `RunInstances`:
|
||||
|
||||
1. **Resource Check**: Validates CPU/memory availability via `ResourceManager`
|
||||
2. **Volume Generation**: Creates boot and EFI volumes via Viperblock
|
||||
A UEFI instance whose record carries a variable store pointer has no EFI volume: its `vars.fd` is fetched from the object under `uefi/` in the configured Predastore bucket that the pointer names, recorded in the `spinifex-uefi-stores` JetStream bucket (or reused when `seeded.json` on this node already names the same object) after a firmware architecture and VARS size check, and a record with both or neither is refused.
|
||||
RunInstances still creates the EFI volume, nothing writes `vars.fd` back to the object store yet, and a terminated instance's store is removed by a cluster-wide reaper.
|
||||
3. **Volume Mount**: Publishes each volume through the EBS provider contract (`PublishVolume` on `ebs.provider.v1.{node}.mount`), receives its export
|
||||
4. **QEMU Launch**: Builds and executes QEMU command with NBD-backed disks
|
||||
5. **QMP Monitoring**: Establishes QEMU Machine Protocol connection for VM management
|
||||
@@ -225,6 +227,9 @@ It may keep writing for 40 seconds after the last write the store acknowledged,
|
||||
A slow acknowledgement therefore shortens the remaining validity rather than extending it, and an acquisition acknowledged after its validity has already run out is refused as retryable.
|
||||
|
||||
For a holder that cannot renew, a timer is armed for the validity deadline, leaving a nominal 5 seconds before the entry can expire; when it fires, the export's nbdkit is fenced with SIGKILL (`volume_fence.go`).
|
||||
The kill comes first: the read that names the winner (bounded at 2 seconds) and the notice to the daemon follow it, so a hung lease store cannot hold the writer alive.
|
||||
The notice is a request that viperblockd repeats every 2 seconds until the local daemon replies with `VolumeFencedAck`, because the fence often runs while the node's NATS server is restarting.
|
||||
On the notice the daemon moves the guest using the volume to `error` with state reason `Server.VolumeFenced` and SIGKILLs its QEMU immediately rather than shutting it down gracefully, because there is no disk left to flush to; it only stops the guest process and removes its taps, and never terminates the instance or deletes a volume.
|
||||
The fence runs on its own goroutine, so Go scheduling, that goroutine and process termination all delay the kill, and enough delay can use up the whole margin; a kill held in uninterruptible sleep, which the fence waits up to 30 seconds for, is the extreme case.
|
||||
Once a lease is lost or has lapsed it stays lost: a renewal that succeeds afterwards is ignored, and another open on the same node cannot share it while the fence runs.
|
||||
|
||||
|
||||
@@ -315,7 +315,7 @@ prefix_len = 24
|
||||
|
||||
### Host access to instances (jumpbox pattern)
|
||||
|
||||
The spinifex host automatically reaches every instance's **private IP**: IGW attach installs a host route into OVN (`<vpc-cidr> via <gateway-transit-ip> dev spx-nat-host`) and exempts the transit net from SNAT, so replies to host-initiated connections come back un-NATted. No per-instance setup.
|
||||
The spinifex host automatically reaches every instance's **private IP**: each node converges a host route into OVN (`<vpc-cidr> via <gateway-transit-ip> dev spx-nat-host`) for every attached VPC, and the IGW attach exempts the transit net from SNAT, so replies to host-initiated connections come back un-NATted. No per-instance setup. This only exists in routed NAT mode; other modes install no host routes. Every node runs this loop itself, on each intent change, on a nudge from the local IGW hooks and every 30 seconds, so a node that missed the attach event still gets the route. A route is removed on every node when no attached VPC holds its CIDR any more, such as after a detach. When several attached VPCs share one CIDR, the bootstrap VPC wins, then the lowest VPC ID, so detaching one gateway repoints the route to another attached VPC instead of removing it.
|
||||
|
||||
Security groups still apply and the default SG is closed to the host, same as AWS — open SSH/ICMP from the transit net first:
|
||||
|
||||
|
||||
@@ -43,6 +43,21 @@ A missing `Name`, `BlockDeviceMappings` or root snapshot answers `MissingParamet
|
||||
Registering with `--ena-support`, `--no-ena-support` and neither, copying the image, and three refusals were run on a three-node cluster with the AWS CLI.
|
||||
Launching an instance from an `EnaSupport` image was not run, so the launch path is unchanged by construction and not by test.
|
||||
|
||||
### CreateSnapshot Error Mapping
|
||||
|
||||
`CreateSnapshot` drains and snapshots the volume inside the EBS provider.
|
||||
A provider that cannot snapshot a volume because it is in use, as the `qemunbd` provider cannot for an attached volume, answers `ErrVolumeInUse`, which becomes `IncorrectState` with the message `The volume 'vol-...' is in use. Detach it before creating a snapshot.`
|
||||
The `viperblockd` provider drains a mounted export and snapshots it, so an attached volume succeeds there.
|
||||
A provider that cannot answer now, for example because another node holds the volume, answers `ServiceUnavailable` so the caller retries.
|
||||
Any other provider failure is `ServerInternal`.
|
||||
The snapshot taken for `CreateImage` maps these errors the same way.
|
||||
|
||||
### EBS Encryption by Default Follows the Platform
|
||||
|
||||
Whether new volumes are encrypted is decided by the platform's encryption key, not by the account.
|
||||
`GetEbsEncryptionByDefault` and `EnableEbsEncryptionByDefault` report `true` when the platform has an encryption key configured and `false` otherwise, and `EnableEbsEncryptionByDefault` does not change that.
|
||||
`DisableEbsEncryptionByDefault` is refused with `OperationNotPermitted` and the message `EBS encryption by default cannot be disabled: this platform encrypts every volume` while the platform encrypts, and answers `false` where it does not.
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation |
|
||||
|
||||
@@ -99,7 +99,7 @@ behavior in other words.
|
||||
| 4432 | Formation server | HTTPS | Cluster (bootstrap only) | Cluster join coordination; active only while a join token is valid. Binds the node's `--bind` (lan) address. See *Formation port lifecycle* below. | Short-lived bearer token + TLS¹ |
|
||||
| 4222 | spinifex-nats (client) | NATS + TLS | Cluster | Internal service bus for EC2/EBS/VPC/S3 handlers | Token + mutual TLS (cluster CA) |
|
||||
| 4248 | spinifex-nats (cluster) | NATS + TLS | Cluster | Inter-node NATS federation | Token + mutual TLS (cluster CA) |
|
||||
| 5001 | iperf2 (admin network test) | TCP | Cluster, any plane | Throughput test between two nodes, started only by the super-admin account. Listens on one plane address for the length of one test, then exits. Runs with no capabilities. | None — peer-scoped by firewall, and the client may only target its own plane subnet |
|
||||
| 5001 | iperf2 (admin network test) | TCP | Cluster, any plane | Throughput test between two nodes, started only by the super-admin account. Listens on one plane address for the length of one test, then exits. Runs with no capabilities. | None — peer-scoped by firewall, and the client is refused unless the target address is the one the named target node reports on that plane |
|
||||
| 5300 | northstar | DNS (UDP + TCP) | Cluster | Forward target for every node's per-instance DNS shim, dialled cross-node. Binds the wildcard by design. | None — restrict by firewall |
|
||||
| 6660 | predastore (blob node) | QUIC / UDP | Cluster | Erasure-coded object shard transport between hosts. Multi-node clusters only — see *Predastore ports* below. | TLS 1.3, server certificate verified against the cluster CA |
|
||||
| 7660 | predastore (meta node) | QUIC / UDP | Cluster | Raft consensus over global state — buckets and the object index — between hosts. Multi-node clusters only. | TLS 1.3, server certificate verified against the cluster CA |
|
||||
|
||||
@@ -77,8 +77,8 @@ func TestEC2ErrorCatalogMetadataAndClasses(t *testing.T) {
|
||||
if catalog.metadata.VerifiedOn == "" {
|
||||
t.Fatal("verifiedOn is empty")
|
||||
}
|
||||
if len(catalog.codes) != 115 {
|
||||
t.Fatalf("catalog code count = %d, want 115", len(catalog.codes))
|
||||
if len(catalog.codes) != 116 {
|
||||
t.Fatalf("catalog code count = %d, want 116", len(catalog.codes))
|
||||
}
|
||||
if catalog.codes["InvalidParameterValue"] != ec2ErrorClassClient {
|
||||
t.Fatal("InvalidParameterValue is not classified as client")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"source": "https://docs.aws.amazon.com/ec2/latest/devguide/errors-overview.html",
|
||||
"verifiedOn": "2026-08-05",
|
||||
"verifiedOn": "2026-11-04",
|
||||
"commonClient": [
|
||||
"AuthFailure",
|
||||
"Blocked",
|
||||
@@ -95,6 +95,7 @@
|
||||
"InvalidVolumeID.Malformed",
|
||||
"InvalidVpcID.NotFound",
|
||||
"InvalidVpcRange",
|
||||
"NatGatewayNotFound",
|
||||
"OperationNotPermitted",
|
||||
"ReservationCapacityExceeded",
|
||||
"Resource.AlreadyAssociated",
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
|
||||
"github.com/mulgadc/spinifex/internal/imdscreds"
|
||||
)
|
||||
|
||||
@@ -57,8 +58,9 @@ func NewInstanceRole() *Signer {
|
||||
}
|
||||
|
||||
// newInstanceRole reads from the IMDS root base; "" means the default endpoint.
|
||||
func newInstanceRole(base string) *Signer {
|
||||
return &Signer{provider: aws.NewCredentialsCache(imdscreds.NewProvider(nil, base))}
|
||||
// optFns adjust the SDK IMDS client, e.g. its retryer; none keeps the defaults.
|
||||
func newInstanceRole(base string, optFns ...func(*imds.Options)) *Signer {
|
||||
return &Signer{provider: aws.NewCredentialsCache(imdscreds.NewProvider(nil, base, optFns...))}
|
||||
}
|
||||
|
||||
// EnsureCredentials retrieves once from the provider, surfacing the error so a
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
)
|
||||
|
||||
@@ -124,27 +125,41 @@ func TestNewIMDS_BuildsProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// imdsStub serves one instance-role credential over IMDSv2. It echoes the token
|
||||
// TTL header as real IMDS does, so the SDK client caches the token rather than
|
||||
// re-fetching it, through the retry backoff, on every later call.
|
||||
func imdsStub(t *testing.T, accessKey, expiration string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/latest/api/token":
|
||||
w.Header().Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
|
||||
_, _ = w.Write([]byte("token"))
|
||||
case "/latest/meta-data/iam/security-credentials/":
|
||||
_, _ = w.Write([]byte("sqsWorkerRole"))
|
||||
case "/latest/meta-data/iam/security-credentials/sqsWorkerRole":
|
||||
_, _ = w.Write([]byte(`{"Code":"Success","AccessKeyId":"` + accessKey + `","SecretAccessKey":"s","Token":"t","Expiration":"` + expiration + `"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// singleAttempt disables IMDS client retries so an unreachable endpoint fails
|
||||
// on the first attempt instead of after the SDK's 1s-per-retry backoff.
|
||||
func singleAttempt(o *imds.Options) { o.Retryer = aws.NopRetryer{} }
|
||||
|
||||
// The instance-role signer ignores environment keys and signs only with the
|
||||
// credentials the metadata service issues.
|
||||
func TestNewInstanceRole_UsesOnlyIMDS(t *testing.T) {
|
||||
t.Setenv("AWS_ACCESS_KEY_ID", testAccessKey)
|
||||
t.Setenv("AWS_SECRET_ACCESS_KEY", testSecretKey)
|
||||
const imdsKey = "ASIAIMDSONLYEXAMPLE1"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/latest/api/token":
|
||||
_, _ = w.Write([]byte("token"))
|
||||
case "/latest/meta-data/iam/security-credentials/":
|
||||
_, _ = w.Write([]byte("sqsWorkerRole"))
|
||||
case "/latest/meta-data/iam/security-credentials/sqsWorkerRole":
|
||||
_, _ = w.Write([]byte(`{"Code":"Success","AccessKeyId":"` + imdsKey + `","SecretAccessKey":"s","Token":"t","Expiration":"2099-01-01T00:00:00Z"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
srv := imdsStub(t, imdsKey, "2099-01-01T00:00:00Z")
|
||||
|
||||
s := newInstanceRole(srv.URL)
|
||||
s := newInstanceRole(srv.URL, singleAttempt)
|
||||
req := httptest.NewRequest(http.MethodPost, "https://gw.example/", nil)
|
||||
if err := s.Sign(req, emptySHA, testService, testRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -154,7 +169,7 @@ func TestNewInstanceRole_UsesOnlyIMDS(t *testing.T) {
|
||||
}
|
||||
|
||||
srv.Close()
|
||||
cold := newInstanceRole(srv.URL)
|
||||
cold := newInstanceRole(srv.URL, singleAttempt)
|
||||
if err := cold.EnsureCredentials(context.Background()); err == nil {
|
||||
t.Fatal("environment keys were used when IMDS was unreachable")
|
||||
}
|
||||
@@ -164,19 +179,8 @@ func TestNewInstanceRole_UsesOnlyIMDS(t *testing.T) {
|
||||
// IMDS must fail the sign rather than reuse the expired key with a new expiry.
|
||||
func TestNewInstanceRole_FailedRefreshErrorsInsteadOfStaleKey(t *testing.T) {
|
||||
const staleKey = "ASIASTALEEXAMPLE0001"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/latest/api/token":
|
||||
_, _ = w.Write([]byte("token"))
|
||||
case "/latest/meta-data/iam/security-credentials/":
|
||||
_, _ = w.Write([]byte("sqsWorkerRole"))
|
||||
case "/latest/meta-data/iam/security-credentials/sqsWorkerRole":
|
||||
_, _ = w.Write([]byte(`{"Code":"Success","AccessKeyId":"` + staleKey + `","SecretAccessKey":"s","Token":"t","Expiration":"2000-01-01T00:00:00Z"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
s := newInstanceRole(srv.URL)
|
||||
srv := imdsStub(t, staleKey, "2000-01-01T00:00:00Z")
|
||||
s := newInstanceRole(srv.URL, singleAttempt)
|
||||
if err := s.EnsureCredentials(t.Context()); err != nil {
|
||||
t.Fatalf("first retrieve: %v", err)
|
||||
}
|
||||
|
||||
@@ -36,10 +36,10 @@ func Fetch(client *http.Client, base string) (aws.Credentials, error) {
|
||||
}
|
||||
|
||||
// NewProvider builds an ec2rolecreds provider against base, an IMDS root URL
|
||||
// such as http://169.254.169.254/latest. A nil client uses the SDK IMDS
|
||||
// client's default HTTP client.
|
||||
func NewProvider(client *http.Client, base string) aws.CredentialsProvider {
|
||||
imdsClient := NewClient(client, base)
|
||||
// such as http://169.254.169.254/latest. client and optFns are passed to
|
||||
// NewClient.
|
||||
func NewProvider(client *http.Client, base string, optFns ...func(*imds.Options)) aws.CredentialsProvider {
|
||||
imdsClient := NewClient(client, base, optFns...)
|
||||
return noExtendOnFailure{provider: ec2rolecreds.New(func(o *ec2rolecreds.Options) {
|
||||
o.Client = imdsClient
|
||||
})}
|
||||
@@ -71,16 +71,15 @@ func (p noExtendOnFailure) AdjustExpiresBy(creds aws.Credentials, dur time.Durat
|
||||
return p.provider.AdjustExpiresBy(creds, dur)
|
||||
}
|
||||
|
||||
// NewClient builds an SDK IMDS client against base, an IMDS root URL such as
|
||||
// http://169.254.169.254/latest. A trailing /latest is stripped since the
|
||||
// client appends its own; both forms of base keep working. A nil client uses
|
||||
// the SDK IMDS client's default HTTP client.
|
||||
func NewClient(client *http.Client, base string) *imds.Client {
|
||||
// NewClient builds an SDK IMDS client against base, an IMDS root URL with or
|
||||
// without a trailing /latest. A nil client uses the SDK's default HTTP client;
|
||||
// optFns adjust the options after the defaults here, as imds.New applies them.
|
||||
func NewClient(client *http.Client, base string, optFns ...func(*imds.Options)) *imds.Client {
|
||||
opts := imds.Options{Endpoint: rootEndpoint(base)}
|
||||
if client != nil {
|
||||
opts.HTTPClient = client
|
||||
}
|
||||
return imds.New(opts)
|
||||
return imds.New(opts, optFns...)
|
||||
}
|
||||
|
||||
// rootEndpoint strips a trailing /latest from base so it can be used as the
|
||||
|
||||
@@ -27,8 +27,7 @@ func StartTestNATS(t testing.TB) (*server.Server, *nats.Conn) {
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
waitReadyForConnections(t, ns)
|
||||
startServer(t, ns)
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
@@ -37,17 +36,14 @@ func StartTestNATS(t testing.TB) (*server.Server, *nats.Conn) {
|
||||
return ns, nc
|
||||
}
|
||||
|
||||
// waitReadyForConnections spins until the server accepts connections. Calling
|
||||
// ReadyForConnections with a longer timeout makes it sleep 25ms per attempt,
|
||||
// which every embedded server in the suite would otherwise pay.
|
||||
func waitReadyForConnections(t testing.TB, ns *server.Server) {
|
||||
// startServer runs Start in this goroutine: it returns once JetStream is
|
||||
// enabled and the client listener is bound, so the readiness check passes on
|
||||
// its first look instead of spinning or sleeping while the server comes up.
|
||||
func startServer(t testing.TB, ns *server.Server) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !ns.ReadyForConnections(time.Millisecond) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("embedded NATS server not ready for connections within 5s")
|
||||
}
|
||||
time.Sleep(200 * time.Microsecond)
|
||||
ns.Start()
|
||||
if !ns.ReadyForConnections(5 * time.Second) {
|
||||
t.Fatal("embedded NATS server not ready for connections within 5s")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +62,7 @@ func StartTestJetStream(t *testing.T) (*server.Server, *nats.Conn, jetstream.Jet
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
waitReadyForConnections(t, ns)
|
||||
startServer(t, ns)
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
|
||||
+4
-1
@@ -98,7 +98,10 @@ Both ship to `/usr/local/share/spinifex/` on every node, so they are available o
|
||||
| `build-system-image.sh` | Builds a minimal system AMI (Alpine or Ubuntu) from a manifest. All customisation happens inside the libguestfs appliance, so the build touches no host block device, no host mount, and needs no sudo. `--import` also registers it as an AMI. |
|
||||
| `publish-system-image.sh` | Converts a built raw image to compressed qcow2, writes the `.sha256` sidecar the catalog verifier expects, and uploads both to the R2 bucket behind `iso.mulgadc.com/system-ami/`. |
|
||||
| `build-microvm-image.sh` | Builds the minimal Alpine kernel + initramfs used by the QEMU microvm machine type and the lb-agent, carrying haproxy (ALB) and nginx-stream (NLB) data planes. |
|
||||
| `build-qboot.sh` | Builds qboot, the firmware the daemon passes to every microvm with `-bios`, from a pinned upstream commit in a digest-pinned Debian container. Fails unless the output matches the recorded sha256, which is also Debian 13's `qboot.rom`. Writes `qboot.rom` and its GPL-2.0 licence beside the microvm kernel, and the matching source tarball, which releases publish, under `build/qboot/`. `make build-microvm-image` runs it. |
|
||||
| `build-lambda-runtime-microvm-image.sh` | Builds the Lambda runtime microVM kernel and initramfs: a guest running the Lambda guest agent (`bin/lambda-agent` by default) with virtio-net for its host control connection. Takes an optional agent binary and output directory (default `build/microvm/lambda-runtime`). Not shipped. |
|
||||
| `build-sqs-store-microvm-image.sh` | Builds the SQS microVM kernel and initramfs: one guest running the dedicated SQS `nats-server` and the SQS worker as separate processes, with ext4 support and a clean power-down. The guest never formats a disk. Output goes to `build/microvm/sqs-worker`. |
|
||||
| `lib/microvm-image.sh` | Shared steps for the two builders above, sourced rather than run: tool checks, the Alpine rootfs, and module stripping that keeps `virtio*`, `*fw_cfg*` and the modules the caller names with their dependencies. |
|
||||
| `build-qboot.sh` | Builds qboot, the firmware the daemon passes to every microvm with `-bios`, from a pinned upstream commit in a digest-pinned Debian container. Fails unless the output matches the recorded sha256, which is also Debian 13's `qboot.rom`. A host whose `/usr/share/qemu/qboot.rom` (`qemu-system-data`) has that hash installs it and skips the container; any other host compiles. Writes `qboot.rom` and its GPL-2.0 licence beside the microvm kernel, and the matching source tarball, which releases publish, under `build/qboot/`. `make build-microvm-image` runs it. |
|
||||
| `mkosi-build.sh` | Runs an mkosi system-image build inside the pinned builder container, so a runner needs only Docker and the toolchain cannot drift. `--image` names an output and expands to the right profile list. |
|
||||
| `mkosi-publish.sh` | Publishes an mkosi-built image to R2 from inside the same pinned container — qcow2 conversion, `.sha256` sidecar, and upload. |
|
||||
| `mkosi-common.sh` | Shared plumbing sourced by both mkosi scripts. Builds the toolchain image once, with the builder account matched to the invoking user so bind-mounted output is not written back as root. |
|
||||
|
||||
+41
-22
@@ -9,7 +9,8 @@
|
||||
#
|
||||
# The build runs in a digest-pinned Debian container, so its output is
|
||||
# reproducible and is checked against a recorded hash. That hash is also the
|
||||
# sha256 of Debian 13's /usr/share/qemu/qboot.rom, which is built from the same commit.
|
||||
# sha256 of Debian 13's /usr/share/qemu/qboot.rom, which is built from the same
|
||||
# commit, so a host whose copy matches installs it and skips the container.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
@@ -20,18 +21,13 @@ QBOOT_REPO="${QBOOT_REPO:-https://gitlab.com/qemu-project/qboot.git}"
|
||||
QBOOT_COMMIT="8ca302e86d685fa05b16e2b208888243da319941"
|
||||
QBOOT_SHA256="5c9c4b12c4438e3cf11d087263afe6519c8c05dd5896727b8b169b8f5dbf9877"
|
||||
BUILD_IMAGE="debian:trixie@sha256:4c25275446ca371766b8b093c696782eabce06263fccacb8d5d813b3dc44b308"
|
||||
# Debian 13's qemu-system-data ships this exact ROM, so a host that has it
|
||||
# skips the container. QBOOT_HOST_ROM moves the path for tests.
|
||||
QBOOT_HOST_ROM="${QBOOT_HOST_ROM:-/usr/share/qemu/qboot.rom}"
|
||||
|
||||
log() { echo "[build-qboot] $*"; }
|
||||
fail() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
CONTAINER_TOOL=""
|
||||
for tool in docker podman; do
|
||||
if command -v "$tool" >/dev/null 2>&1 && "$tool" info >/dev/null 2>&1; then
|
||||
CONTAINER_TOOL=$tool
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "$CONTAINER_TOOL" ] || fail "qboot builds in a container; install docker or podman"
|
||||
command -v git >/dev/null 2>&1 || fail "required tool not found: git"
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
@@ -48,19 +44,42 @@ mkdir -p "$SRC_DIR"
|
||||
src_tarball="$SRC_DIR/qboot-${QBOOT_COMMIT}.tar.gz"
|
||||
git -C "$WORK/src" archive --format=tar.gz --prefix="qboot-${QBOOT_COMMIT}/" -o "$src_tarball" "$QBOOT_COMMIT"
|
||||
|
||||
log "building in $BUILD_IMAGE with $CONTAINER_TOOL"
|
||||
# The firmware comes back on stdout, so nothing the container writes is left
|
||||
# root-owned on the host.
|
||||
"$CONTAINER_TOOL" run --rm --platform linux/amd64 -v "$WORK/src:/src:ro" \
|
||||
-e DEBIAN_FRONTEND=noninteractive "$BUILD_IMAGE" sh -euc '
|
||||
{
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends gcc libc6-dev meson ninja-build binutils
|
||||
meson setup /tmp/build /src
|
||||
ninja -C /tmp/build
|
||||
} >/dev/null
|
||||
cat /tmp/build/bios.bin
|
||||
' > "$WORK/qboot.rom"
|
||||
build_in_container() {
|
||||
local CONTAINER_TOOL=""
|
||||
for tool in docker podman; do
|
||||
if command -v "$tool" >/dev/null 2>&1 && "$tool" info >/dev/null 2>&1; then
|
||||
CONTAINER_TOOL=$tool
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "$CONTAINER_TOOL" ] || fail "qboot builds in a container; install docker or podman"
|
||||
log "building in $BUILD_IMAGE with $CONTAINER_TOOL"
|
||||
# The firmware comes back on stdout, so nothing the container writes is left
|
||||
# root-owned on the host.
|
||||
"$CONTAINER_TOOL" run --rm --platform linux/amd64 -v "$WORK/src:/src:ro" \
|
||||
-e DEBIAN_FRONTEND=noninteractive "$BUILD_IMAGE" sh -euc '
|
||||
{
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends gcc libc6-dev meson ninja-build binutils
|
||||
meson setup /tmp/build /src
|
||||
ninja -C /tmp/build
|
||||
} >/dev/null
|
||||
cat /tmp/build/bios.bin
|
||||
' > "$WORK/qboot.rom"
|
||||
}
|
||||
|
||||
host_sha=""
|
||||
[ -r "$QBOOT_HOST_ROM" ] && host_sha=$(sha256sum "$QBOOT_HOST_ROM" | cut -d' ' -f1)
|
||||
if [ "$host_sha" = "$QBOOT_SHA256" ]; then
|
||||
log "using host ROM $QBOOT_HOST_ROM (sha256 matches)"
|
||||
cp "$QBOOT_HOST_ROM" "$WORK/qboot.rom"
|
||||
elif [ -n "$host_sha" ]; then
|
||||
log "host ROM $QBOOT_HOST_ROM has sha256 $host_sha, not $QBOOT_SHA256; building"
|
||||
build_in_container
|
||||
else
|
||||
log "no host ROM at $QBOOT_HOST_ROM; building"
|
||||
build_in_container
|
||||
fi
|
||||
|
||||
got_sha=$(sha256sum "$WORK/qboot.rom" | cut -d' ' -f1)
|
||||
if [ "$got_sha" != "$QBOOT_SHA256" ]; then
|
||||
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/bin/sh
|
||||
# build-qboot_test.sh — a host ROM matching the recorded hash is installed
|
||||
# without the container; any other host ROM, or none, builds in it, and a
|
||||
# container ROM with the wrong hash still fails.
|
||||
set -eu
|
||||
|
||||
SCRIPTS_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
fails=0
|
||||
pass() { echo " ok - $1"; }
|
||||
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
|
||||
echo "-- build-qboot.sh host ROM"
|
||||
|
||||
COMMIT=$(sed -n 's/^QBOOT_COMMIT="\(.*\)"$/\1/p' "$SCRIPTS_DIR/build-qboot.sh")
|
||||
printf 'pinned rom\n' > "$TMP/good.rom"
|
||||
printf 'other rom\n' > "$TMP/other.rom"
|
||||
GOOD_SHA=$(sha256sum "$TMP/good.rom" | cut -d' ' -f1)
|
||||
|
||||
# git and docker stubs. git fakes a checkout of the pinned commit; docker
|
||||
# prints $STUB_ROM as the firmware and records that it ran.
|
||||
mkdir -p "$TMP/bin"
|
||||
cat > "$TMP/bin/git" <<STUB
|
||||
#!/bin/sh
|
||||
case "\$1" in
|
||||
init) mkdir -p "\$3"; printf 'GPL-2.0\n' > "\$3/LICENSE"; exit 0 ;;
|
||||
esac
|
||||
shift 2
|
||||
case "\$1" in
|
||||
rev-parse) echo "$COMMIT" ;;
|
||||
archive) while [ "\$1" != "-o" ]; do shift; done; printf 'source\n' > "\$2" ;;
|
||||
esac
|
||||
STUB
|
||||
cat > "$TMP/bin/docker" <<'STUB'
|
||||
#!/bin/sh
|
||||
[ "$1" = info ] && exit 0
|
||||
echo ran >> "$DOCKER_LOG"
|
||||
cat "$STUB_ROM"
|
||||
STUB
|
||||
chmod +x "$TMP/bin/git" "$TMP/bin/docker"
|
||||
|
||||
# run <name> <host-rom> <container-rom>: build in a fresh fixture repo.
|
||||
run() {
|
||||
repo="$TMP/$1"
|
||||
mkdir -p "$repo/scripts"
|
||||
sed "s/^QBOOT_SHA256=.*/QBOOT_SHA256=\"$GOOD_SHA\"/" "$SCRIPTS_DIR/build-qboot.sh" > "$repo/scripts/build-qboot.sh"
|
||||
: > "$TMP/$1.docker"
|
||||
PATH="$TMP/bin:$PATH" QBOOT_HOST_ROM="$2" STUB_ROM="$3" DOCKER_LOG="$TMP/$1.docker" \
|
||||
bash "$repo/scripts/build-qboot.sh" > "$TMP/$1.log" 2>&1
|
||||
}
|
||||
|
||||
# outputs <name>: the ROM is the pinned one and the licence and source exist.
|
||||
outputs() {
|
||||
cmp -s "$TMP/$1/build/microvm/qboot.rom" "$TMP/good.rom" &&
|
||||
[ -s "$TMP/$1/build/microvm/qboot.LICENSE" ] &&
|
||||
[ -s "$TMP/$1/build/qboot/qboot-$COMMIT.tar.gz" ]
|
||||
}
|
||||
|
||||
if run match "$TMP/good.rom" "$TMP/other.rom" && outputs match && [ ! -s "$TMP/match.docker" ]; then
|
||||
pass "matching host ROM installed, container not run"
|
||||
else
|
||||
fail "matching host ROM: $(tr '\n' ' ' < "$TMP/match.log")"
|
||||
fi
|
||||
|
||||
if run mismatch "$TMP/other.rom" "$TMP/good.rom" && outputs mismatch && [ -s "$TMP/mismatch.docker" ] &&
|
||||
grep -q 'has sha256' "$TMP/mismatch.log"; then
|
||||
pass "mismatched host ROM builds in the container"
|
||||
else
|
||||
fail "mismatched host ROM: $(tr '\n' ' ' < "$TMP/mismatch.log")"
|
||||
fi
|
||||
|
||||
if run missing "$TMP/absent.rom" "$TMP/good.rom" && outputs missing && [ -s "$TMP/missing.docker" ]; then
|
||||
pass "missing host ROM builds in the container"
|
||||
else
|
||||
fail "missing host ROM: $(tr '\n' ' ' < "$TMP/missing.log")"
|
||||
fi
|
||||
|
||||
if run badbuild "$TMP/absent.rom" "$TMP/other.rom"; then
|
||||
fail "container ROM with the wrong hash was accepted"
|
||||
elif [ -e "$TMP/badbuild/build/microvm/qboot.rom" ]; then
|
||||
fail "container ROM with the wrong hash was installed"
|
||||
else
|
||||
pass "container ROM with the wrong hash fails and installs nothing"
|
||||
fi
|
||||
|
||||
[ "$fails" -eq 0 ] || { echo "$fails failure(s)"; exit 1; }
|
||||
@@ -0,0 +1,27 @@
|
||||
------------------------------------------------- MODULE Capabilities -------------------------------------------------
|
||||
(*********************************************************************************************************************)
|
||||
(* The optional behaviour that an implementation advertises. *)
|
||||
(*********************************************************************************************************************)
|
||||
EXTENDS EBSProvider
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Interface methods
|
||||
|
||||
GetCapabilitiesRequests == {<<>>}
|
||||
|
||||
GetCapabilities(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* The implementation reports which optional behaviour it supports. *)
|
||||
(*******************************************************************************************************************)
|
||||
\* The provider failed to answer.
|
||||
\/ /\ val = None
|
||||
/\ err \in ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\* Every answer is the same, so an earlier one may be cached.
|
||||
\/ /\ val = Capabilities
|
||||
/\ err = None
|
||||
/\ UNCHANGED vars
|
||||
|
||||
=======================================================================================================================
|
||||
@@ -0,0 +1,163 @@
|
||||
------------------------------------------------- MODULE EBSProvider --------------------------------------------------
|
||||
(*********************************************************************************************************************)
|
||||
(* The state that an implementation exposes, and the types and constants that every other module shares. *)
|
||||
(*********************************************************************************************************************)
|
||||
EXTENDS
|
||||
Naturals,
|
||||
Sequences,
|
||||
FiniteSets,
|
||||
Functions,
|
||||
TLC
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Constants
|
||||
|
||||
CONSTANTS
|
||||
MaxBytes,
|
||||
VolumeIDs, VolumeHandles,
|
||||
NodeIDs, NBDURIs, DevicePaths,
|
||||
SnapshotIDs, SnapshotHandles,
|
||||
AvailabilityZones, Timestamps,
|
||||
Parameters, ProviderData,
|
||||
ListTokens, MaxListResults,
|
||||
\* The optional behaviour that the implementation advertises, which never changes.
|
||||
Capabilities,
|
||||
\* Whether the provider may fail to answer at any point in a behaviour.
|
||||
ProviderMayFail,
|
||||
\* Stands in for an optional field that a request or response omitted.
|
||||
None
|
||||
|
||||
ASSUME MaxBytes \in Nat \ {0}
|
||||
ASSUME VolumeIDs /= {} /\ VolumeHandles /= {}
|
||||
ASSUME NodeIDs /= {} => NBDURIs /= {} \/ DevicePaths /= {}
|
||||
ASSUME SnapshotIDs /= {} => SnapshotHandles /= {} /\ Timestamps /= {}
|
||||
ASSUME ListTokens /= {}
|
||||
ASSUME MaxListResults \in Nat \ {0}
|
||||
ASSUME None \notin UNION {VolumeIDs, VolumeHandles, NodeIDs, SnapshotIDs, SnapshotHandles,
|
||||
AvailabilityZones, Timestamps, Parameters, ProviderData, ListTokens}
|
||||
ASSUME Capabilities \in [online_expansion : BOOLEAN,
|
||||
sparse_extent_reporting : BOOLEAN,
|
||||
crash_consistent_snapshot : BOOLEAN,
|
||||
volume_seeding : BOOLEAN,
|
||||
read_only_publish : BOOLEAN,
|
||||
volume_enumeration : BOOLEAN,
|
||||
snapshot_enumeration : BOOLEAN,
|
||||
owner_routing : BOOLEAN,
|
||||
exclusion : [scope : {"none", "node", "cluster"},
|
||||
claim_ttl_seconds : Nat,
|
||||
fences_lost_claim : BOOLEAN]]
|
||||
ASSUME ProviderMayFail \in BOOLEAN
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Variables
|
||||
|
||||
VARIABLES
|
||||
\* Every volume that the implementation exposes, keyed by volume ID.
|
||||
volumes,
|
||||
\* Every publication that the implementation exposes, keyed by the volume and the node it is published to.
|
||||
publications,
|
||||
\* Every snapshot that the implementation exposes, keyed by snapshot ID.
|
||||
snapshots
|
||||
|
||||
vars == <<volumes, publications, snapshots>>
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Miscellaneous
|
||||
|
||||
IsPartialFn(f, D, R) == DOMAIN f \subseteq D /\ \A x \in DOMAIN f : f[x] \in R
|
||||
|
||||
Optional(S) == S \union {None}
|
||||
|
||||
InsertKey(f, x, y) == f @@ (x :> y)
|
||||
|
||||
RemoveKey(f, x) == RestrictDomain(f, LAMBDA x_2 : x_2 /= x)
|
||||
|
||||
KeyExists(f, x) == x \in DOMAIN f
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Types
|
||||
|
||||
Bytes == 0..MaxBytes
|
||||
|
||||
\* The errors that an implementation may return when its provider fails to answer.
|
||||
ProviderErrors == IF ProviderMayFail
|
||||
THEN {"internal", "unavailable"}
|
||||
\* Otherwise an implementation could conform by refusing to answer.
|
||||
ELSE {}
|
||||
|
||||
CapacityRanges == [required_bytes : Bytes, limit_bytes : Bytes]
|
||||
|
||||
VolumeStates == {"available", "in-use"}
|
||||
|
||||
Volumes == [capacity_bytes : 1..MaxBytes,
|
||||
state : VolumeStates,
|
||||
handle : VolumeHandles,
|
||||
availability_zone : Optional(AvailabilityZones),
|
||||
|
||||
\* Never returned, but kept to tell a retry from a conflicting request.
|
||||
source_snapshot_id : Optional(SnapshotIDs),
|
||||
parameters : Optional(Parameters),
|
||||
provider_data : Optional(ProviderData)]
|
||||
|
||||
\* How a published volume is reached on its node, either over NBD or as a host block device.
|
||||
Exports == [kind : {"nbd"},
|
||||
nbd_uri : NBDURIs]
|
||||
\union
|
||||
[kind : {"block"},
|
||||
device_path : DevicePaths,
|
||||
size_bytes : 1..MaxBytes]
|
||||
|
||||
\* A volume published to a node, and whether it was published read-only.
|
||||
Publications == [export : Exports,
|
||||
read_only : BOOLEAN]
|
||||
|
||||
SnapshotStates == {"pending", "completed", "error"}
|
||||
|
||||
Snapshots == [size_bytes : 1..MaxBytes,
|
||||
created_at : Timestamps,
|
||||
state : SnapshotStates,
|
||||
handle : SnapshotHandles,
|
||||
source_volume_id : VolumeIDs]
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* State helpers
|
||||
|
||||
\* The part of a volume that the implementation returns.
|
||||
VolumeView(volID) ==
|
||||
("id" :> volID) @@ Restrict(volumes[volID], {"capacity_bytes",
|
||||
"state",
|
||||
"handle",
|
||||
"availability_zone",
|
||||
"provider_data"})
|
||||
|
||||
\* The nodes that a volume is published to.
|
||||
PublishedNodes(volID) == {n \in NodeIDs : KeyExists(publications, <<volID, n>>)}
|
||||
|
||||
\* The part of a publication that the implementation returns.
|
||||
PublicationView(volID, nodeID) == [volume_id |-> volID,
|
||||
node_id |-> nodeID,
|
||||
export |-> publications[volID, nodeID].export]
|
||||
|
||||
\* The part of a snapshot that the implementation returns.
|
||||
SnapshotView(snapID) == ("id" :> snapID) @@ snapshots[snapID]
|
||||
|
||||
\* The number of refs that a page may hold, with an omitted or oversized request clamped to the maximum.
|
||||
PageSize(m) == IF m <= 0 \/ m > MaxListResults THEN MaxListResults ELSE m
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Invariants
|
||||
|
||||
TypeOK ==
|
||||
/\ IsPartialFn(volumes, VolumeIDs, Volumes)
|
||||
/\ IsPartialFn(publications, VolumeIDs \X NodeIDs, Publications)
|
||||
/\ IsPartialFn(snapshots, SnapshotIDs, Snapshots)
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Initial state
|
||||
|
||||
Init ==
|
||||
/\ volumes = <<>>
|
||||
/\ publications = <<>>
|
||||
/\ snapshots = <<>>
|
||||
|
||||
=======================================================================================================================
|
||||
@@ -0,0 +1,140 @@
|
||||
# EBSProvider specification
|
||||
|
||||
The contract that every implementation of the `EBSProvider` interface must meet.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Provider:** the service that backs an implementation's operations, such as Viperblock.
|
||||
The memory implementation has none.
|
||||
- **Implementation:** a Go implementation of the `EBSProvider` interface, which adapts between the shapes of the contract and the shapes that its provider expects.
|
||||
- **Contract:** the behaviour that this specification requires of every implementation, as observed through the interface.
|
||||
|
||||
## Open questions
|
||||
|
||||
### EBSProvider
|
||||
|
||||
- What may a failed call change?
|
||||
A failed call leaves the state unchanged, but a timeout can arrive after the provider acted, and a crash can leave work half done.
|
||||
- Is the schema version part of the contract?
|
||||
Every method refuses an unsupported version with `unsupported_version`, and the conformance suite tests it, but the specification does not model versions.
|
||||
- Are `internal` and `unavailable` the only errors that a failed provider may cause?
|
||||
- Must a present but malformed ID be refused with `invalid_argument`?
|
||||
IDs are opaque here, so only an omitted one is refused.
|
||||
|
||||
### Capabilities
|
||||
|
||||
#### GetCapabilities
|
||||
|
||||
- Is it a lasting promise that capabilities never change, even when an implementation or its provider is upgraded or reconfigured after an earlier answer was cached?
|
||||
|
||||
### Volumes
|
||||
|
||||
- Who decides whether a volume is `in-use`?
|
||||
An implementation may track attachments itself, or derive the state from whether a publication claim is held.
|
||||
|
||||
#### CreateVolume
|
||||
|
||||
- May the capacity be anywhere in the requested range, as CSI allows?
|
||||
An implementation that rounds up would then fail the comparison that tells a retry from a conflicting request.
|
||||
- May seed data be combined with a source snapshot?
|
||||
The Viperblock implementation refuses, the memory implementation accepts.
|
||||
- Must the source snapshot be completed?
|
||||
AWS refuses one that is not.
|
||||
- Must an implementation store the availability zone, parameters and source snapshot, so that it can detect a conflicting retry?
|
||||
The Viperblock implementation does not.
|
||||
- Should a retry be compared against the original request rather than the current volume?
|
||||
A retry after ExpandVolume conflicts on capacity.
|
||||
- Should a retry with a different source snapshot volume ID conflict?
|
||||
It is not compared.
|
||||
- Is byte equality the meaning of equal parameters, or may an implementation compare them semantically?
|
||||
- Should `MaxSeedBytes` be modelled?
|
||||
Seed data larger than it is refused with `invalid_argument`.
|
||||
|
||||
#### GetVolume
|
||||
|
||||
- Must an implementation compare the handle?
|
||||
The Viperblock implementation ignores it.
|
||||
- Is `not_found` the right error for a volume that was deleted and recreated under the same ID?
|
||||
|
||||
#### ListVolumes
|
||||
|
||||
- Must following next tokens list every volume that exists throughout the walk exactly once?
|
||||
The conformance suite tests it, but the specification describes each call alone, so it does not say how one page relates to the next.
|
||||
- What should a starting token that the implementation never issued do?
|
||||
Every implementation treats any token as a position to resume after, while CSI refuses an invalid one with `ABORTED`.
|
||||
- May a page hold fewer refs than requested yet carry a next token?
|
||||
AWS and CSI allow it, but here a next token comes only with a full page, so that a walk cannot stall on empty pages.
|
||||
|
||||
#### ExpandVolume
|
||||
|
||||
- Should a request below the current capacity succeed without change, as CSI recommends, rather than be refused with `invalid_argument`?
|
||||
It is refused here, as AWS refuses `ModifyVolume` to a smaller size and the conformance suite tests, while CSI says the plugin should reply OK when the volume is already at least the requested size.
|
||||
- Must a block export's size follow an online expansion?
|
||||
A published volume keeps its export's `size_bytes`, so after it grows the export reports the old size.
|
||||
|
||||
#### PublishVolume
|
||||
|
||||
- Should `volume_in_use` depend on the exclusion scope?
|
||||
An implementation whose scope is `none` lets two writers in, and one whose scope is `node` cannot see another node, yet both are refused here.
|
||||
- Must a republish to the same node with a different `read_only` be refused?
|
||||
The Viperblock implementation refuses it with `volume_in_use`, while the memory implementation ignores the flag.
|
||||
- May two volumes on one node share an NBD URI or a device path?
|
||||
- Must a block export's size equal the volume's capacity?
|
||||
|
||||
#### UnpublishVolume
|
||||
|
||||
- Must an implementation compare the handle?
|
||||
Both the Viperblock and memory implementations ignore it.
|
||||
- Is a provider that fails to seal the volume a failed provider?
|
||||
The Viperblock implementation answers `internal` and leaves the volume published, which the specification allows only when the provider may fail.
|
||||
- Should a claim that lapses through its TTL be modelled, as a step that happens without a call?
|
||||
- Should a volume that does not exist be refused with `not_found`?
|
||||
It succeeds here, as CSI requires and the conformance suite tests, while AWS refuses `DetachVolume` on a missing volume with `InvalidVolume.NotFound`.
|
||||
|
||||
#### DeleteVolume
|
||||
|
||||
- Must an implementation compare the handle before refusing a published volume?
|
||||
The memory implementation does, and succeeds without deleting it, while the Viperblock implementation ignores the handle and refuses with `volume_in_use`.
|
||||
- Should a volume that does not exist be refused with `not_found`?
|
||||
It succeeds here, as CSI requires and the conformance suite tests, while AWS refuses `DeleteVolume` on a missing volume with `InvalidVolume.NotFound`.
|
||||
|
||||
### Snapshots
|
||||
|
||||
- Should a pending snapshot's move to `completed` or `error` be modelled, as a step that happens without a call?
|
||||
Without it a pending snapshot never completes, and no snapshot is ever in `error`.
|
||||
|
||||
#### CreateSnapshot
|
||||
|
||||
- May success return a `pending` snapshot?
|
||||
AWS starts a snapshot as `pending`, while the memory implementation returns `completed` and the NATS implementation waits for completion and refuses anything else.
|
||||
- Must a retry compare the volume handle?
|
||||
It is ignored, as in the memory implementation, so a volume deleted and recreated under the same ID returns the old snapshot.
|
||||
- Should a copy's ID count as a retry?
|
||||
A copy keeps its source's volume ID, so creating a snapshot of that volume under the copy's ID returns the copy.
|
||||
- Should `crash_consistent_snapshot` be modelled?
|
||||
The specification does not model volume data, so it cannot say what a snapshot of a published volume holds.
|
||||
|
||||
#### CopySnapshot
|
||||
|
||||
- Should a retried copy return the existing copy, as a retried CreateVolume does, rather than `already_exists`?
|
||||
- May a source that is not completed be copied?
|
||||
AWS refuses until it is.
|
||||
- What state does a copy start in?
|
||||
The memory implementation always returns `completed`, while AWS starts a copy as `pending`.
|
||||
|
||||
#### ListSnapshots
|
||||
|
||||
- The questions for ListVolumes apply here too.
|
||||
- Must a ref carry the source volume ID?
|
||||
The Viperblock implementation leaves it empty, since reading it costs a fetch per snapshot, yet reconciliation needs it to act on a snapshot whose metadata is lost.
|
||||
|
||||
#### DeleteSnapshot
|
||||
|
||||
- Is the handle check part of the contract?
|
||||
The Viperblock implementation ignores the handle and deletes anyway.
|
||||
- May a pending snapshot be deleted?
|
||||
AWS allows it.
|
||||
- What does success promise?
|
||||
The Go docs say the snapshot is gone, but a snapshot whose handle did not match is left in place.
|
||||
- Should a snapshot that does not exist be refused with `not_found`?
|
||||
It succeeds here, as CSI requires and the conformance suite tests, while AWS refuses `DeleteSnapshot` on a missing snapshot with `InvalidSnapshot.NotFound`.
|
||||
@@ -0,0 +1,159 @@
|
||||
-------------------------------------------------- MODULE Snapshots ---------------------------------------------------
|
||||
(*********************************************************************************************************************)
|
||||
(* The lifecycle of a snapshot, from its creation to its deletion. *)
|
||||
(*********************************************************************************************************************)
|
||||
EXTENDS EBSProvider
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Interface methods
|
||||
|
||||
CreateSnapshotRequests == [snapshot_id : Optional(SnapshotIDs),
|
||||
volume_id : Optional(VolumeIDs),
|
||||
volume_handle : Optional(VolumeHandles)]
|
||||
|
||||
CreateSnapshot(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume's data is frozen as a snapshot, from which new volumes can be restored. A retry of a call that *)
|
||||
(* already succeeded returns the same snapshot. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET snapID == req.snapshot_id
|
||||
volID == req.volume_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> None \in {snapID, volID},
|
||||
already_exists |-> /\ KeyExists(snapshots, snapID)
|
||||
/\ snapshots[snapID].source_volume_id /= volID,
|
||||
\* Only checked for a new snapshot, so a retry outlives the volume.
|
||||
not_found |-> /\ ~KeyExists(snapshots, snapID)
|
||||
/\ \/ ~KeyExists(volumes, volID)
|
||||
\/ req.volume_handle \notin {None, volumes[volID].handle}]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
\* A retry returns the existing snapshot.
|
||||
/\ \/ /\ KeyExists(snapshots, snapID)
|
||||
/\ UNCHANGED snapshots
|
||||
|
||||
\/ /\ ~KeyExists(snapshots, snapID)
|
||||
\* The handle, creation time and state are the implementation's choice.
|
||||
/\ \E h \in SnapshotHandles \ {snapshots[x].handle : x \in DOMAIN snapshots},
|
||||
t \in Timestamps,
|
||||
s \in {"pending", "completed"} :
|
||||
snapshots' = InsertKey(snapshots, snapID, [size_bytes |-> volumes[volID].capacity_bytes,
|
||||
created_at |-> t,
|
||||
state |-> s,
|
||||
handle |-> h,
|
||||
source_volume_id |-> volID])
|
||||
/\ val = SnapshotView(snapID)'
|
||||
/\ err = None
|
||||
/\ UNCHANGED <<volumes, publications>>
|
||||
|
||||
|
||||
CopySnapshotRequests == [source_snapshot_id : Optional(SnapshotIDs),
|
||||
destination_snapshot_id : Optional(SnapshotIDs),
|
||||
volume_id : Optional(VolumeIDs)]
|
||||
|
||||
CopySnapshot(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A snapshot is copied under a new ID, sharing its frozen data, and the copy can be deleted independently *)
|
||||
(* of it. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET srcSnapID == req.source_snapshot_id
|
||||
dstSnapID == req.destination_snapshot_id
|
||||
volID == req.volume_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> \/ None \in {srcSnapID, dstSnapID, volID}
|
||||
\/ srcSnapID = dstSnapID
|
||||
\* Only compared, not looked up, since a snapshot outlives its volume.
|
||||
\/ /\ KeyExists(snapshots, srcSnapID)
|
||||
/\ snapshots[srcSnapID].source_volume_id /= volID,
|
||||
already_exists |-> KeyExists(snapshots, dstSnapID),
|
||||
not_found |-> srcSnapID \notin Optional(DOMAIN snapshots)]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
\* The handle and creation time are the implementation's choice.
|
||||
/\ \E h \in SnapshotHandles \ {snapshots[x].handle : x \in DOMAIN snapshots},
|
||||
t \in Timestamps :
|
||||
snapshots' = InsertKey(snapshots, dstSnapID, [created_at |-> t,
|
||||
handle |-> h]
|
||||
@@ snapshots[srcSnapID])
|
||||
/\ val = SnapshotView(dstSnapID)'
|
||||
/\ err = None
|
||||
/\ UNCHANGED <<volumes, publications>>
|
||||
|
||||
|
||||
ListSnapshotsRequests == [max_results : -1..(MaxListResults + 1),
|
||||
starting_token : Optional(ListTokens)]
|
||||
|
||||
ListSnapshots(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* Some of the snapshots are listed, a page at a time, and nothing changes. A call with no starting token that *)
|
||||
(* returns no next token lists every snapshot. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET Errors ==
|
||||
LET checks == [unsupported_capability |-> ~Capabilities.snapshot_enumeration]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
/\ LET refs == {[id |-> id,
|
||||
source_volume_id |-> snapshots[id].source_volume_id,
|
||||
handle |-> snapshots[id].handle] : id \in DOMAIN snapshots}
|
||||
\* The page and the next token are the implementation's choice.
|
||||
IN \E page \in SUBSET refs, next \in Optional(ListTokens) :
|
||||
/\ Cardinality(page) <= PageSize(req.max_results)
|
||||
/\ next /= None => Cardinality(page) = PageSize(req.max_results)
|
||||
/\ req.starting_token = None /\ next = None => page = refs
|
||||
/\ val = [snapshots |-> page, next_token |-> next]
|
||||
/\ err = None
|
||||
/\ UNCHANGED vars
|
||||
|
||||
|
||||
DeleteSnapshotRequests == [snapshot_id : Optional(SnapshotIDs),
|
||||
handle : Optional(SnapshotHandles)]
|
||||
|
||||
DeleteSnapshot(req, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A snapshot is deleted, so that no new volume can be restored from it. Volumes already restored from it, *)
|
||||
(* and copies of it, are unaffected. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET snapID == req.snapshot_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> snapID = None]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
/\ snapshots' = IF /\ KeyExists(snapshots, snapID)
|
||||
/\ req.handle \notin {None, snapshots[snapID].handle}
|
||||
THEN snapshots
|
||||
\* Record removal is idempotent so that retries succeed.
|
||||
ELSE RemoveKey(snapshots, snapID)
|
||||
/\ err = None
|
||||
/\ UNCHANGED <<volumes, publications>>
|
||||
|
||||
=======================================================================================================================
|
||||
@@ -0,0 +1,286 @@
|
||||
--------------------------------------------------- MODULE Volumes ----------------------------------------------------
|
||||
(*********************************************************************************************************************)
|
||||
(* The lifecycle of a volume, from its creation to its deletion. *)
|
||||
(*********************************************************************************************************************)
|
||||
EXTENDS EBSProvider
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Interface methods
|
||||
|
||||
CreateVolumeRequests == [volume_id : Optional(VolumeIDs),
|
||||
capacity_range : CapacityRanges,
|
||||
availability_zone : Optional(AvailabilityZones),
|
||||
source_snapshot_id : Optional(SnapshotIDs),
|
||||
source_snapshot_volume_id : Optional(VolumeIDs),
|
||||
parameters : Optional(Parameters),
|
||||
seed_data : Bytes]
|
||||
|
||||
CreateVolume(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume of the requested capacity is created, either blank or holding a snapshot's data, optionally *)
|
||||
(* with seed data written at its start. A retry of a call that already succeeded returns the same volume. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET volID == req.volume_id
|
||||
snapID == req.source_snapshot_id
|
||||
snapVolID == req.source_snapshot_volume_id
|
||||
capacity == req.capacity_range
|
||||
|
||||
\* Any warranted error may be returned, since the order of checks is not part of the contract.
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> \/ volID = None
|
||||
\/ capacity.required_bytes <= 0
|
||||
\/ /\ capacity.limit_bytes > 0
|
||||
/\ capacity.required_bytes > capacity.limit_bytes
|
||||
\/ req.seed_data > capacity.required_bytes
|
||||
\/ snapID /= None /\ snapVolID = None
|
||||
\* Only checked for a new volume, so a retry outlives the snapshot.
|
||||
\/ /\ ~KeyExists(volumes, volID)
|
||||
/\ KeyExists(snapshots, snapID)
|
||||
/\ \/ capacity.required_bytes < snapshots[snapID].size_bytes
|
||||
\/ snapshots[snapID].source_volume_id /= snapVolID,
|
||||
\* Seed data is not compared, since it only applies on creation.
|
||||
already_exists |-> /\ KeyExists(volumes, volID)
|
||||
/\ \/ volumes[volID].capacity_bytes /= capacity.required_bytes
|
||||
\/ volumes[volID].availability_zone /= req.availability_zone
|
||||
\/ volumes[volID].source_snapshot_id /= snapID
|
||||
\/ volumes[volID].parameters /= req.parameters,
|
||||
not_found |-> /\ ~KeyExists(volumes, volID)
|
||||
/\ snapID \notin Optional(DOMAIN snapshots),
|
||||
unsupported_capability |-> /\ req.seed_data > 0
|
||||
/\ ~Capabilities.volume_seeding]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
\* A retry returns the existing volume.
|
||||
/\ \/ /\ KeyExists(volumes, volID)
|
||||
/\ UNCHANGED volumes
|
||||
|
||||
\/ /\ ~KeyExists(volumes, volID)
|
||||
\* The handle and provider data are the implementation's choice.
|
||||
/\ \E h \in VolumeHandles \ {volumes[x].handle : x \in DOMAIN volumes},
|
||||
pd \in Optional(ProviderData) :
|
||||
volumes' = InsertKey(volumes, volID, [capacity_bytes |-> capacity.required_bytes,
|
||||
state |-> "available",
|
||||
handle |-> h,
|
||||
availability_zone |-> req.availability_zone,
|
||||
source_snapshot_id |-> snapID,
|
||||
parameters |-> req.parameters,
|
||||
provider_data |-> pd])
|
||||
/\ val = VolumeView(volID)'
|
||||
/\ err = None
|
||||
/\ UNCHANGED <<publications, snapshots>>
|
||||
|
||||
|
||||
GetVolumeRequests == [volume_id : Optional(VolumeIDs),
|
||||
handle : Optional(VolumeHandles)]
|
||||
|
||||
GetVolume(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume's current capacity, state and handle are read, and nothing changes. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET volID == req.volume_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> volID = None,
|
||||
not_found |-> \/ ~KeyExists(volumes, volID)
|
||||
\/ req.handle \notin {None, volumes[volID].handle}]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
/\ val = VolumeView(volID)
|
||||
/\ err = None
|
||||
/\ UNCHANGED vars
|
||||
|
||||
|
||||
ListVolumesRequests == [max_results : -1..(MaxListResults + 1),
|
||||
starting_token : Optional(ListTokens)]
|
||||
|
||||
ListVolumes(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* Some of the volumes are listed, a page at a time, and nothing changes. A call with no starting token that *)
|
||||
(* returns no next token lists every volume. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET Errors ==
|
||||
LET checks == [unsupported_capability |-> ~Capabilities.volume_enumeration]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
/\ LET refs == {[id |-> id, handle |-> volumes[id].handle] : id \in DOMAIN volumes}
|
||||
\* The page and the next token are the implementation's choice.
|
||||
IN \E page \in SUBSET refs, next \in Optional(ListTokens) :
|
||||
/\ Cardinality(page) <= PageSize(req.max_results)
|
||||
/\ next /= None => Cardinality(page) = PageSize(req.max_results)
|
||||
/\ req.starting_token = None /\ next = None => page = refs
|
||||
/\ val = [volumes |-> page, next_token |-> next]
|
||||
/\ err = None
|
||||
/\ UNCHANGED vars
|
||||
|
||||
|
||||
ExpandVolumeRequests == [volume_id : Optional(VolumeIDs),
|
||||
handle : Optional(VolumeHandles),
|
||||
capacity_range : CapacityRanges]
|
||||
|
||||
ExpandVolume(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume grows to the requested capacity, keeping its data. A published volume grows only if the *)
|
||||
(* implementation supports online expansion, and a request for the current capacity succeeds without change. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET volID == req.volume_id
|
||||
capacity == req.capacity_range
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> \/ volID = None
|
||||
\/ capacity.required_bytes = 0
|
||||
\/ /\ capacity.limit_bytes > 0
|
||||
/\ capacity.required_bytes > capacity.limit_bytes
|
||||
\/ /\ KeyExists(volumes, volID)
|
||||
/\ capacity.required_bytes < volumes[volID].capacity_bytes,
|
||||
not_found |-> \/ ~KeyExists(volumes, volID)
|
||||
\/ req.handle \notin {None, volumes[volID].handle},
|
||||
volume_in_use |-> /\ PublishedNodes(volID) /= {}
|
||||
/\ ~Capabilities.online_expansion
|
||||
/\ capacity.required_bytes > volumes[volID].capacity_bytes]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
/\ volumes' = [volumes EXCEPT ![volID].capacity_bytes = capacity.required_bytes]
|
||||
/\ val = VolumeView(volID)'
|
||||
/\ err = None
|
||||
/\ UNCHANGED <<publications, snapshots>>
|
||||
|
||||
|
||||
PublishVolumeRequests == [volume_id : Optional(VolumeIDs),
|
||||
handle : Optional(VolumeHandles),
|
||||
node_id : Optional(NodeIDs),
|
||||
read_only : BOOLEAN]
|
||||
|
||||
PublishVolume(req, val, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume's data becomes reachable on a node, through an export that a VM there can attach, either an NBD *)
|
||||
(* URI or a host block device. Reads and writes through the export reach the volume, and a read-only export *)
|
||||
(* refuses writes. Only one node at a time may reach a volume. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET volID == req.volume_id
|
||||
nodeID == req.node_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> None \in {volID, nodeID},
|
||||
not_found |-> \/ ~KeyExists(volumes, volID)
|
||||
\/ req.handle \notin {None, volumes[volID].handle},
|
||||
unsupported_capability |-> /\ req.read_only
|
||||
/\ ~Capabilities.read_only_publish,
|
||||
volume_in_use |-> PublishedNodes(volID) \ {nodeID} /= {}]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ val = None
|
||||
/\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
\* A retry returns the existing publication.
|
||||
/\ \/ /\ KeyExists(publications, <<volID, nodeID>>)
|
||||
/\ UNCHANGED publications
|
||||
|
||||
\/ /\ ~KeyExists(publications, <<volID, nodeID>>)
|
||||
\* The type of export is the implementation's choice.
|
||||
/\ \E x \in Exports :
|
||||
publications' = InsertKey(publications, <<volID, nodeID>>, [export |-> x,
|
||||
read_only |-> req.read_only])
|
||||
/\ volumes' = [volumes EXCEPT ![volID].state = "in-use"]
|
||||
/\ val = PublicationView(volID, nodeID)'
|
||||
/\ err = None
|
||||
/\ UNCHANGED snapshots
|
||||
|
||||
|
||||
UnpublishVolumeRequests == [volume_id : Optional(VolumeIDs),
|
||||
handle : Optional(VolumeHandles),
|
||||
node_id : Optional(NodeIDs)]
|
||||
|
||||
UnpublishVolume(req, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume's export on a node is torn down, so that its data is no longer reachable there. Once no node *)
|
||||
(* can reach the volume, it is available again. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET volID == req.volume_id
|
||||
nodeID == req.node_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> None \in {volID, nodeID}]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
\* Record removal is idempotent so that retries succeed.
|
||||
/\ publications' = RemoveKey(publications, <<volID, nodeID>>)
|
||||
\* The volume is available again once no node holds it.
|
||||
/\ volumes' = IF \/ ~KeyExists(volumes, volID)
|
||||
\/ PublishedNodes(volID)' /= {}
|
||||
THEN volumes
|
||||
ELSE [volumes EXCEPT ![volID].state = "available"]
|
||||
/\ err = None
|
||||
/\ UNCHANGED snapshots
|
||||
|
||||
|
||||
DeleteVolumeRequests == [volume_id : Optional(VolumeIDs),
|
||||
handle : Optional(VolumeHandles)]
|
||||
|
||||
DeleteVolume(req, err) ==
|
||||
(*******************************************************************************************************************)
|
||||
(* A volume is deleted, so that its data is gone. Snapshots taken from it are unaffected. *)
|
||||
(*******************************************************************************************************************)
|
||||
LET volID == req.volume_id
|
||||
|
||||
Errors ==
|
||||
LET checks == [invalid_argument |-> volID = None,
|
||||
volume_in_use |-> /\ PublishedNodes(volID) /= {}
|
||||
/\ req.handle \in {None, volumes[volID].handle}]
|
||||
IN {e \in DOMAIN checks : checks[e]}
|
||||
|
||||
\* The request was rejected, or the provider failed to answer.
|
||||
IN \/ /\ err \in Errors \union ProviderErrors
|
||||
/\ UNCHANGED vars
|
||||
|
||||
\* The request was accepted.
|
||||
\/ /\ Errors = {}
|
||||
\* Record removal is idempotent so that retries succeed.
|
||||
/\ volumes' = IF /\ KeyExists(volumes, volID)
|
||||
/\ req.handle \notin {None, volumes[volID].handle}
|
||||
THEN volumes
|
||||
ELSE RemoveKey(volumes, volID)
|
||||
/\ err = None
|
||||
/\ UNCHANGED <<publications, snapshots>>
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
\* Specification
|
||||
|
||||
=======================================================================================================================
|
||||
@@ -46,12 +46,30 @@ func (l *fakeLink) Complete(ctx context.Context, r hostlink.Response) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runtime polls next and answers using reply.
|
||||
func runtime(t *testing.T, base string, reply func(id string, body []byte) (path string, out string)) {
|
||||
// runtime serves srv and polls next, answering using reply. Cleanup cancels the
|
||||
// poll before closing the server: an in-flight next long-poll otherwise holds
|
||||
// its connection active and blocks Close forever.
|
||||
func runtime(t *testing.T, srv *runtimeapi.Server, reply func(id string, body []byte) (path string, out string)) {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
<-done
|
||||
ts.Close()
|
||||
})
|
||||
do := func(method, url string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ts.Client().Do(req)
|
||||
}
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
resp, err := http.Get(base + api + "invocation/next")
|
||||
resp, err := do(http.MethodGet, ts.URL+api+"invocation/next", nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -59,7 +77,7 @@ func runtime(t *testing.T, base string, reply func(id string, body []byte) (path
|
||||
resp.Body.Close()
|
||||
id := resp.Header.Get("Lambda-Runtime-Aws-Request-Id")
|
||||
path, out := reply(id, body)
|
||||
r, err := http.Post(base+api+"invocation/"+id+path, "application/json", strings.NewReader(out))
|
||||
r, err := do(http.MethodPost, ts.URL+api+"invocation/"+id+path, strings.NewReader(out))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -72,9 +90,7 @@ func deadline(d time.Duration) int64 { return time.Now().Add(d).UnixMilli() }
|
||||
|
||||
func TestRunServesRequestsAndEchoesIDs(t *testing.T) {
|
||||
srv := runtimeapi.New()
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
runtime(t, ts.URL, func(id string, body []byte) (string, string) {
|
||||
runtime(t, srv, func(id string, body []byte) (string, string) {
|
||||
if string(body) == "bad" {
|
||||
return "/error", `{"errorMessage":"no"}`
|
||||
}
|
||||
@@ -149,9 +165,7 @@ func TestRunReturnsLinkError(t *testing.T) {
|
||||
func TestRunBoundsTheResultWriteByTheDeadlinePlusGrace(t *testing.T) {
|
||||
t.Run("on time", func(t *testing.T) {
|
||||
srv := runtimeapi.New()
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
runtime(t, ts.URL, func(string, []byte) (string, string) { return "/response", "ok" })
|
||||
runtime(t, srv, func(string, []byte) (string, string) { return "/response", "ok" })
|
||||
dl := deadline(time.Minute)
|
||||
link := &fakeLink{got: make(chan hostlink.Response, 1), ctxs: make(chan context.Context, 1),
|
||||
reqs: []hostlink.Request{{RequestID: "r", DeadlineMs: dl}}}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// ErrShutdown is returned by Link.Next when the host asks the agent to stop.
|
||||
@@ -22,6 +23,24 @@ type Request struct {
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
DeadlineMs int64 `json:"deadline_ms"`
|
||||
Payload []byte `json:"payload"`
|
||||
// Credential is the environment's execution-role session. Only test-link
|
||||
// hosts send it; release hosts leave it nil.
|
||||
Credential *Credential `json:"credential,omitempty"`
|
||||
}
|
||||
|
||||
// Credential is one execution-role session. It is never logged or stored.
|
||||
type Credential struct {
|
||||
AccessKeyID string `json:"access_key_id"`
|
||||
SecretAccessKey string `json:"secret_access_key"`
|
||||
SessionToken string `json:"session_token"`
|
||||
}
|
||||
|
||||
// LogValue keeps every value out of logs.
|
||||
func (Credential) LogValue() slog.Value { return slog.StringValue("[redacted]") }
|
||||
|
||||
// Complete reports whether all three values are present.
|
||||
func (c Credential) Complete() bool {
|
||||
return c.AccessKeyID != "" && c.SecretAccessKey != "" && c.SessionToken != ""
|
||||
}
|
||||
|
||||
// Response is the outcome of a Request. FunctionError is empty on success. A
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !lambdatestlink
|
||||
|
||||
package hostlink
|
||||
|
||||
import lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
|
||||
// Protocol is the ALPN protocol release hosts and guests offer.
|
||||
const Protocol = lambdav1.ALPNv1
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package hostlink
|
||||
|
||||
// Protocol is the only ALPN protocol a test-link host and guest offer. It
|
||||
// differs from the release protocol because the first frame carries the
|
||||
// execution credential, which an older guest would ignore.
|
||||
const Protocol = "spinifex-lambda-testlink/2"
|
||||
@@ -86,11 +86,11 @@ func (id Identity) ClientConfig() *tls.Config {
|
||||
MinVersion: tls.VersionTLS13,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
Certificates: []tls.Certificate{id.Cert},
|
||||
NextProtos: []string{lambdav1.ALPNv1},
|
||||
NextProtos: []string{Protocol},
|
||||
InsecureSkipVerify: true, //nolint:gosec // VerifyConnection performs full validation against the pinned host certificate.
|
||||
VerifyConnection: func(cs tls.ConnectionState) error {
|
||||
if cs.NegotiatedProtocol != lambdav1.ALPNv1 {
|
||||
return fmt.Errorf("hostlink: application protocol %q, want %q", cs.NegotiatedProtocol, lambdav1.ALPNv1)
|
||||
if cs.NegotiatedProtocol != Protocol {
|
||||
return fmt.Errorf("hostlink: application protocol %q, want %q", cs.NegotiatedProtocol, Protocol)
|
||||
}
|
||||
return pki.VerifyPinned(cs.PeerCertificates, id.HostCert, x509.ExtKeyUsageServerAuth, id.Scope.URI(lambdav1.RoleHost))
|
||||
},
|
||||
|
||||
@@ -69,7 +69,7 @@ func newTestIdent(t *testing.T, scope lambdav1.Scope, role string) testIdent {
|
||||
func hostConfig(host testIdent, pinGuest *x509.Certificate, scope lambdav1.Scope) *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{host.cert},
|
||||
ClientAuth: tls.RequireAnyClientCert, NextProtos: []string{lambdav1.ALPNv1},
|
||||
ClientAuth: tls.RequireAnyClientCert, NextProtos: []string{Protocol},
|
||||
VerifyConnection: func(cs tls.ConnectionState) error {
|
||||
return pki.VerifyPinned(cs.PeerCertificates, pinGuest, x509.ExtKeyUsageClientAuth, scope.URI(lambdav1.RoleGuest))
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -29,6 +30,20 @@ func await[T any](t *testing.T, ch <-chan T) T {
|
||||
}
|
||||
}
|
||||
|
||||
// allocBytesPerOp is the mean heap allocated per call of f, measured the same
|
||||
// way testing.Benchmark does but over a fixed number of runs.
|
||||
func allocBytesPerOp(runs int, f func()) int64 {
|
||||
f()
|
||||
runtime.GC()
|
||||
var before, after runtime.MemStats
|
||||
runtime.ReadMemStats(&before)
|
||||
for range runs {
|
||||
f()
|
||||
}
|
||||
runtime.ReadMemStats(&after)
|
||||
return int64(after.TotalAlloc-before.TotalAlloc) / int64(runs)
|
||||
}
|
||||
|
||||
// arm puts a delivered, unclaimed flight in place without any HTTP traffic.
|
||||
func arm(s *Server, id string) *flight {
|
||||
f := &flight{inv: inv(id, time.Minute), state: stRunning, done: make(chan outcome, 1)}
|
||||
@@ -136,16 +151,13 @@ func TestDeclaredOversizeCompletesWithoutPayloadBuffer(t *testing.T) {
|
||||
assert.Equal(t, ErrTypeUnhandled, res.FunctionError)
|
||||
assert.Equal(t, ErrTypeResponseTooLarge, errType(t, res.Payload))
|
||||
|
||||
br := testing.Benchmark(func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
s := New()
|
||||
f := arm(s, "req-1")
|
||||
postTo(s, "response", "req-1", &patterned{n: n}, int64(n))
|
||||
await(t, f.done)
|
||||
}
|
||||
perOp := allocBytesPerOp(8, func() {
|
||||
s := New()
|
||||
f := arm(s, "req-1")
|
||||
postTo(s, "response", "req-1", &patterned{n: n}, int64(n))
|
||||
await(t, f.done)
|
||||
})
|
||||
assert.Less(t, br.AllocedBytesPerOp(), int64(256<<10), "an oversize body must not be buffered")
|
||||
assert.Less(t, perOp, int64(256<<10), "an oversize body must not be buffered")
|
||||
}
|
||||
|
||||
func TestDeclaredOversizeCutShortIsUnknown(t *testing.T) {
|
||||
@@ -209,16 +221,13 @@ func TestChunkedBodyCap(t *testing.T) {
|
||||
})
|
||||
t.Run("peak allocation stays within the cap plus one segment", func(t *testing.T) {
|
||||
for _, n := range []int{MaxResponseBytes, MaxResponseBytes + 1} {
|
||||
br := testing.Benchmark(func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
s := New()
|
||||
f := arm(s, "req-1")
|
||||
postTo(s, "response", "req-1", &patterned{n: n}, -1)
|
||||
await(t, f.done)
|
||||
}
|
||||
perOp := allocBytesPerOp(8, func() {
|
||||
s := New()
|
||||
f := arm(s, "req-1")
|
||||
postTo(s, "response", "req-1", &patterned{n: n}, -1)
|
||||
await(t, f.done)
|
||||
})
|
||||
assert.Less(t, br.AllocedBytesPerOp(), int64(MaxResponseBytes+segmentBytes+64<<10), "body of %d bytes", n)
|
||||
assert.Less(t, perOp, int64(MaxResponseBytes+segmentBytes+64<<10), "body of %d bytes", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -459,6 +459,7 @@ func TestAcknowledgeBootstrap_WaitsForTheReceiptThenRetiresIt(t *testing.T) {
|
||||
// instance's volume carries the source instance's receipt. Acknowledging on one
|
||||
// would destroy the ciphertext this instance still needs.
|
||||
func TestAcknowledgeBootstrap_IgnoresAForeignReceipt(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := map[string]struct{ payloadID, dbID string }{
|
||||
"another payload": {payloadID: "bp-somebody-else", dbID: "db-resolved"},
|
||||
"another instance": {payloadID: testPayloadID, dbID: "db-the-snapshot-source"},
|
||||
@@ -466,6 +467,7 @@ func TestAcknowledgeBootstrap_IgnoresAForeignReceipt(t *testing.T) {
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cp := newFakeControlPlane()
|
||||
cfg := testConfig(t)
|
||||
cfg.DataMount = t.TempDir()
|
||||
@@ -568,6 +570,7 @@ func TestRun_SendsConfiguredIdentifier(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRun_WaitsForDataMountBeforePollingCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
cp := newFakeControlPlane()
|
||||
cp.bootstrapOut = bootstrapOutput(nil)
|
||||
cfg := testConfig(t)
|
||||
@@ -854,6 +857,7 @@ func TestAcknowledgeBootstrap_StopsOnATerminalDenial(t *testing.T) {
|
||||
// window. Reporting that would put a failure on every healthy create's beat, and
|
||||
// then into the reason a create that timed out for some other cause records.
|
||||
func TestAcknowledgeBootstrap_DoesNotReportAnAbsentReceiptAsAFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
cp := newFakeControlPlane()
|
||||
cfg := testConfig(t)
|
||||
cfg.DataMount = t.TempDir()
|
||||
|
||||
@@ -126,6 +126,8 @@ type commander struct {
|
||||
id identity
|
||||
registry commandRegistry
|
||||
wait time.Duration
|
||||
// The wait after a failed poll, pollErrorBackoff unless a test shortens it.
|
||||
pollBackoff time.Duration
|
||||
// The control plane's budget per type, capped by the remaining budget the
|
||||
// issuer stamps on each copy and measured from receipt. IssuedAt is not
|
||||
// compared: the guest clock is not trusted against the host's.
|
||||
@@ -152,12 +154,13 @@ func newCommander(cp controlPlane, registry commandRegistry, wait time.Duration)
|
||||
wait = defaultPollWait
|
||||
}
|
||||
return &commander{
|
||||
cp: cp,
|
||||
registry: registry,
|
||||
wait: wait,
|
||||
budget: controlPlaneBudget,
|
||||
serial: make(chan queuedCommand, serialQueueDepth),
|
||||
inFlight: make(map[string]struct{}),
|
||||
cp: cp,
|
||||
registry: registry,
|
||||
wait: wait,
|
||||
pollBackoff: pollErrorBackoff,
|
||||
budget: controlPlaneBudget,
|
||||
serial: make(chan queuedCommand, serialQueueDepth),
|
||||
inFlight: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +190,7 @@ func (c *commander) Run(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(pollErrorBackoff):
|
||||
case <-time.After(c.pollBackoff):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -158,19 +158,30 @@ func TestCommander_RetainsAndRetriesTheCompletePendingBatch(t *testing.T) {
|
||||
// A poll error must not spin the loop: without a backoff a gateway that is down
|
||||
// would be re-polled at line rate for as long as it stayed down.
|
||||
func TestCommander_BacksOffAfterAFailedPoll(t *testing.T) {
|
||||
t.Parallel()
|
||||
cp := newFakeControlPlane()
|
||||
cp.pollErr = errors.New("gateway unreachable")
|
||||
|
||||
c := newCommander(cp, newCommandRegistry(&fakeEngine{}, &fakeStorage{}), 10*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
// Outlasts the test, so any second poll inside the window is a missing backoff.
|
||||
c.pollBackoff = time.Hour
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() { c.Run(ctx); close(done) }()
|
||||
|
||||
select {
|
||||
case <-cp.polled:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("the commander never polled")
|
||||
}
|
||||
// Without the backoff the loop re-polls within microseconds of the failure.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
// pollErrorBackoff is 5s, so the window admits the first poll and no more.
|
||||
if polls := len(cp.snapshotReplies()); polls > 1 {
|
||||
t.Errorf("polled %d times in 200ms against a broken channel, want 1", polls)
|
||||
if polls := len(cp.snapshotReplies()); polls != 1 {
|
||||
t.Errorf("polled %d times against a broken channel inside the backoff, want 1", polls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +317,7 @@ func TestCommander_ACopyArrivingBeforeTheReplyIsCarriedDoesNotRunAgain(t *testin
|
||||
// A quiesce that waited out its issuer's budget behind another command would
|
||||
// put the engine in backup mode for a snapshot nobody is taking.
|
||||
func TestCommander_AStaleQuiesceNeverTakesAHold(t *testing.T) {
|
||||
t.Parallel()
|
||||
cp := newFakeControlPlane()
|
||||
cp.pollQueue = [][]rdsinstance.Command{
|
||||
{{CommandID: "cmd-apply", Type: rdsinstance.CommandApplyParams}},
|
||||
@@ -351,6 +363,7 @@ func TestCommander_AStaleQuiesceNeverTakesAHold(t *testing.T) {
|
||||
// Engine commands still run one at a time: a parameter apply rewrites the
|
||||
// option file a graceful stop would otherwise race.
|
||||
func TestCommander_AParameterApplyNeverRunsAlongsideAnEngineStop(t *testing.T) {
|
||||
t.Parallel()
|
||||
cp := newFakeControlPlane()
|
||||
cp.pollQueue = [][]rdsinstance.Command{
|
||||
{{CommandID: "cmd-apply", Type: rdsinstance.CommandApplyParams}},
|
||||
@@ -414,6 +427,7 @@ func (c *commander) pendingReplies() []rdsinstance.CommandReply {
|
||||
// its issuer about to give up. A quiesce granted a fresh budget then would take
|
||||
// a hold nobody is left to release.
|
||||
func TestCommander_AQuiesceStampedNearlyOutOfBudgetNeverTakesAHold(t *testing.T) {
|
||||
t.Parallel()
|
||||
cp := newFakeControlPlane()
|
||||
cp.pollQueue = [][]rdsinstance.Command{
|
||||
{{CommandID: "cmd-apply", Type: rdsinstance.CommandApplyParams}},
|
||||
|
||||
@@ -16,8 +16,16 @@ func testLoadConfig(t *testing.T, engine string) Config {
|
||||
if err := os.WriteFile(stamp, []byte(engine+"\n"), 0o444); err != nil {
|
||||
t.Fatalf("write the engine stamp: %v", err)
|
||||
}
|
||||
t.Setenv("RDS_ENGINE_FILE", stamp)
|
||||
cfg := LoadConfig(filepath.Join(t.TempDir(), "absent.env"))
|
||||
// Delivered through the env file rather than t.Setenv, so callers can run in
|
||||
// parallel.
|
||||
envFile := filepath.Join(t.TempDir(), "agent.env")
|
||||
if err := os.WriteFile(envFile, []byte("RDS_ENGINE_FILE="+stamp+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write the env file: %v", err)
|
||||
}
|
||||
cfg := LoadConfig(envFile)
|
||||
if cfg.EngineFile != stamp {
|
||||
t.Fatalf("engine file = %q, want the stamp %q; is RDS_ENGINE_FILE set in the environment?", cfg.EngineFile, stamp)
|
||||
}
|
||||
cfg.EngineCatalog = testEngineCatalogLookup
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -883,6 +883,7 @@ func TestMariaDBProbe_SeparatesRecoveringFromAbsent(t *testing.T) {
|
||||
// mariadbd will not accept makes it exit during startup, so the pidfile names a
|
||||
// process that is gone and the deadline runs.
|
||||
func TestMariaDBProbe_BoundsAStalledClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := mariadbTestProbeConfig(t)
|
||||
cfg.EngineProbeTimeout = 20 * time.Millisecond
|
||||
writePid(t, cfg.EnginePidFile, 4242)
|
||||
@@ -946,6 +947,7 @@ func TestMariaDBProbe_MapsToTheHealthTheControlPlaneReads(t *testing.T) {
|
||||
// the server mid-recovery — a rollback that exists to break a boot loop would
|
||||
// create one, on an instance whose parameters were never at fault.
|
||||
func TestMariaDBProbe_LiveButUnreachableEngineResetsTheRollbackDeadline(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := mariadbTestProbeConfig(t)
|
||||
writePid(t, cfg.EnginePidFile, 4242)
|
||||
probe := newEngineProbe(cfg.EnginePort, mariadbProbeState(cfg,
|
||||
@@ -1152,12 +1154,18 @@ func TestMariaDBEngine_EveryResolvedDynamicValueRenders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Far below the production interval, so a hold test can wait out several polls
|
||||
// cheaply.
|
||||
const testHoldPoll = 5 * time.Millisecond
|
||||
|
||||
// ALTER USER under a held BACKUP STAGE swaps the password in memory and then
|
||||
// waits at its commit, so a rotation issued mid-backup must not reach the
|
||||
// engine until the hold is released.
|
||||
func TestMariaDBEngine_SetPasswordWaitsForTheBackupHoldToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := &recordingRunner{}
|
||||
engine := newTestMariaDBEngine(t, runner.run)
|
||||
engine.holdPoll = testHoldPoll
|
||||
engine.startSess = func(context.Context, command) (engineSession, error) { return &fakeSession{}, nil }
|
||||
if err := engine.Quiesce(context.Background(), "orders-db-automated", time.Minute); err != nil {
|
||||
t.Fatalf("Quiesce: %v", err)
|
||||
@@ -1168,7 +1176,7 @@ func TestMariaDBEngine_SetPasswordWaitsForTheBackupHoldToEnd(t *testing.T) {
|
||||
select {
|
||||
case err := <-rotated:
|
||||
t.Fatalf("SetPassword returned %v while the engine was held for a backup", err)
|
||||
case <-time.After(3 * holdPollInterval):
|
||||
case <-time.After(10 * testHoldPoll):
|
||||
}
|
||||
|
||||
if err := engine.Unquiesce(context.Background()); err != nil {
|
||||
@@ -1190,9 +1198,11 @@ func TestMariaDBEngine_SetPasswordWaitsForTheBackupHoldToEnd(t *testing.T) {
|
||||
// A hold that outlasts the wait fails the rotation before anything is sent, so
|
||||
// the control plane's error is the truth rather than half of it.
|
||||
func TestMariaDBEngine_SetPasswordFailsUntouchedWhenTheHoldOutlastsItsWait(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := &recordingRunner{}
|
||||
engine := newTestMariaDBEngine(t, runner.run)
|
||||
engine.holdWait = 3 * holdPollInterval
|
||||
engine.holdPoll = testHoldPoll
|
||||
engine.holdWait = 3 * testHoldPoll
|
||||
engine.startSess = func(context.Context, command) (engineSession, error) { return &fakeSession{}, nil }
|
||||
if err := engine.Quiesce(context.Background(), "orders-db-automated", time.Minute); err != nil {
|
||||
t.Fatalf("Quiesce: %v", err)
|
||||
@@ -1246,7 +1256,9 @@ func TestMariaDBEngine_SetPasswordBoundsTheStatementByTheRemainingBudget(t *test
|
||||
// A quiesce that cannot get the hold bookkeeping must give up with its command,
|
||||
// not wait on the mutex after its issuer has moved on.
|
||||
func TestMariaDBEngine_QuiesceGivesUpWaitingForTheBookkeepingWithItsContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestMariaDBEngine(t, (&recordingRunner{}).run)
|
||||
engine.holdPoll = testHoldPoll
|
||||
started := 0
|
||||
engine.startSess = func(context.Context, command) (engineSession, error) {
|
||||
started++
|
||||
@@ -1255,7 +1267,7 @@ func TestMariaDBEngine_QuiesceGivesUpWaitingForTheBookkeepingWithItsContext(t *t
|
||||
engine.mu.Lock()
|
||||
defer engine.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*holdPollInterval)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*testHoldPoll)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- engine.Quiesce(ctx, "orders-db-automated", time.Minute) }()
|
||||
|
||||
@@ -83,6 +83,7 @@ func TestEngineProbe_ProbesTheAssignedPort(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecProbeRunner_PreservesTheProbeDeadline(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/safecast"
|
||||
rdsinstance "github.com/mulgadc/spinifex/spinifex/domains/rds/instance"
|
||||
)
|
||||
|
||||
@@ -106,12 +108,29 @@ func (s parameterStore) chownToEngine(path string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chown(path, int(credential.Uid), int(credential.Gid)); err != nil {
|
||||
uid, err := ownerID(credential.Uid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uid of %s: %w", s.osUser, err)
|
||||
}
|
||||
gid, err := ownerID(credential.Gid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gid of %s: %w", s.osUser, err)
|
||||
}
|
||||
if err := os.Chown(path, uid, gid); err != nil {
|
||||
return fmt.Errorf("hand %s to %s: %w", path, s.osUser, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rejects rather than saturates: a clamped ID would hand the file to another
|
||||
// account while the engine still runs as the real one.
|
||||
func ownerID(id uint32) (int, error) {
|
||||
if uint64(id) > math.MaxInt {
|
||||
return 0, fmt.Errorf("%d does not fit an int", id)
|
||||
}
|
||||
return safecast.Uint64ToInt(uint64(id)), nil
|
||||
}
|
||||
|
||||
// Snapshots the installed set as the rollback target, but only once the engine
|
||||
// is running all of it. A set still pending a restart has not been shown to
|
||||
// serve, so promoting it would point the rollback at a config about to fail.
|
||||
|
||||
@@ -385,6 +385,7 @@ func newTestGuard(engine parameterRecovery, probe *engineProbe) *paramGuard {
|
||||
// pg_isready exit 2 is an engine that is not there at all, which after a
|
||||
// parameter change is the boot-loop shape this breaks.
|
||||
func TestParamGuard_RollsBackReportsAndRestartsWhenTheEngineNeverComesUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := &fakeRecovery{restored: true}
|
||||
cp := newFakeControlPlane()
|
||||
guard := newParamGuard(engine, stubProbe(t, 2, nil), cp)
|
||||
@@ -408,6 +409,7 @@ func TestParamGuard_RollsBackReportsAndRestartsWhenTheEngineNeverComesUp(t *test
|
||||
// the file can be replaced and the call still fail. The rollback has happened
|
||||
// either way, and the worse outcome is an instance left down and unreported.
|
||||
func TestParamGuard_ReportsAndRestartsWhenOnlyTheEnforcementFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := &fakeRecovery{restored: true, restoreErr: errors.New("write the TLS enforcement rule: read-only file system")}
|
||||
cp := newFakeControlPlane()
|
||||
guard := newParamGuard(engine, stubProbe(t, 2, nil), cp)
|
||||
@@ -431,6 +433,7 @@ func TestParamGuard_ReportsAndRestartsWhenOnlyTheEnforcementFailed(t *testing.T)
|
||||
// the instance on the set it is already failing under, so restarting it would
|
||||
// only churn a cluster that is down for a reason the rollback cannot fix.
|
||||
func TestParamGuard_DoesNotRestartWhenTheRestoreReplacedNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := &fakeRecovery{restored: false, restoreErr: errors.New("read the last known good parameters: input/output error")}
|
||||
cp := newFakeControlPlane()
|
||||
guard := newParamGuard(engine, stubProbe(t, 2, nil), cp)
|
||||
@@ -457,6 +460,7 @@ func TestParamGuard_DoesNothingWhenTheEngineIsServing(t *testing.T) {
|
||||
// pg_isready exit 1 is a postmaster that is up and replaying WAL. It is making
|
||||
// progress on its own, and restarting it would throw the recovery away.
|
||||
func TestParamGuard_LeavesARecoveringEngineAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := &fakeRecovery{restored: true}
|
||||
guard := newTestGuard(engine, stubProbe(t, 1, nil))
|
||||
// Bounded so the deadline-resetting loop cannot run forever if it stops
|
||||
@@ -473,6 +477,7 @@ func TestParamGuard_LeavesARecoveringEngineAlone(t *testing.T) {
|
||||
// The parameters are only the cause if they differ from the set the engine last
|
||||
// accepted; restarting on an identical set would churn for nothing.
|
||||
func TestParamGuard_DoesNotRestartWhenTheParametersAreAlreadyTheAcceptedSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := &fakeRecovery{restored: false}
|
||||
newTestGuard(engine, stubProbe(t, 2, nil)).Run(t.Context())
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ type quiesceState struct {
|
||||
// Guarded because the expiry timer releases the hold on its own goroutine.
|
||||
mu sync.Mutex
|
||||
held *quiesceHold
|
||||
// How often a waiter looks again; zero means defaultHoldPollInterval.
|
||||
holdPoll time.Duration
|
||||
}
|
||||
|
||||
// The two things every quiesce needs, checked before a session is opened so a
|
||||
@@ -121,14 +123,21 @@ func (q *quiesceState) lockWithin(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for the backup bookkeeping: %w", ctx.Err())
|
||||
case <-time.After(holdPollInterval):
|
||||
case <-time.After(q.holdPollInterval()):
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// How often a statement waiting for a backup hold to end looks again.
|
||||
const holdPollInterval = 100 * time.Millisecond
|
||||
const defaultHoldPollInterval = 100 * time.Millisecond
|
||||
|
||||
func (q *quiesceState) holdPollInterval() time.Duration {
|
||||
if q.holdPoll > 0 {
|
||||
return q.holdPoll
|
||||
}
|
||||
return defaultHoldPollInterval
|
||||
}
|
||||
|
||||
// Runs fn while no backup hold is on the engine, and keeps a new one from
|
||||
// starting until it returns. A hold that outlasts wait fails the call before fn
|
||||
@@ -151,7 +160,7 @@ func (q *quiesceState) outsideHold(ctx context.Context, wait time.Duration, fn f
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
return fmt.Errorf("the engine is still held for a backup after %s", wait)
|
||||
case <-time.After(holdPollInterval):
|
||||
case <-time.After(q.holdPollInterval()):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func localStore(t *testing.T, g *guest, port int) *server.Server {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go ns.Start()
|
||||
ns.Start()
|
||||
if !ns.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("store server did not start")
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/zoneid"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/clustersize"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -254,9 +255,11 @@ type Config struct {
|
||||
Node string `json:"Node" mapstructure:"node"`
|
||||
Host string `json:"Host" mapstructure:"host"` // Unique hostname or IP of this node
|
||||
// AdvertiseIP is the off-host dial target. Empty falls back to Host for backward compat.
|
||||
AdvertiseIP string `json:"AdvertiseIP" mapstructure:"advertise"`
|
||||
Region string `json:"Region" mapstructure:"region"`
|
||||
AZ string `json:"AZ" mapstructure:"az"`
|
||||
AdvertiseIP string `json:"AdvertiseIP" mapstructure:"advertise"`
|
||||
Region string `json:"Region" mapstructure:"region"`
|
||||
AZ string `json:"AZ" mapstructure:"az"`
|
||||
// AZShortCode overrides the Region's AZ ID prefix (the apse2 in apse2-az1). Empty uses AWS's table or the derived code.
|
||||
AZShortCode string `json:"AZShortCode" mapstructure:"az_short_code"`
|
||||
DataDir string `json:"DataDir" mapstructure:"data_dir"`
|
||||
Services []string `json:"Services" mapstructure:"services"` // Which services this node runs locally
|
||||
|
||||
@@ -907,6 +910,9 @@ func validateClusterConfig(cc *ClusterConfig) error {
|
||||
// Range checks only. Both settings are deliberately unbounded above by
|
||||
// anything host-aware: they are operator tunables, and the host cost is
|
||||
// the value times the volume count for the operator to weigh.
|
||||
if c := nodeCfg.AZShortCode; c != "" && !zoneid.ValidShortCode(c) {
|
||||
return fmt.Errorf("config: [nodes.%s] az_short_code=%q must match [a-z0-9]{2,8}; fix it or remove the key to use the Region's default", nodeName, c)
|
||||
}
|
||||
if t := nodeCfg.EBS.DefaultThreads; t < 0 || t > MaxNBDKitThreads {
|
||||
return fmt.Errorf("config: [nodes.%s.ebs] default_threads=%d out of range; use 1-%d, or remove the key for the default of %d", nodeName, t, MaxNBDKitThreads, DefaultNBDKitThreads)
|
||||
}
|
||||
|
||||
@@ -1680,3 +1680,24 @@ func TestNodesRunning(t *testing.T) {
|
||||
assert.Equal(t, []string{"a", "c", "d"}, c.NodesRunning("daemon"))
|
||||
assert.Empty(t, c.NodesRunning("nonesuch"))
|
||||
}
|
||||
|
||||
func TestLoadConfig_AZShortCodeParsedAndInvalidRefused(t *testing.T) {
|
||||
load := func(t *testing.T, code string) (*ClusterConfig, error) {
|
||||
t.Helper()
|
||||
resetViper(t)
|
||||
path := filepath.Join(t.TempDir(), "spinifex.toml")
|
||||
toml := fmt.Sprintf("node = \"n1\"\n\n[nodes.n1]\nregion = \"mulga-lab\"\naz = \"mulga-laba\"\naz_short_code = %q\n", code)
|
||||
require.NoError(t, os.WriteFile(path, []byte(toml), 0600))
|
||||
return LoadConfig(path)
|
||||
}
|
||||
|
||||
cfg, err := load(t, "lab1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "lab1", cfg.Nodes["n1"].AZShortCode)
|
||||
|
||||
for _, bad := range []string{"a", "UPPER", "has-dash", "toolongcode"} {
|
||||
_, err := load(t, bad)
|
||||
require.Error(t, err, bad)
|
||||
assert.Contains(t, err.Error(), "az_short_code")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,16 @@ var (
|
||||
leafKeyBits = 2048
|
||||
)
|
||||
|
||||
// ShortenKeyBitsForTest generates tenant CA and leaf keys at bits for the rest
|
||||
// of one test and restores the previous sizes afterwards. There is one size per
|
||||
// process, so the test must not run in parallel with anything generating keys.
|
||||
func ShortenKeyBitsForTest(tb testing.TB, bits int) {
|
||||
tb.Helper()
|
||||
prevCA, prevLeaf := caKeyBits, leafKeyBits
|
||||
caKeyBits, leafKeyBits = bits, bits
|
||||
tb.Cleanup(func() { caKeyBits, leafKeyBits = prevCA, prevLeaf })
|
||||
}
|
||||
|
||||
const (
|
||||
// tenantCAValidity is long because the root is a one-time trust-store
|
||||
// install on every client device: rotating it invalidates all of them at
|
||||
|
||||
@@ -808,7 +808,7 @@ func TestRequestCertificate_NoTenantCAIsAClientErrorNamingTheCause(t *testing.T)
|
||||
assert.Equal(t, awserrors.ErrorResourceNotFound, code)
|
||||
assert.NotEqual(t, awserrors.ErrorInternalError, code, "a retryable 500 hangs the provider's create")
|
||||
|
||||
assert.Equal(t, 404, awserrors.ErrorLookup[code].HTTPCode)
|
||||
assert.Equal(t, 400, awserrors.LookupErrorMessage("acm", code).HTTPCode, "AWS answers a missing ACM resource with 400")
|
||||
assert.True(t, awserrors.IsTerminal(err), "the provider must fail fast, not retry with backoff")
|
||||
|
||||
assert.Contains(t, message, "tenant CA", "the message must name the missing tenant CA")
|
||||
|
||||
@@ -175,11 +175,12 @@ type InstanceTypeResolver func(accountID, instanceID string) (instanceType strin
|
||||
// NATSInstanceTypeResolver builds the production resolver from a node-identity
|
||||
// checked describe of the one instance. A found instance resolves even with a node
|
||||
// silent; an absence a silent node could hide is a retryable ServiceUnavailable.
|
||||
func NATSInstanceTypeResolver(natsConn *nats.Conn, nodeIDs func() []string) InstanceTypeResolver {
|
||||
// opts reach the describe fan-out; production passes none.
|
||||
func NATSInstanceTypeResolver(natsConn *nats.Conn, nodeIDs func() []string, opts ...ec2instanceapi.DescribeOption) InstanceTypeResolver {
|
||||
return func(accountID, instanceID string) (string, bool, error) {
|
||||
out, err := ec2instanceapi.DescribeInstancesChecked(context.Background(),
|
||||
&ec2.DescribeInstancesInput{InstanceIds: []*string{aws.String(instanceID)}},
|
||||
natsConn, 0, nodeIDs(), accountID)
|
||||
natsConn, 0, nodeIDs(), accountID, opts...)
|
||||
if err != nil {
|
||||
if code, ok := awserrors.ResolveErrorCode(err); ok && code == awserrors.ErrorInvalidInstanceIDNotFound {
|
||||
return "", false, nil
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -206,6 +208,11 @@ func TestInstanceTypeFromReservations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// silentNodeFanoutTimeout bounds the describe fan-out in tests where a node never
|
||||
// answers, so they wait this long instead of the 3s production deadline. Local
|
||||
// replies land in milliseconds, leaving wide margin under -race.
|
||||
const silentNodeFanoutTimeout = 500 * time.Millisecond
|
||||
|
||||
// replyAsNode answers every request on subject with reply, attributed to node.
|
||||
func replyAsNode(t *testing.T, nc *nats.Conn, subject, node string, reply any) {
|
||||
t.Helper()
|
||||
@@ -231,6 +238,7 @@ func replyAsNode(t *testing.T, nc *nats.Conn, subject, node string, reply any) {
|
||||
// Counting replies reads that as every node heard from and the instance as
|
||||
// absent, waving the retype past the cap; the gate must see node-2 is missing.
|
||||
func TestNATSInstanceTypeResolver_RefusesWhenOneNodeAnswersTwiceAndAnotherIsSilent(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newVCPUService(t, Limits{Enabled: true, VCPUs: 8})
|
||||
if err := s.AddVCPU(t.Context(), testAccount, 7); err != nil {
|
||||
t.Fatalf("seed AddVCPU: %v", err)
|
||||
@@ -244,7 +252,8 @@ func TestNATSInstanceTypeResolver_RefusesWhenOneNodeAnswersTwiceAndAnotherIsSile
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
|
||||
resolve := NATSInstanceTypeResolver(nc, func() []string { return []string{"node-1", "node-2"} })
|
||||
resolve := NATSInstanceTypeResolver(nc, func() []string { return []string{"node-1", "node-2"} },
|
||||
ec2instanceapi.WithFanoutTimeout(silentNodeFanoutTimeout))
|
||||
delta, err := s.EnforceRetype(t.Context(), resolve, testAccount, "i-on-node2", "m5.xlarge")
|
||||
if err == nil {
|
||||
t.Fatalf("EnforceRetype = (%d, nil), want a refusal while node-2 is silent", delta)
|
||||
@@ -258,6 +267,7 @@ func TestNATSInstanceTypeResolver_RefusesWhenOneNodeAnswersTwiceAndAnotherIsSile
|
||||
// With every node answering, the resolver finds the instance on node-2 and the
|
||||
// gate judges the retype against the cap.
|
||||
func TestNATSInstanceTypeResolver_FindsInstanceWhenEveryNodeAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newVCPUService(t, Limits{Enabled: true, VCPUs: 8})
|
||||
if err := s.AddVCPU(t.Context(), testAccount, 7); err != nil {
|
||||
t.Fatalf("seed AddVCPU: %v", err)
|
||||
@@ -283,6 +293,7 @@ func TestNATSInstanceTypeResolver_FindsInstanceWhenEveryNodeAnswers(t *testing.T
|
||||
// An instance absent from a complete listing charges nothing and returns no
|
||||
// error, so the daemon rejects the modify with the AWS NotFound error.
|
||||
func TestNATSInstanceTypeResolver_AbsentOnCompleteListingDefersToDaemon(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newVCPUService(t, Limits{Enabled: true, VCPUs: 8})
|
||||
if err := s.AddVCPU(t.Context(), testAccount, 7); err != nil {
|
||||
t.Fatalf("seed AddVCPU: %v", err)
|
||||
@@ -306,6 +317,7 @@ func TestNATSInstanceTypeResolver_AbsentOnCompleteListingDefersToDaemon(t *testi
|
||||
// A retype targets a stopped instance, which the shared stopped bucket serves
|
||||
// whatever the node fan-out does, so a silent node must not block judging it.
|
||||
func TestNATSInstanceTypeResolver_FindsStoppedInstanceWhileANodeIsSilent(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newVCPUService(t, Limits{Enabled: true, VCPUs: 8})
|
||||
if err := s.AddVCPU(t.Context(), testAccount, 7); err != nil {
|
||||
t.Fatalf("seed AddVCPU: %v", err)
|
||||
@@ -320,7 +332,8 @@ func TestNATSInstanceTypeResolver_FindsStoppedInstanceWhileANodeIsSilent(t *test
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
|
||||
resolve := NATSInstanceTypeResolver(nc, func() []string { return []string{"node-1", "node-2"} })
|
||||
resolve := NATSInstanceTypeResolver(nc, func() []string { return []string{"node-1", "node-2"} },
|
||||
ec2instanceapi.WithFanoutTimeout(silentNodeFanoutTimeout))
|
||||
_, err := s.EnforceRetype(t.Context(), resolve, testAccount, "i-stopped", "m5.xlarge")
|
||||
if err == nil || err.Error() != awserrors.ErrorResourceLimitExceeded {
|
||||
t.Fatalf("EnforceRetype err = %v, want %q", err, awserrors.ErrorResourceLimitExceeded)
|
||||
|
||||
@@ -64,9 +64,10 @@ type ComputeRequest struct {
|
||||
Force bool `json:"force,omitempty"`
|
||||
}
|
||||
|
||||
// ComputeResult is what one compute run found and did. Clean means nothing was
|
||||
// blocked and every read completed; it is a snapshot, not evidence of absence.
|
||||
type ComputeResult struct {
|
||||
// Outcome is what one compute or storage run found and did. Clean means nothing
|
||||
// was blocked or pending and every read completed; it is a snapshot, not
|
||||
// evidence of absence.
|
||||
type Outcome struct {
|
||||
AccountID string `json:"account_id"`
|
||||
CorrelationRef string `json:"correlation_ref,omitempty"`
|
||||
Status string `json:"status"`
|
||||
@@ -74,6 +75,12 @@ type ComputeResult struct {
|
||||
Items []Item `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// ComputeResult and StorageResult are the outcomes of the two runs.
|
||||
type (
|
||||
ComputeResult = Outcome
|
||||
StorageResult = Outcome
|
||||
)
|
||||
|
||||
// ComputeCleaner terminates an account's instances and cancels its spot
|
||||
// requests. It is destructive; the caller must hold an accepted account deletion.
|
||||
type ComputeCleaner interface {
|
||||
@@ -331,7 +338,8 @@ func (r *ComputeResult) finish() {
|
||||
r.Status = StatusIncomplete
|
||||
}
|
||||
for _, it := range r.Items {
|
||||
if it.Disposition == DispositionBlocked || it.Disposition == DispositionPending {
|
||||
switch it.Disposition {
|
||||
case DispositionBlocked, DispositionPending, DispositionFailed, DispositionResidue:
|
||||
r.Status = StatusIncomplete
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
package accountcleanup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountinventory"
|
||||
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
|
||||
ec2spotinstance "github.com/mulgadc/spinifex/spinifex/domains/ec2/spotinstance"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
)
|
||||
|
||||
// Storage kinds and the extra reasons the storage run reports.
|
||||
const (
|
||||
KindVolume = "volume"
|
||||
KindTag = "tag"
|
||||
|
||||
// ReasonHolderPresent: the volume record shows an attachment, or a live
|
||||
// instance still maps the volume.
|
||||
ReasonHolderPresent = "holder-present"
|
||||
// ReasonVolumeStateUnproven: the volume is not in a state that proves it is
|
||||
// free, so it is kept.
|
||||
ReasonVolumeStateUnproven = "volume-state-unproven"
|
||||
// ReasonVolumeDeleting: the owner is reclaiming the volume; the record is
|
||||
// not gone yet.
|
||||
ReasonVolumeDeleting = "volume-deleting"
|
||||
|
||||
// Incomplete reasons.
|
||||
ReasonVolumeInventoryIncomplete = "volume-inventory-incomplete"
|
||||
ReasonHolderProofIncomplete = "holder-proof-incomplete"
|
||||
ReasonTagIndexUnread = "tag-index-unread"
|
||||
ReasonTagOwnerUnresolved = "tag-owner-unresolved"
|
||||
)
|
||||
|
||||
// StorageRequest names the account whose volumes and tag residue are cleaned.
|
||||
// Force is accepted and bypasses nothing: a holder, an unproven terminate or an
|
||||
// incomplete inventory always keeps the volume.
|
||||
type StorageRequest struct {
|
||||
AccountID string `json:"account_id"`
|
||||
CorrelationRef string `json:"correlation_ref,omitempty"`
|
||||
Force bool `json:"force,omitempty"`
|
||||
}
|
||||
|
||||
// StorageCleaner deletes an account's free volumes and the tag residue of
|
||||
// instances, volumes and spot requests that no longer exist. It is
|
||||
// destructive; the caller must hold an accepted account deletion.
|
||||
type StorageCleaner interface {
|
||||
CleanupStorage(ctx context.Context, req *StorageRequest) (*StorageResult, error)
|
||||
}
|
||||
|
||||
// VolumeLister is the complete-or-error volume inventory.
|
||||
type VolumeLister interface {
|
||||
VolumeInventory(ctx context.Context, accountID string) ([]accountinventory.VolumeItem, error)
|
||||
}
|
||||
|
||||
// VolumeDeleter sends the existing EC2 volume delete for one volume. It
|
||||
// returns once the owner accepted the delete, not when the backing is reclaimed.
|
||||
type VolumeDeleter interface {
|
||||
DeleteVolume(ctx context.Context, accountID, volumeID string) error
|
||||
}
|
||||
|
||||
// SpotScanner is the spot-request owner's account scan.
|
||||
type SpotScanner interface {
|
||||
ScanAccount(ctx context.Context, accountID string) (*ec2spotinstance.AccountScan, error)
|
||||
}
|
||||
|
||||
var (
|
||||
_ VolumeLister = (*accountinventory.Inventory)(nil)
|
||||
_ StorageCleaner = (*StorageCleanup)(nil)
|
||||
)
|
||||
|
||||
// Resource-ID prefixes whose tag residue the storage run removes.
|
||||
var storageTagPrefixes = []string{"i-", "vol-", "sir-"}
|
||||
|
||||
// StorageCleanup composes the storage owners.
|
||||
type StorageCleanup struct {
|
||||
volumes VolumeLister
|
||||
deleter VolumeDeleter
|
||||
instances InstanceInventory
|
||||
teardown TeardownReader
|
||||
spot SpotScanner
|
||||
tags TagIndexOwner
|
||||
}
|
||||
|
||||
// NewStorage builds a storage cleanup. A nil dependency makes every run fail
|
||||
// before it acts.
|
||||
func NewStorage(volumes VolumeLister, deleter VolumeDeleter, instances InstanceInventory, teardown TeardownReader, spot SpotScanner, tags TagIndexOwner) *StorageCleanup {
|
||||
return &StorageCleanup{volumes: volumes, deleter: deleter, instances: instances, teardown: teardown, spot: spot, tags: tags}
|
||||
}
|
||||
|
||||
// CleanupStorage deletes each volume that is available, shows no attachment and
|
||||
// that no live instance maps, then removes tag-index entries whose owner record
|
||||
// is gone. It never waits for reclaim: a volume the owner is deleting is
|
||||
// reported pending and a rerun converges it. A read that does not complete
|
||||
// deletes nothing.
|
||||
func (c *StorageCleanup) CleanupStorage(ctx context.Context, req *StorageRequest) (*StorageResult, error) {
|
||||
if req == nil || req.AccountID == "" {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorMissingParameter, "The request must specify an account ID.")
|
||||
}
|
||||
if !awsidentifiers.IsAccountID(req.AccountID) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "The account ID must be 12 digits.")
|
||||
}
|
||||
if c == nil || c.volumes == nil || c.deleter == nil || c.instances == nil || c.teardown == nil || c.spot == nil || c.tags == nil {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "Storage cleanup is not fully wired.")
|
||||
}
|
||||
res := &StorageResult{AccountID: req.AccountID, CorrelationRef: req.CorrelationRef}
|
||||
defer res.finish()
|
||||
|
||||
volumes, err := c.volumes.VolumeInventory(ctx, req.AccountID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "CleanupStorage: volume inventory incomplete, nothing deleted", "account_id", req.AccountID, "err", err)
|
||||
res.incomplete(ReasonVolumeInventoryIncomplete)
|
||||
return res, nil
|
||||
}
|
||||
live, err := c.instances.AccountInstances(ctx, req.AccountID)
|
||||
if err != nil || !inventoryUsable(live) {
|
||||
slog.WarnContext(ctx, "CleanupStorage: no complete instance view, nothing deleted", "account_id", req.AccountID, "err", err)
|
||||
res.incomplete(ReasonHolderProofIncomplete)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
outstanding, err := c.teardown.AccountTeardownOutstanding(req.AccountID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "CleanupStorage: teardown unread, nothing deleted", "account_id", req.AccountID, "err", err)
|
||||
res.incomplete(ReasonHolderProofIncomplete)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
c.deleteFreeVolumes(ctx, req.AccountID, volumes, live, outstanding, res)
|
||||
c.clearTagResidue(ctx, req.AccountID, live, outstanding, res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// deleteFreeVolumes deletes the free volumes, re-reads the inventory and
|
||||
// reports each volume from that re-read.
|
||||
func (c *StorageCleanup) deleteFreeVolumes(ctx context.Context, accountID string, volumes []accountinventory.VolumeItem, live *ec2.DescribeInstancesOutput, outstanding []ec2instance.OutstandingInstance, res *StorageResult) {
|
||||
issued := map[string]bool{}
|
||||
blocked := map[string]string{}
|
||||
unproven := terminatedTeardownOutstanding(outstanding)
|
||||
for _, v := range volumes {
|
||||
switch {
|
||||
case v.State == ec2.VolumeStateDeleting:
|
||||
// The owner is already reclaiming it; the re-read reports it.
|
||||
case v.AttachedInstance != "" || volumeMappedByLiveInstance(live, v.VolumeID):
|
||||
blocked[v.VolumeID] = ReasonHolderPresent
|
||||
case unproven:
|
||||
// A terminated record whose teardown is unfinished may still have a
|
||||
// QEMU holding a volume this record no longer names.
|
||||
blocked[v.VolumeID] = ReasonTerminationUnproven
|
||||
case v.State != ec2.VolumeStateAvailable:
|
||||
blocked[v.VolumeID] = ReasonVolumeStateUnproven
|
||||
default:
|
||||
if err := c.deleter.DeleteVolume(ctx, accountID, v.VolumeID); err != nil {
|
||||
slog.WarnContext(ctx, "CleanupStorage: volume delete refused", "account_id", accountID, "volume_id", v.VolumeID, "err", err)
|
||||
if code, ok := awserrors.ResolveErrorCode(err); ok && code == awserrors.ErrorVolumeInUse {
|
||||
blocked[v.VolumeID] = ReasonHolderPresent
|
||||
continue
|
||||
}
|
||||
res.Items = append(res.Items, Item{ResourceID: v.VolumeID, Kind: KindVolume, State: v.State, Disposition: DispositionFailed, Reason: ReasonOwnerDeleteFailed})
|
||||
continue
|
||||
}
|
||||
issued[v.VolumeID] = true
|
||||
}
|
||||
}
|
||||
|
||||
after, err := c.volumes.VolumeInventory(ctx, accountID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "CleanupStorage: volume re-read failed", "account_id", accountID, "err", err)
|
||||
res.incomplete(ReasonVolumeInventoryIncomplete)
|
||||
for _, v := range volumes {
|
||||
if issued[v.VolumeID] || v.State == ec2.VolumeStateDeleting {
|
||||
res.Items = append(res.Items, Item{ResourceID: v.VolumeID, Kind: KindVolume, State: v.State, Disposition: DispositionPending, Reason: ReasonAbsenceUnproven})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
present := map[string]accountinventory.VolumeItem{}
|
||||
for _, v := range after {
|
||||
present[v.VolumeID] = v
|
||||
}
|
||||
initial := map[string]bool{}
|
||||
for _, v := range volumes {
|
||||
initial[v.VolumeID] = true
|
||||
}
|
||||
for _, v := range after {
|
||||
if !initial[v.VolumeID] {
|
||||
res.Items = append(res.Items, Item{ResourceID: v.VolumeID, Kind: KindVolume, State: v.State, Disposition: DispositionPending, Reason: ReasonAppearedDuringCleanup})
|
||||
}
|
||||
}
|
||||
for _, v := range volumes {
|
||||
now, still := present[v.VolumeID]
|
||||
switch {
|
||||
case !issued[v.VolumeID] && v.State != ec2.VolumeStateDeleting:
|
||||
continue
|
||||
case !still:
|
||||
res.Items = append(res.Items, Item{ResourceID: v.VolumeID, Kind: KindVolume, Disposition: DispositionDeleted})
|
||||
case now.State == ec2.VolumeStateDeleting:
|
||||
res.Items = append(res.Items, Item{ResourceID: v.VolumeID, Kind: KindVolume, State: now.State, Disposition: DispositionPending, Reason: ReasonVolumeDeleting})
|
||||
default:
|
||||
res.Items = append(res.Items, Item{ResourceID: v.VolumeID, Kind: KindVolume, State: now.State, Disposition: DispositionFailed, Reason: ReasonStillPresent})
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, reason := range blocked {
|
||||
res.Items = append(res.Items, Item{ResourceID: id, Kind: KindVolume, Disposition: DispositionBlocked, Reason: reason})
|
||||
}
|
||||
}
|
||||
|
||||
// terminatedTeardownOutstanding reports whether an instance whose record says
|
||||
// terminated still has unfinished teardown. Its mappings are gone from the
|
||||
// live listing, so no volume can be shown free of it.
|
||||
func terminatedTeardownOutstanding(outstanding []ec2instance.OutstandingInstance) bool {
|
||||
return slices.ContainsFunc(outstanding, func(o ec2instance.OutstandingInstance) bool {
|
||||
return o.State == ec2.InstanceStateNameTerminated
|
||||
})
|
||||
}
|
||||
|
||||
// volumeMappedByLiveInstance reports whether a live instance's block device
|
||||
// mappings name the volume. A volume record's own attachment fields can drift,
|
||||
// so this is checked as well; terminated instances are not in the listing.
|
||||
func volumeMappedByLiveInstance(live *ec2.DescribeInstancesOutput, volumeID string) bool {
|
||||
for _, reservation := range live.Reservations {
|
||||
for _, in := range reservation.Instances {
|
||||
if in.State != nil && aws.StringValue(in.State.Name) == ec2.InstanceStateNameTerminated {
|
||||
continue
|
||||
}
|
||||
for _, bdm := range in.BlockDeviceMappings {
|
||||
if bdm != nil && bdm.Ebs != nil && aws.StringValue(bdm.Ebs.VolumeId) == volumeID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// clearTagResidue removes the tag entries of instances, volumes and spot
|
||||
// requests whose owner re-read resolved and holds no record. An owner that did
|
||||
// not resolve keeps its entries and the run incomplete.
|
||||
func (c *StorageCleanup) clearTagResidue(ctx context.Context, accountID string, live *ec2.DescribeInstancesOutput, outstanding []ec2instance.OutstandingInstance, res *StorageResult) {
|
||||
tagged, err := c.tags.ScanAccountResourceIDs(ctx, accountID, storageTagPrefixes...)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "CleanupStorage: tag index read failed", "account_id", accountID, "err", err)
|
||||
res.incomplete(ReasonTagIndexUnread)
|
||||
return
|
||||
}
|
||||
owned, resolved := c.ownedResourceIDs(ctx, accountID, live, outstanding)
|
||||
var cleared []string
|
||||
for _, id := range tagged {
|
||||
prefix := tagPrefix(id)
|
||||
switch {
|
||||
case !resolved[prefix]:
|
||||
res.incomplete(ReasonTagOwnerUnresolved)
|
||||
res.Items = append(res.Items, Item{ResourceID: id, Kind: KindTag, Disposition: DispositionResidue, Reason: ReasonOwnerRecordsUnresolved})
|
||||
case owned[id]:
|
||||
// The resource still exists; its tags are not residue.
|
||||
default:
|
||||
if err := c.tags.DeleteAllTags(ctx, accountID, id); err != nil {
|
||||
slog.WarnContext(ctx, "CleanupStorage: tag clear failed", "account_id", accountID, "resource_id", id, "err", err)
|
||||
res.incomplete(ReasonOwnerDeleteFailed)
|
||||
res.Items = append(res.Items, Item{ResourceID: id, Kind: KindTag, Disposition: DispositionFailed, Reason: ReasonOwnerDeleteFailed})
|
||||
continue
|
||||
}
|
||||
cleared = append(cleared, id)
|
||||
}
|
||||
}
|
||||
if len(cleared) == 0 {
|
||||
return
|
||||
}
|
||||
after, err := c.tags.ScanAccountResourceIDs(ctx, accountID, storageTagPrefixes...)
|
||||
if err != nil {
|
||||
res.incomplete(ReasonTagIndexUnread)
|
||||
}
|
||||
for _, id := range cleared {
|
||||
switch {
|
||||
case err != nil:
|
||||
res.Items = append(res.Items, Item{ResourceID: id, Kind: KindTag, Disposition: DispositionPending, Reason: ReasonAbsenceUnproven})
|
||||
case slices.Contains(after, id):
|
||||
res.Items = append(res.Items, Item{ResourceID: id, Kind: KindTag, Disposition: DispositionFailed, Reason: ReasonStillPresent})
|
||||
default:
|
||||
res.Items = append(res.Items, Item{ResourceID: id, Kind: KindTag, Disposition: DispositionDeleted})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ownedResourceIDs reads, per prefix, the IDs whose owner still holds a record,
|
||||
// and whether that read completed. Instances are owned while listed live or
|
||||
// while their teardown is unfinished; a missing teardown record is not proof
|
||||
// of completion, but tags of an instance with no live record are residue.
|
||||
func (c *StorageCleanup) ownedResourceIDs(ctx context.Context, accountID string, live *ec2.DescribeInstancesOutput, outstanding []ec2instance.OutstandingInstance) (map[string]bool, map[string]bool) {
|
||||
owned := map[string]bool{}
|
||||
resolved := map[string]bool{}
|
||||
|
||||
volumes, err := c.volumes.VolumeInventory(ctx, accountID)
|
||||
if err == nil {
|
||||
resolved["vol-"] = true
|
||||
for _, v := range volumes {
|
||||
owned[v.VolumeID] = true
|
||||
}
|
||||
}
|
||||
scan, err := c.spot.ScanAccount(ctx, accountID)
|
||||
if err == nil {
|
||||
resolved["sir-"] = true
|
||||
for _, r := range slices.Concat(scan.Active, scan.Terminal) {
|
||||
owned[r.RequestID] = true
|
||||
}
|
||||
}
|
||||
resolved["i-"] = true
|
||||
for _, o := range outstanding {
|
||||
owned[o.InstanceID] = true
|
||||
}
|
||||
for _, reservation := range live.Reservations {
|
||||
for _, in := range reservation.Instances {
|
||||
owned[aws.StringValue(in.InstanceId)] = true
|
||||
}
|
||||
}
|
||||
return owned, resolved
|
||||
}
|
||||
|
||||
func tagPrefix(id string) string {
|
||||
for _, prefix := range storageTagPrefixes {
|
||||
if strings.HasPrefix(id, prefix) {
|
||||
return prefix
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package accountcleanup
|
||||
|
||||
//test:in-package — the storage owners are fakes so each test controls the
|
||||
// read or delete that fails and can assert no delete happened.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountinventory"
|
||||
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
|
||||
ec2spotinstance "github.com/mulgadc/spinifex/spinifex/domains/ec2/spotinstance"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
freeVol = "vol-0aaaaaaaaaaaaaaa1"
|
||||
attachedVol = "vol-0aaaaaaaaaaaaaaa2"
|
||||
mappedVol = "vol-0aaaaaaaaaaaaaaa3"
|
||||
deletingVol = "vol-0aaaaaaaaaaaaaaa4"
|
||||
driftedVol = "vol-0aaaaaaaaaaaaaaa5"
|
||||
)
|
||||
|
||||
type fakeVolumes struct {
|
||||
vols []accountinventory.VolumeItem
|
||||
// failAt makes the nth read (1-based) fail; 0 means never.
|
||||
failAt int
|
||||
reads int
|
||||
}
|
||||
|
||||
func (f *fakeVolumes) VolumeInventory(context.Context, string) ([]accountinventory.VolumeItem, error) {
|
||||
f.reads++
|
||||
if f.failAt == f.reads || f.failAt < 0 {
|
||||
return nil, errors.New("volume store unreadable")
|
||||
}
|
||||
return slices.Clone(f.vols), nil
|
||||
}
|
||||
|
||||
// fakeDeleter acts on the volume fake: reclaim "gone" removes the record,
|
||||
// "deleting" marks it, anything else leaves it.
|
||||
type fakeDeleter struct {
|
||||
store *fakeVolumes
|
||||
reclaim string
|
||||
errFor map[string]error
|
||||
deleted []string
|
||||
onDelete func()
|
||||
}
|
||||
|
||||
func (f *fakeDeleter) DeleteVolume(_ context.Context, _, id string) error {
|
||||
if err := f.errFor[id]; err != nil {
|
||||
return err
|
||||
}
|
||||
f.deleted = append(f.deleted, id)
|
||||
if f.onDelete != nil {
|
||||
f.onDelete()
|
||||
}
|
||||
f.store.vols = slices.DeleteFunc(f.store.vols, func(v accountinventory.VolumeItem) bool {
|
||||
if v.VolumeID != id {
|
||||
return false
|
||||
}
|
||||
return f.reclaim == "gone"
|
||||
})
|
||||
if f.reclaim == "deleting" {
|
||||
for i := range f.store.vols {
|
||||
if f.store.vols[i].VolumeID == id {
|
||||
f.store.vols[i].State = ec2.VolumeStateDeleting
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeSpotScan struct {
|
||||
scan *ec2spotinstance.AccountScan
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSpotScan) ScanAccount(context.Context, string) (*ec2spotinstance.AccountScan, error) {
|
||||
return f.scan, f.err
|
||||
}
|
||||
|
||||
type fakeTags struct {
|
||||
ids []string
|
||||
scanErr error
|
||||
rescanErr error
|
||||
deleteErr map[string]error
|
||||
scans int
|
||||
deleted []string
|
||||
keep map[string]bool // ids a delete does not actually remove
|
||||
}
|
||||
|
||||
func (f *fakeTags) ScanAccountResourceIDs(context.Context, string, ...string) ([]string, error) {
|
||||
f.scans++
|
||||
if f.scanErr != nil {
|
||||
return nil, f.scanErr
|
||||
}
|
||||
if f.scans > 1 && f.rescanErr != nil {
|
||||
return nil, f.rescanErr
|
||||
}
|
||||
return slices.Clone(f.ids), nil
|
||||
}
|
||||
|
||||
func (f *fakeTags) DeleteAllTags(_ context.Context, _, id string) error {
|
||||
if err := f.deleteErr[id]; err != nil {
|
||||
return err
|
||||
}
|
||||
f.deleted = append(f.deleted, id)
|
||||
if !f.keep[id] {
|
||||
f.ids = slices.DeleteFunc(f.ids, func(s string) bool { return s == id })
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func volume(id, state, attached string) accountinventory.VolumeItem {
|
||||
return accountinventory.VolumeItem{VolumeID: id, State: state, AttachedInstance: attached}
|
||||
}
|
||||
|
||||
type storageRig struct {
|
||||
volumes *fakeVolumes
|
||||
deleter *fakeDeleter
|
||||
instances *fakeInventory
|
||||
teardown *fakeTeardown
|
||||
spot *fakeSpotScan
|
||||
tags *fakeTags
|
||||
}
|
||||
|
||||
func newStorageRig() *storageRig {
|
||||
store := &fakeVolumes{vols: []accountinventory.VolumeItem{volume(freeVol, ec2.VolumeStateAvailable, "")}}
|
||||
return &storageRig{
|
||||
volumes: store,
|
||||
deleter: &fakeDeleter{store: store, reclaim: "gone"},
|
||||
instances: &fakeInventory{out: listing(nil)},
|
||||
teardown: &fakeTeardown{},
|
||||
spot: &fakeSpotScan{scan: &ec2spotinstance.AccountScan{}},
|
||||
tags: &fakeTags{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *storageRig) cleanup() *StorageCleanup {
|
||||
return NewStorage(r.volumes, r.deleter, r.instances, r.teardown, r.spot, r.tags)
|
||||
}
|
||||
|
||||
func (r *storageRig) run(t *testing.T, force bool) *StorageResult {
|
||||
t.Helper()
|
||||
res, err := r.cleanup().CleanupStorage(t.Context(), &StorageRequest{AccountID: testAccount, Force: force})
|
||||
require.NoError(t, err)
|
||||
return res
|
||||
}
|
||||
|
||||
func mapping(volumeID string) *ec2.Instance {
|
||||
return &ec2.Instance{
|
||||
InstanceId: aws.String(runningID),
|
||||
State: &ec2.InstanceState{Name: aws.String(ec2.InstanceStateNameRunning)},
|
||||
BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{{Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String(volumeID)}}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStorage_DeletesFreeVolume(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
res := rig.run(t, false)
|
||||
|
||||
assert.Equal(t, []string{freeVol}, rig.deleter.deleted)
|
||||
assert.Equal(t, DispositionDeleted, itemFor(res, freeVol).Disposition)
|
||||
assert.Equal(t, StatusClean, res.Status)
|
||||
}
|
||||
|
||||
// ADR-0003:S7. A read the run cannot complete deletes nothing.
|
||||
func TestS7_CleanupStorageUnprovenReadDeletesNothing(t *testing.T) {
|
||||
cases := map[string]func(*storageRig){
|
||||
"volume inventory error": func(r *storageRig) { r.volumes.failAt = -1 },
|
||||
"instance inventory error": func(r *storageRig) {
|
||||
r.instances.err = errors.New("incomplete listing")
|
||||
},
|
||||
"unidentified instance entry": func(r *storageRig) {
|
||||
r.instances.out = &ec2.DescribeInstancesOutput{Reservations: []*ec2.Reservation{{Instances: []*ec2.Instance{nil}}}}
|
||||
},
|
||||
}
|
||||
for name, arrange := range cases {
|
||||
for _, force := range []bool{false, true} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
arrange(rig)
|
||||
|
||||
res := rig.run(t, force)
|
||||
|
||||
assert.Empty(t, rig.deleter.deleted, "ADR-0003:S7: an unproven read must delete nothing (force=%v)", force)
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
assert.NotEmpty(t, res.IncompleteReasons)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ADR-0003:S7. A volume with an attachment, a live mapping or an unproven
|
||||
// state is never deleted, whatever Force says. The drifted record has no state
|
||||
// at all, as a running root volume's can.
|
||||
func TestS7_CleanupStorageHeldVolumeIsNeverDeleted(t *testing.T) {
|
||||
for _, force := range []bool{false, true} {
|
||||
rig := newStorageRig()
|
||||
rig.volumes.vols = []accountinventory.VolumeItem{
|
||||
volume(attachedVol, ec2.VolumeStateInUse, runningID),
|
||||
volume(mappedVol, ec2.VolumeStateAvailable, ""),
|
||||
volume(driftedVol, "", ""),
|
||||
}
|
||||
rig.instances.out = &ec2.DescribeInstancesOutput{Reservations: []*ec2.Reservation{{Instances: []*ec2.Instance{mapping(mappedVol)}}}}
|
||||
|
||||
res := rig.run(t, force)
|
||||
|
||||
assert.Empty(t, rig.deleter.deleted, "ADR-0003:S7: a held volume must not be deleted (force=%v)", force)
|
||||
for _, id := range []string{attachedVol, mappedVol} {
|
||||
assert.Equal(t, ReasonHolderPresent, itemFor(res, id).Reason, id)
|
||||
}
|
||||
assert.Equal(t, ReasonVolumeStateUnproven, itemFor(res, driftedVol).Reason)
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// A mapping on a terminated instance is stale; it does not hold the volume.
|
||||
func TestCleanupStorage_TerminatedMappingDoesNotHold(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
stale := mapping(freeVol)
|
||||
stale.State.Name = aws.String(ec2.InstanceStateNameTerminated)
|
||||
rig.instances.out = &ec2.DescribeInstancesOutput{Reservations: []*ec2.Reservation{{Instances: []*ec2.Instance{stale}}}}
|
||||
|
||||
rig.run(t, false)
|
||||
|
||||
assert.Equal(t, []string{freeVol}, rig.deleter.deleted)
|
||||
}
|
||||
|
||||
// The delete path re-checks the holder; its refusal is a held volume, not a failure.
|
||||
func TestCleanupStorage_OwnerRefusalIsHolderPresent(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
rig.deleter.errFor = map[string]error{freeVol: errors.New(awserrors.ErrorVolumeInUse)}
|
||||
|
||||
res := rig.run(t, false)
|
||||
|
||||
assert.Equal(t, ReasonHolderPresent, itemFor(res, freeVol).Reason)
|
||||
assert.Equal(t, DispositionBlocked, itemFor(res, freeVol).Disposition)
|
||||
|
||||
rig = newStorageRig()
|
||||
rig.deleter.errFor = map[string]error{freeVol: errors.New("store down")}
|
||||
res = rig.run(t, false)
|
||||
assert.Equal(t, DispositionFailed, itemFor(res, freeVol).Disposition)
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
}
|
||||
|
||||
func TestCleanupStorage_DeletingVolumeIsPendingAndNotRedeleted(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
rig.volumes.vols = []accountinventory.VolumeItem{volume(deletingVol, ec2.VolumeStateDeleting, "")}
|
||||
|
||||
res := rig.run(t, false)
|
||||
|
||||
assert.Empty(t, rig.deleter.deleted)
|
||||
assert.Equal(t, DispositionPending, itemFor(res, deletingVol).Disposition)
|
||||
assert.Equal(t, ReasonVolumeDeleting, itemFor(res, deletingVol).Reason)
|
||||
assert.Len(t, res.Items, 1, "one outcome for the volume, not a second blocked entry")
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
}
|
||||
|
||||
// ADR-0003:S8. The call returns with a pending outcome while the owner
|
||||
// reclaims; it re-reads once and does not poll for the record to go.
|
||||
func TestS8_CleanupStorageReturnsWhileReclaimPending(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
rig.deleter.reclaim = "deleting"
|
||||
|
||||
res := rig.run(t, false)
|
||||
|
||||
assert.Equal(t, DispositionPending, itemFor(res, freeVol).Disposition)
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
assert.Equal(t, []string{freeVol}, rig.deleter.deleted, "deleted once, not retried inside the call")
|
||||
assert.Equal(t, 3, rig.volumes.reads, "before, after the delete, and for tag ownership; no polling")
|
||||
}
|
||||
|
||||
func TestCleanupStorage_RereadFailureKeepsDeletePending(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
rig.volumes.failAt = 2
|
||||
|
||||
res := rig.run(t, false)
|
||||
|
||||
assert.Equal(t, DispositionPending, itemFor(res, freeVol).Disposition)
|
||||
assert.Equal(t, ReasonAbsenceUnproven, itemFor(res, freeVol).Reason)
|
||||
assert.Contains(t, res.IncompleteReasons, ReasonVolumeInventoryIncomplete)
|
||||
}
|
||||
|
||||
func TestCleanupStorage_ClearsTagResidueOfMissingOwners(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
rig.volumes.vols = nil
|
||||
rig.spot.scan = &ec2spotinstance.AccountScan{Terminal: []ec2spotinstance.AccountSpotRequest{{RequestID: "sir-closed", State: ec2.SpotInstanceStateClosed}}}
|
||||
rig.tags.ids = []string{"i-0gone", "vol-0gone", "sir-0gone", "sir-closed"}
|
||||
|
||||
res := rig.run(t, false)
|
||||
|
||||
assert.ElementsMatch(t, []string{"i-0gone", "vol-0gone", "sir-0gone"}, rig.tags.deleted)
|
||||
for _, id := range rig.tags.deleted {
|
||||
assert.Equal(t, DispositionDeleted, itemFor(res, id).Disposition)
|
||||
}
|
||||
assert.Nil(t, itemFor(res, "sir-closed"), "a request that still has a record keeps its tags")
|
||||
assert.Equal(t, StatusClean, res.Status)
|
||||
}
|
||||
|
||||
func TestCleanupStorage_KeepsTagsOfResourcesThatStillExist(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
rig.volumes.vols = []accountinventory.VolumeItem{volume(attachedVol, ec2.VolumeStateInUse, runningID)}
|
||||
rig.instances.out = listing(map[string]string{runningID: ec2.InstanceStateNameRunning})
|
||||
rig.teardown.outstanding = nil
|
||||
rig.tags.ids = []string{attachedVol, runningID}
|
||||
|
||||
rig.run(t, false)
|
||||
|
||||
assert.Empty(t, rig.tags.deleted)
|
||||
}
|
||||
|
||||
// An owner that cannot be read keeps its entries; absence is not shown.
|
||||
func TestS7_CleanupStorageUnresolvedOwnerKeepsItsTags(t *testing.T) {
|
||||
for _, force := range []bool{false, true} {
|
||||
rig := newStorageRig()
|
||||
rig.volumes.vols = nil
|
||||
rig.volumes.failAt = 3 // the tag-ownership read
|
||||
rig.spot.err = errors.New("spot scan failed")
|
||||
rig.tags.ids = []string{"vol-0x", "sir-0x"}
|
||||
|
||||
res := rig.run(t, force)
|
||||
|
||||
assert.Empty(t, rig.tags.deleted, "ADR-0003:S7: unresolved owners keep their tag entries (force=%v)", force)
|
||||
assert.Contains(t, res.IncompleteReasons, ReasonTagOwnerUnresolved)
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStorage_TagDeleteFailureKeepsRunIncomplete(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
rig.volumes.vols = nil
|
||||
rig.tags.ids = []string{"vol-0gone"}
|
||||
rig.tags.deleteErr = map[string]error{"vol-0gone": errors.New("tag store down")}
|
||||
|
||||
res := rig.run(t, false)
|
||||
|
||||
assert.Equal(t, DispositionFailed, itemFor(res, "vol-0gone").Disposition)
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
}
|
||||
|
||||
func TestCleanupStorage_TagStillPresentAfterDeleteIsFailed(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
rig.volumes.vols = nil
|
||||
rig.tags.ids = []string{"vol-0gone"}
|
||||
rig.tags.keep = map[string]bool{"vol-0gone": true}
|
||||
|
||||
res := rig.run(t, false)
|
||||
|
||||
assert.Equal(t, DispositionFailed, itemFor(res, "vol-0gone").Disposition)
|
||||
assert.Equal(t, ReasonStillPresent, itemFor(res, "vol-0gone").Reason)
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
}
|
||||
|
||||
func TestCleanupStorage_TagRescanFailureIsPending(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
rig.volumes.vols = nil
|
||||
rig.tags.ids = []string{"vol-0gone"}
|
||||
rig.tags.rescanErr = errors.New("rescan failed")
|
||||
|
||||
res := rig.run(t, false)
|
||||
|
||||
assert.Equal(t, DispositionPending, itemFor(res, "vol-0gone").Disposition)
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
}
|
||||
|
||||
func TestCleanupStorage_UnreadableTagIndexKeepsRunIncomplete(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
rig.tags.scanErr = errors.New("tag index unreadable")
|
||||
|
||||
res := rig.run(t, false)
|
||||
|
||||
assert.Contains(t, res.IncompleteReasons, ReasonTagIndexUnread)
|
||||
assert.Equal(t, DispositionDeleted, itemFor(res, freeVol).Disposition, "volumes are not held up by the tag index")
|
||||
}
|
||||
|
||||
func TestCleanupStorage_RejectsBadRequestAndNilDependency(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
c := rig.cleanup()
|
||||
for _, req := range []*StorageRequest{nil, {}, {AccountID: "123"}} {
|
||||
_, err := c.CleanupStorage(t.Context(), req)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
_, err := NewStorage(rig.volumes, rig.deleter, rig.instances, rig.teardown, rig.spot, nil).
|
||||
CleanupStorage(t.Context(), &StorageRequest{AccountID: testAccount})
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, rig.deleter.deleted)
|
||||
assert.Zero(t, rig.volumes.reads, "nothing is read before the wiring is proven")
|
||||
}
|
||||
|
||||
// ADR-0003:S7. Unreadable teardown deletes no volume; a terminated record with
|
||||
// unfinished teardown may still have a QEMU holding a volume no record names.
|
||||
func TestS7_CleanupStorageUnprovenTeardownDeletesNoVolume(t *testing.T) {
|
||||
cases := map[string]func(*storageRig){
|
||||
"teardown unreadable": func(r *storageRig) { r.teardown.err = errors.New("store down") },
|
||||
"terminated record with failed teardown": func(r *storageRig) {
|
||||
r.teardown.outstanding = []ec2instance.OutstandingInstance{{InstanceID: runningID, State: ec2.InstanceStateNameTerminated, Teardown: map[string]string{"qemu": "failed"}}}
|
||||
},
|
||||
}
|
||||
for name, arrange := range cases {
|
||||
for _, force := range []bool{false, true} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
arrange(rig)
|
||||
|
||||
res := rig.run(t, force)
|
||||
|
||||
assert.Empty(t, rig.deleter.deleted, "ADR-0003:S7: unproven teardown must not allow a volume delete (force=%v)", force)
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A volume that first shows up in the re-read stays visible.
|
||||
func TestCleanupStorage_VolumeAppearingDuringCleanupIsReported(t *testing.T) {
|
||||
rig := newStorageRig()
|
||||
rig.deleter.reclaim = "gone"
|
||||
rig.deleter.onDelete = func() {
|
||||
rig.volumes.vols = append(rig.volumes.vols, volume(deletingVol, ec2.VolumeStateDeleting, ""))
|
||||
}
|
||||
|
||||
res := rig.run(t, false)
|
||||
|
||||
item := itemFor(res, deletingVol)
|
||||
require.NotNil(t, item)
|
||||
assert.Equal(t, DispositionPending, item.Disposition)
|
||||
assert.Equal(t, ReasonAppearedDuringCleanup, item.Reason)
|
||||
assert.Equal(t, StatusIncomplete, res.Status)
|
||||
}
|
||||
@@ -202,7 +202,7 @@ var ec2Actions = map[string]ec2Action{
|
||||
return ec2instanceapi.DescribeInstanceTypes(ctx, input, c.NATS, c.ExpectedNodes, c.NodeIDs, accountID)
|
||||
}),
|
||||
"DescribeInstanceTypeOfferings": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceTypeOfferingsInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceTypeOfferings(ctx, input, c.NATS, c.ExpectedNodes, accountID, c.Region, c.AZ)
|
||||
return ec2instanceapi.DescribeInstanceTypeOfferings(ctx, input, c.NATS, c.ExpectedNodes, accountID, c.Region, c.AZ, c.ZoneIDs)
|
||||
}),
|
||||
"DescribeInstanceStatus": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceStatusInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceStatus(ctx, input, c.NATS, c.ActiveNodes(ctx), accountID, c.AZ, c.InstanceStatus)
|
||||
@@ -290,7 +290,7 @@ var ec2Actions = map[string]ec2Action{
|
||||
return ec2zoneapi.DescribeRegions(input, c.Region, c.Endpoint)
|
||||
}),
|
||||
"DescribeAvailabilityZones": ec2Handler(func(ctx context.Context, input *ec2.DescribeAvailabilityZonesInput, c *call, accountID string) (any, error) {
|
||||
return ec2zoneapi.DescribeAvailabilityZones(input, c.Region, c.AZ)
|
||||
return ec2zoneapi.DescribeAvailabilityZones(ctx, input, c.Region, c.AZ, c.ZoneIDs)
|
||||
}),
|
||||
"DescribeVolumes": ec2Handler(func(ctx context.Context, input *ec2.DescribeVolumesInput, c *call, accountID string) (any, error) {
|
||||
if err := ec2volumeapi.ValidateDescribeVolumesInput(input); err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
ec2zoneapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/zone"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -37,6 +38,8 @@ type Deps struct {
|
||||
ExpectedNodes int
|
||||
NodeIDs []string
|
||||
AZ string
|
||||
// ZoneIDs supplies the pinned availability-zone IDs every zone-reporting call reads.
|
||||
ZoneIDs ec2zoneapi.IDSource
|
||||
// Endpoint is the gateway's own dialable base URL DescribeRegions advertises.
|
||||
Endpoint string
|
||||
InstanceStatus ec2instanceapi.StatusSynthesis
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -69,9 +70,15 @@ func formRequest(body string) *http.Request {
|
||||
func newTestHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
// A daemon that answers every EC2 subject with an empty result, so each
|
||||
// action reaches its domain call and returns instead of waiting.
|
||||
sub, err := nc.Subscribe("ec2.>", func(m *nats.Msg) { _ = m.Respond([]byte("{}")) })
|
||||
// A daemon that answers every EC2 subject with an empty result as the one
|
||||
// configured node, so each action reaches its domain call and returns, and
|
||||
// identity fan-outs settle on its reply instead of waiting out the deadline.
|
||||
sub, err := nc.Subscribe("ec2.>", func(m *nats.Msg) {
|
||||
reply := nats.NewMsg(m.Reply)
|
||||
reply.Header.Set(natsmsg.NodeIDHeader, "node-1")
|
||||
reply.Data = []byte("{}")
|
||||
_ = m.RespondMsg(reply)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
|
||||
|
||||
@@ -38,8 +38,16 @@ func subscribeAccountRecords(t *testing.T, nc *nats.Conn, data []byte) {
|
||||
|
||||
const strictAccount = "123456789012"
|
||||
|
||||
// strictFanoutWindow is the collect window: strict mode always waits it out,
|
||||
// so it is short, yet still far above a loopback reply's round trip.
|
||||
const strictFanoutWindow = 500 * time.Millisecond
|
||||
|
||||
func strictList(nc *nats.Conn, nodes ...string) (*ec2.DescribeInstancesOutput, error) {
|
||||
return DescribeAccountInstancesStrict(context.Background(), nc, nodes, strictAccount, WithFanoutTimeout(2*time.Second))
|
||||
return strictListWithin(nc, strictFanoutWindow, nodes...)
|
||||
}
|
||||
|
||||
func strictListWithin(nc *nats.Conn, window time.Duration, nodes ...string) (*ec2.DescribeInstancesOutput, error) {
|
||||
return DescribeAccountInstancesStrict(context.Background(), nc, nodes, strictAccount, WithFanoutTimeout(window))
|
||||
}
|
||||
|
||||
// Existence comes from the record store, so an instance no node holds is still
|
||||
@@ -139,7 +147,7 @@ func TestDescribeAccountInstancesStrict_LateFrameFromUnconfiguredNodeFails(t *te
|
||||
subscribeAsNode(t, nc, "ec2.DescribeInstances", "node-1", []byte(`{}`))
|
||||
late := mustMarshalDescribeOutput(t, strictReservations(strictInstance("i-on-stray", "running")))
|
||||
_, err := nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) {
|
||||
time.AfterFunc(600*time.Millisecond, func() {
|
||||
time.AfterFunc(250*time.Millisecond, func() {
|
||||
reply := nats.NewMsg(msg.Reply)
|
||||
reply.Data = late
|
||||
reply.Header.Set(natsmsg.NodeIDHeader, "node-stray")
|
||||
@@ -149,7 +157,7 @@ func TestDescribeAccountInstancesStrict_LateFrameFromUnconfiguredNodeFails(t *te
|
||||
require.NoError(t, err)
|
||||
nc.Flush()
|
||||
|
||||
out, err := strictList(nc, "node-1")
|
||||
out, err := strictListWithin(nc, time.Second, "node-1")
|
||||
|
||||
assertIncompleteListing(t, out, err)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
@@ -24,10 +23,14 @@ const (
|
||||
// also augments from the stopped-instance KV bucket. The aggregator propagates
|
||||
// the first deterministic 4xx only when no data was collected, matching
|
||||
// DescribeInstances.
|
||||
func DescribeInstanceStatus(ctx context.Context, input *ec2.DescribeInstanceStatusInput, natsConn *nats.Conn, expectedNodes int, accountID, az string, synthesis StatusSynthesis) (*ec2.DescribeInstanceStatusOutput, error) {
|
||||
func DescribeInstanceStatus(ctx context.Context, input *ec2.DescribeInstanceStatusInput, natsConn *nats.Conn, expectedNodes int, accountID, az string, synthesis StatusSynthesis, opts ...DescribeOption) (*ec2.DescribeInstanceStatusOutput, error) {
|
||||
if input == nil {
|
||||
input = &ec2.DescribeInstanceStatusInput{}
|
||||
}
|
||||
cfg := describeConfig{fanoutTimeout: defaultDescribeFanoutTimeout}
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
@@ -36,7 +39,7 @@ func DescribeInstanceStatus(ctx context.Context, input *ec2.DescribeInstanceStat
|
||||
}
|
||||
|
||||
frames, sum, err := natsmsg.Gather(ctx, natsConn, "ec2.DescribeInstanceStatus", jsonData,
|
||||
natsmsg.GatherOpts{Timeout: 3 * time.Second, ExpectedResponders: expectedNodes, AccountID: accountID})
|
||||
natsmsg.GatherOpts{Timeout: cfg.fanoutTimeout, ExpectedResponders: expectedNodes, AccountID: accountID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ var describeInstanceTypeOfferingsValidFilters = map[string]bool{
|
||||
// DescribeInstanceTypeOfferings projects the supported instance type catalogue
|
||||
// over the location list. Both inputs are read live on every call, so the answer
|
||||
// cannot advertise a type the platform is unable to place.
|
||||
func DescribeInstanceTypeOfferings(ctx context.Context, input *ec2.DescribeInstanceTypeOfferingsInput, natsConn *nats.Conn, expectedNodes int, accountID, region, az string) (*ec2.DescribeInstanceTypeOfferingsOutput, error) {
|
||||
func DescribeInstanceTypeOfferings(ctx context.Context, input *ec2.DescribeInstanceTypeOfferingsInput, natsConn *nats.Conn, expectedNodes int, accountID, region, az string, ids ec2zoneapi.IDSource) (*ec2.DescribeInstanceTypeOfferingsOutput, error) {
|
||||
if input == nil {
|
||||
input = &ec2.DescribeInstanceTypeOfferingsInput{}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func DescribeInstanceTypeOfferings(ctx context.Context, input *ec2.DescribeInsta
|
||||
locationType = ec2.LocationTypeRegion
|
||||
}
|
||||
|
||||
locations, err := offeringLocations(locationType, region, az)
|
||||
locations, err := offeringLocations(ctx, locationType, region, az, ids)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "DescribeInstanceTypeOfferings: invalid location type", "location_type", locationType)
|
||||
return nil, err
|
||||
@@ -96,7 +96,7 @@ func DescribeInstanceTypeOfferings(ctx context.Context, input *ec2.DescribeInsta
|
||||
// offeringLocations resolves the locations an offering can be reported at. The
|
||||
// zone list is read rather than assumed, so the operation follows the zone model
|
||||
// if it ever widens.
|
||||
func offeringLocations(locationType, region, az string) ([]string, error) {
|
||||
func offeringLocations(ctx context.Context, locationType, region, az string, ids ec2zoneapi.IDSource) ([]string, error) {
|
||||
switch locationType {
|
||||
case ec2.LocationTypeRegion:
|
||||
return []string{region}, nil
|
||||
@@ -105,7 +105,7 @@ func offeringLocations(locationType, region, az string) ([]string, error) {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
zones, err := ec2zoneapi.DescribeAvailabilityZones(&ec2.DescribeAvailabilityZonesInput{}, region, az)
|
||||
zones, err := ec2zoneapi.DescribeAvailabilityZones(ctx, &ec2.DescribeAvailabilityZonesInput{}, region, az, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -46,12 +46,12 @@ func TestDescribeInstanceTypeOfferings_AvailabilityZone(t *testing.T) {
|
||||
|
||||
types, err := DescribeInstanceTypes(context.Background(), &ec2.DescribeInstanceTypesInput{}, nc, 1, nil, "")
|
||||
require.NoError(t, err)
|
||||
zones, err := ec2zoneapi.DescribeAvailabilityZones(&ec2.DescribeAvailabilityZonesInput{}, offeringsTestRegion, offeringsTestAZ)
|
||||
zones, err := ec2zoneapi.DescribeAvailabilityZones(context.Background(), &ec2.DescribeAvailabilityZonesInput{}, offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{
|
||||
LocationType: aws.String(ec2.LocationTypeAvailabilityZone),
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ)
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
require.NoError(t, err)
|
||||
|
||||
var want []*ec2.InstanceTypeOffering
|
||||
@@ -76,7 +76,7 @@ func TestDescribeInstanceTypeOfferings_Region(t *testing.T) {
|
||||
|
||||
output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{
|
||||
LocationType: aws.String(ec2.LocationTypeRegion),
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ)
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, output.InstanceTypeOfferings, 2)
|
||||
@@ -93,7 +93,7 @@ func TestDescribeInstanceTypeOfferings_DefaultLocationTypeIsRegion(t *testing.T)
|
||||
_, nc := startTestNATSServer(t)
|
||||
serveInstanceTypes(t, nc, "t3.micro")
|
||||
|
||||
output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{}, nc, 1, "", offeringsTestRegion, offeringsTestAZ)
|
||||
output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{}, nc, 1, "", offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, output.InstanceTypeOfferings, 1)
|
||||
@@ -106,14 +106,14 @@ func TestDescribeInstanceTypeOfferings_AvailabilityZoneID(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
serveInstanceTypes(t, nc, "t3.micro")
|
||||
|
||||
zones, err := ec2zoneapi.DescribeAvailabilityZones(&ec2.DescribeAvailabilityZonesInput{}, offeringsTestRegion, offeringsTestAZ)
|
||||
zones, err := ec2zoneapi.DescribeAvailabilityZones(context.Background(), &ec2.DescribeAvailabilityZonesInput{}, offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
require.NoError(t, err)
|
||||
zoneID := aws.StringValue(zones.AvailabilityZones[0].ZoneId)
|
||||
require.NotEqual(t, offeringsTestAZ, zoneID, "zone ID must differ from zone name for this assertion to mean anything")
|
||||
|
||||
output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{
|
||||
LocationType: aws.String(ec2.LocationTypeAvailabilityZoneId),
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ)
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, output.InstanceTypeOfferings, 1)
|
||||
@@ -127,7 +127,7 @@ func TestDescribeInstanceTypeOfferings_UnknownLocationType(t *testing.T) {
|
||||
|
||||
_, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{
|
||||
LocationType: aws.String("outpost"),
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ)
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
@@ -177,7 +177,7 @@ func TestDescribeInstanceTypeOfferings_Filters(t *testing.T) {
|
||||
output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{
|
||||
LocationType: aws.String(ec2.LocationTypeAvailabilityZone),
|
||||
Filters: tc.filters,
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ)
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
require.NoError(t, err)
|
||||
|
||||
var got []string
|
||||
@@ -198,7 +198,7 @@ func TestDescribeInstanceTypeOfferings_UnknownFilter(t *testing.T) {
|
||||
_, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{
|
||||
LocationType: aws.String(ec2.LocationTypeAvailabilityZone),
|
||||
Filters: []*ec2.Filter{{Name: aws.String("not-a-filter"), Values: []*string{aws.String("x")}}},
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ)
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
@@ -221,7 +221,7 @@ func TestDescribeInstanceTypeOfferings_FullTraversal(t *testing.T) {
|
||||
LocationType: aws.String(ec2.LocationTypeAvailabilityZone),
|
||||
MaxResults: aws.Int64(2),
|
||||
NextToken: token,
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ)
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, offering := range output.InstanceTypeOfferings {
|
||||
@@ -252,7 +252,7 @@ func TestDescribeInstanceTypeOfferings_MalformedNextToken(t *testing.T) {
|
||||
_, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{
|
||||
LocationType: aws.String(ec2.LocationTypeAvailabilityZone),
|
||||
NextToken: aws.String("not-a-token"),
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ)
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
@@ -268,7 +268,7 @@ func TestDescribeInstanceTypeOfferings_TokenPastEnd(t *testing.T) {
|
||||
output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{
|
||||
LocationType: aws.String(ec2.LocationTypeAvailabilityZone),
|
||||
NextToken: aws.String("99"),
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ)
|
||||
}, nc, 1, "", offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, output.InstanceTypeOfferings)
|
||||
@@ -281,7 +281,7 @@ func TestDescribeInstanceTypeOfferings_EmptyCatalogue(t *testing.T) {
|
||||
|
||||
output, err := DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{
|
||||
LocationType: aws.String(ec2.LocationTypeAvailabilityZone),
|
||||
}, nc, 0, "", offeringsTestRegion, offeringsTestAZ)
|
||||
}, nc, 0, "", offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, output.InstanceTypeOfferings)
|
||||
@@ -299,7 +299,7 @@ func TestDescribeInstanceTypeOfferings_CatalogueError(t *testing.T) {
|
||||
|
||||
_, err = DescribeInstanceTypeOfferings(context.Background(), &ec2.DescribeInstanceTypeOfferingsInput{
|
||||
LocationType: aws.String(ec2.LocationTypeAvailabilityZone),
|
||||
}, closedNC, 1, "", offeringsTestRegion, offeringsTestAZ)
|
||||
}, closedNC, 1, "", offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -309,9 +309,16 @@ func TestDescribeInstanceTypeOfferings_NilInput(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
serveInstanceTypes(t, nc, "t3.micro")
|
||||
|
||||
output, err := DescribeInstanceTypeOfferings(context.Background(), nil, nc, 1, "", offeringsTestRegion, offeringsTestAZ)
|
||||
output, err := DescribeInstanceTypeOfferings(context.Background(), nil, nc, 1, "", offeringsTestRegion, offeringsTestAZ, offeringsTestIDs)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, output.InstanceTypeOfferings, 1)
|
||||
assert.Equal(t, offeringsTestRegion, aws.StringValue(output.InstanceTypeOfferings[0].Location))
|
||||
}
|
||||
|
||||
// stubZoneIDs answers one fixed ID for every zone.
|
||||
type stubZoneIDs string
|
||||
|
||||
func (s stubZoneIDs) ZoneID(context.Context, string) (string, error) { return string(s), nil }
|
||||
|
||||
var offeringsTestIDs ec2zoneapi.IDSource = stubZoneIDs("apse2-az1")
|
||||
|
||||
@@ -96,6 +96,12 @@ func ValidateRunInstancesInput(input *ec2.RunInstancesInput) (err error) {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
// A volume with no device name cannot be attached anywhere, so the mapping
|
||||
// is refused as AWS refuses an invalid device.
|
||||
if ec2instance.UnnamedBlockDeviceMapping(input.BlockDeviceMappings) {
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidBlockDeviceMapping, "BlockDeviceMappings: a mapping names no device")
|
||||
}
|
||||
|
||||
// A volume the launch creates beyond the root is empty and sized by the
|
||||
// request, so a mapping asking for anything else is refused by name rather
|
||||
// than served as something it did not ask for.
|
||||
|
||||
@@ -251,6 +251,15 @@ func TestParseRunInstances(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MappingNamingNoDeviceIsRefused", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := defaults
|
||||
input.BlockDeviceMappings = []*ec2.BlockDeviceMapping{{Ebs: &ec2.EbsBlockDevice{VolumeSize: aws.Int64(6)}}}
|
||||
err := ValidateRunInstancesInput(&input)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorInvalidBlockDeviceMapping)
|
||||
})
|
||||
|
||||
t.Run("MaxCountAtInt32IsValid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := defaults
|
||||
|
||||
@@ -180,8 +180,19 @@ func TestDistributeInstancesSpread_NoEligibleNodes(t *testing.T) {
|
||||
|
||||
// Capacity exists for a different instance type only, so nothing is eligible.
|
||||
h := mockSpreadCluster(t, nc, map[string]int{}, nil, nil, "")
|
||||
otherTypeSub, err := nc.Subscribe("spinifex.node.status", func(msg *nats.Msg) {
|
||||
for _, node := range []string{"node-1", "node-2"} {
|
||||
data, _ := json.Marshal(types.NodeStatusResponse{
|
||||
Node: node,
|
||||
InstanceTypes: []types.InstanceTypeCap{{Name: "m5.large", Available: 4}},
|
||||
})
|
||||
_ = nc.Publish(msg.Reply, data)
|
||||
}
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = otherTypeSub.Unsubscribe() })
|
||||
|
||||
_, err := distributeInstancesSpread(context.Background(), spreadInput(2, 2), nc, "test-account", "my-spread", 2)
|
||||
_, err = distributeInstancesSpread(context.Background(), spreadInput(2, 2), nc, "test-account", "my-spread", 2)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, err.Error())
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ import (
|
||||
|
||||
const synthAccount = "123456789012"
|
||||
|
||||
// synthSilentNodeWindow bounds a fan-out that must time out because an expected
|
||||
// node never identifies itself; the answering replies are local and immediate.
|
||||
const synthSilentNodeWindow = 500 * time.Millisecond
|
||||
|
||||
// fakeRecords is a cache stand-in: a fixed record set plus a readiness flag,
|
||||
// so a test can assert the cold-cache case as well as the warm one.
|
||||
type fakeRecords struct {
|
||||
@@ -342,7 +346,8 @@ func TestSynthesis_SilentButLiveNodeShowsInsufficientData(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
|
||||
out, err := DescribeInstanceStatus(context.Background(), &ec2.DescribeInstanceStatusInput{}, nc, 2, synthAccount, "az-a", synth)
|
||||
out, err := DescribeInstanceStatus(context.Background(), &ec2.DescribeInstanceStatusInput{}, nc, 2, synthAccount, "az-a", synth,
|
||||
WithFanoutTimeout(synthSilentNodeWindow))
|
||||
require.NoError(t, err)
|
||||
|
||||
byID := statusByID(out)
|
||||
@@ -418,7 +423,8 @@ func TestSynthesis_UnidentifiedFrameFallsBackToHeartbeatOnly(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
|
||||
out, err := DescribeInstanceStatus(context.Background(), &ec2.DescribeInstanceStatusInput{}, nc, 1, synthAccount, "az-a", synth)
|
||||
out, err := DescribeInstanceStatus(context.Background(), &ec2.DescribeInstanceStatusInput{}, nc, 1, synthAccount, "az-a", synth,
|
||||
WithFanoutTimeout(synthSilentNodeWindow))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.InstanceStatuses, 1,
|
||||
"without responder identity, a live node's instance is skipped by the fallback rule")
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
ec2zoneapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/zone"
|
||||
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/zoneid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// With a non-default override in force, the three calls that report a zone ID
|
||||
// read one source and so cannot disagree.
|
||||
func TestZoneIDs_ThreeProducersAgreeUnderAnOverride(t *testing.T) {
|
||||
const region, az, account = "mulga-lab", "mulga-laba", "123456789012"
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
testutil.StubVpcdSGResponder(t, nc)
|
||||
serveInstanceTypes(t, nc, "t3.micro")
|
||||
|
||||
ids, err := zoneid.NewResolver(js, region, "ov99")
|
||||
require.NoError(t, err)
|
||||
|
||||
vpcSvc, err := ec2vpc.NewVPCServiceImplWithNATS(t.Context(), nil, nc)
|
||||
require.NoError(t, err)
|
||||
vpcSvc.SetZoneIDSource(ids)
|
||||
vpc, err := vpcSvc.CreateVpc(t.Context(), &ec2.CreateVpcInput{CidrBlock: aws.String("10.0.0.0/16")}, account)
|
||||
require.NoError(t, err)
|
||||
_, err = vpcSvc.CreateSubnet(t.Context(), &ec2.CreateSubnetInput{
|
||||
VpcId: vpc.Vpc.VpcId, CidrBlock: aws.String("10.0.1.0/24"), AvailabilityZone: aws.String(az),
|
||||
}, account)
|
||||
require.NoError(t, err)
|
||||
|
||||
zones, err := ec2zoneapi.DescribeAvailabilityZones(t.Context(), &ec2.DescribeAvailabilityZonesInput{}, region, az, ids)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, zones.AvailabilityZones, 1)
|
||||
fromZones := aws.StringValue(zones.AvailabilityZones[0].ZoneId)
|
||||
|
||||
subnets, err := vpcSvc.DescribeSubnets(t.Context(), &ec2.DescribeSubnetsInput{}, account)
|
||||
require.NoError(t, err)
|
||||
var fromSubnet string
|
||||
for _, s := range subnets.Subnets {
|
||||
if aws.StringValue(s.AvailabilityZone) == az {
|
||||
fromSubnet = aws.StringValue(s.AvailabilityZoneId)
|
||||
}
|
||||
}
|
||||
|
||||
offerings, err := DescribeInstanceTypeOfferings(t.Context(), &ec2.DescribeInstanceTypeOfferingsInput{
|
||||
LocationType: aws.String(ec2.LocationTypeAvailabilityZoneId),
|
||||
}, nc, 1, account, region, az, ids)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, offerings.InstanceTypeOfferings)
|
||||
fromOfferings := aws.StringValue(offerings.InstanceTypeOfferings[0].Location)
|
||||
|
||||
assert.Equal(t, "ov99-az1", fromZones)
|
||||
assert.Equal(t, fromZones, fromSubnet)
|
||||
assert.Equal(t, fromZones, fromOfferings)
|
||||
}
|
||||
@@ -4,16 +4,24 @@
|
||||
package zone
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
filterutil "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/zoneid"
|
||||
)
|
||||
|
||||
// IDSource returns the stored availability-zone ID for a zone name, pinning it first if needed.
|
||||
type IDSource interface {
|
||||
ZoneID(ctx context.Context, az string) (string, error)
|
||||
}
|
||||
|
||||
var _ IDSource = (*zoneid.Resolver)(nil)
|
||||
|
||||
// The filter names AWS accepts on each call; any other, tag filters included,
|
||||
// is refused.
|
||||
var (
|
||||
@@ -39,18 +47,25 @@ var (
|
||||
|
||||
// DescribeAvailabilityZones implements the EC2 action for the single zone az in region. Unknown
|
||||
// ZoneNames or ZoneIds return InvalidParameterValue; non-matching filters yield an empty list.
|
||||
func DescribeAvailabilityZones(input *ec2.DescribeAvailabilityZonesInput, region string, az string) (output *ec2.DescribeAvailabilityZonesOutput, err error) {
|
||||
func DescribeAvailabilityZones(ctx context.Context, input *ec2.DescribeAvailabilityZonesInput, region string, az string, ids IDSource) (output *ec2.DescribeAvailabilityZonesOutput, err error) {
|
||||
filters, err := parseFilters(input.Filters, describeAvailabilityZonesValidFilters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ids == nil {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "availability zone ids are not configured")
|
||||
}
|
||||
zoneID, err := ids.ZoneID(ctx, az)
|
||||
if err != nil {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "availability zone id: %v", err)
|
||||
}
|
||||
|
||||
zone := &ec2.AvailabilityZone{
|
||||
State: aws.String("available"),
|
||||
OptInStatus: aws.String("opt-in-not-required"),
|
||||
RegionName: aws.String(region),
|
||||
ZoneName: aws.String(az),
|
||||
ZoneId: aws.String(ec2vpc.SingleZoneID),
|
||||
ZoneId: aws.String(zoneID),
|
||||
GroupName: aws.String(region),
|
||||
NetworkBorderGroup: aws.String(region),
|
||||
ZoneType: aws.String("availability-zone"),
|
||||
@@ -64,7 +79,7 @@ func DescribeAvailabilityZones(input *ec2.DescribeAvailabilityZonesInput, region
|
||||
}
|
||||
}
|
||||
for _, id := range aws.StringValueSlice(input.ZoneIds) {
|
||||
if id != ec2vpc.SingleZoneID {
|
||||
if id != zoneID {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "Invalid availability zone-id: [%s]", id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package zone
|
||||
import (
|
||||
"testing"
|
||||
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/zoneid"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
@@ -10,28 +13,39 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newIDs(t *testing.T, region, override string) *zoneid.Resolver {
|
||||
t.Helper()
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
r, err := zoneid.NewResolver(js, region, override)
|
||||
require.NoError(t, err)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestDescribeAvailabilityZones(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
region string
|
||||
az string
|
||||
zoneID string
|
||||
}{
|
||||
{
|
||||
name: "Sydney",
|
||||
region: "ap-southeast-2",
|
||||
az: "ap-southeast-2a",
|
||||
zoneID: "apse2-az1",
|
||||
},
|
||||
{
|
||||
name: "US East",
|
||||
region: "us-east-1",
|
||||
az: "us-east-1a",
|
||||
zoneID: "use1-az1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := &ec2.DescribeAvailabilityZonesInput{}
|
||||
output, err := DescribeAvailabilityZones(input, tt.region, tt.az)
|
||||
output, err := DescribeAvailabilityZones(t.Context(), input, tt.region, tt.az, newIDs(t, tt.region, ""))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, output)
|
||||
@@ -42,7 +56,7 @@ func TestDescribeAvailabilityZones(t *testing.T) {
|
||||
assert.Equal(t, "opt-in-not-required", *zone.OptInStatus)
|
||||
assert.Equal(t, tt.region, *zone.RegionName)
|
||||
assert.Equal(t, tt.az, *zone.ZoneName)
|
||||
assert.Equal(t, "spinifexz1", *zone.ZoneId)
|
||||
assert.Equal(t, tt.zoneID, *zone.ZoneId)
|
||||
assert.Equal(t, tt.region, *zone.GroupName)
|
||||
assert.Equal(t, tt.region, *zone.NetworkBorderGroup)
|
||||
assert.Equal(t, "availability-zone", *zone.ZoneType)
|
||||
@@ -51,6 +65,14 @@ func TestDescribeAvailabilityZones(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAvailabilityZones_ZoneIDsSelectBySydneyID(t *testing.T) {
|
||||
ids := newIDs(t, "ap-southeast-2", "")
|
||||
out, err := DescribeAvailabilityZones(t.Context(), &ec2.DescribeAvailabilityZonesInput{ZoneIds: aws.StringSlice([]string{"apse2-az1"})}, "ap-southeast-2", "ap-southeast-2a", ids)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.AvailabilityZones, 1)
|
||||
assert.Equal(t, "ap-southeast-2a", aws.StringValue(out.AvailabilityZones[0].ZoneName))
|
||||
}
|
||||
|
||||
func TestDescribeRegions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -104,6 +126,7 @@ func requireInvalidParameterValue(t *testing.T, err error, msg string) {
|
||||
// is as valid as an AWS one. The messages are AWS's own.
|
||||
func TestDescribeAvailabilityZones_Selection(t *testing.T) {
|
||||
const region, az = "mulga-west", "mulga-west-a"
|
||||
ids := newIDs(t, region, "")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
@@ -111,7 +134,7 @@ func TestDescribeAvailabilityZones_Selection(t *testing.T) {
|
||||
want int
|
||||
}{
|
||||
{"zone name", &ec2.DescribeAvailabilityZonesInput{ZoneNames: aws.StringSlice([]string{az})}, 1},
|
||||
{"zone id", &ec2.DescribeAvailabilityZonesInput{ZoneIds: aws.StringSlice([]string{"spinifexz1"})}, 1},
|
||||
{"zone id", &ec2.DescribeAvailabilityZonesInput{ZoneIds: aws.StringSlice([]string{"muw1-az1"})}, 1},
|
||||
{"zone-name filter", &ec2.DescribeAvailabilityZonesInput{Filters: filter("zone-name", az)}, 1},
|
||||
{"wildcard filter", &ec2.DescribeAvailabilityZonesInput{Filters: filter("region-name", "mulga-*")}, 1},
|
||||
{"state filter", &ec2.DescribeAvailabilityZonesInput{Filters: filter("state", "available")}, 1},
|
||||
@@ -119,7 +142,7 @@ func TestDescribeAvailabilityZones_Selection(t *testing.T) {
|
||||
{"filter on an absent field", &ec2.DescribeAvailabilityZonesInput{Filters: filter("parent-zone-id", "*")}, 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := DescribeAvailabilityZones(tc.input, region, az)
|
||||
out, err := DescribeAvailabilityZones(t.Context(), tc.input, region, az, ids)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.AvailabilityZones, tc.want)
|
||||
})
|
||||
@@ -134,10 +157,11 @@ func TestDescribeAvailabilityZones_Selection(t *testing.T) {
|
||||
{"tag filter", &ec2.DescribeAvailabilityZonesInput{Filters: filter("tag:Name", "x")}, "The filter 'tag:Name' is invalid"},
|
||||
{"network-border-group filter", &ec2.DescribeAvailabilityZonesInput{Filters: filter("network-border-group", "x")}, "The filter 'network-border-group' is invalid"},
|
||||
{"first unknown name in request order", &ec2.DescribeAvailabilityZonesInput{ZoneNames: aws.StringSlice([]string{az, "zz-nowhere-9z", "yy-nowhere-8y"})}, "Invalid availability zone: [zz-nowhere-9z]"},
|
||||
{"unknown zone id", &ec2.DescribeAvailabilityZonesInput{ZoneIds: aws.StringSlice([]string{"spinifexz1", "zz-az9"})}, "Invalid availability zone-id: [zz-az9]"},
|
||||
{"unknown zone id", &ec2.DescribeAvailabilityZonesInput{ZoneIds: aws.StringSlice([]string{"muw1-az1", "zz-az9"})}, "Invalid availability zone-id: [zz-az9]"},
|
||||
{"the retired constant is an unknown zone id", &ec2.DescribeAvailabilityZonesInput{ZoneIds: aws.StringSlice([]string{"spinifexz1"})}, "Invalid availability zone-id: [spinifexz1]"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := DescribeAvailabilityZones(tc.input, region, az)
|
||||
_, err := DescribeAvailabilityZones(t.Context(), tc.input, region, az, ids)
|
||||
requireInvalidParameterValue(t, err, tc.msg)
|
||||
})
|
||||
}
|
||||
@@ -180,3 +204,9 @@ func TestDescribeRegions_Selection(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAvailabilityZones_NoIDSourceFailsClosed(t *testing.T) {
|
||||
_, err := DescribeAvailabilityZones(t.Context(), &ec2.DescribeAvailabilityZonesInput{}, "mulga-lab", "mulga-laba", nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
@@ -875,14 +875,14 @@ func (s *EIPServiceImpl) AssociatedPublicIPForInstance(ctx context.Context, acco
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ReleaseAddressByInstanceID disassociates and releases every EIP still recorded
|
||||
// against instanceID, across all accounts. Idempotent backstop for system-VM
|
||||
// teardown paths that lose the cached allocation ID — when the VM is already
|
||||
// gone from the manager the per-instance release never runs, so an
|
||||
// internet-facing ALB's EIP would otherwise orphan. No-op when nothing matches.
|
||||
func (s *EIPServiceImpl) ReleaseAddressByInstanceID(instanceID string) error {
|
||||
// ReleaseAllocationOnENI disassociates and releases allocID, wherever its record
|
||||
// lives across accounts, but only while it is still associated with eniID. It is
|
||||
// the idempotent backstop for the launch allocation of a system VM whose owning
|
||||
// node never ran the per-instance release. Any other address, including one a
|
||||
// customer associated to the same ENI, is left alone. No-op when nothing matches.
|
||||
func (s *EIPServiceImpl) ReleaseAllocationOnENI(allocID, eniID string) error {
|
||||
ctx := context.Background()
|
||||
if instanceID == "" {
|
||||
if allocID == "" || eniID == "" {
|
||||
return nil
|
||||
}
|
||||
keys, err := s.eipKV.Keys(ctx)
|
||||
@@ -892,9 +892,9 @@ func (s *EIPServiceImpl) ReleaseAddressByInstanceID(instanceID string) error {
|
||||
}
|
||||
return fmt.Errorf("list eip keys: %w", err)
|
||||
}
|
||||
var errs []error
|
||||
for _, k := range keys {
|
||||
if k == kvutil.VersionKey {
|
||||
accountID, keyAlloc, found := strings.Cut(k, ".")
|
||||
if !found || keyAlloc != allocID {
|
||||
continue
|
||||
}
|
||||
entry, err := s.eipKV.Get(ctx, k)
|
||||
@@ -905,28 +905,23 @@ func (s *EIPServiceImpl) ReleaseAddressByInstanceID(instanceID string) error {
|
||||
if err := json.Unmarshal(entry.Value(), &record); err != nil {
|
||||
continue
|
||||
}
|
||||
if record.InstanceId != instanceID || record.AllocationId == "" {
|
||||
if record.AllocationId != allocID || record.ENIId != eniID || record.AssociationId == "" {
|
||||
continue
|
||||
}
|
||||
// Key is "{accountID}.{allocID}"; the account scopes the release calls.
|
||||
accountID, _, found := strings.Cut(k, ".")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if record.AssociationId != "" {
|
||||
if _, err := s.DisassociateAddress(ctx, &ec2.DisassociateAddressInput{
|
||||
AssociationId: aws.String(record.AssociationId),
|
||||
}, accountID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("disassociate %s: %w", record.AllocationId, err))
|
||||
}
|
||||
var errs []error
|
||||
if _, err := s.DisassociateAddress(ctx, &ec2.DisassociateAddressInput{
|
||||
AssociationId: aws.String(record.AssociationId),
|
||||
}, accountID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("disassociate %s: %w", allocID, err))
|
||||
}
|
||||
if _, err := s.ReleaseAddress(ctx, &ec2.ReleaseAddressInput{
|
||||
AllocationId: aws.String(record.AllocationId),
|
||||
AllocationId: aws.String(allocID),
|
||||
}, accountID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("release %s: %w", record.AllocationId, err))
|
||||
errs = append(errs, fmt.Errorf("release %s: %w", allocID, err))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishNATEvent publishes a NAT lifecycle event to NATS for vpcd (fire-and-forget).
|
||||
|
||||
@@ -176,29 +176,31 @@ func TestEIP_ReleaseWhileAssociated(t *testing.T) {
|
||||
assert.Equal(t, "Address "+record.PublicIp+" is in use.", message)
|
||||
}
|
||||
|
||||
func TestEIP_ReleaseByInstanceID_ReclaimsAssociatedEIP(t *testing.T) {
|
||||
func TestEIP_ReleaseAllocationOnENI_ReclaimsAssociatedEIP(t *testing.T) {
|
||||
svc, _, _ := setupTestEIP(t)
|
||||
|
||||
out, err := svc.AllocateAddress(context.Background(), &ec2.AllocateAddressInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
allocID := *out.AllocationId
|
||||
|
||||
// Mark the EIP associated with a system instance, as an internet-facing ALB
|
||||
// leaves it. ENIId stays empty so disassociate skips the VPC-service lookup.
|
||||
// Mark the EIP associated with an ENI, as an internet-facing ALB leaves it.
|
||||
entry, err := svc.eipKV.Get(t.Context(), testAccountID+"."+allocID)
|
||||
require.NoError(t, err)
|
||||
var record EIPRecord
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &record))
|
||||
record.State = "associated"
|
||||
record.AssociationId = "eipassoc-test"
|
||||
record.InstanceId = "i-alb-test"
|
||||
record.ENIId = "eni-alb"
|
||||
data, err := json.Marshal(record)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.eipKV.Update(t.Context(), testAccountID+"."+allocID, data, entry.Revision())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Backstop release by instance disassociates then frees the allocation.
|
||||
require.NoError(t, svc.ReleaseAddressByInstanceID("i-alb-test"))
|
||||
// The backstop disassociates then frees the allocation, but not for another ENI.
|
||||
require.NoError(t, svc.ReleaseAllocationOnENI(allocID, "eni-other"))
|
||||
_, err = svc.eipKV.Get(t.Context(), testAccountID+"."+allocID)
|
||||
require.NoError(t, err, "an allocation on a different ENI is left alone")
|
||||
require.NoError(t, svc.ReleaseAllocationOnENI(allocID, "eni-alb"))
|
||||
|
||||
_, err = svc.ReleaseAddress(context.Background(), &ec2.ReleaseAddressInput{AllocationId: aws.String(allocID)}, testAccountID)
|
||||
assert.Error(t, err)
|
||||
@@ -322,21 +324,22 @@ func TestEIP_DisassociateByENI_SkipsMalformedRecord(t *testing.T) {
|
||||
assert.True(t, found)
|
||||
}
|
||||
|
||||
func TestEIP_ReleaseByInstanceID_NoMatchIsNoOp(t *testing.T) {
|
||||
func TestEIP_ReleaseAllocationOnENI_NoMatchIsNoOp(t *testing.T) {
|
||||
svc, _, _ := setupTestEIP(t)
|
||||
|
||||
out, err := svc.AllocateAddress(context.Background(), &ec2.AllocateAddressInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
allocID := *out.AllocationId
|
||||
|
||||
// An instance with no recorded EIP must leave existing allocations untouched.
|
||||
require.NoError(t, svc.ReleaseAddressByInstanceID("i-unrelated"))
|
||||
// An unknown allocation, or one not associated to the ENI, leaves others untouched.
|
||||
require.NoError(t, svc.ReleaseAllocationOnENI("eipalloc-unrelated", "eni-x"))
|
||||
require.NoError(t, svc.ReleaseAllocationOnENI(allocID, "eni-x"))
|
||||
|
||||
_, err = svc.eipKV.Get(t.Context(), testAccountID+"."+allocID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Empty instance ID is a no-op too.
|
||||
require.NoError(t, svc.ReleaseAddressByInstanceID(""))
|
||||
require.NoError(t, svc.ReleaseAllocationOnENI("", "eni-x"))
|
||||
require.NoError(t, svc.ReleaseAllocationOnENI(allocID, ""))
|
||||
}
|
||||
|
||||
func TestEIP_ReleaseMissingParams(t *testing.T) {
|
||||
|
||||
@@ -136,11 +136,10 @@ func isEphemeralMapping(bdm *ec2.BlockDeviceMapping) bool {
|
||||
}
|
||||
|
||||
// SelectRootBlockDeviceMapping returns the index of the mapping addressing the
|
||||
// root device, which is the one naming ebsmetadata.RootDeviceName. Position
|
||||
// decides only when no mapping names it, so a caller addressing a device this
|
||||
// platform's images do not have still gets the volume they sized.
|
||||
// root device, which is the one naming ebsmetadata.RootDeviceName, or -1 when
|
||||
// none does. A mapping naming any other device is an additional volume and the
|
||||
// root keeps the image's own mapping, as AWS does.
|
||||
func SelectRootBlockDeviceMapping(mappings []*ec2.BlockDeviceMapping) int {
|
||||
first := -1
|
||||
for i, bdm := range mappings {
|
||||
if bdm == nil || isEphemeralMapping(bdm) {
|
||||
continue
|
||||
@@ -148,11 +147,23 @@ func SelectRootBlockDeviceMapping(mappings []*ec2.BlockDeviceMapping) int {
|
||||
if aws.StringValue(bdm.DeviceName) == ebsmetadata.RootDeviceName {
|
||||
return i
|
||||
}
|
||||
if first < 0 {
|
||||
first = i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// UnnamedBlockDeviceMapping reports whether a non-root EBS mapping names no
|
||||
// device, which would leave its volume attached at an empty device name.
|
||||
func UnnamedBlockDeviceMapping(mappings []*ec2.BlockDeviceMapping) bool {
|
||||
root := SelectRootBlockDeviceMapping(mappings)
|
||||
for i, bdm := range mappings {
|
||||
if i == root || bdm == nil || bdm.Ebs == nil || isEphemeralMapping(bdm) {
|
||||
continue
|
||||
}
|
||||
if aws.StringValue(bdm.DeviceName) == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return first
|
||||
return false
|
||||
}
|
||||
|
||||
// UnservedBlockDeviceMappings names the non-root EBS mappings the launch path
|
||||
|
||||
@@ -438,7 +438,7 @@ func TestParseVolumeParams_BlockDeviceMappingNoEbs(t *testing.T) {
|
||||
|
||||
p := parseVolumeParams(input)
|
||||
|
||||
assert.Equal(t, "/dev/xvda", p.deviceName)
|
||||
assert.Equal(t, ebsmetadata.RootDeviceName, p.deviceName, "a mapping not naming the root leaves the root device alone")
|
||||
assert.Equal(t, 4*1024*1024*1024, p.size, "size should stay at default without Ebs")
|
||||
assert.True(t, p.deleteOnTermination)
|
||||
}
|
||||
@@ -469,7 +469,7 @@ func TestParseVolumeParams_PartialEbs(t *testing.T) {
|
||||
|
||||
p := parseVolumeParams(input)
|
||||
|
||||
assert.Equal(t, 8*1024*1024*1024, p.size)
|
||||
assert.Equal(t, 4*1024*1024*1024, p.size, "an unnamed mapping does not size the root")
|
||||
assert.Equal(t, ebsmetadata.RootDeviceName, p.deviceName, "device should stay at default")
|
||||
assert.Empty(t, p.volumeType, "volumeType should stay empty")
|
||||
assert.Zero(t, p.iops, "iops should stay zero")
|
||||
@@ -593,23 +593,61 @@ func TestParseVolumeParams_RootMappingSelectedByName(t *testing.T) {
|
||||
assert.Equal(t, "gp3", p.volumeType)
|
||||
}
|
||||
|
||||
func TestParseVolumeParams_SingleNonRootMappingStillLaunches(t *testing.T) {
|
||||
// ECS and EKS both launch with one mapping named /dev/vda, which is not the
|
||||
// AMI root device name. Position decides when nothing names the root.
|
||||
func TestParseVolumeParams_LoneNonRootMappingIsADataVolume(t *testing.T) {
|
||||
// A mapping that does not name the AMI root device adds a volume; the root
|
||||
// keeps the image's own mapping, as AWS does.
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-vda"),
|
||||
ImageId: aws.String("ami-lone"),
|
||||
BlockDeviceMappings: []*ec2.BlockDeviceMapping{
|
||||
{
|
||||
DeviceName: aws.String("/dev/vda"),
|
||||
DeviceName: aws.String("/dev/sdf"),
|
||||
Ebs: &ec2.EbsBlockDevice{
|
||||
VolumeSize: aws.Int64(6),
|
||||
DeleteOnTermination: aws.Bool(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, -1, SelectRootBlockDeviceMapping(input.BlockDeviceMappings))
|
||||
|
||||
p := parseVolumeParams(input)
|
||||
assert.Equal(t, 4*1024*1024*1024, p.size, "root keeps its default size")
|
||||
assert.Equal(t, ebsmetadata.RootDeviceName, p.deviceName)
|
||||
assert.True(t, p.deleteOnTermination, "root keeps its default")
|
||||
|
||||
data := parseDataVolumeParams(input)
|
||||
require.Len(t, data, 1)
|
||||
assert.Equal(t, "/dev/sdf", data[0].deviceName)
|
||||
assert.Equal(t, int64(6), data[0].sizeGiB)
|
||||
assert.False(t, data[0].deleteOnTermination)
|
||||
assert.Empty(t, UnservedBlockDeviceMappings(input.BlockDeviceMappings))
|
||||
}
|
||||
|
||||
func TestParseVolumeParams_LoneNonRootMappingWithoutSizeIsRefused(t *testing.T) {
|
||||
mappings := []*ec2.BlockDeviceMapping{
|
||||
{DeviceName: aws.String("/dev/sdf"), Ebs: &ec2.EbsBlockDevice{}},
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"/dev/sdf (names no size)"}, UnservedBlockDeviceMappings(mappings))
|
||||
}
|
||||
|
||||
func TestParseVolumeParams_LoneRootNamedMappingIsTheRoot(t *testing.T) {
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-lone-root"),
|
||||
BlockDeviceMappings: []*ec2.BlockDeviceMapping{
|
||||
{
|
||||
DeviceName: aws.String(ebsmetadata.RootDeviceName),
|
||||
Ebs: &ec2.EbsBlockDevice{VolumeSize: aws.Int64(20)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, 0, SelectRootBlockDeviceMapping(input.BlockDeviceMappings))
|
||||
p := parseVolumeParams(input)
|
||||
assert.Equal(t, 20*1024*1024*1024, p.size)
|
||||
assert.Equal(t, "/dev/vda", p.deviceName)
|
||||
assert.Empty(t, UnservedBlockDeviceMappings(input.BlockDeviceMappings))
|
||||
assert.Equal(t, ebsmetadata.RootDeviceName, p.deviceName)
|
||||
assert.Empty(t, parseDataVolumeParams(input))
|
||||
}
|
||||
|
||||
func TestUnservedBlockDeviceMappings(t *testing.T) {
|
||||
@@ -5238,3 +5276,19 @@ func TestPrepareRunInstances_ArchitectureMismatch(t *testing.T) {
|
||||
}
|
||||
assert.NoError(t, checkImageArchitecture(types["t4g.micro"], "aarch64"))
|
||||
}
|
||||
|
||||
func TestUnnamedBlockDeviceMapping(t *testing.T) {
|
||||
root := &ec2.BlockDeviceMapping{
|
||||
DeviceName: aws.String(ebsmetadata.RootDeviceName),
|
||||
Ebs: &ec2.EbsBlockDevice{VolumeSize: aws.Int64(8)},
|
||||
}
|
||||
unnamed := &ec2.BlockDeviceMapping{Ebs: &ec2.EbsBlockDevice{VolumeSize: aws.Int64(4)}}
|
||||
emptyName := &ec2.BlockDeviceMapping{DeviceName: aws.String(""), Ebs: &ec2.EbsBlockDevice{VolumeSize: aws.Int64(4)}}
|
||||
ephemeral := &ec2.BlockDeviceMapping{VirtualName: aws.String("ephemeral0")}
|
||||
|
||||
assert.False(t, UnnamedBlockDeviceMapping(nil))
|
||||
assert.False(t, UnnamedBlockDeviceMapping([]*ec2.BlockDeviceMapping{root}))
|
||||
assert.False(t, UnnamedBlockDeviceMapping([]*ec2.BlockDeviceMapping{root, ephemeral}))
|
||||
assert.True(t, UnnamedBlockDeviceMapping([]*ec2.BlockDeviceMapping{unnamed}))
|
||||
assert.True(t, UnnamedBlockDeviceMapping([]*ec2.BlockDeviceMapping{root, emptyName}))
|
||||
}
|
||||
|
||||
@@ -48,8 +48,15 @@ type KeyServiceImpl struct {
|
||||
// Optional: injected after construction. Nil leaves the central tag store
|
||||
// untouched by create, import and delete.
|
||||
centralTags CentralTagStore
|
||||
|
||||
// rsaKeyBits sizes the RSA keys CreateKeyPair generates. Zero takes
|
||||
// defaultRSAKeyBits, which is what production runs; only tests set it.
|
||||
rsaKeyBits int
|
||||
}
|
||||
|
||||
// defaultRSAKeyBits is the size of the RSA key pairs AWS CreateKeyPair returns.
|
||||
const defaultRSAKeyBits = 2048
|
||||
|
||||
// CentralTagStore keeps the central tag index in step with key-pair records, so
|
||||
// DescribeTags sees creation tags. Implemented by domains/ec2/tags.TagsServiceImpl.
|
||||
type CentralTagStore interface {
|
||||
@@ -169,7 +176,7 @@ func (s *KeyServiceImpl) CreateKeyPair(ctx context.Context, input *ec2.CreateKey
|
||||
}
|
||||
|
||||
// Generate the key in process; the private half never reaches local disk.
|
||||
privateKeyData, publicKey, err := generateKeyPair(keyType)
|
||||
privateKeyData, publicKey, err := generateKeyPair(keyType, s.rsaBits())
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to generate key pair", "keyName", keyName, "keyType", keyType, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
@@ -255,10 +262,18 @@ func (s *KeyServiceImpl) CreateKeyPair(ctx context.Context, input *ec2.CreateKey
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// rsaBits returns the RSA key size CreateKeyPair generates.
|
||||
func (s *KeyServiceImpl) rsaBits() int {
|
||||
if s.rsaKeyBits > 0 {
|
||||
return s.rsaKeyBits
|
||||
}
|
||||
return defaultRSAKeyBits
|
||||
}
|
||||
|
||||
// generateKeyPair generates a key pair of the given EC2 key type, returning the
|
||||
// private key in the PEM container AWS hands back for that type alongside the
|
||||
// public half.
|
||||
func generateKeyPair(keyType string) ([]byte, ssh.PublicKey, error) {
|
||||
// public half. rsaBits sizes an RSA key and is ignored for ED25519.
|
||||
func generateKeyPair(keyType string, rsaBits int) ([]byte, ssh.PublicKey, error) {
|
||||
var block *pem.Block
|
||||
var cryptoPub crypto.PublicKey
|
||||
|
||||
@@ -275,10 +290,10 @@ func generateKeyPair(keyType string) ([]byte, ssh.PublicKey, error) {
|
||||
cryptoPub = pub
|
||||
|
||||
case "rsa":
|
||||
// RSA 2048-bit in PKCS#1 PEM, as AWS returns it. OpenSSH reads that format
|
||||
// for SSH either way, but GetPasswordData's --priv-launch-key cannot read
|
||||
// the OpenSSH container at all.
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
// RSA in PKCS#1 PEM, as AWS returns it. OpenSSH reads that format for SSH
|
||||
// either way, but GetPasswordData's --priv-launch-key cannot read the
|
||||
// OpenSSH container at all.
|
||||
priv, err := rsa.GenerateKey(rand.Reader, rsaBits)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate rsa key: %w", err)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package key
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
@@ -100,9 +101,12 @@ const (
|
||||
testRSAPrivKeyImportedFingerprint = "66:81:50:28:0a:74:9e:48:9f:d8:79:da:27:86:22:79"
|
||||
)
|
||||
|
||||
// newTestKeyService generates RSA keys at 1024 bits, the smallest crypto/rsa
|
||||
// accepts, since only TestCreateKeyPair_RSA checks the production size.
|
||||
func newTestKeyService() (*KeyServiceImpl, *objectstore.MemoryObjectStore) {
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
svc := NewKeyServiceImplWithStore(store, testBucket)
|
||||
svc.rsaKeyBits = 1024
|
||||
return svc, store
|
||||
}
|
||||
|
||||
@@ -183,7 +187,7 @@ func TestCreateKeyPair_ED25519(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateKeyPair_RSA(t *testing.T) {
|
||||
svc, _ := newTestKeyService()
|
||||
svc := NewKeyServiceImplWithStore(objectstore.NewMemoryObjectStore(), testBucket)
|
||||
|
||||
out, err := svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{
|
||||
KeyName: aws.String("my-rsa-key"),
|
||||
@@ -208,6 +212,11 @@ func TestCreateKeyPair_RSA(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
sum := sha1.Sum(der)
|
||||
|
||||
// AWS generates 2048-bit RSA key pairs.
|
||||
rsaKey, ok := rawKey.(*rsa.PrivateKey)
|
||||
require.True(t, ok, "RSA key material parsed as %T", rawKey)
|
||||
assert.Equal(t, 2048, rsaKey.N.BitLen())
|
||||
|
||||
assert.Equal(t, colonHex(sum[:]), *out.KeyFingerprint)
|
||||
// 20 hex pairs -- a SHA-1, not the 16-pair MD5 this used to return.
|
||||
assert.Regexp(t, `^([0-9a-f]{2}:){19}[0-9a-f]{2}$`, *out.KeyFingerprint)
|
||||
|
||||
@@ -17,18 +17,18 @@ import (
|
||||
|
||||
// TestRLC1_NatGatewayDeleteNotFoundOnAbsent enforces the Common Resource
|
||||
// Lifecycle Contract rule #1 (AWS-faithful delete, per-service): the EC2
|
||||
// DeleteNatGateway API returns InvalidNatGatewayID.NotFound for an absent NAT
|
||||
// DeleteNatGateway API returns NatGatewayNotFound for an absent NAT
|
||||
// gateway, not success. Idempotent convergence belongs to destroy orchestration, which
|
||||
// tolerates NotFound via awserrors.IsNotFound; the public API stays AWS compatible.
|
||||
func TestRLC1_NatGatewayDeleteNotFoundOnAbsent(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.DeleteNatGateway(t.Context(), &ec2.DeleteNatGatewayInput{
|
||||
NatGatewayId: aws.String("nat-absent00000000"),
|
||||
NatGatewayId: aws.String("nat-0a1b2c3d4e5f60718"),
|
||||
}, testAccountID)
|
||||
|
||||
require.Errorf(t, err, "DeleteNatGateway on an absent NAT gateway must return %s, not success (RLC rule #1 AWS-faithful delete): destroy orchestration tolerates NotFound, the API must not", awserrors.ErrorInvalidNatGatewayIDNotFound)
|
||||
assert.ErrorContains(t, err, awserrors.ErrorInvalidNatGatewayIDNotFound, "DeleteNatGateway on an absent NAT gateway must return the canonical NotFound (RLC rule #1)")
|
||||
require.Errorf(t, err, "DeleteNatGateway on an absent NAT gateway must return %s, not success (RLC rule #1 AWS-faithful delete): destroy orchestration tolerates NotFound, the API must not", awserrors.ErrorNatGatewayNotFound)
|
||||
assert.ErrorContains(t, err, awserrors.ErrorNatGatewayNotFound, "DeleteNatGateway on an absent NAT gateway must return the canonical NotFound (RLC rule #1)")
|
||||
}
|
||||
|
||||
// TestRLC3_NatGatewayDeletesWhileRouted enforces Q-98: a NAT gateway is
|
||||
|
||||
@@ -190,9 +190,9 @@ func (s *NatGatewayServiceImpl) DeleteNatGateway(ctx context.Context, input *ec2
|
||||
entry, err := s.natgwKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
// AWS-faithful: an absent NAT gateway is NotFound (provider
|
||||
// AWS-faithful: an absent NAT gateway is NatGatewayNotFound (provider
|
||||
// tolerates it on destroy); destroy orchestration tolerates it too.
|
||||
return nil, errors.New(awserrors.ErrorInvalidNatGatewayIDNotFound)
|
||||
return nil, errors.New(awserrors.ErrorNatGatewayNotFound)
|
||||
}
|
||||
slog.ErrorContext(ctx, "Failed to read NAT Gateway", "natGatewayId", natgwID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
@@ -419,7 +419,7 @@ func (s *NatGatewayServiceImpl) DescribeNatGateways(ctx context.Context, input *
|
||||
entry, err := s.deletedNatgwKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, errors.New(awserrors.ErrorInvalidNatGatewayIDNotFound)
|
||||
return nil, errors.New(awserrors.ErrorNatGatewayNotFound)
|
||||
}
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
@@ -205,9 +205,27 @@ func TestDescribeNatGateways_ByID(t *testing.T) {
|
||||
|
||||
// Non-existent ID
|
||||
_, err = svc.DescribeNatGateways(t.Context(), &ec2.DescribeNatGatewaysInput{
|
||||
NatGatewayIds: []*string{aws.String("nat-nope")},
|
||||
NatGatewayIds: []*string{aws.String("nat-0a1b2c3d4e5f60718")},
|
||||
}, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidNatGatewayIDNotFound)
|
||||
assert.EqualError(t, err, awserrors.ErrorNatGatewayNotFound)
|
||||
}
|
||||
|
||||
// AWS fails the whole call when any named ID is absent, rather than returning
|
||||
// the IDs it does hold.
|
||||
func TestDescribeNatGateways_UnknownIDAmongKnownFailsWholeCall(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createOut, err := svc.CreateNatGateway(t.Context(), &ec2.CreateNatGatewayInput{
|
||||
SubnetId: aws.String("subnet-pub1"),
|
||||
AllocationId: aws.String("eipalloc-test1"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := svc.DescribeNatGateways(t.Context(), &ec2.DescribeNatGatewaysInput{
|
||||
NatGatewayIds: []*string{createOut.NatGateway.NatGatewayId, aws.String("nat-0a1b2c3d4e5f60718")},
|
||||
}, testAccountID)
|
||||
|
||||
assert.EqualError(t, err, awserrors.ErrorNatGatewayNotFound)
|
||||
assert.Nil(t, out, "no partial list when one ID is absent")
|
||||
}
|
||||
|
||||
func TestDescribeNatGateways_FilterByState(t *testing.T) {
|
||||
@@ -277,9 +295,9 @@ func TestDescribeNatGateways_DeletedGatewayVisible(t *testing.T) {
|
||||
|
||||
// Non-existent ID should still error
|
||||
_, err = svc.DescribeNatGateways(t.Context(), &ec2.DescribeNatGatewaysInput{
|
||||
NatGatewayIds: []*string{aws.String("nat-never-existed")},
|
||||
NatGatewayIds: []*string{aws.String("nat-0a1b2c3d4e5f60718")},
|
||||
}, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidNatGatewayIDNotFound)
|
||||
assert.EqualError(t, err, awserrors.ErrorNatGatewayNotFound)
|
||||
}
|
||||
|
||||
func TestDescribeNatGateways_FilterByNatGatewayId(t *testing.T) {
|
||||
|
||||
@@ -388,6 +388,22 @@ func TestCreateRoute(t *testing.T) {
|
||||
assert.Equal(t, "igw-test1", record.Routes[1].GatewayId)
|
||||
}
|
||||
|
||||
// AWS answers a route to an unknown NAT gateway with InvalidNatGatewayID.NotFound,
|
||||
// unlike the NAT gateway describe and delete calls, which use NatGatewayNotFound.
|
||||
func TestCreateRoute_UnknownNatGatewayKeepsInvalidIDCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
rtbID := createTestRtb(t, svc)
|
||||
|
||||
_, err := svc.CreateRoute(t.Context(), &ec2.CreateRouteInput{
|
||||
RouteTableId: aws.String(rtbID),
|
||||
DestinationCidrBlock: aws.String("0.0.0.0/0"),
|
||||
NatGatewayId: aws.String("nat-0a1b2c3d4e5f60718"),
|
||||
}, testAccountID)
|
||||
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidNatGatewayIDNotFound)
|
||||
}
|
||||
|
||||
func TestCreateRoute_DuplicateDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
|
||||
@@ -10,6 +10,42 @@ import "errors"
|
||||
// Callers treat it as idempotent success so a retried delete does not block on a gone VM.
|
||||
var ErrSystemInstanceNotFound = errors.New("sysinstance: instance not found")
|
||||
|
||||
// ErrSystemInstanceNotSystem is returned by TerminateSystemInstance when the ID
|
||||
// names a customer instance. Nothing was touched, and it is not "gone": callers
|
||||
// must stop and never act on that ID.
|
||||
var ErrSystemInstanceNotSystem = errors.New("sysinstance: not a system instance")
|
||||
|
||||
// ErrSystemInstanceOwnershipUnknown is returned by TerminateSystemInstance when
|
||||
// the instance's record could not be read. Nothing was terminated; callers must
|
||||
// stop and retry, never treat it as the VM being gone.
|
||||
var ErrSystemInstanceOwnershipUnknown = errors.New("sysinstance: instance ownership unknown")
|
||||
|
||||
// Ownership says who an instance ID belongs to.
|
||||
type Ownership string
|
||||
|
||||
const (
|
||||
// OwnershipSystem is a platform VM: a known system manager in the system account.
|
||||
OwnershipSystem Ownership = "system"
|
||||
// OwnershipCustomer is any other instance that has a record.
|
||||
OwnershipCustomer Ownership = "customer"
|
||||
// OwnershipAbsent is an ID with no live, stopped or terminated record.
|
||||
OwnershipAbsent Ownership = "absent"
|
||||
)
|
||||
|
||||
// Classification is what the system terminate path learns about an ID. For a
|
||||
// system VM it also carries the public IP allocation and ENI recorded at launch.
|
||||
type Classification struct {
|
||||
Ownership Ownership
|
||||
PublicIPAllocID string
|
||||
ENIID string
|
||||
}
|
||||
|
||||
// OwnershipClassifier tells the system terminate path whether an ID is a
|
||||
// system VM, without exposing instance records to it.
|
||||
type OwnershipClassifier interface {
|
||||
Classify(instanceID string) (Classification, error)
|
||||
}
|
||||
|
||||
// BootMode selects how a system instance boots.
|
||||
type BootMode string
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit/deadpid"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const leakGateNode = "node-home"
|
||||
|
||||
// failedKill is the record a failed QEMU kill leaves: QEMU and volumes failed.
|
||||
func failedKill(id, node string) *vm.VM {
|
||||
return &vm.VM{
|
||||
ID: id, LastNode: node, Status: vm.StateTerminated,
|
||||
Teardown: map[string]string{vm.TeardownQEMU: string(vm.TeardownFailed), vm.TeardownVolumes: string(vm.TeardownFailed)},
|
||||
}
|
||||
}
|
||||
|
||||
// sweepAfterFailedKill runs the real leak reaper over a seeded in-use volume
|
||||
// using the leaked set the daemon derives from the terminated record.
|
||||
func sweepAfterFailedKill(t *testing.T, rec *vm.VM) (state, attached string, orphaned bool) {
|
||||
t.Helper()
|
||||
svc := newTestVolumeServiceWithStore("ap-southeast-2a", objectstore.NewMemoryObjectStore())
|
||||
seedVolume(t, svc, "vol-leakgate", "in-use", rec.ID)
|
||||
reaper := svc.NewVolumeLeakReaper(func() (map[string]bool, error) {
|
||||
return vm.LeakedVolumeInstances([]*vm.VM{rec}, leakGateNode), nil
|
||||
})
|
||||
_, err := reaper.Sweep(context.Background())
|
||||
require.NoError(t, err)
|
||||
meta, err := svc.GetVolumeMetadata(testVolAccountID, "vol-leakgate")
|
||||
require.NoError(t, err)
|
||||
return meta.State, meta.AttachedInstance, meta.Tags[orphanTagKey] != ""
|
||||
}
|
||||
|
||||
func isolatedPidDir(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||
}
|
||||
|
||||
// stamped is the failed-kill record with QEMU teardown set to state.
|
||||
func stamped(state vm.TeardownState) *vm.VM {
|
||||
rec := failedKill("i-leakgate", leakGateNode)
|
||||
rec.Teardown[vm.TeardownQEMU] = string(state)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestS7_LeakReaperNeverFreesAVolumeWhileQEMUMayBeAlive enforces ADR-0003:S7: a
|
||||
// failed kill leaves the volume in-use and attached until QEMU is proven gone.
|
||||
func TestS7_LeakReaperNeverFreesAVolumeWhileQEMUMayBeAlive(t *testing.T) {
|
||||
for _, stamp := range []vm.TeardownState{vm.TeardownFailed, vm.TeardownDone} {
|
||||
t.Run("qemu teardown "+string(stamp), func(t *testing.T) { leakGateKeepsVolume(t, stamp) })
|
||||
}
|
||||
}
|
||||
|
||||
func leakGateKeepsVolume(t *testing.T, stamp vm.TeardownState) {
|
||||
for _, content := range []string{"not-a-pid", "0", "-1"} {
|
||||
t.Run("pid file "+content, func(t *testing.T) {
|
||||
isolatedPidDir(t)
|
||||
path, err := hostprocess.GeneratePidFile("i-leakgate")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
||||
state, attached, orphaned := sweepAfterFailedKill(t, stamped(stamp))
|
||||
assert.Equal(t, "in-use", state)
|
||||
assert.Equal(t, "i-leakgate", attached)
|
||||
assert.False(t, orphaned)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("live process", func(t *testing.T) {
|
||||
isolatedPidDir(t)
|
||||
cmd := exec.Command("sleep", "60")
|
||||
require.NoError(t, cmd.Start())
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { _ = cmd.Wait() })
|
||||
t.Cleanup(func() { _ = cmd.Process.Kill(); wg.Wait() })
|
||||
require.NoError(t, hostprocess.WritePidFile("i-leakgate", cmd.Process.Pid))
|
||||
state, attached, orphaned := sweepAfterFailedKill(t, stamped(stamp))
|
||||
assert.Equal(t, "in-use", state)
|
||||
assert.Equal(t, "i-leakgate", attached)
|
||||
assert.False(t, orphaned)
|
||||
})
|
||||
|
||||
t.Run("unsignalable process", func(t *testing.T) {
|
||||
isolatedPidDir(t)
|
||||
t.Cleanup(hostprocess.SetSignalProbe(func(int) error { return syscall.EPERM }))
|
||||
require.NoError(t, hostprocess.WritePidFile("i-leakgate", deadpid.PID))
|
||||
state, attached, orphaned := sweepAfterFailedKill(t, stamped(stamp))
|
||||
assert.Equal(t, "in-use", state)
|
||||
assert.Equal(t, "i-leakgate", attached)
|
||||
assert.False(t, orphaned)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLeakReaperFreesTheVolumeOnceQEMUIsProvenGone keeps the existing outcome
|
||||
// for each proof of exit: the volume is tagged orphaned and made available.
|
||||
func TestLeakReaperFreesTheVolumeOnceQEMUIsProvenGone(t *testing.T) {
|
||||
t.Run("dead process", func(t *testing.T) {
|
||||
isolatedPidDir(t)
|
||||
require.NoError(t, hostprocess.WritePidFile("i-leakgate", deadpid.PID))
|
||||
state, attached, orphaned := sweepAfterFailedKill(t, failedKill("i-leakgate", leakGateNode))
|
||||
assert.Equal(t, "available", state)
|
||||
assert.Empty(t, attached)
|
||||
assert.True(t, orphaned)
|
||||
})
|
||||
t.Run("absent pid file", func(t *testing.T) {
|
||||
isolatedPidDir(t)
|
||||
state, _, orphaned := sweepAfterFailedKill(t, failedKill("i-leakgate", leakGateNode))
|
||||
assert.Equal(t, "available", state)
|
||||
assert.True(t, orphaned)
|
||||
})
|
||||
}
|
||||
|
||||
// TestS7_LeakedSetJudgesOnlyTheHomeNode enforces ADR-0003:S7: another node's
|
||||
// record is never in the set and its PID file is never probed. A counting probe
|
||||
// over a present PID file shows the home-node check runs before the QEMU one.
|
||||
func TestS7_LeakedSetJudgesOnlyTheHomeNode(t *testing.T) {
|
||||
isolatedPidDir(t)
|
||||
var probes int
|
||||
t.Cleanup(hostprocess.SetSignalProbe(func(int) error { probes++; return syscall.ESRCH }))
|
||||
require.NoError(t, hostprocess.WritePidFile("i-other", deadpid.PID))
|
||||
require.NoError(t, hostprocess.WritePidFile("i-empty", deadpid.PID))
|
||||
|
||||
leaked := vm.LeakedVolumeInstances([]*vm.VM{failedKill("i-other", "node-other"), failedKill("i-empty", "")}, leakGateNode)
|
||||
assert.Empty(t, leaked)
|
||||
assert.Zero(t, probes, "a PID file was judged on a node that does not own the instance")
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"fmt"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/tagmirror"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -23,6 +25,7 @@ import (
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/zoneid"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -43,11 +46,6 @@ const (
|
||||
KVBucketVPCsVersion = 1
|
||||
KVBucketSubnetsVersion = 1
|
||||
KVBucketVNICounterVersion = 1
|
||||
|
||||
// SingleZoneID is the only availability-zone id the gateway exposes. The
|
||||
// deployment is single-AZ, so every subnet and every DescribeAvailabilityZones
|
||||
// result reports it (AWS clients resolve AZ topology by zone-id, not name).
|
||||
SingleZoneID = "spinifexz1"
|
||||
)
|
||||
|
||||
// VPCRecord represents a stored VPC. AZ is stamped at create time; empty AZ
|
||||
@@ -85,6 +83,7 @@ type VPCServiceImpl struct {
|
||||
natsConn *nats.Conn
|
||||
vpcKV jetstream.KeyValue
|
||||
subnetKV jetstream.KeyValue
|
||||
zoneIDs ZoneIDSource
|
||||
vniKV jetstream.KeyValue
|
||||
eniKV jetstream.KeyValue
|
||||
sgKV jetstream.KeyValue
|
||||
@@ -133,6 +132,14 @@ func (s *VPCServiceImpl) SetPrivateDNSNamer(namer func(privateIP string) string)
|
||||
s.privateDNSName = namer
|
||||
}
|
||||
|
||||
// ZoneIDSource returns the stored availability-zone ID for a zone name.
|
||||
type ZoneIDSource interface {
|
||||
ZoneID(ctx context.Context, az string) (string, error)
|
||||
}
|
||||
|
||||
// SetZoneIDSource sets where subnets read their availability-zone ID. Without one the ID is omitted.
|
||||
func (s *VPCServiceImpl) SetZoneIDSource(src ZoneIDSource) { s.zoneIDs = src }
|
||||
|
||||
// SetCentralTagStore injects the central tag store so create paths project
|
||||
// their record tags into it and delete paths clear them. Nil leaves both
|
||||
// unchanged.
|
||||
@@ -784,6 +791,16 @@ func (s *VPCServiceImpl) CreateSubnet(ctx context.Context, input *ec2.CreateSubn
|
||||
az = s.config.AZ
|
||||
}
|
||||
|
||||
// Pin the zone's ID before anything is written, so a refused zone leaves no subnet behind.
|
||||
zoneID, err := s.zoneIDFor(ctx, az)
|
||||
if err != nil {
|
||||
if errors.Is(err, zoneid.ErrTooManyZones) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "The Region already has the maximum of %d distinct availability zones", zoneid.MaxZones)
|
||||
}
|
||||
slog.ErrorContext(ctx, "CreateSubnet: zone id pin failed", "az", az, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
subnetID := awsidentifiers.GenerateResourceID("subnet")
|
||||
|
||||
// Calculate available IPs (total hosts minus AWS reserved: network, router, DNS, future, broadcast)
|
||||
@@ -816,9 +833,7 @@ func (s *VPCServiceImpl) CreateSubnet(ctx context.Context, input *ec2.CreateSubn
|
||||
// Publish vpc.create-subnet event for vpcd topology translation
|
||||
s.publishSubnetEvent(networkv1.SubnetCreateSubject, record.SubnetId, record.VpcId, record.CidrBlock)
|
||||
|
||||
return &ec2.CreateSubnetOutput{
|
||||
Subnet: s.subnetRecordToEC2(&record, totalHosts, accountID),
|
||||
}, nil
|
||||
return &ec2.CreateSubnetOutput{Subnet: s.subnetRecordToEC2(&record, totalHosts, accountID, zoneID)}, nil
|
||||
}
|
||||
|
||||
// DeleteSubnet deletes a subnet.
|
||||
@@ -1043,7 +1058,12 @@ func (s *VPCServiceImpl) DescribeSubnets(ctx context.Context, input *ec2.Describ
|
||||
continue
|
||||
}
|
||||
|
||||
if len(parsedFilters) > 0 && !subnetMatchesFilters(&record, parsedFilters) {
|
||||
zoneID, err := s.zoneIDFor(ctx, record.AvailabilityZone)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "subnet zone id lookup failed", "subnetId", record.SubnetId, "az", record.AvailabilityZone, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if len(parsedFilters) > 0 && !subnetMatchesFilters(&record, parsedFilters, aws.StringValue(zoneID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1055,7 +1075,7 @@ func (s *VPCServiceImpl) DescribeSubnets(ctx context.Context, input *ec2.Describ
|
||||
availableIPs = max((1<<(32-ones))-5, 0) //#nosec G115 - ones from validated CIDR
|
||||
}
|
||||
|
||||
subnets = append(subnets, s.subnetRecordToEC2(&record, availableIPs, accountID))
|
||||
subnets = append(subnets, s.subnetRecordToEC2(&record, availableIPs, accountID, zoneID))
|
||||
}
|
||||
|
||||
// If specific subnet IDs were requested but not found, return error
|
||||
@@ -1245,18 +1265,19 @@ var describeSubnetsPaging = paging.EC2{
|
||||
|
||||
// describeSubnetsValidFilters defines the set of filter names accepted by DescribeSubnets.
|
||||
var describeSubnetsValidFilters = map[string]bool{
|
||||
"subnet-id": true,
|
||||
"vpc-id": true,
|
||||
"availability-zone": true,
|
||||
"cidr-block": true,
|
||||
"state": true,
|
||||
"default-for-az": true,
|
||||
"tag-key": true,
|
||||
"tag-value": true,
|
||||
"subnet-id": true,
|
||||
"vpc-id": true,
|
||||
"availability-zone": true,
|
||||
"availability-zone-id": true,
|
||||
"cidr-block": true,
|
||||
"state": true,
|
||||
"default-for-az": true,
|
||||
"tag-key": true,
|
||||
"tag-value": true,
|
||||
}
|
||||
|
||||
// subnetMatchesFilters checks whether a SubnetRecord satisfies all parsed awsfilters.
|
||||
func subnetMatchesFilters(record *SubnetRecord, filters map[string][]string) bool {
|
||||
func subnetMatchesFilters(record *SubnetRecord, filters map[string][]string, zoneID string) bool {
|
||||
for name, values := range filters {
|
||||
if awsfilters.IsTagFilter(name) {
|
||||
continue
|
||||
@@ -1270,6 +1291,8 @@ func subnetMatchesFilters(record *SubnetRecord, filters map[string][]string) boo
|
||||
field = record.VpcId
|
||||
case "availability-zone":
|
||||
field = record.AvailabilityZone
|
||||
case "availability-zone-id":
|
||||
field = zoneID
|
||||
case "cidr-block":
|
||||
field = record.CidrBlock
|
||||
case "state":
|
||||
@@ -1313,13 +1336,25 @@ func (s *VPCServiceImpl) vpcRecordToEC2(record *VPCRecord, accountID string) *ec
|
||||
return vpc
|
||||
}
|
||||
|
||||
func (s *VPCServiceImpl) subnetRecordToEC2(record *SubnetRecord, availableIPs int, accountID string) *ec2.Subnet {
|
||||
// zoneIDFor returns the stored zone ID for az, pinning it if needed. It is nil when there is no source or no zone name.
|
||||
func (s *VPCServiceImpl) zoneIDFor(ctx context.Context, az string) (*string, error) {
|
||||
if s.zoneIDs == nil || az == "" {
|
||||
return nil, nil
|
||||
}
|
||||
id, err := s.zoneIDs.ZoneID(ctx, az)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aws.String(id), nil
|
||||
}
|
||||
|
||||
func (s *VPCServiceImpl) subnetRecordToEC2(record *SubnetRecord, availableIPs int, accountID string, zoneID *string) *ec2.Subnet {
|
||||
subnet := &ec2.Subnet{
|
||||
SubnetId: aws.String(record.SubnetId),
|
||||
VpcId: aws.String(record.VpcId),
|
||||
CidrBlock: aws.String(record.CidrBlock),
|
||||
AvailabilityZone: aws.String(record.AvailabilityZone),
|
||||
AvailabilityZoneId: aws.String(SingleZoneID),
|
||||
AvailabilityZoneId: zoneID,
|
||||
State: aws.String(record.State),
|
||||
DefaultForAz: aws.Bool(record.IsDefault),
|
||||
AvailableIpAddressCount: aws.Int64(int64(availableIPs)),
|
||||
@@ -1679,3 +1714,35 @@ func (s *VPCServiceImpl) publishSubnetEvent(topic, subnetId, vpcId, cidrBlock st
|
||||
}
|
||||
client.CreateSubnet(evt)
|
||||
}
|
||||
|
||||
// SubnetAvailabilityZones returns the distinct zone names held by subnet records of every account, sorted.
|
||||
func (s *VPCServiceImpl) SubnetAvailabilityZones(ctx context.Context) ([]string, error) {
|
||||
keys, err := s.subnetKV.Keys(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("list subnets: %w", err)
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, k := range keys {
|
||||
if k == kvutil.VersionKey {
|
||||
continue
|
||||
}
|
||||
entry, err := s.subnetKV.Get(ctx, k)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read subnet %s: %w", k, err)
|
||||
}
|
||||
var rec SubnetRecord
|
||||
if err := json.Unmarshal(entry.Value(), &rec); err != nil {
|
||||
return nil, fmt.Errorf("decode subnet %s: %w", k, err)
|
||||
}
|
||||
if rec.AvailabilityZone != "" {
|
||||
seen[rec.AvailabilityZone] = struct{}{}
|
||||
}
|
||||
}
|
||||
return slices.Sorted(maps.Keys(seen)), nil
|
||||
}
|
||||
|
||||
@@ -3,15 +3,22 @@ package vpc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/zoneid"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -2554,3 +2561,135 @@ func TestVpc_ModifyVpcAttribute_MultipleAttributes(t *testing.T) {
|
||||
assert.Contains(t, msg, "enableDnsSupport")
|
||||
assert.Contains(t, msg, "enableDnsHostnames")
|
||||
}
|
||||
|
||||
func TestSubnetAvailabilityZones_DistinctSortedAcrossAccounts(t *testing.T) {
|
||||
svc := setupTestVPCService(t)
|
||||
put := func(key, az string) {
|
||||
data, err := json.Marshal(SubnetRecord{SubnetId: key, AvailabilityZone: az})
|
||||
require.NoError(t, err)
|
||||
_, err = svc.subnetKV.Put(t.Context(), key, data)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
empty, err := svc.SubnetAvailabilityZones(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, empty)
|
||||
|
||||
put("111111111111.subnet-1", "rack-b")
|
||||
put("222222222222.subnet-2", "rack-a")
|
||||
put("222222222222.subnet-3", "rack-b")
|
||||
put("222222222222.subnet-4", "")
|
||||
|
||||
got, err := svc.SubnetAvailabilityZones(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"rack-a", "rack-b"}, got)
|
||||
}
|
||||
|
||||
func TestDescribeSubnets_AvailabilityZoneIDFilter(t *testing.T) {
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
testutil.StubVpcdSGResponder(t, nc)
|
||||
svc, err := NewVPCServiceImplWithNATS(t.Context(), nil, nc)
|
||||
require.NoError(t, err)
|
||||
ids, err := zoneid.NewResolver(js, "mulga-lab", "")
|
||||
require.NoError(t, err)
|
||||
svc.SetZoneIDSource(ids)
|
||||
|
||||
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
|
||||
mk := func(cidr, az string) string {
|
||||
out, err := svc.CreateSubnet(t.Context(), &ec2.CreateSubnetInput{
|
||||
VpcId: aws.String(vpcID), CidrBlock: aws.String(cidr), AvailabilityZone: aws.String(az),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
return aws.StringValue(out.Subnet.SubnetId)
|
||||
}
|
||||
inA := mk("10.0.1.0/24", "mulga-laba")
|
||||
inB := mk("10.0.2.0/24", "mulga-labb")
|
||||
idA, err := ids.ZoneID(t.Context(), "mulga-laba")
|
||||
require.NoError(t, err)
|
||||
idB, err := ids.ZoneID(t.Context(), "mulga-labb")
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, idA, idB)
|
||||
|
||||
by := func(id string) []string {
|
||||
out, err := svc.DescribeSubnets(t.Context(), &ec2.DescribeSubnetsInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("availability-zone-id"), Values: aws.StringSlice([]string{id})}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
var got []string
|
||||
for _, s := range out.Subnets {
|
||||
got = append(got, aws.StringValue(s.SubnetId))
|
||||
}
|
||||
return got
|
||||
}
|
||||
assert.Equal(t, []string{inA}, by(idA))
|
||||
assert.Equal(t, []string{inB}, by(idB))
|
||||
assert.Empty(t, by("zz9-az9"), "an unknown zone id matches nothing and is not an error")
|
||||
}
|
||||
|
||||
func TestCreateSubnet_RefusesA65thDistinctZone(t *testing.T) {
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
testutil.StubVpcdSGResponder(t, nc)
|
||||
svc, err := NewVPCServiceImplWithNATS(t.Context(), nil, nc)
|
||||
require.NoError(t, err)
|
||||
ids, err := zoneid.NewResolver(js, "mulga-lab", "")
|
||||
require.NoError(t, err)
|
||||
svc.SetZoneIDSource(ids)
|
||||
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
|
||||
|
||||
create := func(i int, az string) error {
|
||||
_, err := svc.CreateSubnet(t.Context(), &ec2.CreateSubnetInput{
|
||||
VpcId: aws.String(vpcID), CidrBlock: aws.String(fmt.Sprintf("10.0.%d.0/24", i)), AvailabilityZone: aws.String(az),
|
||||
}, testAccountID)
|
||||
return err
|
||||
}
|
||||
for i := 1; i <= zoneid.MaxZones; i++ {
|
||||
require.NoError(t, create(i, fmt.Sprintf("rack-%02d", i)))
|
||||
}
|
||||
pinned, err := ids.Pin(t.Context(), "rack-01", "rack-64")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = create(65, "rack-65")
|
||||
testutil.RequireAWSError(t, err, awserrors.ErrorInvalidParameterValue, "The Region already has the maximum of 64 distinct availability zones")
|
||||
|
||||
out, err := svc.DescribeSubnets(t.Context(), &ec2.DescribeSubnetsInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.Subnets, zoneid.MaxZones, "the refused subnet must not exist")
|
||||
again, err := ids.Pin(t.Context(), "rack-01", "rack-64")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pinned, again)
|
||||
|
||||
require.NoError(t, create(66, "rack-01"), "an already pinned zone still takes new subnets")
|
||||
}
|
||||
|
||||
type failingZoneIDs struct{}
|
||||
|
||||
func (failingZoneIDs) ZoneID(context.Context, string) (string, error) {
|
||||
return "", errors.New("zone store down")
|
||||
}
|
||||
|
||||
func TestCreateSubnet_ZoneStoreFailureWritesNothingAndPublishesNothing(t *testing.T) {
|
||||
svc, nc := setupTestVPCServiceWithNC(t)
|
||||
svc.SetZoneIDSource(failingZoneIDs{})
|
||||
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
|
||||
|
||||
var events atomic.Int32
|
||||
sub, err := nc.Subscribe(networkv1.SubnetCreateSubject, func(*nats.Msg) { events.Add(1) })
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
_, err = svc.CreateSubnet(t.Context(), &ec2.CreateSubnetInput{
|
||||
VpcId: aws.String(vpcID), CidrBlock: aws.String("10.0.1.0/24"), AvailabilityZone: aws.String("mulga-laba"),
|
||||
}, testAccountID)
|
||||
testutil.RequireAWSError(t, err, awserrors.ErrorServerInternal, "")
|
||||
|
||||
require.NoError(t, nc.Flush())
|
||||
keys, err := svc.subnetKV.Keys(t.Context())
|
||||
if err != nil {
|
||||
require.ErrorIs(t, err, jetstream.ErrNoKeysFound)
|
||||
}
|
||||
for _, k := range keys {
|
||||
assert.Equal(t, kvutil.VersionKey, k, "no subnet record may exist")
|
||||
}
|
||||
assert.Zero(t, events.Load(), "no topology event may be published")
|
||||
}
|
||||
|
||||
@@ -121,12 +121,21 @@ func TestCreateService_CapacityProviderStrategyRange(t *testing.T) {
|
||||
// AWS resolves the task definition before range-checking the strategy, so a
|
||||
// missing task definition wins over a bad weight.
|
||||
func TestLaunch_TaskDefinitionResolvedBeforeStrategy(t *testing.T) {
|
||||
notFound := errors.New(awserrors.ErrorECSClientException)
|
||||
svc := &fakeECSService{describeErr: notFound}
|
||||
svc := &fakeECSService{describeErr: errors.New(awserrors.ErrorECSClientException)}
|
||||
_, err := runTask(context.Background(), svc, "123456789012",
|
||||
&ecs.RunTaskInput{TaskDefinition: aws.String("ghost:1"), CapacityProviderStrategy: strategy(1001, 0)}, allowCheck(t, testTaskRoleARN))
|
||||
assert.Equal(t, notFound, err)
|
||||
assertTaskDefinitionNotFound(t, err)
|
||||
_, err = createService(context.Background(), svc, "123456789012",
|
||||
&ecs.CreateServiceInput{TaskDefinition: aws.String("ghost:1"), CapacityProviderStrategy: strategy(1001, 0)}, allowCheck(t, testTaskRoleARN))
|
||||
assert.Equal(t, notFound, err)
|
||||
assertTaskDefinitionNotFound(t, err)
|
||||
assert.False(t, svc.runCalled)
|
||||
assert.False(t, svc.createCalled)
|
||||
}
|
||||
|
||||
func assertTaskDefinitionNotFound(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
||||
require.True(t, ok, "err = %v", err)
|
||||
assert.Equal(t, awserrors.ErrorECSClientException, code)
|
||||
assert.Equal(t, "TaskDefinition not found.", msg)
|
||||
}
|
||||
|
||||
@@ -139,11 +139,14 @@ func TestS8_EKSTeardownLeaseOutlivesTheRequest(t *testing.T) {
|
||||
f := newDeleteClusterFixture(t, "alpha")
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
short, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: "eks-teardown-short-ttl", TTL: time.Second})
|
||||
// A short TTL with frequent renewals keeps the wait brief while leaving
|
||||
// several renewal chances inside each TTL window.
|
||||
const ttl = 400 * time.Millisecond
|
||||
short, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: "eks-teardown-short-ttl", TTL: ttl})
|
||||
require.NoError(t, err)
|
||||
f.svc.leaderKV = short
|
||||
origTTL, origRenew := teardownLeaseTTL, teardownLeaseRenew
|
||||
teardownLeaseTTL, teardownLeaseRenew = time.Second, 200*time.Millisecond
|
||||
teardownLeaseTTL, teardownLeaseRenew = ttl, 50*time.Millisecond
|
||||
t.Cleanup(func() { teardownLeaseTTL, teardownLeaseRenew = origTTL, origRenew })
|
||||
|
||||
entered, release := blockTerminate(t, f.inst)
|
||||
@@ -152,7 +155,7 @@ func TestS8_EKSTeardownLeaseOutlivesTheRequest(t *testing.T) {
|
||||
waitEntered(t, entered)
|
||||
cancel()
|
||||
|
||||
time.Sleep(2500 * time.Millisecond)
|
||||
time.Sleep(ttl * 5 / 2)
|
||||
_, err = short.Create(t.Context(), teardownLeaderKey(testAccountID, "alpha"), []byte("node-2"))
|
||||
require.ErrorIs(t, err, jetstream.ErrKeyExists,
|
||||
"the running teardown must still hold its lease after the request context is gone")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/eks"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/systeminstance"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -551,3 +553,37 @@ func TestDeleteClusterCPVPC_OVNGCSelection(t *testing.T) {
|
||||
assert.ErrorIs(t, err, nats.ErrTimeout, "no known vpc id: nothing to GC, must not publish")
|
||||
})
|
||||
}
|
||||
|
||||
// A control-plane VM that may still be running, or is not a system VM at all,
|
||||
// must stop the whole teardown: the egress address, ENIs and network it leans on
|
||||
// stay for the retry. An ordinary terminate failure keeps going, as before.
|
||||
func TestDeleteCluster_RefusedControlPlaneTerminateStopsTeardown(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
stop bool
|
||||
}{
|
||||
{"ownership unknown", fmt.Errorf("%w: i-cp", systeminstance.ErrSystemInstanceOwnershipUnknown), true},
|
||||
{"not a system instance", fmt.Errorf("%w: i-cp", systeminstance.ErrSystemInstanceNotSystem), true},
|
||||
{"ordinary failure", errors.New("hypervisor unreachable"), false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newDeleteClusterFixture(t, "alpha")
|
||||
f.inst.terminateErr = tc.err
|
||||
|
||||
deleteAndSettle(t, f.svc, "alpha")
|
||||
|
||||
meta := requireTeardownRetained(t, f.kv, "alpha")
|
||||
assert.Equal(t, stepControlPlane, meta.DeleteFailedStep)
|
||||
if tc.stop {
|
||||
assert.Empty(t, f.eip.releaseCalls, "egress address must be left alone")
|
||||
assert.Empty(t, f.vpc.deleteCalls, "no ENI may be deleted under a VM that may be running")
|
||||
assert.Empty(t, f.vpc.detachCalls)
|
||||
return
|
||||
}
|
||||
assert.NotEmpty(t, f.eip.releaseCalls, "an ordinary failure still runs the later steps")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +301,12 @@ func TerminateK3sServerVM(ctx context.Context,
|
||||
if instanceID != "" {
|
||||
// "instance not found" on a retry is idempotent success; proceed to the ENI/SG/KV sweep.
|
||||
if err := instSvc.TerminateSystemInstance(instanceID); err != nil {
|
||||
if errors.Is(err, systeminstance.ErrSystemInstanceOwnershipUnknown) ||
|
||||
errors.Is(err, systeminstance.ErrSystemInstanceNotSystem) {
|
||||
// Possibly still running, or not ours to end: leave its ENI and the sweep.
|
||||
slog.WarnContext(ctx, "TerminateK3sServerVM: terminate refused, leaving the ENI", "instanceId", instanceID, "err", err)
|
||||
return fmt.Errorf("terminate instance %s: %w", instanceID, err)
|
||||
}
|
||||
if errors.Is(err, systeminstance.ErrSystemInstanceNotFound) {
|
||||
slog.DebugContext(ctx, "TerminateK3sServerVM: instance already gone", "instanceId", instanceID)
|
||||
} else {
|
||||
|
||||
@@ -994,3 +994,19 @@ func assertEC2TaggedAsEKSControlPlane(t *testing.T, ec2Tags []*ec2.Tag, clusterN
|
||||
assert.Equal(t, clusterName, got[clusterEKSClusterTagKey])
|
||||
assert.Equal(t, clusterEKSRoleControlPlane, got[clusterEKSRoleTagKey])
|
||||
}
|
||||
|
||||
func TestTerminateK3sServerVM_RefusedTerminateStopsBeforeENIDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, sentinel := range []error{systeminstance.ErrSystemInstanceOwnershipUnknown, systeminstance.ErrSystemInstanceNotSystem} {
|
||||
t.Run(sentinel.Error(), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
vpc := &fakeK3sVPC{}
|
||||
inst := &fakeK3sInst{terminateErr: fmt.Errorf("%w: i-aaa111", sentinel)}
|
||||
|
||||
err := TerminateK3sServerVM(context.Background(), vpc, inst, "111122223333", "i-aaa111", "eni-aaa111")
|
||||
require.ErrorIs(t, err, sentinel)
|
||||
assert.Empty(t, vpc.detachCalls, "the VM may still be running; its ENI must be left alone")
|
||||
assert.Empty(t, vpc.deleteCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1535,6 +1535,12 @@ func (s *EKSServiceImpl) teardownClusterInfra(ctx context.Context, accountID, na
|
||||
if err := TerminateK3sServerVM(ctx, s.deps.VPCK3s, s.deps.Instance,
|
||||
infraAcct, cp.InstanceID, cp.ENIID); err != nil {
|
||||
fail(stepControlPlane, fmt.Errorf("terminate K3s VM %s: %w", cp.InstanceID, err))
|
||||
// The VM may still be running or is not ours to end, so its egress,
|
||||
// ENIs and network must stay for the retry.
|
||||
if errors.Is(err, systeminstance.ErrSystemInstanceOwnershipUnknown) ||
|
||||
errors.Is(err, systeminstance.ErrSystemInstanceNotSystem) {
|
||||
return failedSteps, errors.Join(teardownErrs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2077,16 +2077,6 @@ func (s *ELBv2ServiceImpl) CreateTargetGroup(ctx context.Context, input *elbv2.C
|
||||
}
|
||||
}
|
||||
|
||||
// Check duplicate name within VPC
|
||||
existing, err := s.store.GetTargetGroupByName(ctx, name, vpcID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "CreateTargetGroup: failed to check duplicate name", "name", name, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, errors.New(awserrors.ErrorELBv2DuplicateTargetGroup)
|
||||
}
|
||||
|
||||
targetType := TargetTypeInstance
|
||||
if input.TargetType != nil && *input.TargetType != "" {
|
||||
targetType = *input.TargetType
|
||||
@@ -2147,6 +2137,23 @@ func (s *ELBv2ServiceImpl) CreateTargetGroup(ctx context.Context, input *elbv2.C
|
||||
hc.Matcher = *input.Matcher.HttpCode
|
||||
}
|
||||
|
||||
// A repeated create with the same settings, after defaults, returns the existing group.
|
||||
existing, err := s.store.GetTargetGroupByName(ctx, accountID, name, vpcID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "CreateTargetGroup: failed to check duplicate name", "name", name, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.Protocol == protocol && existing.ProtocolVersion == protocolVersion && existing.Port == port &&
|
||||
existing.TargetType == targetType && existing.HealthCheck == hc {
|
||||
return &elbv2.CreateTargetGroupOutput{
|
||||
TargetGroups: []*elbv2.TargetGroup{s.tgRecordToSDK(existing, nil)},
|
||||
}, nil
|
||||
}
|
||||
return nil, awserrors.Errorf(awserrors.ErrorELBv2DuplicateTargetGroup,
|
||||
"A target group with the same name '%s' exists, but with different settings", name)
|
||||
}
|
||||
|
||||
tgID := awsidentifiers.GenerateResourceID("tg")
|
||||
tgArn := resourcearn.FormatELBv2TargetGroup(s.region, accountID, name, tgID)
|
||||
|
||||
|
||||
@@ -659,22 +659,55 @@ func TestCreateTargetGroup_AcceptsHealthCheckBoundaryLengths(t *testing.T) {
|
||||
assert.Equal(t, maxMatcher, *out.TargetGroups[0].Matcher.HttpCode)
|
||||
}
|
||||
|
||||
func TestCreateTargetGroup_DuplicateName(t *testing.T) {
|
||||
// A repeat with the same settings, once defaults apply, returns the existing group.
|
||||
func TestCreateTargetGroup_DuplicateNameSameSettingsReturnsExisting(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
|
||||
first, err := svc.CreateTargetGroup(context.Background(), &elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("dup-tg"),
|
||||
VpcId: aws.String("vpc-1"),
|
||||
Protocol: aws.String(ProtocolHTTP),
|
||||
Port: aws.Int64(80),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := svc.CreateTargetGroup(context.Background(), &elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("dup-tg"),
|
||||
VpcId: aws.String("vpc-1"),
|
||||
Protocol: aws.String(ProtocolHTTP),
|
||||
Port: aws.Int64(80),
|
||||
TargetType: aws.String(TargetTypeInstance),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, second.TargetGroups, 1)
|
||||
assert.Equal(t, *first.TargetGroups[0].TargetGroupArn, *second.TargetGroups[0].TargetGroupArn)
|
||||
|
||||
desc, err := svc.DescribeTargetGroups(context.Background(), &elbv2.DescribeTargetGroupsInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, desc.TargetGroups, 1)
|
||||
}
|
||||
|
||||
func TestCreateTargetGroup_DuplicateNameDifferentSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
|
||||
_, err := svc.CreateTargetGroup(context.Background(), &elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("dup-tg"),
|
||||
VpcId: aws.String("vpc-1"),
|
||||
Port: aws.Int64(80),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateTargetGroup(context.Background(), &elbv2.CreateTargetGroupInput{
|
||||
Name: aws.String("dup-tg"),
|
||||
VpcId: aws.String("vpc-1"),
|
||||
Port: aws.Int64(81),
|
||||
}, testAccountID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "DuplicateTargetGroupName")
|
||||
code, msg, ok := awserrors.ResolveErrorDetail(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorELBv2DuplicateTargetGroup, code)
|
||||
assert.Equal(t, "A target group with the same name 'dup-tg' exists, but with different settings", msg)
|
||||
}
|
||||
|
||||
func TestModifyTargetGroup(t *testing.T) {
|
||||
|
||||
@@ -476,14 +476,14 @@ func (s *Store) TargetGroupInUse(ctx context.Context, tgArn string) (bool, error
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// GetTargetGroupByName finds a target group by name within a VPC.
|
||||
func (s *Store) GetTargetGroupByName(ctx context.Context, name, vpcID string) (*TargetGroupRecord, error) {
|
||||
// GetTargetGroupByName finds an account's target group by name within a VPC.
|
||||
func (s *Store) GetTargetGroupByName(ctx context.Context, accountID, name, vpcID string) (*TargetGroupRecord, error) {
|
||||
tgs, err := s.ListTargetGroups(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, tg := range tgs {
|
||||
if tg.Name == name && tg.VpcId == vpcID {
|
||||
if tg.AccountID == accountID && tg.Name == name && tg.VpcId == vpcID {
|
||||
return tg, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,12 +327,17 @@ func TestGetTargetGroupByName(t *testing.T) {
|
||||
tg := newTestTG("tgname", "named-tg")
|
||||
require.NoError(t, store.PutTargetGroup(t.Context(), tg))
|
||||
|
||||
got, err := store.GetTargetGroupByName(t.Context(), "named-tg", "vpc-test123")
|
||||
got, err := store.GetTargetGroupByName(t.Context(), testAccountID, "named-tg", "vpc-test123")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
|
||||
// Wrong VPC should not find it
|
||||
got, err = store.GetTargetGroupByName(t.Context(), "named-tg", "vpc-other")
|
||||
got, err = store.GetTargetGroupByName(t.Context(), testAccountID, "named-tg", "vpc-other")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got)
|
||||
|
||||
// Another account's group with the same name and VPC is not a match
|
||||
got, err = store.GetTargetGroupByName(t.Context(), "210987654321", "named-tg", "vpc-test123")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got)
|
||||
}
|
||||
|
||||
@@ -17,13 +17,17 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() { ipsecCertKeyBits = 2048 }
|
||||
// testIPSecKeyBits sizes every RSA key this package's tests generate. 1024 is
|
||||
// the smallest crypto/rsa accepts, and the cert structure is all these check.
|
||||
const testIPSecKeyBits = 1024
|
||||
|
||||
func init() { ipsecCertKeyBits = testIPSecKeyBits }
|
||||
|
||||
// writeTestCA writes a throwaway self-signed RSA CA in the PEM/PKCS8 form the
|
||||
// IPsec issuer loads.
|
||||
func writeTestCA(t *testing.T, certPath, keyPath string) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
key, err := rsa.GenerateKey(rand.Reader, testIPSecKeyBits)
|
||||
require.NoError(t, err)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
|
||||
@@ -233,15 +233,16 @@ func LaunchServingVM(ctx context.Context, deps LaunchDeps, in LaunchInput) (out
|
||||
// The VM is torn down first regardless of creation order: the ENI and
|
||||
// weights volume are attached while it runs, and deleting those is
|
||||
// rejected as InUse.
|
||||
var terminateVM func(context.Context)
|
||||
var terminateVM func(context.Context) (proceed bool)
|
||||
|
||||
// A context detached from the caller's, so a failure caused by the
|
||||
// caller's own deadline expiring can still clean up.
|
||||
unwind := func(ctx context.Context) {
|
||||
rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), rollbackTimeout)
|
||||
defer cancel()
|
||||
if terminateVM != nil {
|
||||
terminateVM(rbCtx)
|
||||
// Leave the ENIs and volumes alone while the VM may still be running.
|
||||
if terminateVM != nil && !terminateVM(rbCtx) {
|
||||
return
|
||||
}
|
||||
for _, v := range slices.Backward(rollback) {
|
||||
v(rbCtx)
|
||||
@@ -317,12 +318,14 @@ func LaunchServingVM(ctx context.Context, deps LaunchDeps, in LaunchInput) (out
|
||||
return nil, fmt.Errorf("bedrock: launch serving VM for %s: launcher returned no instance", in.GroupID)
|
||||
}
|
||||
instanceID := sysOut.InstanceID
|
||||
terminateVM = func(ctx context.Context) {
|
||||
if termErr := deps.Instance.TerminateSystemInstance(instanceID); termErr != nil &&
|
||||
!errors.Is(termErr, systeminstance.ErrSystemInstanceNotFound) {
|
||||
terminateVM = func(ctx context.Context) bool {
|
||||
termErr := deps.Instance.TerminateSystemInstance(instanceID)
|
||||
if termErr != nil && !errors.Is(termErr, systeminstance.ErrSystemInstanceNotFound) {
|
||||
slog.WarnContext(ctx, "bedrock: rollback terminate of failed serving VM failed",
|
||||
"group", in.GroupID, "instanceId", instanceID, "err", termErr)
|
||||
}
|
||||
return !errors.Is(termErr, systeminstance.ErrSystemInstanceOwnershipUnknown) &&
|
||||
!errors.Is(termErr, systeminstance.ErrSystemInstanceNotSystem)
|
||||
}
|
||||
|
||||
memberOut := make(map[string]LaunchMemberOutput, len(in.Members))
|
||||
@@ -366,7 +369,13 @@ func TerminateServingVM(ctx context.Context, deps LaunchDeps, rec EndpointRecord
|
||||
if rec.InstanceID != "" {
|
||||
if err := deps.Instance.TerminateSystemInstance(rec.InstanceID); err != nil &&
|
||||
!errors.Is(err, systeminstance.ErrSystemInstanceNotFound) {
|
||||
errs = append(errs, fmt.Errorf("terminate instance %s: %w", rec.InstanceID, err))
|
||||
wrapped := fmt.Errorf("terminate instance %s: %w", rec.InstanceID, err)
|
||||
if errors.Is(err, systeminstance.ErrSystemInstanceOwnershipUnknown) ||
|
||||
errors.Is(err, systeminstance.ErrSystemInstanceNotSystem) {
|
||||
// Possibly still running, or not ours to end: leave its ENI and volumes.
|
||||
return wrapped
|
||||
}
|
||||
errs = append(errs, wrapped)
|
||||
}
|
||||
}
|
||||
if rec.ENIID != "" {
|
||||
|
||||
@@ -3,9 +3,11 @@ package ochre
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/systeminstance"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -219,3 +221,37 @@ func TestTerminateServingVM_EmptyRecordIsANoop(t *testing.T) {
|
||||
assert.Empty(t, h.vpc.deleted)
|
||||
assert.Empty(t, h.volumes.deleted)
|
||||
}
|
||||
|
||||
func TestTerminateServingVM_RefusedTerminateLeavesEverything(t *testing.T) {
|
||||
for _, sentinel := range []error{systeminstance.ErrSystemInstanceOwnershipUnknown, systeminstance.ErrSystemInstanceNotSystem} {
|
||||
t.Run(sentinel.Error(), func(t *testing.T) {
|
||||
h := newLaunchHarness()
|
||||
h.launcher.terminateErr = fmt.Errorf("%w: i-42", sentinel)
|
||||
rec := EndpointRecord{
|
||||
InstanceID: "i-42",
|
||||
ENIID: "eni-42",
|
||||
Members: map[string]MemberEndpoint{testModelID: {Port: 8000, WeightsVolumeID: "vol-42"}},
|
||||
}
|
||||
|
||||
err := TerminateServingVM(t.Context(), h.deps(), rec)
|
||||
require.ErrorIs(t, err, sentinel)
|
||||
assert.Empty(t, h.vpc.deleted)
|
||||
assert.Empty(t, h.volumes.deleted)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchServingVM_RollbackKeepsResourcesWhenTerminateRefused(t *testing.T) {
|
||||
for _, sentinel := range []error{systeminstance.ErrSystemInstanceOwnershipUnknown, systeminstance.ErrSystemInstanceNotSystem} {
|
||||
t.Run(sentinel.Error(), func(t *testing.T) {
|
||||
h := newLaunchHarness()
|
||||
h.attacher = &fakeAttacher{failErr: errors.New("no free hot-plug port")}
|
||||
h.launcher.terminateErr = fmt.Errorf("%w: vm", sentinel)
|
||||
|
||||
_, err := LaunchServingVM(t.Context(), h.deps(), testLaunchInput())
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, h.vpc.deleted, "the VM may still be running; its ENI must be left alone")
|
||||
assert.Empty(t, h.volumes.deleted)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user