Compare commits

..

9 Commits

1145 changed files with 9700 additions and 34454 deletions
+1 -2
View File
@@ -110,8 +110,7 @@
"arch": "x86_64",
"runner": "ci-single-nvme",
"extra": "eks",
"suite": "eks",
"timeout_minutes": 60
"suite": "eks"
},
{
"cell": 26,
-36
View File
@@ -1,36 +0,0 @@
# Median seconds per top-level test in the single suite, from E2E push runs
# 42920949994, 42959551411 and 43080409096. e2e-shard.sh balances on these.
TestVPCEgressPaths 301
TestGuestChurnDurability 197
TestInstanceStatus 140
TestLaunchTemplateBoot 134
TestIMDS 121
TestEarlyRebootLiveness 104
TestIMDSDatapathSurvivesVswitchdRestart 102
TestAccountScoping 100
TestVolumeLifecycle 98
TestCreateImage 97
TestSGPolicyDatapath 77
TestSGReachabilityPolicy 71
TestWANEgressSMTPBlock 70
TestIAMInstanceProfileAssociation 51
TestPartialBlockConcurrentWriteDurability 50
TestSpotInstanceLifecycle 50
TestAttachToStopped 44
TestStopStart 44
TestClusterStatsCLI 9
TestInstanceClusterStats 5
TestIAMAuthorization 1
TestKVReplication 1
TestSnapshotLifecycle 1
TestCertIssuance 0
TestConsoleOutput 0
TestDiscovery 0
TestEnvironment 0
TestImageCatalogReachable 0
TestInstanceMetadata 0
TestNegativeErrorPaths 0
TestRebootLoop 0
TestSnapshotBackedLaunch 0
TestTLSPosture 0
TestVolumeStatus 0
+6 -24
View File
@@ -16,12 +16,6 @@ FLOOR_MIN=60
BASE_RUNNER=ci-single
SERVICE_RUNNER=ci-pseudo
# The single suite is most of the push gate's wall clock, so it is split across
# this many ci-single legs. The rest of the base set moves to a ci-pseudo leg,
# keeping the ci-single legs to the runners there are.
SINGLE_SHARDS="${E2E_SINGLE_SHARDS:-3}"
[[ "$SINGLE_SHARDS" =~ ^[1-9][0-9]*$ ]] || { printf '[e2e-select] error: E2E_SINGLE_SHARDS=%s\n' "$SINGLE_SHARDS" >&2; exit 1; }
log() { printf '[e2e-select] %s\n' "$*" >&2; }
die() { log "error: $*"; exit 1; }
@@ -60,31 +54,19 @@ prefixed() {
printf '%s' "$out"
}
# leg <name> <runner> <suites> [<shard> <shards>]: one matrix entry, or nothing
# for no suites. shard is 0 for a leg that runs its suites whole.
# 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")" --argjson shard "${4:-0}" --argjson shards "${5:-0}" \
'{leg: $leg, runner: $runner, suites: $suites, timeout: $timeout, shard: $shard, shards: $shards}'
--argjson timeout "$(budget "$3")" \
'{leg: $leg, runner: $runner, suites: $suites, timeout: $timeout}'
}
# emit <base-single> <service-single> <multi>
emit() {
local legs rest k
if [ "$SINGLE_SHARDS" -gt 1 ] && in_list single "$1"; then
rest=$(for s in $1; do [ "$s" = single ] || printf '%s ' "$s"; done)
legs=$( {
for ((k = 1; k <= SINGLE_SHARDS; k++)); do leg "single-$k" "${BASE_RUNNER}" single "$k" "$SINGLE_SHARDS"; done
leg base "${SERVICE_RUNNER}" "${rest% }"
leg service "${SERVICE_RUNNER}" "$2"
} | jq -sc .)
log "single legs: ${SINGLE_SHARDS} shards on ${BASE_RUNNER}"
log "base leg: ${rest:-none}"
else
legs=$( { leg base "${BASE_RUNNER}" "$1"; leg service "${SERVICE_RUNNER}" "$2"; } | jq -sc .)
log "base leg: ${1:-none}"
fi
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"
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
# Tests for e2e-select.sh's single-suite sharding: the shard legs, where the
# rest of the base set goes, and the unsharded fallback.
set -uo pipefail
here=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
select="$here/e2e-select.sh"
fail=0
check() { if [ "$2" = "$3" ]; then echo "ok $1"; else echo "FAIL $1: want '$3', got '$2'"; fail=1; fi; }
legs() { sed -n 's/^single_legs=//p'; }
out=$(bash "$select" suites "e2e-single e2e-iam" 2>/dev/null | legs)
check "three single shards on ci-single" \
"$(jq -c '[.[]|select(.suites=="e2e-single")|[.leg,.runner,.shard,.shards]]' <<<"$out")" \
'[["single-1","ci-single",1,3],["single-2","ci-single",2,3],["single-3","ci-single",3,3]]'
check "the rest of the base set runs whole on ci-pseudo" \
"$(jq -c '[.[]|select(.leg=="base")|[.runner,.suites,.shard]]' <<<"$out")" '[["ci-pseudo","e2e-iam",0]]'
out=$(bash "$select" suites "e2e-single" 2>/dev/null | legs)
check "no base leg when single is all there is" "$(jq -c '[.[]|.leg]' <<<"$out")" '["single-1","single-2","single-3"]'
out=$(bash "$select" suites "e2e-iam" 2>/dev/null | legs)
check "no shards without the single suite" "$(jq -c '[.[]|[.leg,.runner,.suites]]' <<<"$out")" '[["base","ci-single","e2e-iam"]]'
out=$(E2E_SINGLE_SHARDS=1 bash "$select" suites "e2e-single e2e-iam" 2>/dev/null | legs)
check "one shard keeps the old single leg" "$(jq -c '[.[]|[.leg,.runner,.suites]]' <<<"$out")" '[["base","ci-single","e2e-single e2e-iam"]]'
E2E_SINGLE_SHARDS=0 bash "$select" suites "e2e-single" >/dev/null 2>&1
check "rejects a zero shard count" "$?" "1"
exit "$fail"
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env bash
# Usage: e2e-shard.sh [--skip] <shard> <shards> [weights-file] < test-names
#
# Prints a regex matching shard <shard> (1-based) of <shards> from the
# top-level test names on stdin; with --skip, one matching every other shard,
# for -test.skip, so -test.run never names a test and opt-in tests stay off.
# Every leg runs this on the same list, so every test lands in exactly one
# shard, including tests added since the weights were measured.
#
# Longest-first onto the least-loaded shard, using measured seconds from the
# weights file ("TestName seconds" per line) and DEFAULT_WEIGHT_S otherwise.
# Prints nothing for an empty shard; the caller skips the suite. Exits 3 when
# stdin holds no test names, so a failed listing cannot pass as an empty shard.
set -euo pipefail
mode=run
if [ "${1:-}" = --skip ]; then mode=skip; shift; fi
shard="${1:?usage: $0 <shard> <shards> [weights-file] < names}"
shards="${2:?usage: $0 <shard> <shards> [weights-file] < names}"
weights="${3:-/dev/null}"
default_w="${DEFAULT_WEIGHT_S:-60}"
[[ "$shard" =~ ^[0-9]+$ && "$shards" =~ ^[0-9]+$ ]] || { echo "e2e-shard: shard and shards must be integers" >&2; exit 2; }
[ "$shards" -ge 1 ] && [ "$shard" -ge 1 ] && [ "$shard" -le "$shards" ] ||
{ echo "e2e-shard: shard $shard is not in 1..$shards" >&2; exit 2; }
[ -r "$weights" ] || { echo "e2e-shard: cannot read $weights" >&2; exit 2; }
awk -v mode="$mode" -v shard="$shard" -v shards="$shards" -v dflt="$default_w" -v wfile="$weights" '
BEGIN { while ((getline line < wfile) > 0) { split(line, f, " "); if (f[1] ~ /^Test/ && f[2] ~ /^[0-9]+$/) w[f[1]] = f[2] } }
$1 ~ /^Test[A-Za-z0-9_]*$/ && !seen[$1]++ { n++; name[n] = $1; wt[n] = ($1 in w) ? w[$1] : dflt }
END {
if (n == 0) { print "e2e-shard: no Test names on stdin" > "/dev/stderr"; exit 3 }
# Heaviest first, ties by name, so every caller orders identically.
for (i = 2; i <= n; i++) {
kn = name[i]; kw = wt[i]
for (j = i - 1; j >= 1 && (wt[j] < kw || (wt[j] == kw && name[j] > kn)); j--) {
name[j + 1] = name[j]; wt[j + 1] = wt[j]
}
name[j + 1] = kn; wt[j + 1] = kw
}
for (s = 1; s <= shards; s++) load[s] = 0
out = ""
for (i = 1; i <= n; i++) {
best = 1
for (s = 2; s <= shards; s++) if (load[s] < load[best]) best = s
load[best] += wt[i]
if ((best == shard) == (mode == "run")) out = out (out == "" ? "" : "|") name[i]
}
if (out != "") print "^(" out ")$"
}
'
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env bash
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
# Tests for e2e-shard.sh: the shards partition the input exactly, balance by
# weight, and reject bad arguments.
set -uo pipefail
here=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
shard="$here/e2e-shard.sh"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
fail=0
check() { if [ "$2" = "$3" ]; then echo "ok $1"; else echo "FAIL $1: want '$3', got '$2'"; fail=1; fi; }
# Names the regexes select, one per line, sorted.
selected() { sed -E 's/^\^\(//; s/\)\$$//' | tr '|' '\n' | grep -v '^$' | sort; }
printf 'TestA\nTestB\nTestC\nTestD\nTestE\nTestF\nTestG\nBenchmarkX\nExampleY\nTestA\n' >"$tmp/names"
printf 'TestA 300\nTestB 200\nTestC 150\n# comment\nTestZ 999\n' >"$tmp/w"
all=""
for k in 1 2 3; do
all+=$("$shard" "$k" 3 "$tmp/w" <"$tmp/names" | selected)$'\n'
done
check "every test in exactly one shard" "$(grep -v '^$' <<<"$all" | sort | tr '\n' ' ')" "TestA TestB TestC TestD TestE TestF TestG "
check "benchmarks, examples and duplicates are dropped" "$(grep -c -E 'Benchmark|Example' <<<"$all")" "0"
# 300 | 200 | 150, then the four 60s defaults onto the lightest each time.
check "shard 1 takes the heaviest" "$("$shard" 1 3 "$tmp/w" <"$tmp/names")" '^(TestA)$'
check "lighter shards absorb the defaults" "$("$shard" 3 3 "$tmp/w" <"$tmp/names")" '^(TestC|TestD|TestF)$'
check "--skip matches every other shard" \
"$("$shard" --skip 3 3 "$tmp/w" <"$tmp/names" | selected | tr '\n' ' ')" "TestA TestB TestE TestG "
check "--skip of a lone shard matches nothing" "$("$shard" --skip 1 1 <"$tmp/names")" ""
check "one shard selects everything" "$("$shard" 1 1 <"$tmp/names" | selected | wc -l)" "7"
check "an empty shard prints nothing" "$(printf 'TestA\n' | "$shard" 2 2)" ""
check "the same input gives the same answer" "$("$shard" 2 3 "$tmp/w" <"$tmp/names")" "$("$shard" 2 3 "$tmp/w" <"$tmp/names")"
"$shard" 4 3 </dev/null 2>/dev/null
check "rejects a shard past the count" "$?" "2"
"$shard" 1 x </dev/null 2>/dev/null
check "rejects a non-integer count" "$?" "2"
"$shard" 1 2 "$tmp/missing" </dev/null 2>/dev/null
check "rejects an unreadable weights file" "$?" "2"
"$shard" 1 2 </dev/null 2>/dev/null
check "fails on an empty listing" "$?" "3"
printf 'ok \tgithub.com/x\nBenchmarkX\n' | "$shard" 1 2 2>/dev/null
check "fails on a listing with no tests" "$?" "3"
exit "$fail"
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env bash
# Usage: hv-lock.sh <env.tfvars> -- <command> [args...]
# or: . hv-lock.sh; hv_lock_acquire <env.tfvars> (held until the shell exits)
#
# Holds an exclusive lock on every hypervisor the tofu env names. Concurrent
# destroy, scrub and apply against one libvirtd drop its connections, so
# provisioning is serialised per hypervisor, not per env.
#
# Locks are files on the runner host, so they only exclude runners that share
# it; every e2e runner lives on one host. Hosts are locked in sorted order, so
# two envs sharing hypervisors cannot deadlock.
#
# Env: HV_LOCK_DIR ($HOME/.cache/spinifex-ci/hv-locks), HV_LOCK_TIMEOUT_S (1800).
# hv_lock_hosts <env.tfvars>: the distinct node names, sorted, one per line.
hv_lock_hosts() {
grep -v '^[[:space:]]*#' "$1" |
sed -nE 's/.*\{[[:space:]]*name[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' | sort -u
}
# hv_lock_acquire <env.tfvars>: locks every host, or returns 1 on timeout and
# 2 on a file it cannot use. The descriptors stay open in the calling shell.
hv_lock_acquire() {
local tfvars="$1" dir="${HV_LOCK_DIR:-$HOME/.cache/spinifex-ci/hv-locks}"
local timeout_s="${HV_LOCK_TIMEOUT_S:-1800}" hosts host fd start remaining
if [ ! -r "$tfvars" ]; then
echo "::error::hv-lock: cannot read $tfvars" >&2
return 2
fi
hosts=$(hv_lock_hosts "$tfvars")
if [ -z "$hosts" ]; then
echo "::error::hv-lock: no node names in $tfvars; refusing to run unlocked" >&2
return 2
fi
mkdir -p "$dir" || { echo "::error::hv-lock: cannot create $dir" >&2; return 2; }
start=$SECONDS
for host in $hosts; do
exec {fd}>"$dir/$host.lock" || { echo "::error::hv-lock: cannot open $dir/$host.lock" >&2; return 2; }
if ! flock -n "$fd"; then
echo "hv-lock: waiting for $host"
remaining=$(( timeout_s - (SECONDS - start) ))
if [ "$remaining" -le 0 ] || ! flock -w "$remaining" "$fd"; then
echo "::error::hv-lock: $host still locked after ${timeout_s}s; another job is provisioning on it" >&2
return 1
fi
fi
done
echo "hv-lock: holding $(tr '\n' ' ' <<<"$hosts")(waited $(( SECONDS - start ))s)"
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
set -uo pipefail
usage() { echo "usage: $0 <env.tfvars> -- <command> [args...]" >&2; exit 2; }
[ $# -ge 3 ] && [ "$2" = "--" ] || usage
tfvars="$1"
shift 2
hv_lock_acquire "$tfvars" || exit $?
"$@"
fi
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env bash
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
# Tests for hv-lock.sh: host parsing, exclusion between envs sharing a
# hypervisor, independence of envs that share none, and the timeout.
set -uo pipefail
here=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
lock="$here/hv-lock.sh"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
export HV_LOCK_DIR="$tmp/locks"
fail=0
check() { if [ "$2" = "$3" ]; then echo "ok $1"; else echo "FAIL $1: want '$3', got '$2'"; fail=1; fi; }
cat >"$tmp/a.tfvars" <<'EOF'
cluster_name = "envA"
nodes = [
{ name = "casuarina", wan_ip = "192.168.0.22", storage_pool = "drive2-pool" },
# { name = "banksia", wan_ip = "192.168.0.99", storage_pool = "drive1-pool" },
{ name = "bottlebrush", wan_ip = "192.168.0.23", storage_pool = "drive2-pool" },
]
EOF
cat >"$tmp/b.tfvars" <<'EOF'
nodes = [
{ name = "bottlebrush", wan_ip = "192.168.0.40", storage_pool = "drive1-pool" },
]
EOF
cat >"$tmp/c.tfvars" <<'EOF'
nodes = [
{ name = "wattle", wan_ip = "192.168.0.50", storage_pool = "drive1-pool" },
]
EOF
printf 'cluster_name = "empty"\n' >"$tmp/empty.tfvars"
out=$("$lock" "$tmp/a.tfvars" -- echo ran)
check "runs the command" "$(tail -n1 <<<"$out")" "ran"
check "locks named hosts, skips commented ones" "$(ls "$HV_LOCK_DIR" | tr '\n' ' ')" "bottlebrush.lock casuarina.lock "
"$lock" "$tmp/a.tfvars" -- false
check "returns the command's status" "$?" "1"
"$lock" "$tmp/empty.tfvars" -- true 2>/dev/null
check "refuses a file with no nodes" "$?" "2"
"$lock" "$tmp/a.tfvars" true 2>/dev/null
check "requires the -- separator" "$?" "2"
# Sourced: the lock is held by the calling shell until it exits.
# shellcheck source=hv-lock.sh
( . "$lock"; hv_lock_acquire "$tmp/c.tfvars" >/dev/null; sleep 2 ) &
sleep 0.5
HV_LOCK_TIMEOUT_S=1 "$lock" "$tmp/c.tfvars" -- true >/dev/null 2>&1
check "a sourced acquire holds the lock in its shell" "$?" "1"
wait
"$lock" "$tmp/c.tfvars" -- true >/dev/null
check "and releases it when that shell exits" "$?" "0"
# A holds bottlebrush for 2s; B shares it and must wait, C shares nothing.
"$lock" "$tmp/a.tfvars" -- sleep 2 >/dev/null &
sleep 0.5
t0=$SECONDS
HV_LOCK_TIMEOUT_S=10 "$lock" "$tmp/b.tfvars" -- true >/dev/null
check "a shared hypervisor waits" "$(( SECONDS - t0 >= 1 ))" "1"
wait
"$lock" "$tmp/a.tfvars" -- sleep 3 >/dev/null &
sleep 0.5
t0=$SECONDS
"$lock" "$tmp/c.tfvars" -- true >/dev/null
check "a disjoint env does not wait" "$(( SECONDS - t0 ))" "0"
HV_LOCK_TIMEOUT_S=1 "$lock" "$tmp/b.tfvars" -- true >/dev/null 2>&1
check "gives up after the timeout" "$?" "1"
wait
exit "$fail"
+2 -2
View File
@@ -2,7 +2,7 @@
# start-net-capture.sh — kick off background tcpdump on br-wan.
# Pair: capture-net-diag.sh stops it and copies the pcap into net-diag/.
#
# Filter: anything on br-wan involving the WAN segment (192.168.0.0/21) plus
# Filter: anything on br-wan involving the EIP pool (192.168.0.0/23) plus
# ARP for the same range. Single ring buffer (-W 1 -G 0) → grows until stop;
# capped at 64 MiB via -C 64. Snaplen 200 bytes (headers + a bit) to keep
# size sane while preserving L2/L3/L4 + first payload bytes.
@@ -29,7 +29,7 @@ fi
sudo nohup tcpdump -i br-wan -s 200 -C 64 -W 1 -U \
-w "$PCAP" \
'(net 192.168.0.0/21) or (arp and (arp[24:4]&0xfffff800=0xc0a80000))' \
'(net 192.168.0.0/23) or (arp and (arp[24:4]&0xfffffe00=0xc0a80000))' \
>/tmp/spinifex-e2e-tcpdump.log 2>&1 &
PID=$!
echo "$PID" | sudo tee "$PIDFILE" >/dev/null
-3
View File
@@ -203,9 +203,6 @@ e2e_suite_timeout() {
# sweep after its last write-back, on top of three stop/starts and a boot.
# A pass is estimated at 40 minutes, unmeasured; the rest is the watch's cap.
uefistore) echo "60m" ;;
# eks finishes rather than hangs: passing runs took 26-29m, and one ran
# out at 30m inside DeleteCluster. The default left it minutes of headroom.
eks) echo "45m" ;;
*) echo "30m" ;;
esac
}
+8 -8
View File
@@ -65,7 +65,7 @@ workflows:
- 'actions/setup-go@v7.0.0'
- 'actions/upload-artifact@v7.0.2'
- 'docker/setup-buildx-action@v4.4.1'
- 'softprops/action-gh-release@v3.0.4'
- 'softprops/action-gh-release@v3.0.3'
'.github/workflows/segscan-oracle.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
@@ -79,7 +79,7 @@ workflows:
- 'actions/checkout@v7.0.1'
- 'actions/setup-node@v7.1.0'
- 'actions/upload-artifact@v7.0.2'
- 'pnpm/action-setup@v6.1.1'
- 'pnpm/action-setup@v6.1.0'
dependencies:
'actions/cache@v6.1.0':
ref: 'v6.1.0'
@@ -131,13 +131,13 @@ dependencies:
commit: 'sha1-dfe3c3f87815947d99a8997f908cb6525fc44e9e'
owner_id: 761456
repo_id: 258271226
'pnpm/action-setup@v6.1.1':
ref: 'v6.1.1'
commit: 'sha1-73e46992e05b19abd31f2f3c20e0e7370d762115'
'pnpm/action-setup@v6.1.0':
ref: 'v6.1.0'
commit: 'sha1-ea17c68df8912ef543352723c149a84f56e3d413'
owner_id: 21320719
repo_id: 262289747
'softprops/action-gh-release@v3.0.4':
ref: 'v3.0.4'
commit: 'sha1-8a0cd651e5b2365bd640854a3fa5dfc70e4875f3'
'softprops/action-gh-release@v3.0.3':
ref: 'v3.0.3'
commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64'
owner_id: 2242
repo_id: 204253808
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
topologies:
description: "Space-separated topologies (blank = vm-single vm-multi bm vm-single-principal vm-single-ocibsd vm-multi-ocibsd). One name runs one topology."
description: "Space-separated topologies (blank = vm-single vm-multi bm vm-single-principal). One name runs one topology."
required: false
workbooks:
description: "Space-separated workbooks (blank = the driver's default list)"
@@ -62,10 +62,9 @@ jobs:
# a dedicated label keeps a two-hour cloud wait off the hypervisor labels and
# lifts the six-hour cap a hosted job carries.
runs-on: ${{ inputs.runner || vars.OCI_E2E_RUNNER || 'ubuntu-26.04' }}
# Six topologies including a bare-metal build, each with its own install,
# formation and workbook pass, so this is the hosted runner's cap. The sweep
# still runs if this fires, ocibsd volumes and backups included.
timeout-minutes: 360
# Three topologies including a bare-metal build, each with its own install,
# formation and workbook pass. The sweep still runs if this fires.
timeout-minutes: 300
steps:
# Set here rather than in a job-level env block: the runner context is not
# available at job level, only from a step onwards, and a workflow that
@@ -237,8 +236,8 @@ jobs:
# always(), and before the artifact upload: an OCI bare-metal host left
# running by a cancelled or timed-out job is the most expensive way this
# workflow can fail. It destroys by state file, then deletes OCI volumes and
# backups by the cluster tag each ocibsd topology recorded.
# workflow can fail. The sweep works from the Terraform state files, so it
# can only destroy what this run created.
- name: Sweep anything left behind
if: always() && !inputs.keep_on_fail
working-directory: spinifex/scripts/terraform/oci-spx
-5
View File
@@ -98,8 +98,6 @@ jobs:
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars"
export TF_VAR_git_branch="${GIT_BRANCH}"
quiet_run "tofu init" "$TOFU_LOG" tofu init -input=false
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
@@ -228,9 +226,6 @@ jobs:
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars" || {
echo "::error::teardown skipped: ${SPINIFEX_CI_ENV} is left up for the next job's pre-clean destroy"; exit 1; }
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
retry_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
+6 -12
View File
@@ -131,11 +131,9 @@ jobs:
matrix.host }}${{ matrix.extra && format('/{0}', matrix.extra) || '' }})"
needs: select
if: needs.select.outputs.any == 'true'
# Six is the ci-single and ci-multi runner count. Provisioning is serialised
# per hypervisor by hv-lock.sh, which is what made more than three unsafe.
strategy:
fail-fast: false
max-parallel: 6
max-parallel: 3
matrix:
include: ${{ fromJSON(needs.select.outputs.cells) }}
runs-on: [self-hosted, "${{ matrix.runner }}"]
@@ -232,8 +230,6 @@ jobs:
esac
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
@@ -909,9 +905,6 @@ jobs:
oracle-*) TOFU_VAR_ARGS="-var=host_image_override=${ORACLE_GOLD_IMAGE} -var=host_os=oracle" ;;
esac
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars" || {
echo "::error::teardown skipped: ${SPINIFEX_CI_ENV} is left up for the next job's pre-clean destroy"; exit 1; }
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
# shellcheck disable=SC2086
@@ -1419,7 +1412,7 @@ jobs:
# also overrides cancellation and keeps the run starting jobs after a cancel.
performance:
name: cell-23 (predastore/throughput)
needs: [matrix, baremetal, iso-single, iso-multi, stress]
needs: [matrix, baremetal, iso-single, iso-multi]
if: >-
!cancelled() && (
inputs.cells == '' ||
@@ -1525,11 +1518,12 @@ jobs:
# Fault injection, not throughput. Cell-23 asks how fast predastore is when
# every node is healthy; this asks whether it survives one that is not.
#
# Beside the cells rather than after them: it measures survival, not speed.
# Cell-23 waits for it, so the benchmark still never shares a machine with it.
# After cell-23 rather than beside it: both want a quiet single runner, and a
# benchmark sharing a machine with a fault injection run measures the wrong
# thing. The port offset keeps them apart even so.
stress:
name: cell-24 (predastore/fault-injection)
needs: [select]
needs: [matrix, baremetal, iso-single, iso-multi, performance]
if: >-
!cancelled() && (
inputs.cells == '' ||
-10
View File
@@ -174,8 +174,6 @@ jobs:
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars"
export TF_VAR_git_branch="${GIT_BRANCH}"
quiet_run "tofu init" "$TOFU_LOG" tofu init -input=false
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
@@ -502,9 +500,6 @@ jobs:
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars" || {
echo "::error::teardown skipped: ${SPINIFEX_CI_ENV} is left up for the next job's pre-clean destroy"; exit 1; }
if [ -n "${WAN_IP}" ] && [ -n "${SSH_KEY}" ]; then
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "${SSH_KEY}" \
tf-user@${WAN_IP} \
@@ -629,8 +624,6 @@ jobs:
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars"
export TF_VAR_git_branch="${GIT_BRANCH}"
quiet_run "tofu init" "$TOFU_LOG" tofu init -input=false
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
@@ -890,9 +883,6 @@ jobs:
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars" || {
echo "::error::teardown skipped: ${SPINIFEX_CI_ENV} is left up for the next job's pre-clean destroy"; exit 1; }
if [ -n "${NODES_CSV}" ] && [ -n "${SSH_KEY}" ]; then
IFS=',' read -ra NODE_LIST <<< "${NODES_CSV}"
for ip in "${NODE_LIST[@]}"; do
+3 -42
View File
@@ -65,8 +65,6 @@ jobs:
EVENT: ${{ github.event_name }}
BEFORE: ${{ github.event.before }}
IN_SUITES: ${{ inputs.suites }}
# A test_run regex already narrows the suite, so it runs in one leg.
E2E_SINGLE_SHARDS: ${{ inputs.test_run && '1' || '' }}
run: |
set -euo pipefail
SELECT=.github/scripts/e2e-select.sh
@@ -95,18 +93,15 @@ jobs:
# 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 }}
# Non-zero on a single-suite shard leg: which share of the suite it runs.
SHARD: ${{ matrix.shard }}
SHARDS: ${{ matrix.shards }}
# 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' || startsWith(matrix.leg, 'single-') && matrix.leg || format('single-{0}', matrix.leg) }}
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=${{ matrix.leg == 'base' && 'single' || startsWith(matrix.leg, 'single-') && matrix.leg || format('single-{0}', matrix.leg) }}
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: |
@@ -192,8 +187,6 @@ jobs:
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars"
export TF_VAR_git_branch="${GIT_BRANCH}"
quiet_run "tofu init" "$TOFU_LOG" tofu init -input=false
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
@@ -466,28 +459,6 @@ jobs:
else
TEST_RUN_FLAG=""
fi
# A shard leg runs only the single suite, so the regex applies to it alone.
# Every leg lists the same binary, so the shards partition the suite.
if [ "${SHARDS:-0}" -gt 0 ]; then
BIN="${GITHUB_WORKSPACE}/spinifex/tests/e2e/_bin/single.test"
# Listed separately so a failed listing fails the leg; the sharder
# also refuses a listing with no tests in it.
# The leg skips the other shards' tests rather than naming its own:
# a test named in -test.run counts as opted in (TestRebootLoop).
SHARDER="${GITHUB_WORKSPACE}/spinifex/.github/scripts/e2e-shard.sh"
WEIGHTS="${GITHUB_WORKSPACE}/spinifex/.github/e2e-weights/single.txt"
TEST_LIST=$("${BIN}" -test.list '.*') &&
SHARD_RE=$("${SHARDER}" "${SHARD}" "${SHARDS}" "${WEIGHTS}" <<<"${TEST_LIST}") &&
SKIP_RE=$("${SHARDER}" --skip "${SHARD}" "${SHARDS}" "${WEIGHTS}" <<<"${TEST_LIST}") || {
echo "::error::cannot list or shard the single suite"; echo "rc=1" >> "$GITHUB_OUTPUT"; exit 0; }
if [ -z "${SHARD_RE}" ]; then
echo "shard ${SHARD}/${SHARDS} has no tests; skipping"
echo "rc=0" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "shard ${SHARD}/${SHARDS}: $(tr '|' '\n' <<<"${SHARD_RE}" | wc -l) tests: ${SHARD_RE}"
if [ -n "${SKIP_RE}" ]; then TEST_RUN_FLAG="-test.skip='${SKIP_RE}'"; fi
fi
VM_HOST="tf-user@${{ steps.vm.outputs.wan_ip }}"
SSH_KEY="${{ steps.vm.outputs.ssh_key }}"
cleanup_remote() {
@@ -623,9 +594,6 @@ jobs:
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars" || {
echo "::error::teardown skipped: ${SPINIFEX_CI_ENV} is left up for the next job's pre-clean destroy"; exit 1; }
if [ -n "${{ steps.vm.outputs.wan_ip }}" ] && [ -n "${{ steps.vm.outputs.ssh_key }}" ]; then
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
-i "${{ steps.vm.outputs.ssh_key }}" \
@@ -770,8 +738,6 @@ jobs:
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars"
export TF_VAR_git_branch="${GIT_BRANCH}"
quiet_run "tofu init" "$TOFU_LOG" tofu init -input=false
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
@@ -1087,9 +1053,6 @@ jobs:
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars" || {
echo "::error::teardown skipped: ${SPINIFEX_CI_ENV} is left up for the next job's pre-clean destroy"; exit 1; }
if [ -n "${{ steps.cluster.outputs.node1_ip }}" ] && [ -n "${{ steps.cluster.outputs.ssh_key }}" ]; then
IFS=',' read -ra NODE_LIST <<< "${{ steps.cluster.outputs.nodes_csv }}"
for ip in "${NODE_LIST[@]}"; do
@@ -1182,9 +1145,7 @@ jobs:
name: ES CI Run Summary
needs: [plan, e2e_single, e2e_multi]
if: always()
# Any self-hosted runner reaches the sink, and this holds no env. Pinned
# to ci-single it waited up to 40 minutes behind a nightly's cells.
runs-on: [self-hosted]
runs-on: [self-hosted, ci-single]
timeout-minutes: 5
permissions:
actions: read
-5
View File
@@ -167,8 +167,6 @@ jobs:
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars"
export TF_VAR_git_branch="${GIT_BRANCH}"
quiet_run "tofu init" "$TOFU_LOG" tofu init -input=false
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
@@ -386,9 +384,6 @@ jobs:
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars" || {
echo "::error::teardown skipped: ${SPINIFEX_CI_ENV} is left up for the next job's pre-clean destroy"; exit 1; }
UID_SELF=$(id -u)
WS="${GITHUB_WORKSPACE}"
reap() {
+18 -54
View File
@@ -1,20 +1,21 @@
name: Publish System AMIs
# Builds the system AMIs and publishes each compressed qcow2 + .sha256 sidecar
# to the Cloudflare R2 bucket that backs iso.mulgadc.com/system-ami/. The Alpine
# images build with libguestfs (build-system-image.sh); the Ubuntu GPU images
# build with mkosi, which installs from resolute-updates and resolute-security,
# so each release ships the newest kernel. Eight images ship:
# spinifex-eks-node Alpine EKS K3s node (server|agent, role at boot)
# spinifex-ecs-node Alpine ECS container instance
# spinifex-rds-postgres Alpine RDS PostgreSQL instance
# spinifex-rds-mariadb Alpine RDS MariaDB instance
# spinifex-eks-node-gpu Ubuntu EKS K3s GPU worker (mkosi)
# spinifex-ecs-node-gpu Ubuntu ECS GPU container instance (mkosi)
# ubuntu-26.04-nvidia-gpu-x86_64 Ubuntu NVIDIA GPU base image (mkosi)
# ubuntu-26.04-amd-gpu-x86_64 Ubuntu AMD ROCm GPU base image (mkosi)
# Builds the system AMIs (libguestfs, on the runner) and publishes each
# compressed qcow2 + .sha256 sidecar to the Cloudflare R2 bucket that backs
# iso.mulgadc.com/system-ami/. Six images ship:
# spinifex-eks-node Alpine EKS K3s node (server|agent, role at boot)
# spinifex-ecs-node Alpine ECS container instance
# spinifex-eks-node-gpu Ubuntu EKS K3s GPU worker (NVIDIA driver + toolkit)
# spinifex-ecs-node-gpu Ubuntu ECS GPU container instance
# spinifex-rds-postgres Alpine RDS PostgreSQL instance
# spinifex-rds-mariadb Alpine RDS MariaDB instance
# Operators then install them on any cluster with:
# spx admin images import --name <catalog key>
# spx admin images import --name spinifex-eks-node
# spx admin images import --name spinifex-ecs-node
# spx admin images import --name spinifex-eks-node-gpu
# spx admin images import --name spinifex-ecs-node-gpu
# spx admin images import --name spinifex-rds-postgres
# spx admin images import --name spinifex-rds-mariadb
#
# Publishing overwrites the mirror artifact, so it runs only from release.yml
# once a tag has passed its gates, with siblings at the tags go.mod requires.
@@ -53,12 +54,9 @@ jobs:
VARIANT: ${{ inputs.variant }}
run: |
set -euo pipefail
# builder=legacy entries carry a manifest for build-system-image.sh;
# builder=mkosi entries carry the mkosi-build.sh --image name and the
# R2 object base, which must equal the catalog URL basename.
cpu='{"name":"spinifex-eks-node","builder":"legacy","manifest":"scripts/images/eks-node/manifest.conf"},{"name":"spinifex-ecs-node","builder":"legacy","manifest":"scripts/images/ecs-agent/manifest.conf"}'
gpu='{"name":"spinifex-eks-node-gpu","builder":"mkosi","image":"spinifex-eks-node-gpu","object":"spinifex-eks-node-gpu-x86_64"},{"name":"spinifex-ecs-node-gpu","builder":"mkosi","image":"spinifex-ecs-node-gpu","object":"spinifex-ecs-node-gpu-x86_64"},{"name":"ubuntu-26.04-nvidia-gpu","builder":"mkosi","image":"ubuntu-gpu-nvidia","object":"ubuntu-26.04-nvidia-gpu-x86_64"},{"name":"ubuntu-26.04-amd-gpu","builder":"mkosi","image":"ubuntu-gpu-amd","object":"ubuntu-26.04-amd-gpu-x86_64"}'
rds='{"name":"spinifex-rds-postgres","builder":"legacy","manifest":"scripts/images/rds-postgres/manifest.conf"},{"name":"spinifex-rds-mariadb","builder":"legacy","manifest":"scripts/images/rds-mariadb/manifest.conf"}'
cpu='{"name":"spinifex-eks-node","manifest":"scripts/images/eks-node/manifest.conf"},{"name":"spinifex-ecs-node","manifest":"scripts/images/ecs-agent/manifest.conf"}'
gpu='{"name":"spinifex-eks-node-gpu","manifest":"scripts/images/eks-node-gpu/manifest.conf"},{"name":"spinifex-ecs-node-gpu","manifest":"scripts/images/ecs-agent-gpu/manifest.conf"}'
rds='{"name":"spinifex-rds-postgres","manifest":"scripts/images/rds-postgres/manifest.conf"},{"name":"spinifex-rds-mariadb","manifest":"scripts/images/rds-mariadb/manifest.conf"}'
case "${VARIANT}" in
cpu) items="${cpu}" ;;
gpu) items="${gpu}" ;;
@@ -134,8 +132,6 @@ jobs:
go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
- name: Install build + upload tooling
# mkosi legs build and upload inside the pinned builder container.
if: matrix.builder == 'legacy'
run: |
set -euo pipefail
sudo apt-get update -qq
@@ -148,7 +144,6 @@ jobs:
fi
- name: Build + publish ${{ matrix.name }} to R2
if: matrix.builder == 'legacy'
working-directory: spinifex
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
@@ -173,34 +168,3 @@ jobs:
FLAGS+=(--dry-run)
fi
./scripts/publish-system-image.sh "${MANIFEST}" "${FLAGS[@]}"
- name: Build + publish ${{ matrix.name }} to R2 (mkosi)
if: matrix.builder == 'mkosi'
working-directory: spinifex
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.CF_R2_ENDPOINT_URL }}
R2_BUCKET: spinifex-iso
DRY_RUN: ${{ inputs.dry_run }}
IMAGE: ${{ matrix.image }}
OBJECT: ${{ matrix.object }}
# On-disk scratch for the same reason as the legacy legs: the GPU raw
# and its qcow2 do not fit a RAM-backed /tmp.
MKOSI_OUTPUT_DIR: ${{ runner.temp }}/mkosi-output
run: |
set -euo pipefail
mkdir -p "${MKOSI_OUTPUT_DIR}"
./scripts/mkosi-build.sh --image "${IMAGE}" -- --manifest-format=json
# The kernel this image boots, from mkosi's package manifest.
KERNEL=$(jq -r '.packages[] | select(.name | test("^linux-image-[0-9]")) | "\(.name) \(.version)"' "${MKOSI_OUTPUT_DIR}/${IMAGE}.manifest")
if [[ -z "${KERNEL}" ]]; then
echo "::error::no linux-image-<version> package in ${IMAGE}.manifest" >&2
exit 1
fi
echo "kernel: ${KERNEL}"
FLAGS=(--image "${IMAGE}" --name "${OBJECT}")
if [[ "${DRY_RUN}" == "true" ]]; then
FLAGS+=(--dry-run)
fi
./scripts/mkosi-publish.sh "${FLAGS[@]}"
+1 -6
View File
@@ -150,7 +150,7 @@ jobs:
path: artifacts
- name: Create Draft GitHub Release
uses: softprops/action-gh-release@v3.0.4
uses: softprops/action-gh-release@v3.0.3
with:
draft: true
files: |
@@ -218,8 +218,6 @@ jobs:
working-directory: mulga/scripts/tofu-cluster
run: |
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
@@ -238,9 +236,6 @@ jobs:
working-directory: mulga/scripts/tofu-cluster
run: |
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/hv-lock.sh"
hv_lock_acquire "envs/${SPINIFEX_CI_ENV}.tfvars" || {
echo "::error::teardown skipped: ${SPINIFEX_CI_ENV} is left up for the next job's pre-clean destroy"; exit 1; }
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
retry_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
+6 -37
View File
@@ -263,21 +263,9 @@ jobs:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ github.run_id }}
# Three parallel jobs: run serially, integration and the gated suites made
# this the longest PR check. lint_and_security_result reports all three.
lint_and_security:
name: ${{ matrix.name }}
name: Lint + Security + Integration
runs-on: ubuntu-26.04
strategy:
fail-fast: false
matrix:
include:
- check: lint
name: Lint and Govulncheck
- check: integration
name: Integration
- check: gated
name: Gated Suites
steps:
- name: Checkout Spinifex
@@ -350,10 +338,8 @@ jobs:
uses: actions/cache/restore@v6.1.0
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ matrix.check }}-${{ github.run_id }}
restore-keys: |
gobuild-${{ github.job }}-${{ matrix.check }}-
gobuild-${{ github.job }}-
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
@@ -366,22 +352,20 @@ jobs:
run: go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
- name: Lint
if: ${{ matrix.check == 'lint' }}
uses: golangci/golangci-lint-action@v9.3.0
with:
version: v2.14.0
working-directory: spinifex
- name: Govulncheck
if: ${{ matrix.check == 'lint' && !cancelled() }}
run: make -C spinifex govulncheck
- name: Run Integration Tests
if: ${{ matrix.check == 'integration' }}
if: ${{ !cancelled() }}
run: make -C spinifex test-integration
- name: Run Gated Suites
if: ${{ matrix.check == 'gated' }}
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
@@ -396,19 +380,4 @@ jobs:
uses: actions/cache/save@v6.1.0
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ matrix.check }}-${{ github.run_id }}
# The context main's ruleset requires, passing only when all three jobs pass.
lint_and_security_result:
name: Lint and Security
if: ${{ always() }}
needs: [lint_and_security]
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
- name: Require lint, integration and gated suites
env:
RESULT: ${{ needs.lint_and_security.result }}
run: |
echo "lint, integration and gated suites: ${RESULT}"
[ "${RESULT}" = success ]
key: gobuild-${{ github.job }}-${{ github.run_id }}
+2 -2
View File
@@ -51,7 +51,7 @@ jobs:
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
- name: Setup pnpm
uses: pnpm/action-setup@v6.1.1
uses: pnpm/action-setup@v6.1.0
with:
package_json_file: spinifex/runtime/roles/spinifexui/frontend/package.json
@@ -127,7 +127,7 @@ jobs:
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
- name: Setup pnpm
uses: pnpm/action-setup@v6.1.1
uses: pnpm/action-setup@v6.1.0
with:
package_json_file: spinifex/runtime/roles/spinifexui/frontend/package.json
+20 -24
View File
@@ -276,15 +276,8 @@ install-microvm: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Install microVM arti
# -j2 overlaps the only two heavy gates, lint and test-cover: alone each leaves
# most of the machine idle, so pairing them cuts a cold run by about a quarter.
# -Otarget keeps each gate's output in one block instead of interleaving them.
# The shell suites mostly wait rather than compute, so they run beside the -j2
# Go gates instead of taking one of their two slots.
preflight:
@sh_log=$$(mktemp); trap 'rm -f "$$sh_log"' EXIT; \
$(MAKE) --no-print-directory QUIET=1 test-build-scripts > "$$sh_log" 2>&1 & sh_pid=$$!; \
$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness; go_rc=$$?; \
wait $$sh_pid; sh_rc=$$?; \
cat "$$sh_log"; \
[ "$$go_rc" -eq 0 ] && [ "$$sh_rc" -eq 0 ]
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness test-build-scripts
@echo -e "\n ✅ Preflight passed — safe to commit."
# Shell suites + shellcheck for build/scripts/, the systemd-unit helpers that
@@ -298,24 +291,27 @@ preflight:
# deletion, not a style note.
NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh scripts/lib/distro.sh scripts/lib/stage-sqs-store-microvm.sh
# One target per suite so make runs them in parallel; -k reports every failing
# suite rather than the first, and -Otarget keeps each suite's output together.
SHELL_SUITES := $(addprefix shell-suite/,$(sort $(shell find build/scripts -name '*_test.sh'; \
find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib scripts/appliance -name '*_test.sh'; \
find scripts/terraform/latitude-spx .github/scripts -maxdepth 1 -name '*_test.sh')))
.PHONY: $(SHELL_SUITES)
$(SHELL_SUITES): shell-suite/%:
@t=$*; log=$$(mktemp); trap 'rm -f "$$log"' EXIT; $(_RUNSH)
test-build-scripts:
@$(_HDR) "\n....Running the *_test.sh suites in parallel...."
@$(MAKE) --no-print-directory -k -j$$(nproc) -Otarget $(SHELL_SUITES)
@$(_HDR) "\n....Running build/scripts/**/*_test.sh...."
@log=$$(mktemp); trap 'rm -f "$$log"' EXIT; for t in $$(find build/scripts -name '*_test.sh' | sort); do \
$(_RUNSH); \
done
@$(_HDR) "\n....Running scripts/*_test.sh, scripts/lib/*_test.sh and scripts/appliance/*_test.sh...."
@log=$$(mktemp); trap 'rm -f "$$log"' EXIT; for t in $$( (find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib scripts/appliance -name '*_test.sh') | sort); do \
$(_RUNSH); \
done
@$(_HDR) "\n....Running scripts/terraform/latitude-spx/*_test.sh...."
@log=$$(mktemp); trap 'rm -f "$$log"' EXIT; for t in $$(find scripts/terraform/latitude-spx -maxdepth 1 -name '*_test.sh' | sort); do \
$(_RUNSH); \
done
@$(_HDR) "\n....Running .github/scripts/*_test.sh...."
@log=$$(mktemp); trap 'rm -f "$$log"' EXIT; for t in $$(find .github/scripts -maxdepth 1 -name '*_test.sh' | sort); do \
$(_RUNSH); \
done
$(_Q)shellcheck -S warning .github/scripts/hypervisor-disk-preflight.sh \
.github/scripts/wait-for-memory.sh \
.github/scripts/go-build-cache.sh \
.github/scripts/e2e-select.sh \
.github/scripts/e2e-shard.sh \
.github/scripts/hv-lock.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)
@@ -519,9 +515,9 @@ install-system:
fi
install-go:
@echo -e "\n....Installing Go 1.27.4 for $(ARCH) ($(GO_ARCH))...."
@echo -e "\n....Installing Go 1.27.3 for $(ARCH) ($(GO_ARCH))...."
@if [ ! -d "/usr/local/go" ]; then \
curl -L https://go.dev/dl/go1.27.4.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
curl -L https://go.dev/dl/go1.27.3.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
else \
echo "Go already installed in /usr/local/go"; \
fi
+2 -2
View File
@@ -70,8 +70,8 @@ The platform serves **405 AWS API operations** across the services below. See ex
| **RDS** | Databases | PostgreSQL and MariaDB, with automated backups | Available |
| **Route 53** | DNS | [Northstar](https://github.com/mulgadc/northstar): authoritative DNS with hosted zones | Available |
| **Bedrock** | AI deployment | vLLM for text generation and Hugging Face TEI for embeddings and reranking | Q4 2026 |
| **Lambda** | Serverless functions | Firecracker microVM execution environments | Q1 2027 |
| **SQS** | Message queues | A dedicated NATS JetStream store in a per-Region microVM guest | Q1 2027 |
| **Lambda** | Serverless functions | QEMU `microvm` execution environments | Q1 2027 |
| **SQS** | Message queues | A dedicated NATS JetStream store in a per-Region QEMU `microvm` guest | Q1 2027 |
Roadmap services ship under the same AWS API surface, so code written for AWS today works the moment they land. [Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
+1 -3
View File
@@ -1,6 +1,6 @@
ARG VERSION=dev
FROM golang:1.27.4-bookworm@sha256:4fa7c968ba93f1635670522dbb01a2c78150443e1385746924566079f36570ea AS builder
FROM golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9 AS builder
ARG VERSION
@@ -51,8 +51,6 @@ RUN cp spinifex/scripts/setup.sh /out/setup.sh 2>/dev/null || true
RUN cp spinifex/scripts/setup-ovn.sh /out/setup-ovn.sh
RUN cd spinifex/scripts && cp install-node.sh smoke-test.sh node-reset.sh uninstall-spx.sh /out/
RUN mkdir -p /out/udev && cp spinifex/build/udev/* /out/udev/
# Staged by setup.sh on an OCI VM; spx admin init or join installs it on ocibsd nodes.
RUN cp -r spinifex/build/ocibsd /out/ocibsd
RUN mkdir -p /out/lib && cp spinifex/scripts/lib/distro.sh /out/lib/distro.sh
RUN mkdir -p /out/systemd && cp spinifex/build/systemd/* /out/systemd/
RUN mkdir -p /out/scripts && cp spinifex/build/scripts/* /out/scripts/
+1 -2
View File
@@ -15,7 +15,6 @@ SQSW_LIB=${SQSW_LIB:-/usr/local/lib/sqs-worker/sqs-worker-lib.sh}
SQSW_NATS_BIN=${SQSW_NATS_BIN:-/usr/local/bin/nats-server}
SQSW_STORE_PORT=${SQSW_STORE_PORT:-4222}
SQSW_STOP_BUDGET_TENTHS=${SQSW_STOP_BUDGET_TENTHS:-100}
SQSW_KILL_WAIT_TENTHS=${SQSW_KILL_WAIT_TENTHS:-30}
SQSW_RESPAWN_GAP=${SQSW_RESPAWN_GAP:-5}
SQSW_PARK=${SQSW_PARK:-sleep 2147483647}
SQSW_BOOT_RETRY=${SQSW_BOOT_RETRY:-30}
@@ -159,7 +158,7 @@ sqsw_end() {
sqsw_log "stop: $1 pid=$pid still running after $((SQSW_STOP_BUDGET_TENTHS / 10))s, SIGKILL"
kill -KILL "$pid" 2>/dev/null || true
j=0
while sqsw_same "$pid" "$st" && [ "$j" -lt "$SQSW_KILL_WAIT_TENTHS" ]; do sleep 0.1; j=$((j + 1)); done
while sqsw_same "$pid" "$st" && [ "$j" -lt 30 ]; do sleep 0.1; j=$((j + 1)); done
fi
if sqsw_same "$pid" "$st"; then
sqsw_log "stop: $1 pid=$pid STILL ALIVE"
-4
View File
@@ -1,4 +0,0 @@
# Give QEMU (group spinifex) read-write on the disks of OCI Block Volume
# attachments, named by ID_PATH, which is also the /dev/disk/by-path link.
# Partitions and every other block device keep their default ownership.
SUBSYSTEM=="block", ACTION!="remove", ENV{DEVTYPE}=="disk", ENV{ID_PATH}=="ip-*-iscsi-iqn.2015-12.com.oracleiaas:*", GROUP="spinifex", MODE="0660"
@@ -1,7 +0,0 @@
# ocibsd nodes only: QEMU, in the daemon's device cgroup, opens the SCSI disk
# an iSCSI login creates, including one attached after the daemon started.
[Unit]
After=spinifex-ocibsd.service
[Service]
DeviceAllow=block-sd rw
-67
View File
@@ -1,67 +0,0 @@
# spinifex-unit-version: 1
[Unit]
Description=Spinifex ocibsd (OCI Block Volumes)
PartOf=spinifex.target
After=spinifex-nats.service iscsid.socket iscsid.service
Wants=spinifex-nats.service iscsid.socket
[Service]
EnvironmentFile=-/etc/spinifex/telemetry.env
Type=simple
Slice=spinifex-system.slice
# Root without capabilities: iscsid answers iscsiadm only for uid 0, and the
# node records, lock and devices it touches are root-owned. iscsid does the
# privileged session and module work.
User=root
Group=spinifex
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service ocibsd start
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
OOMScoreAdjust=-500
MemoryHigh=10%
MemoryMax=20%
EnvironmentFile=/etc/spinifex/systemd.env
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/ocibsd/
StateDirectory=spinifex/ocibsd
# Filesystem access: iscsiadm writes node records and its lock
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=no
DevicePolicy=closed
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/systemd.env /etc/spinifex/ca.pem
ReadWritePaths=/var/log/spinifex -/etc/iscsi -/var/lib/iscsi -/run/lock
# read-only, not yes: a config_file credential may default to ~/.oci/config
ProtectHome=read-only
# Hardening
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
AmbientCapabilities=
# The kernel hides an iSCSI session's credential attributes without CAP_SYS_ADMIN,
# and iscsiadm then lists no session and logs out nothing.
CapabilityBoundingSet=CAP_SYS_ADMIN
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
-7
View File
@@ -8,7 +8,6 @@ import (
"github.com/mulgadc/predastore/s3api"
"github.com/mulgadc/spinifex/internal/awsmodel"
"github.com/mulgadc/spinifex/internal/awsmodel/sdkv1"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
@@ -97,12 +96,6 @@ func compareAll() ([]awsmodel.OperationCoverage, error) {
return nil, fmt.Errorf("no gateway inventory for %s", service)
}
sdkV1, ok := sdkv1.Operations(service)
if !ok {
return nil, fmt.Errorf("no aws-sdk-go v1 client registered for %s in internal/awsmodel/sdkv1", service)
}
modelInventory.SDKV1Operations = sdkV1
coverage, err := awsmodel.CompareOperations(service, modelInventory)
if err != nil {
return nil, err
@@ -1,96 +0,0 @@
package main
import (
"fmt"
"slices"
"testing"
"github.com/mulgadc/spinifex/internal/awsmodel"
"github.com/mulgadc/spinifex/internal/awsmodel/sdkv1"
)
type serviceOperation struct {
service awsmodel.Service
operation string
}
// stubsKeptWithoutSDKV1 lists the operations registered as a stub or a refusal
// although aws-sdk-go v1 cannot call them. Each one returns an error and never
// a fake success. Do not add to it: delete the registration instead.
var stubsKeptWithoutSDKV1 = map[serviceOperation]string{
{awsmodel.ECR, "ListRepositories"}: "returns NotImplemented (501); registered before the SDK v1 check existed",
{awsmodel.ECR, "ReplicateImage"}: "returns NotImplemented (501); registered before the SDK v1 check existed",
{awsmodel.ECR, "GetImageScanningConfiguration"}: "returns OperationNotSupportedException because Spinifex runs no scanner; registered before the SDK v1 check existed",
}
// noSDKV1Violations returns the stubbed or refused operations absent from
// aws-sdk-go v1 and not allowlisted, and the allowlist entries that no longer
// apply. A real handler is not checked: only a no-op registration is the
// problem this guards.
func noSDKV1Violations(coverages []awsmodel.OperationCoverage, allow map[serviceOperation]string) (violations, stale []string, err error) {
seen := map[serviceOperation]bool{}
for _, coverage := range coverages {
if coverage.Opaque {
continue
}
v1, ok := sdkv1.Operations(coverage.Service)
if !ok {
return nil, nil, fmt.Errorf("no aws-sdk-go v1 client for %s", coverage.Service)
}
for _, operation := range slices.Concat(coverage.Stubbed, coverage.Unsupported) {
if slices.Contains(v1, operation) {
continue
}
key := serviceOperation{coverage.Service, operation}
seen[key] = true
if _, listed := allow[key]; !listed {
violations = append(violations, fmt.Sprintf("%s %s", coverage.Service, operation))
}
}
}
for key := range allow {
if !seen[key] {
stale = append(stale, fmt.Sprintf("%s %s", key.service, key.operation))
}
}
slices.Sort(stale)
return violations, stale, nil
}
func TestNoStubsForOperationsAbsentFromSDKV1(t *testing.T) {
coverages, err := compareAll()
if err != nil {
t.Fatal(err)
}
violations, stale, err := noSDKV1Violations(coverages, stubsKeptWithoutSDKV1)
if err != nil {
t.Fatal(err)
}
for _, v := range violations {
t.Errorf("%s is registered as a stub or refusal but is not in aws-sdk-go v1, so nothing Spinifex is tested with can call it. "+
"Remove the registration: an unregistered operation already returns the proper unsupported error, and a stub that fakes success is worse. "+
"A coverage \"missing\" entry is not a task, and an operation in requires_sdk_v2 is out of scope until the SDK v2 migration (internal/awsmodel/models/README.md)", v)
}
for _, s := range stale {
t.Errorf("%s is allowlisted in stubsKeptWithoutSDKV1 but is no longer a stub or refusal absent from aws-sdk-go v1: remove the entry", s)
}
}
func TestNoSDKV1GuardFlagsAnUnlistedStub(t *testing.T) {
coverage := awsmodel.OperationCoverage{
Service: awsmodel.ECR,
Stubbed: []string{"ListRepositories", "PutImage"},
}
violations, stale, err := noSDKV1Violations([]awsmodel.OperationCoverage{coverage}, map[serviceOperation]string{
{awsmodel.ECR, "Retired"}: "gone",
})
if err != nil {
t.Fatal(err)
}
if want := []string{"ecr ListRepositories"}; !slices.Equal(violations, want) {
t.Errorf("violations = %v, want %v (PutImage is in SDK v1)", violations, want)
}
if want := []string{"ecr Retired"}; !slices.Equal(stale, want) {
t.Errorf("stale = %v, want %v", stale, want)
}
}
-3
View File
@@ -84,9 +84,6 @@ type DetachVolumeData struct {
type AttachENIData struct {
NetworkInterfaceID string `json:"network_interface_id"`
DeviceIndex int64 `json:"device_index"`
// AssignFreeIndex skips the occupied-index refusal for platform callers that
// accept whichever hot-plug slot is free. The customer API never sets it.
AssignFreeIndex bool `json:"assign_free_index,omitempty"`
}
// DetachENIData carries parameters for a detach-network-interface command.
+92
View File
@@ -0,0 +1,92 @@
# Viperblockd legacy contracts, version 1
This package owns the deployed, transitional configuration-update and
state-reload request/reply protocols between Spinifex's legacy control plane
and `viperblockd`. It makes those existing compatibility boundaries explicit
while the generic `providers/ebs` contract is adopted; it is not the EBS
provider abstraction and new provider integrations must not depend on it.
## Configuration update routes
`ConfigUpdateSubject` is `ebs.config`. Every `viperblockd` daemon subscribes
to it in the `spinifex-workers` queue group. It is the fallback path for an
encrypted volume with no live mount: the selected daemon opens the volume
exclusively and reseals its state.
`VolumeConfigUpdateSubject(volume)` is `ebs.config.<volume>`. The daemon with
the live mounted volume subscribes directly so the sole holder of the volume's
state sequence number applies the update.
Both routes carry `EBSConfigUpdateRequest` and respond with
`EBSConfigUpdateResponse`. The JSON field spelling, route shapes, and
request/reply semantics are deployed compatibility behaviour. Incompatible
changes require a new contract version or an explicit migration plan.
## State reload route
`SyncSubject` is `ebs.sync`. Every `viperblockd` worker subscribes to it in the `spinifex-workers` queue group, and the selected worker reloads the volume's state from its backend and responds with `EBSSyncResponse`.
It returns an error when it does not own a live mount of the volume.
No control-plane code sends this request: `ExpandVolume` persists the new size before it returns and refuses a mounted volume, so every later mount loads the new size at open.
The subscriber stays until the legacy subjects are retired.
The route carries `EBSSyncRequest` and `EBSSyncResponse`. Their JSON shape and
the queue-group route are compatibility behaviour.
## Volume lifecycle routes
`MountSubject(node)` and `UnmountSubject(node)` retain the deployed mount and
unmount routes. With a node name they address that node's Viperblockd process
(`ebs.<node>.mount` or `ebs.<node>.unmount`); without one they retain the
single-node `spinifex-workers` queue-group routes. Both carry `EBSRequest`.
`EBSMountResponse` supplies the exported NBD URI, while
`EBSUnMountResponse` reports the seal result. The response broadcasts remain
`ebs.mount.response` and `ebs.unmount.response` alongside ordinary
request/reply replies.
`DeleteSubject` retains the `ebs.delete` queue-group route. It carries
`EBSDeleteRequest` and responds with `EBSDeleteResponse` after Viperblockd has
removed its local state and export. These routes and JSON shapes are deployed
legacy compatibility behaviour, not a generic EBS-provider interface.
## Abandon route
`VolumeAbandonSubject(node)` is `ebs.<node>.abandon`. The daemon uses
request/reply to ask the Viperblock adapter on that node to tear an export
down without sealing, because the instance using it belongs to another node
now. The route carries `VolumeAbandonRequest` and responds with
`VolumeAbandonResponse`. `Abandoned` is false with no error when the volume
was not exported there, which is the ordinary case: the request is
idempotent by design, since the caller is reconciling rather than
transacting.
## Dirty-marker bucket
`DirtyBucket` is the JetStream KV bucket `VIPERBLOCK_VOLUME_DIRTY`, keyed by
volume name under `VolumeKeyPattern`, storing `DirtyRecord` JSON. It records
which node holds writes for a volume that the backend may not have.
Owner: `providers/ebs/viperblock` owns the dirty-volume state and its
transitions. `services/viperblockd` was only its transitional custodian.
Producers: only the Viperblock adapter writes, clears or changes a dirty
record.
Consumers: Viperblock recovery and mount logic on any node. `spx admin` may
obtain a read-only operational view.
Storage contract: the bucket name, `VolumeKeyPattern` and the `DirtyRecord`
JSON shape are this package's legacy v1 contract. There is no TTL; a record
persists until an explicit adapter-owned recovery or unseal transition
removes it.
Compatibility: this is a deployed legacy v1 contract, and coordinated
writers and readers support N/N-1. No renames, type changes or semantic
reinterpretation of existing fields; a breaking change needs a successor
contract and a migration.
CLI authority: the CLI is never a writer and must not perform arbitrary
bucket operations.
This package does not authorize requests, validate a volume ID, store volume
state, or define the generic storage-provider API. Those concerns remain with
the consuming daemon, the EC2 domain, and `providers/ebs` respectively.
@@ -0,0 +1,36 @@
package viperblocklegacyv1
// VolumeAbandonSubject is where the local daemon asks for an export to be torn
// down because the instance using it belongs to another node now.
//
// It exists because an unmount is the wrong request and killing the guest is not
// enough. An unmount seals, and this node's copy is the stale one, so sealing
// would publish an older state over the winner's. Meanwhile the export holds the
// volume lease, and a lease this node keeps renewing is one the winner can never
// acquire — the guest would be stopped here and unable to start anywhere.
//
// Node-addressed, like mount and unmount: the export is local, and no other
// node's viperblockd has anything it could tear down.
func VolumeAbandonSubject(node string) string {
if node == "" {
return "ebs.abandon"
}
return "ebs." + node + ".abandon"
}
// VolumeAbandonRequest asks for one volume's export to go away without sealing.
// Reason is logged, so a node that dropped an export can be told apart from one
// that was fenced.
type VolumeAbandonRequest struct {
Volume string `json:"volume"`
Reason string `json:"reason"`
}
// VolumeAbandonResponse reports what happened. Abandoned is false with no error
// when the volume was not exported here, which is the ordinary case and not a
// failure: the request is idempotent by design, since the caller is reconciling
// rather than transacting.
type VolumeAbandonResponse struct {
Abandoned bool `json:"abandoned"`
Error string `json:"error,omitempty"`
}
@@ -0,0 +1,63 @@
package viperblocklegacyv1_test
import (
"encoding/json"
"testing"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestVolumeAbandonSubject_BothForms pins the abandon route: node-addressed,
// with an empty-node fallback for a single-node daemon.
func TestVolumeAbandonSubject_BothForms(t *testing.T) {
assert.Equal(t, "ebs.node-a.abandon", viperblocklegacyv1.VolumeAbandonSubject("node-a"))
assert.Equal(t, "ebs.abandon", viperblocklegacyv1.VolumeAbandonSubject(""))
}
// TestVolumeAbandonRequest_JSON pins the request runtime/daemon/vm_adapters.go sends
// on the abandon route.
func TestVolumeAbandonRequest_JSON(t *testing.T) {
golden := `{"volume":"vol-1","reason":"instance owned by another node"}`
req := viperblocklegacyv1.VolumeAbandonRequest{Volume: "vol-1", Reason: "instance owned by another node"}
marshaled, err := json.Marshal(req)
require.NoError(t, err)
assert.JSONEq(t, golden, string(marshaled))
var decoded viperblocklegacyv1.VolumeAbandonRequest
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
assert.Equal(t, req, decoded)
}
// TestVolumeAbandonResponse_JSON pins both response shapes: the ordinary
// reconciling case with no error, and the failure case where Error is set.
// Error is omitempty, so the two must not be mistaken for the same shape.
func TestVolumeAbandonResponse_JSON(t *testing.T) {
t.Run("abandoned, no error", func(t *testing.T) {
golden := `{"abandoned":true}`
resp := viperblocklegacyv1.VolumeAbandonResponse{Abandoned: true}
marshaled, err := json.Marshal(resp)
require.NoError(t, err)
assert.JSONEq(t, golden, string(marshaled))
var decoded viperblocklegacyv1.VolumeAbandonResponse
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
assert.Equal(t, resp, decoded)
})
t.Run("not abandoned, with error", func(t *testing.T) {
golden := `{"abandoned":false,"error":"nbdkit did not exit"}`
resp := viperblocklegacyv1.VolumeAbandonResponse{Abandoned: false, Error: "nbdkit did not exit"}
marshaled, err := json.Marshal(resp)
require.NoError(t, err)
assert.JSONEq(t, golden, string(marshaled))
var decoded viperblocklegacyv1.VolumeAbandonResponse
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
assert.Equal(t, resp, decoded)
})
}
@@ -0,0 +1,37 @@
// Package viperblocklegacyv1 contains the versioned, transitional wire
// contracts between Spinifex's legacy control-plane routes and viperblockd.
package viperblocklegacyv1
import "encoding/json"
const (
// ConfigUpdateSubject is the legacy queue-group route for an encrypted
// volume configuration update when no mounted volume owns the request.
ConfigUpdateSubject = "ebs.config"
)
// VolumeConfigUpdateSubject returns the legacy route for an update handled by
// the node with a live mount of volume. Volume remains unvalidated here: route
// authorization and volume-name validation are responsibilities of the
// consumer, as they were before this contract was extracted.
func VolumeConfigUpdateSubject(volume string) string {
return ConfigUpdateSubject + "." + volume
}
// EBSConfigUpdateRequest carries a control-plane VolumeConfig update for an
// encrypted volume. config.json is a sealed VBState; only the master-key holder
// (viperblockd) can reseal it, so the EC2 edge ships the new config here instead
// of rewriting the object directly. VolumeConfig is a marshaled
// viperblock.VolumeConfig (RawMessage keeps this contract dependency-free).
type EBSConfigUpdateRequest struct {
Volume string `json:"Volume"`
VolumeConfig json.RawMessage `json:"VolumeConfig"`
}
// EBSConfigUpdateResponse is the request/reply response for either legacy
// configuration-update route.
type EBSConfigUpdateResponse struct {
Volume string `json:"Volume"`
Success bool `json:"Success"`
Error string `json:"Error"`
}
@@ -0,0 +1,35 @@
package viperblocklegacyv1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestConfigUpdateSubjects(t *testing.T) {
require.Equal(t, "ebs.config", ConfigUpdateSubject)
require.Equal(t, "ebs.config.vol-123", VolumeConfigUpdateSubject("vol-123"))
// The deployed route builder did not validate its final token. Validation
// remains with the handler and is not silently introduced by this move.
require.Equal(t, "ebs.config.", VolumeConfigUpdateSubject(""))
}
func TestConfigUpdateRequestJSONContract(t *testing.T) {
data, err := json.Marshal(EBSConfigUpdateRequest{
Volume: "vol-123",
VolumeConfig: json.RawMessage(`{"size_gib":8}`),
})
require.NoError(t, err)
require.JSONEq(t, `{"Volume":"vol-123","VolumeConfig":{"size_gib":8}}`, string(data))
}
func TestConfigUpdateResponseJSONContract(t *testing.T) {
data, err := json.Marshal(EBSConfigUpdateResponse{
Volume: "vol-123",
Success: true,
Error: "",
})
require.NoError(t, err)
require.JSONEq(t, `{"Volume":"vol-123","Success":true,"Error":""}`, string(data))
}
@@ -1,4 +1,4 @@
package dirty
package viperblocklegacyv1
import (
"regexp"
@@ -1,11 +1,11 @@
package dirty_test
package viperblocklegacyv1_test
import (
"encoding/json"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/providers/ebs/viperblock/dirty"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -13,7 +13,7 @@ import (
// TestDirtyBucket_Name pins the JetStream KV bucket name. Renaming it without
// a migration would strand every existing dirty marker in an unread bucket.
func TestDirtyBucket_Name(t *testing.T) {
assert.Equal(t, "VIPERBLOCK_VOLUME_DIRTY", dirty.DirtyBucket)
assert.Equal(t, "VIPERBLOCK_VOLUME_DIRTY", viperblocklegacyv1.DirtyBucket)
}
// TestDirtyRecord_JSON pins the stored JSON shape read across nodes from the
@@ -21,7 +21,7 @@ func TestDirtyBucket_Name(t *testing.T) {
func TestDirtyRecord_JSON(t *testing.T) {
since := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
golden := `{"owner":"node-a","generation":7,"since":"2024-01-02T03:04:05Z","reason":"seal volume: predastore unreachable"}`
record := dirty.DirtyRecord{
record := viperblocklegacyv1.DirtyRecord{
Owner: "node-a",
Generation: 7,
Since: since,
@@ -32,7 +32,7 @@ func TestDirtyRecord_JSON(t *testing.T) {
require.NoError(t, err)
assert.JSONEq(t, golden, string(marshaled))
var decoded dirty.DirtyRecord
var decoded viperblocklegacyv1.DirtyRecord
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
assert.True(t, decoded.Since.Equal(since))
decoded.Since = since
@@ -46,14 +46,14 @@ func TestDirtyRecord_JSON(t *testing.T) {
func TestVolumeKeyPattern_PinsValidAndInvalidKeys(t *testing.T) {
valid := []string{"vol-1", "VOL_1", "abcXYZ09", "a", "A-B_c9"}
for _, key := range valid {
if !dirty.VolumeKeyPattern.MatchString(key) {
if !viperblocklegacyv1.VolumeKeyPattern.MatchString(key) {
t.Errorf("expected %q to be accepted as a volume key", key)
}
}
invalid := []string{"", "vol.1", "vol/1", "vol:1", "vol 1", "vol>1", "ebs.*", "day's"}
for _, key := range invalid {
if dirty.VolumeKeyPattern.MatchString(key) {
if viperblocklegacyv1.VolumeKeyPattern.MatchString(key) {
t.Errorf("expected %q to be rejected as a volume key", key)
}
}
+22
View File
@@ -0,0 +1,22 @@
package viperblocklegacyv1
const (
// SyncSubject is the legacy queue-group route that asks a viperblockd
// worker to reload a mounted volume's state from its backend.
SyncSubject = "ebs.sync"
)
// EBSSyncRequest identifies the volume whose mounted Viperblock state should
// be reloaded by the selected legacy worker.
type EBSSyncRequest struct {
Volume string `json:"Volume"`
}
// EBSSyncResponse reports the selected worker's reload result. A worker that
// does not own the mounted volume returns an error rather than forwarding the
// request.
type EBSSyncResponse struct {
Volume string `json:"Volume"`
Synced bool `json:"Synced"`
Error string `json:"Error"`
}
@@ -0,0 +1,28 @@
package viperblocklegacyv1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestSyncSubject(t *testing.T) {
require.Equal(t, "ebs.sync", SyncSubject)
}
func TestSyncRequestJSONContract(t *testing.T) {
data, err := json.Marshal(EBSSyncRequest{Volume: "vol-123"})
require.NoError(t, err)
require.JSONEq(t, `{"Volume":"vol-123"}`, string(data))
}
func TestSyncResponseJSONContract(t *testing.T) {
data, err := json.Marshal(EBSSyncResponse{
Volume: "vol-123",
Synced: true,
Error: "",
})
require.NoError(t, err)
require.JSONEq(t, `{"Volume":"vol-123","Synced":true,"Error":""}`, string(data))
}
+87
View File
@@ -0,0 +1,87 @@
package viperblocklegacyv1
import "fmt"
const (
// DeleteSubject is the legacy queue-group route that deletes a volume's
// Viperblockd-local state and any active export.
DeleteSubject = "ebs.delete"
// MountResponseSubject is the legacy broadcast emitted after an ebs.mount
// request is handled. Request/reply callers receive the same payload.
MountResponseSubject = "ebs.mount.response"
// UnmountResponseSubject is the legacy broadcast emitted after an
// ebs.unmount request is handled. Request/reply callers receive the same
// payload.
UnmountResponseSubject = "ebs.unmount.response"
)
// MountSubject returns the deployed mount route. An empty node retains the
// single-node queue-group route; a named node selects the Viperblockd worker
// colocated with the instance runtime.
func MountSubject(node string) string {
if node == "" {
return "ebs.mount"
}
return fmt.Sprintf("ebs.%s.mount", node)
}
// UnmountSubject returns the deployed unmount route. Its empty-node behaviour
// matches MountSubject for single-node compatibility.
func UnmountSubject(node string) string {
if node == "" {
return "ebs.unmount"
}
return fmt.Sprintf("ebs.%s.unmount", node)
}
// EBSRequest is the deployed request payload shared by the legacy mount and
// unmount routes. It is intentionally separate from the generic EBS provider
// contract: this is Viperblockd's transitional NATS protocol.
type EBSRequest struct {
Name string `json:"Name"`
VolType string `json:"VolType"`
Boot bool `json:"Boot"`
EFI bool `json:"EFI"`
DeleteOnTermination bool `json:"DeleteOnTermination"`
NBDURI string `json:"NBDURI"`
DeviceName string `json:"DeviceName"`
HotplugPort int `json:"HotplugPort,omitempty"`
// BlockDevice and BlockSizeBytes describe a host block device export, the
// alternative to NBDURI; the guest sees BlockSizeBytes of it. Exactly one
// of NBDURI and BlockDevice may be set.
BlockDevice string `json:"BlockDevice,omitempty"`
BlockSizeBytes uint64 `json:"BlockSizeBytes,omitempty"`
}
// EBSMountResponse is the response to an EBSRequest on MountSubject.
type EBSMountResponse struct {
URI string `json:"URI"`
Mounted bool `json:"Mounted"`
Error string `json:"Error"`
Retryable bool `json:"Retryable"`
}
// EBSUnMountResponse is the response to an EBSRequest on UnmountSubject.
// NotFound reports an idempotent retry after an already-complete seal; Reaped
// reports a leaked export that was cleaned up without a registry entry.
type EBSUnMountResponse struct {
Volume string `json:"Volume"`
Mounted bool `json:"Mounted"`
Error string `json:"Error"`
NotFound bool `json:"NotFound"`
Reaped bool `json:"Reaped,omitempty"`
}
// EBSDeleteRequest identifies the volume to delete on DeleteSubject.
type EBSDeleteRequest struct {
Volume string `json:"Volume"`
}
// EBSDeleteResponse is the response to EBSDeleteRequest on DeleteSubject.
type EBSDeleteResponse struct {
Volume string `json:"Volume"`
Success bool `json:"Success"`
Error string `json:"Error"`
}
@@ -0,0 +1,78 @@
package viperblocklegacyv1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestVolumeSubjects(t *testing.T) {
require.Equal(t, "ebs.delete", DeleteSubject)
require.Equal(t, "ebs.mount", MountSubject(""))
require.Equal(t, "ebs.unmount", UnmountSubject(""))
require.Equal(t, "ebs.node-a.mount", MountSubject("node-a"))
require.Equal(t, "ebs.node-a.unmount", UnmountSubject("node-a"))
require.Equal(t, "ebs.mount.response", MountResponseSubject)
require.Equal(t, "ebs.unmount.response", UnmountResponseSubject)
}
func TestEBSRequestJSONContract(t *testing.T) {
data, err := json.Marshal(EBSRequest{
Name: "vol-123",
VolType: "gp3",
Boot: true,
EFI: true,
DeleteOnTermination: true,
NBDURI: "nbd:unix:/run/spinifex/vol-123.sock",
DeviceName: "/dev/sdf",
HotplugPort: 3,
})
require.NoError(t, err)
require.JSONEq(t, `{
"Name":"vol-123", "VolType":"gp3", "Boot":true, "EFI":true,
"DeleteOnTermination":true,
"NBDURI":"nbd:unix:/run/spinifex/vol-123.sock",
"DeviceName":"/dev/sdf", "HotplugPort":3
}`, string(data))
block, err := json.Marshal(EBSRequest{
Name: "vol-456",
BlockDevice: "/dev/sdx",
BlockSizeBytes: 10 << 30,
})
require.NoError(t, err)
require.JSONEq(t, `{
"Name":"vol-456", "VolType":"", "Boot":false, "EFI":false,
"DeleteOnTermination":false, "NBDURI":"", "DeviceName":"",
"BlockDevice":"/dev/sdx", "BlockSizeBytes":10737418240
}`, string(block))
}
func TestVolumeResponseJSONContracts(t *testing.T) {
mount, err := json.Marshal(EBSMountResponse{
URI: "nbd:unix:/run/spinifex/vol-123.sock", Mounted: true, Retryable: true,
})
require.NoError(t, err)
require.JSONEq(t, `{
"URI":"nbd:unix:/run/spinifex/vol-123.sock", "Mounted":true,
"Error":"", "Retryable":true
}`, string(mount))
unmount, err := json.Marshal(EBSUnMountResponse{
Volume: "vol-123", NotFound: true, Reaped: true,
})
require.NoError(t, err)
require.JSONEq(t, `{
"Volume":"vol-123", "Mounted":false, "Error":"",
"NotFound":true, "Reaped":true
}`, string(unmount))
deleteRequest, err := json.Marshal(EBSDeleteRequest{Volume: "vol-123"})
require.NoError(t, err)
require.JSONEq(t, `{"Volume":"vol-123"}`, string(deleteRequest))
deleteResponse, err := json.Marshal(EBSDeleteResponse{Volume: "vol-123", Success: true})
require.NoError(t, err)
require.JSONEq(t, `{"Volume":"vol-123", "Success":true, "Error":""}`, string(deleteResponse))
}
+1 -1
View File
@@ -695,7 +695,7 @@ series above; "needs decision" is open.
| `gateway/{ecs,elbv2}` | — | Done. The action adapters moved to `domains/{ecs,elbv2}/awsapi` in `spinifex#1273` with no API, NATS subject, storage or behaviour change; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
| `gateway/spx` | — | Done. The gather helpers, storage-status config and image-promote request moved to `operator/admin` with no behaviour change and the package is removed. The `spinifex` Query dispatch and the `/admin/{method}` handlers that call them now live in `operator/admin` as one seam registration. |
| `gateway/tagging` | — | Done. `domains/tagging` owns the read-only cross-resource `GetResources` aggregation and its API semantics (`0d830e901`, `aedd0b2c4`); AWSGW composes the owner listers, the EC2 tag index and ELBv2 (`f3545e3ea`); the dispatch moved to `domains/tagging/awsapi` and the root `gateway` no longer serves Tagging. The package is removed, and its two allowlist rows and frozen-legacy entry are gone. Owner tag mutation capabilities remain future direction, so the RDS and ECS tag-writing edges above stay open. |
| `services/viperblockd` | — | Done. The adapter is `providers/ebs/viperblock` (with the `dirty` package, which owns the dirty-marker bucket and its read-only reader), it serves only the `providers/ebs` contract, and the role is `runtime/roles/viperblockd`. `services/` is empty. |
| `services/viperblockd` | — | Done. The adapter is `providers/ebs/viperblock` (with the read-only `dirty` reader), the shared vocabulary is `contracts/viperblockd/legacy/v1`, and the role is `runtime/roles/viperblockd`. `services/` is empty. |
| `utils` | — | Done and deleted. `ec2_subject.go` went to `contracts/ec2/v1`, the vpcd protocol to `contracts/network/v1` with the typed client in `domains/network/projection`, and the master-key loader to `domains/ec2/ebs/encryption`. |
| `vpcd` | — | Done. The IMDS local-file reader and record-space opener moved to `domains/ec2/instance/state` and are injected through `guestmetadata.LocalStateReader`/`RecordLoader` (recorded above), so `vpcd` no longer imports `daemon` directly; its inter-process boundary now exists (`contracts/network/v1`, with `domains/network/subscribers` as the consumer and `domains/network/projection` as the producer client). The package moved unchanged to `runtime/roles/vpcd` (recorded below), and the legacy directory is removed. |
| `daemon` | `runtime/daemon` (whole-package move, above) | Done. Q-28's ownership and deployment refactor remains deferred debt; the amended ADR-0001 row makes it not a relocation prerequisite. |
+1 -1
View File
@@ -59,7 +59,7 @@ Authentication uses **EKS access entries** (the API authentication mode) — IAM
- **Access entries are the only auth path.** `authentication_mode` accepts `API` (the default when unset) and `API_AND_CONFIG_MAP`; bare `CONFIG_MAP` is rejected. Spinifex has no `aws-auth` ConfigMap path, so `API_AND_CONFIG_MAP` behaves as `API`: grant access with access entries.
- **The cluster security group is auto-managed.** As on EKS, Spinifex creates one `eks-cluster-sg-<name>-<id>` in your VPC, tagged `kubernetes.io/cluster/<name>=owned` and `aws:eks:cluster-name`, allowing all traffic from itself. It is attached to the workers and the private-endpoint network interface, and `DescribeCluster` reports it as `clusterSecurityGroupId`. A launch template that names security groups replaces it on the workers, as on AWS. Clusters created before this change keep their `-control-plane-sg` and `-nodegroup-sg` until you recreate them. Deleting a cluster deletes the cluster security group and removes only the rules on your other security groups that reference it. `vpc_config.security_group_ids` is ignored.
- **Node group deletes are asynchronous.** `DeleteNodegroup` returns at once with the group `DELETING`, and `DescribeNodegroup` answers `ResourceNotFoundException` once the workers and the group's instance profile are gone. Deleting a cluster or creating a group of the same name while a group is `DELETING` returns `ResourceInUseException`. A second delete of a `DELETING` group returns the group as it stands and starts nothing new, as on EKS.
- **Node group deletes are asynchronous.** `DeleteNodegroup` returns at once with the group `DELETING`, and `DescribeNodegroup` answers `ResourceNotFoundException` once the workers and the group's instance profile are gone. Deleting a cluster or creating a group of the same name while a group is `DELETING` returns `ResourceInUseException`, as does a second delete of a `DELETING` group.
- **If a node group delete cannot finish,** the group moves to `DELETE_FAILED` after 30 minutes, with a `NODE_TERMINATION_FAILURE` health issue naming the workers that are still running. Run `DeleteNodegroup` again to resume it.
- **Each node group owns an EKS-managed instance profile.** Spinifex creates it, tags it `spinifex:eks-cluster`, `spinifex:eks-nodegroup` and `spinifex:eks-profile-id`, and puts the node role in it. It is removed with the group, so you can delete the node role afterwards with no extra steps. Spinifex never deletes an instance profile it did not create, or one whose tags, name or role no longer match what it recorded; it leaves the profile and logs a warning with its name.
- **The worker image is fixed.** Node groups always boot Spinifex's `eks-node` image. `ami_type` is recorded but does not select the image, and the image must be registered on the cluster before you create a node group.
+14 -16
View File
@@ -19,19 +19,17 @@ tags:
The platform serves **406 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
| Service | Operations | Requires AWS SDK v2 |
|---|---:|---:|
| [ACM](/coverage/acm) | 9 | 25 |
| [EC2](/coverage/ec2) | 127 | 186 |
| [ECR](/coverage/ecr) | 23 | 15 |
| [ECS](/coverage/ecs) | 31 | 32 |
| [EKS](/coverage/eks) | 27 | 14 |
| [ELBv2](/coverage/elbv2) | 36 | 3 |
| [IAM](/coverage/iam) | 82 | 21 |
| [RDS](/coverage/rds) | 27 | 6 |
| [Route 53](/coverage/route53) | 13 | 1 |
| [S3](/coverage/s3) | 26 | 17 |
| [STS](/coverage/sts) | 5 | 3 |
| **Total** | **406** | **323** |
A further **323 operations** in the pinned models are not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2; each service page lists its own under Requires AWS SDK v2.
| Service | Operations |
|---|---:|
| [ACM](/coverage/acm) | 9 |
| [EC2](/coverage/ec2) | 127 |
| [ECR](/coverage/ecr) | 23 |
| [ECS](/coverage/ecs) | 31 |
| [EKS](/coverage/eks) | 27 |
| [ELBv2](/coverage/elbv2) | 36 |
| [IAM](/coverage/iam) | 82 |
| [RDS](/coverage/rds) | 27 |
| [Route 53](/coverage/route53) | 13 |
| [S3](/coverage/s3) | 26 |
| [STS](/coverage/sts) | 5 |
| **Total** | **406** |
-32
View File
@@ -41,35 +41,3 @@ Terraform's canonical certificate, DNS record and validation flow works unmodifi
| `ListTagsForCertificate` |
| `RemoveTagsFromCertificate` |
| `RequestCertificate` |
### Requires AWS SDK v2
These 25 operations are in the pinned AWS model but not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2.
| Operation |
|---|
| `CreateAcmeDomainValidation` |
| `CreateAcmeEndpoint` |
| `CreateAcmeExternalAccountBinding` |
| `DeleteAcmeDomainValidation` |
| `DeleteAcmeEndpoint` |
| `DeleteAcmeExternalAccountBinding` |
| `DescribeAcmeAccount` |
| `DescribeAcmeDomainValidation` |
| `DescribeAcmeEndpoint` |
| `DescribeAcmeExternalAccountBinding` |
| `GetAcmeExternalAccountBindingCredentials` |
| `ListAcmeAccounts` |
| `ListAcmeDomainValidations` |
| `ListAcmeEndpoints` |
| `ListAcmeExternalAccountBindings` |
| `ListCertificateDomainValidations` |
| `ListTagsForResource` |
| `RevokeAcmeAccount` |
| `RevokeAcmeExternalAccountBinding` |
| `RevokeCertificate` |
| `SearchCertificates` |
| `TagResource` |
| `UntagResource` |
| `UpdateAcmeDomainValidation` |
| `UpdateAcmeEndpoint` |
File diff suppressed because it is too large Load Diff
-193
View File
@@ -189,196 +189,3 @@ Whether new volumes are encrypted is decided by the platform's encryption key, n
| `UnmonitorInstances` |
| `UpdateSecurityGroupRuleDescriptionsEgress` |
| `UpdateSecurityGroupRuleDescriptionsIngress` |
### Requires AWS SDK v2
These 186 operations are in the pinned AWS model but not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2.
| Operation |
|---|
| `AcceptCapacityReservationBillingOwnership` |
| `AcceptTransitGatewayClientVpnAttachment` |
| `AssociateApplicationStatusCheck` |
| `AssociateCapacityReservationBillingOwner` |
| `AssociateRouteServer` |
| `AssociateSecurityGroupVpc` |
| `AttachImageWatermark` |
| `BatchModifyIpamRoutingPolicyRegistrations` |
| `CancelDeclarativePoliciesReport` |
| `CopyVolumes` |
| `CreateApplicationStatusCheck` |
| `CreateCapacityManagerDataExport` |
| `CreateCapacityReservationBySplitting` |
| `CreateCapacityReservationCancellationQuote` |
| `CreateCapacityReservationDateChangeQuote` |
| `CreateDelegateMacVolumeOwnershipTask` |
| `CreateImageUsageReport` |
| `CreateInterruptibleCapacityReservationAllocation` |
| `CreateIpamInternetRegistryAssociation` |
| `CreateIpamPolicy` |
| `CreateIpamPrefixListResolver` |
| `CreateIpamPrefixListResolverTarget` |
| `CreateIpamRoutingPolicyRegistration` |
| `CreateLocalGatewayVirtualInterface` |
| `CreateLocalGatewayVirtualInterfaceGroup` |
| `CreateMacSystemIntegrityProtectionModificationTask` |
| `CreateRouteServer` |
| `CreateRouteServerEndpoint` |
| `CreateRouteServerPeer` |
| `CreateSecondaryNetwork` |
| `CreateSecondarySubnet` |
| `CreateTransitGatewayMeteringPolicy` |
| `CreateTransitGatewayMeteringPolicyEntry` |
| `CreateTransitGatewayPolicyTableEntry` |
| `CreateVpcBlockPublicAccessExclusion` |
| `CreateVpcEncryptionControl` |
| `CreateVpnConcentrator` |
| `DeleteApplicationStatusCheck` |
| `DeleteCapacityManagerDataExport` |
| `DeleteClientVpnEndpointAuthorizationPolicy` |
| `DeleteImageUsageReport` |
| `DeleteIpamInternetRegistryAssociation` |
| `DeleteIpamPolicy` |
| `DeleteIpamPrefixListResolver` |
| `DeleteIpamPrefixListResolverTarget` |
| `DeleteIpamRoutingPolicyRegistration` |
| `DeleteLocalGatewayVirtualInterface` |
| `DeleteLocalGatewayVirtualInterfaceGroup` |
| `DeleteRouteServer` |
| `DeleteRouteServerEndpoint` |
| `DeleteRouteServerPeer` |
| `DeleteSecondaryNetwork` |
| `DeleteSecondarySubnet` |
| `DeleteTransitGatewayClientVpnAttachment` |
| `DeleteTransitGatewayMeteringPolicy` |
| `DeleteTransitGatewayMeteringPolicyEntry` |
| `DeleteTransitGatewayPolicyTableEntry` |
| `DeleteVpcBlockPublicAccessExclusion` |
| `DeleteVpcEncryptionControl` |
| `DeleteVpnConcentrator` |
| `DescribeAccountVpcEncryptionControl` |
| `DescribeApplicationStatus` |
| `DescribeApplicationStatusCheckAssociations` |
| `DescribeApplicationStatusChecks` |
| `DescribeCapacityBlockExtensionHistory` |
| `DescribeCapacityBlockExtensionOfferings` |
| `DescribeCapacityBlockStatus` |
| `DescribeCapacityBlocks` |
| `DescribeCapacityManagerDataExports` |
| `DescribeCapacityReservationBillingRequests` |
| `DescribeCapacityReservationCancellationQuotes` |
| `DescribeCapacityReservationDateChangeQuotes` |
| `DescribeCapacityReservationTopology` |
| `DescribeDeclarativePoliciesReports` |
| `DescribeImageReferences` |
| `DescribeImageUsageReportEntries` |
| `DescribeImageUsageReports` |
| `DescribeInstanceImageMetadata` |
| `DescribeInstanceSqlHaHistoryStates` |
| `DescribeInstanceSqlHaStates` |
| `DescribeIpamInternetRegistryAssociations` |
| `DescribeIpamPolicies` |
| `DescribeIpamPoolAllocations` |
| `DescribeIpamPrefixListResolverTargets` |
| `DescribeIpamPrefixListResolvers` |
| `DescribeMacModificationTasks` |
| `DescribeOutpostLags` |
| `DescribeRouteServerEndpoints` |
| `DescribeRouteServerPeers` |
| `DescribeRouteServers` |
| `DescribeSecondaryInterfaces` |
| `DescribeSecondaryNetworks` |
| `DescribeSecondarySubnets` |
| `DescribeSecurityGroupVpcAssociations` |
| `DescribeServiceLinkVirtualInterfaces` |
| `DescribeTransitGatewayMeteringPolicies` |
| `DescribeVpcBlockPublicAccessExclusions` |
| `DescribeVpcBlockPublicAccessOptions` |
| `DescribeVpcEncryptionControls` |
| `DescribeVpcEndpointAssociations` |
| `DescribeVpnConcentrators` |
| `DetachImageWatermark` |
| `DisableAllowedImagesSettings` |
| `DisableApplicationStatusCheckSuppression` |
| `DisableCapacityManager` |
| `DisableInstanceSqlHaStandbyDetections` |
| `DisableIpamPolicy` |
| `DisableRouteServerPropagation` |
| `DisassociateApplicationStatusCheck` |
| `DisassociateCapacityReservationBillingOwner` |
| `DisassociateRouteServer` |
| `DisassociateSecurityGroupVpc` |
| `EnableAllowedImagesSettings` |
| `EnableApplicationStatusCheckSuppression` |
| `EnableCapacityManager` |
| `EnableInstanceSqlHaStandbyDetections` |
| `EnableIpamInternetRegistryAssociation` |
| `EnableIpamPolicy` |
| `EnableRouteServerPropagation` |
| `ExportVerifiedAccessInstanceClientConfiguration` |
| `GetActiveVpnTunnelStatus` |
| `GetAllowedImagesSettings` |
| `GetCapacityManagerAttributes` |
| `GetCapacityManagerMetricData` |
| `GetCapacityManagerMetricDimensions` |
| `GetCapacityManagerMonitoredTagKeys` |
| `GetCapacityManagerSupportabilityScores` |
| `GetClientVpnEndpointAuthorizationPolicy` |
| `GetDeclarativePoliciesReportSummary` |
| `GetEnabledIpamPolicy` |
| `GetImageAncestry` |
| `GetImageFindings` |
| `GetIpamDiscoveredRoutes` |
| `GetIpamInternetRegistryAssociationAsns` |
| `GetIpamInternetRegistryAssociationCidrs` |
| `GetIpamPolicyAllocationRules` |
| `GetIpamPolicyOrganizationTargets` |
| `GetIpamPrefixListResolverRules` |
| `GetIpamPrefixListResolverVersionEntries` |
| `GetIpamPrefixListResolverVersions` |
| `GetIpamRouteOriginAuthorizations` |
| `GetIpamRouteProtectionFindings` |
| `GetIpamRoutingPolicyRegistrationDeltas` |
| `GetIpamRoutingPolicyRegistrations` |
| `GetManagedResourceVisibility` |
| `GetRouteServerAssociations` |
| `GetRouteServerPropagations` |
| `GetRouteServerRoutingDatabase` |
| `GetTransitGatewayAttachmentNatConfiguration` |
| `GetTransitGatewayMeteringPolicyEntries` |
| `GetVerifiedAccessEndpointTargets` |
| `GetVpcResourcesBlockingEncryptionEnforcement` |
| `ListVolumesInRecycleBin` |
| `ModifyAccountVpcEncryptionControl` |
| `ModifyApplicationStatusCheck` |
| `ModifyClientVpnEndpointAuthorizationPolicy` |
| `ModifyInstanceConnectEndpoint` |
| `ModifyInstanceCpuOptions` |
| `ModifyInstanceNetworkPerformanceOptions` |
| `ModifyIpamPolicyAllocationRules` |
| `ModifyIpamPoolAllocation` |
| `ModifyIpamPrefixListResolver` |
| `ModifyIpamPrefixListResolverTarget` |
| `ModifyIpamRoutingPolicyRegistration` |
| `ModifyManagedResourceVisibility` |
| `ModifyPublicIpDnsNameOptions` |
| `ModifyRouteServer` |
| `ModifyTransitGatewayAttachmentNatConfiguration` |
| `ModifyTransitGatewayMeteringPolicy` |
| `ModifyTransitGatewayPolicyTableEntry` |
| `ModifyVpcBlockPublicAccessExclusion` |
| `ModifyVpcBlockPublicAccessOptions` |
| `ModifyVpcEncryptionControl` |
| `ModifyVpcEndpointPayerResponsibility` |
| `MoveCapacityReservationInstances` |
| `PurchaseCapacityBlockExtension` |
| `RejectCapacityReservationBillingOwnership` |
| `RejectTransitGatewayClientVpnAttachment` |
| `ReplaceImageCriteriaInAllowedImagesSettings` |
| `ReplaceImageInstanceTypeSpecification` |
| `RestoreVolumeFromRecycleBin` |
| `StartDeclarativePoliciesReport` |
| `UpdateCapacityManagerMonitoredTagKeys` |
| `UpdateCapacityManagerOrganizationsAccess` |
| `UpdateInterruptibleCapacityReservationAllocation` |
| `ValidateSecurityGroupQuotasForInterface` |
-15
View File
@@ -20,18 +20,3 @@ The same form is used for a bad `Architecture`, `BootMode` or `Name` length.
A missing `Name`, `BlockDeviceMappings` or root snapshot answers `MissingParameter` and names what is missing.
Registering with `--ena-support`, `--no-ena-support` and neither, copying the image, and three refusals were run on a three-node cluster with the AWS CLI.
Launching an instance from an `EnaSupport` image was not run, so the launch path is unchanged by construction and not by test.
### 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.
-22
View File
@@ -55,25 +55,3 @@ Registry replication is a stub for the same kind of reason: a deployment is a si
| `StartLifecyclePolicyPreview` |
| `TagResource` |
| `UntagResource` |
### Requires AWS SDK v2
These 15 operations are in the pinned AWS model but not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2.
| Operation |
|---|
| `DeleteSigningConfiguration` |
| `DeregisterPullTimeUpdateExclusion` |
| `DescribeImageSigningStatus` |
| `DescribeReplicationBackfillResults` |
| `GetAccountSetting` |
| `GetSigningConfiguration` |
| `ListImageReferrers` |
| `ListPullTimeUpdateExclusions` |
| `ListReplicationBackfills` |
| `PutAccountSetting` |
| `PutSigningConfiguration` |
| `RegisterPullTimeUpdateExclusion` |
| `StartReplicationBackfill` |
| `StopReplicationBackfill` |
| `UpdateImageStorageClass` |
-39
View File
@@ -59,42 +59,3 @@ Clusters, services and tasks run on EC2 container instances. There is no Fargate
| `UntagResource` |
| `UpdateContainerInstancesState` |
| `UpdateService` |
### Requires AWS SDK v2
These 32 operations are in the pinned AWS model but not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2.
| Operation |
|---|
| `AssociateGatewayWithCluster` |
| `ContinueServiceDeployment` |
| `CreateDaemon` |
| `CreateExpressGatewayService` |
| `CreateGateway` |
| `DeleteDaemon` |
| `DeleteDaemonTaskDefinition` |
| `DeleteExpressGatewayService` |
| `DeleteGateway` |
| `DescribeDaemon` |
| `DescribeDaemonDeployments` |
| `DescribeDaemonRevisions` |
| `DescribeDaemonTaskDefinition` |
| `DescribeExpressGatewayService` |
| `DescribeGateway` |
| `DescribeGatewayResources` |
| `DescribeGatewayRoute` |
| `DescribeServiceDeployments` |
| `DescribeServiceRevisions` |
| `DisassociateGatewayFromCluster` |
| `ListDaemonDeployments` |
| `ListDaemonTaskDefinitions` |
| `ListDaemons` |
| `ListGatewayClusterAssociations` |
| `ListGatewayRoutes` |
| `ListGateways` |
| `ListServiceDeployments` |
| `RegisterDaemonTaskDefinition` |
| `StopServiceDeployment` |
| `UpdateDaemon` |
| `UpdateExpressGatewayService` |
| `UpdateGateway` |
-21
View File
@@ -51,24 +51,3 @@ Spinifex implements **27 operations** in the EKS `2017-11-01` API model.
| `UpdateAccessEntry` |
| `UpdateAddon` |
| `UpdateNodegroupConfig` |
### Requires AWS SDK v2
These 14 operations are in the pinned AWS model but not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2.
| Operation |
|---|
| `ActivateCertificateAuthority` |
| `CancelUpdate` |
| `CreateCapability` |
| `CreateCertificateAuthority` |
| `DeleteCapability` |
| `DeleteCertificateAuthority` |
| `DescribeCapability` |
| `DescribeCertificateAuthority` |
| `DescribeClusterVersions` |
| `DescribeInsightsRefresh` |
| `ListCapabilities` |
| `ListCertificateAuthorities` |
| `StartInsightsRefresh` |
| `UpdateCapability` |
-10
View File
@@ -132,13 +132,3 @@ Behaviour against AWS was not compared, and the cases above were not run with an
| `SetRulePriorities` |
| `SetSecurityGroups` |
| `SetSubnets` |
### Requires AWS SDK v2
These 3 operations are in the pinned AWS model but not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2.
| Operation |
|---|
| `DescribeCapacityReservation` |
| `ModifyCapacityReservation` |
| `ModifyIpPools` |
-28
View File
@@ -110,31 +110,3 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
| `UpdateRole` |
| `UpdateRoleDescription` |
| `UpdateUser` |
### Requires AWS SDK v2
These 21 operations are in the pinned AWS model but not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2.
| Operation |
|---|
| `AcceptDelegationRequest` |
| `AcquireRole` |
| `AssociateDelegationRequest` |
| `CreateDelegationRequest` |
| `DisableOrganizationsRootCredentialsManagement` |
| `DisableOrganizationsRootSessions` |
| `DisableOutboundWebIdentityFederation` |
| `EnableOrganizationsRootCredentialsManagement` |
| `EnableOrganizationsRootSessions` |
| `EnableOutboundWebIdentityFederation` |
| `GetAccountProperties` |
| `GetDelegationRequest` |
| `GetHumanReadableSummary` |
| `GetOutboundWebIdentityFederationInfo` |
| `GetRoleTemplateVersion` |
| `ListDelegationRequests` |
| `ListOrganizationsFeatures` |
| `PutAccountProperties` |
| `RejectDelegationRequest` |
| `SendDelegationToken` |
| `UpdateDelegationRequest` |
File diff suppressed because it is too large Load Diff
-13
View File
@@ -101,16 +101,3 @@ A parameter whose omission would create a false safety, security or availability
| `RestoreDBInstanceFromDBSnapshot` |
| `StartDBInstance` |
| `StopDBInstance` |
### Requires AWS SDK v2
These 6 operations are in the pinned AWS model but not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2.
| Operation |
|---|
| `CancelDBCapacityReservation` |
| `CreateDBCapacityReservation` |
| `DescribeDBCapacityReservations` |
| `DescribeDBMajorEngineVersions` |
| `DescribeServerlessV2PlatformVersions` |
| `ModifyDBCapacityReservation` |
-8
View File
@@ -36,11 +36,3 @@ Spinifex implements **13 operations** in the Route 53 `2013-04-01` API model.
| `ListTagsForResource` |
| `ListTagsForResources` |
| `UpdateHostedZoneComment` |
### Requires AWS SDK v2
These 1 operations are in the pinned AWS model but not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2.
| Operation |
|---|
| `UpdateHostedZoneFeatures` |
-24
View File
@@ -60,27 +60,3 @@ That behaviour is measured separately, against the `ceph/s3-tests` suite Ceph RG
| `PutObject` |
| `UploadPart` |
| `UploadPartCopy` |
### Requires AWS SDK v2
These 17 operations are in the pinned AWS model but not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2.
| Operation |
|---|
| `CreateBucketMetadataConfiguration` |
| `CreateBucketMetadataTableConfiguration` |
| `DeleteBucketMetadataConfiguration` |
| `DeleteBucketMetadataTableConfiguration` |
| `DeleteObjectAnnotation` |
| `GetBucketAbac` |
| `GetBucketMetadataConfiguration` |
| `GetBucketMetadataTableConfiguration` |
| `GetObjectAnnotation` |
| `ListObjectAnnotations` |
| `PutBucketAbac` |
| `PutObjectAnnotation` |
| `RenameObject` |
| `UpdateBucketMetadataAnnotationTableConfiguration` |
| `UpdateBucketMetadataInventoryTableConfiguration` |
| `UpdateBucketMetadataJournalTableConfiguration` |
| `UpdateObjectEncryption` |
-10
View File
@@ -39,13 +39,3 @@ Several inputs the model describes are deliberately rejected rather than accepte
| `GetAccessKeyInfo` |
| `GetCallerIdentity` |
| `GetSessionToken` |
### Requires AWS SDK v2
These 3 operations are in the pinned AWS model but not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2.
| Operation |
|---|
| `AssumeRoot` |
| `GetDelegatedAccessToken` |
| `GetWebIdentityToken` |
+3 -3
View File
@@ -77,8 +77,8 @@ Mirror the cloud image you intend to run as guest VMs, together with the sums fi
```bash
mkdir -p images
cd images
curl -fsSLO "https://cloud.debian.org/images/cloud/trixie/20261103-2651/debian-13-genericcloud-amd64-20261103-2651.qcow2"
curl -fsSLO "https://cloud.debian.org/images/cloud/trixie/20261103-2651/SHA512SUMS"
curl -fsSLO "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2"
curl -fsSLO "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS"
sha512sum -c --ignore-missing SHA512SUMS
cd ..
```
@@ -147,7 +147,7 @@ sudo systemctl start spinifex.target
Register the pre-staged image with Spinifex, verifying it against the sums file carried across with it:
```bash
sudo spx admin images import --file /mnt/usb/images/debian-13-genericcloud-amd64-20261103-2651.qcow2 \
sudo spx admin images import --file /mnt/usb/images/debian-13-genericcloud-amd64.qcow2 \
--checksum /mnt/usb/images/SHA512SUMS \
--distro debian --version 13 --arch x86_64 --boot-mode uefi
```
-112
View File
@@ -41,8 +41,6 @@ resources:
- [Harden it before production](#harden-it-before-production)
- [Next steps](#next-steps)
- [Removing the deployment](#removing-the-deployment)
- [EBS on OCI Block Volumes](#ebs-on-oci-block-volumes)
- [Testing the OCI Block Volumes provider live](#testing-the-oci-block-volumes-provider-live)
- [Troubleshooting](#troubleshooting)
---
@@ -775,116 +773,6 @@ oci limits resource-availability get --service-name vcn \
--limit-name reserved-public-ip-count --compartment-id "$TENANCY_OCID"
```
## EBS on OCI Block Volumes
On an OCI virtual machine, EBS volumes can be backed by OCI Block Volumes through the `ocibsd` provider instead of Viperblock. Each EBS volume is one OCI block volume, attached over iSCSI to the node that runs the instance using it.
### How the provider is chosen
There is no flag. `spx admin init` takes the provider from the first of these that gives one:
1. The existing `/etc/spinifex/spinifex.toml`, whose provider the node keeps; a node table that omits it is `viperblockd`.
2. The `SPINIFEX_EBS_PROVIDER` environment variable: `viperblockd`, `qemunbd` or `ocibsd`.
3. The OCI instance metadata service (IMDS v2), read with a one-second timeout. An OCI VM shape selects `ocibsd`; a `BM.*` shape, or no answer, selects `viperblockd`.
Init prints its choice and where it came from, for example `EBS provider: ocibsd (detected: OCI virtual machine <shape>)`. Joining nodes take the provider from formation rather than choosing their own. The choice is written once for every node and cannot be changed without wiping and re-forming the cluster.
For `ocibsd`, init fills the compartment and availability domain from instance metadata unless the existing config names them, generates a cluster ID that tags every OCI volume and backup the cluster makes, and defaults `auth` to `instance_principal` when the existing config names no credential. It then reads the compartment's volumes with that credential, and stops with an error naming the needed policy, `manage volume-family` and `use instances`, if the read is refused. It never falls back to Viperblock.
The settings land in the node's table in `spinifex.toml`:
```toml
[nodes.<node>.ebs]
provider = "ocibsd"
[nodes.<node>.ebs.oci]
compartment_id = "<compartment-ocid>"
availability_domain = "<availability-domain>"
cluster_id = "<generated-cluster-id>"
auth = "instance_principal"
```
On an OCI VM, `setup.sh` installs the iSCSI initiator (`open-iscsi` with apt, `iscsi-initiator-utils` with dnf) and stages the `ocibsd` files under `/usr/local/share/spinifex/ocibsd`. When the provider resolves to `ocibsd`, init and join install the `spinifex-ocibsd.service` unit, its udev rule and a `spinifex-daemon` drop-in, mask `spinifex-viperblock.service`, and enable `spinifex-ocibsd.service`, so exactly one service answers for EBS on the node.
To keep Viperblock on an OCI VM, name it before init:
```bash
sudo env SPINIFEX_EBS_PROVIDER=viperblockd spx admin init <your usual flags>
```
`validate-topology.sh` does this for every topology except `vm-single-ocibsd` and `vm-multi-ocibsd`, which rely on detection and need `--distro`.
### One availability domain per cluster
Every volume is created in the availability domain the cluster recorded at init, whichever node handles the request. Config validation refuses nodes that name different compartments or availability domains, and `spinifex-ocibsd` refuses to start on a node whose instance metadata places it in another availability domain. Deploy every node of an `ocibsd` cluster into one availability domain.
### IAM policy
The node's credential needs two grants in the compartment, on top of the four the address allocator needs:
```text
Allow dynamic-group <dynamic-group-name> to manage volume-family in compartment id <compartment-ocid>
Allow dynamic-group <dynamic-group-name> to use instances in compartment id <compartment-ocid>
```
`manage volume-family` covers volumes, backups and attachments, and `use instances` covers attaching them to the node. The instance-principal policy that `--setup-instance-principal` on an `ocibsd` topology (or `setup-identity.sh --ebs-provider ocibsd`) creates grants both, for a Dynamic Group matching `ALL {instance.compartment.id = '<compartment-ocid>'}`. Built for `viperblockd`, the default, it grants neither, and because the policy is shared by every deployment in the compartment, re-running `setup-identity.sh` without the flag removes them.
For an API key, grant the same two verbs to the key user's group, alongside the four from [section 2.2](#grant-the-group-its-four-permissions):
```text
Allow group <group-name> to manage volume-family in compartment id <compartment-ocid>
Allow group <group-name> to use instances in compartment id <compartment-ocid>
```
`terraform output -json api_key_policy_statements` prints all six for the group named by `api_key_group_name` (default `spinifex-nodes`) when `ebs_provider = "ocibsd"`, and only the four address grants otherwise. Because init defaults to the instance principal, an API-key node needs its credential named in `spinifex.toml` before init, which init then keeps:
```toml
[nodes.<node>.ebs]
provider = "ocibsd"
[nodes.<node>.ebs.oci]
auth = "config_file"
config_file = "/etc/spinifex/oci/config"
config_profile = "spinifex"
```
`validate-topology.sh` writes this for you on an API-key run of an `ocibsd` topology, after the credential hook has installed the key.
### The 50 GB floor
OCI creates no block volume smaller than 50 GB, where OCI's GB is 2^30 bytes. The OCI volume is the requested size rounded up to a whole GB, and never less than 50 GB. Spinifex records the requested size in a `spinifex-capacity-bytes` tag, reports it as the EBS volume's size, and presents exactly that size to the guest, so a 1 GiB EBS volume is 1 GiB to the API and the guest. OCI bills for the 50 GB volume underneath it.
## Testing the OCI Block Volumes provider live
`providers/ebs/ocibsd/live_test.go` runs the EBS provider conformance suite, in process and over NATS, against a real compartment and one OCI instance.
It also checks that OCI's GB is 2^30 bytes on the attached device, and that recreating a deleted volume under the same ID never answers with the deleted volume.
The test runs on your workstation with the API-key profile from `~/.oci/config`, and drives `sudo iscsiadm` and the device check on the instance over SSH, so no credential is placed on the instance.
Every subtest skips unless `SPINIFEX_OCIBSD_LIVE` is set.
| Variable | Meaning |
| --- | --- |
| `SPINIFEX_OCIBSD_LIVE` | Any value runs the live tests |
| `SPINIFEX_OCIBSD_COMPARTMENT_ID` | Compartment the volumes and backups are created in |
| `SPINIFEX_OCIBSD_AVAILABILITY_DOMAIN` | The instance's availability domain, where every volume is created |
| `SPINIFEX_OCIBSD_INSTANCE_ID` | OCID of the instance volumes are attached to |
| `SPINIFEX_OCIBSD_SSH_HOST` | Address of that instance |
| `SPINIFEX_OCIBSD_SSH_USER` | SSH user with passwordless `sudo`, `opc` when unset |
| `SPINIFEX_OCIBSD_SSH_KEY` | Private key for that user |
| `SPINIFEX_OCIBSD_SSH_KNOWN_HOSTS` | Known-hosts file, a fresh temporary one when unset |
| `SPINIFEX_OCIBSD_CLUSTER_ID` | `spinifex-cluster` tag value for the run, random when unset and logged either way |
| `SPINIFEX_OCIBSD_OCI_PROFILE` | OCI config profile, `DEFAULT` when unset |
The instance needs `iscsi-initiator-utils` and the Oracle Cloud Agent "Block Volume Management" plugin disabled, so no agent logs sessions in or out behind the test.
The profile needs `manage volume-family` and `use instances` in the compartment.
```bash
cd spinifex
SPINIFEX_OCIBSD_LIVE=1 go test ./providers/ebs/ocibsd/ -run TestLive -count=1 -v -timeout 60m
```
The suite deletes what it creates, but a failed run can leave volumes and backups behind, and OCI bills for them.
Sweep everything tagged `spinifex-cluster=<cluster ID>` in the compartment after every run.
## Troubleshooting
The deploy stops at the first failure and names the log it wrote, all under `.validate-<topology>/`. Add `--keep-on-fail` to leave a failed deployment up so you can log in and look. **It keeps billing** until you run `--destroy-only`.
+1 -1
View File
@@ -139,7 +139,7 @@ Spinifex nodes initiate a small, fixed set of outbound connections.
| Destination | Purpose | Protocol | Verification |
|-------------|---------|----------|--------------|
| `https://cloud.debian.org/images/cloud/trixie/20261103-2651/` | Debian 13 cloud image download | HTTPS | TLS + checksum verification |
| `https://cloud.debian.org/images/cloud/trixie/latest/` | Debian 13 cloud image download | HTTPS | TLS + checksum verification |
| `https://cloud-images.ubuntu.com/releases/resolute/release/` | Ubuntu 26.04 LTS cloud image download | HTTPS | TLS + checksum verification |
| `https://dl.rockylinux.org/pub/rocky/10/images/` | Rocky Linux 10 cloud image download | HTTPS | TLS + checksum verification |
| `https://dl-cdn.alpinelinux.org/alpine/` | Alpine Linux cloud image download | HTTPS | TLS + checksum verification |
+1 -1
View File
@@ -4,7 +4,7 @@
# distroless base (no shell, no package manager) for a small, production-shaped
# image suitable for pushing to ECR and pulling onto EKS workers.
FROM golang:1.27.4-alpine@sha256:0a5f7fe796599ff487605987432e7619ae45beeb2fba4c2ce0b907879932aebf AS build
FROM golang:1.27.3-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/mulgadc/spinifex-demo
go 1.27.4
go 1.27.3
+2 -12
View File
@@ -13,12 +13,6 @@ Suites live in `tests/e2e/<name>/` and build to `tests/e2e/_bin/<name>.test`. Ev
The nightly permutation sets are deliberately narrower than the full ones: those cells have a ~35 minute budget and exist to prove every install / network / host-OS combination boots and serves. `eks`, `ecs`, `rds`, `storagefault`, `instancerecovery`, `lbrecovery` and `kvquorum` each get a dedicated cell instead.
**The push E2E runs `single` as three shards** on separate `ci-single` envs (`E2E_SINGLE_SHARDS`, default 3; a `test_run` dispatch runs it whole).
Each leg lists `single.test -test.list` itself and `.github/scripts/e2e-shard.sh` assigns the names longest-first by the seconds in `.github/e2e-weights/single.txt`, so every leg computes the same partition and a test missing from the weights still lands in exactly one shard.
A leg passes the other shards' names to `-test.skip` and never names a test in `-test.run`, so opt-in tests such as `TestRebootLoop` stay skipped as in a whole-suite run.
A failed or empty listing fails the leg, and any failed leg fails the `e2e` status.
The rest of the push base set (`iam`, `cert`, `partialblock`) runs whole on a `ci-pseudo` env.
**`multinode` carries the KV replication sweep as well**, which is why the thing `kvquorum` proves is still checked in every multi-node permutation cell even though the suite itself is not. The read-only half costs a NATS connection and a stream listing; only the node-loss half needs a cell of its own.
## The suites
@@ -34,7 +28,7 @@ The largest suite, 27 top-level tests. Notable entry points:
- `TestWANEgressSMTPBlock` — egress port blocking.
- `TestInstanceMetadata` — IMDS from inside a guest.
- **`TestIMDSDatapathSurvivesVswitchdRestart`** restarts `ovs-vswitchd` under a guest with an Elastic IP, once through systemd and once with SIGKILL. After each, the guest must answer over its EIP and fetch its instance ID through IMDSv2 within 60 seconds, and `br-imds` must hold all 7 of the guest's own flows. It is sequential because it takes the whole node's datapath down.
- `TestStopStart`, `TestEarlyRebootLiveness`, `TestGuestChurnDurability`, `TestAttachedENISurvivesTerminate` (an interface attached with `AttachNetworkInterface` defaults to `DeleteOnTermination=false`, toggles through `ModifyNetworkInterfaceAttribute`, and outlives `TerminateInstances`), `TestSnapshotLifecycle`, `TestSnapshotBackedLaunch`, `TestCreateImage`, `TestVolumeLifecycle`, `TestSpotInstanceLifecycle`, `TestLaunchTemplateBoot`.
- `TestStopStart`, `TestEarlyRebootLiveness`, `TestGuestChurnDurability`, `TestSnapshotLifecycle`, `TestSnapshotBackedLaunch`, `TestCreateImage`, `TestVolumeLifecycle`, `TestSpotInstanceLifecycle`, `TestLaunchTemplateBoot`.
- **`TestRebootLoop`** is opt-in and skipped in a whole-suite run. Name it in `-test.run`, or set `SPINIFEX_REBOOT_LOOP_ROUNDS` (default 10), and it reboots one fresh guest through the API that many times and then once from inside, requiring a new boot ID every time.
- `TestNegativeErrorPaths` — the `8a`–`8t` AWS error-shape cases (malformed AMI ID, invalid instance type, volume in use, detach-root forbidden, unknown or malformed instance IDs on the lifecycle verbs, and so on).
- **`TestKVReplication`** — the single-node half of the KV replication policy, and it is not the multi-node assertion with a smaller number in it. On one node one replica per bucket is the correct answer rather than the defect, so what it proves is the opposite: that nothing declared a replica count this deployment cannot place, that `spx admin kv replicas` exits 0 and reports `WANT 1`, and that `--repair` run twice against a cluster with nothing to repair changes no bucket's replica count and leaves every bucket serving. The multi-node suites cannot cover any of that, because on three nodes the unplaceable count is the right one. It lives here rather than in its own suite so it runs in every single-node nightly cell, across every host and network permutation.
@@ -131,16 +125,13 @@ Internal and internet-facing ALB and NLB, HTTPS listeners, listener rules, `Modi
Unhealthy-target routing: an NLB with an HTTP health check on a TCP target group stops sending traffic to a target that fails it while still accepting TCP, sends traffic again once it recovers, and fails open when every target is unhealthy.
An ALB whose targets all fail their health check keeps serving from them instead of answering 503.
`Internal_NLB_DeletionProtection` enables `deletion_protection.enabled` on an internal NLB.
`DeleteLoadBalancer` must then fail with `OperationNotPermitted` and AWS's message, leave the listener and ENI in place and keep serving traffic, and succeed once protection is turned off.
`TestInternalALBPrivateSubnet` launches an internal ALB in a private subnet of a non-default VPC with no internet gateway and no public route.
The load balancer must reach active with a healthy target, which needs the owner stamp on its ENI to resolve, and a client inside the VPC must be served by it on every request.
The client reports on its serial console because the runner has no route into the VPC.
### `eks`, `ecs`, `ecr` — container services
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod whose spec names only a ServiceAccount with the role-arn annotation, where the pod-identity webhook injects the token volume, `AWS_*` environment, STS endpoint and CA bundle and the pod assumes the role, while a pod of an unannotated ServiceAccount is left untouched. The unmodified aws-load-balancer-controller chart 1.11.0 installs with the values an AWS user passes plus the gateway endpoint override, the Shield and WAF switches and the single-subnet feature gate, and reconciles an Ingress into an active ALB. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. The private-endpoint NIC of the cluster's endpoint NLB is disabled through QMP and every worker must be Ready again within ten minutes while the cluster stays `ACTIVE` and the NLB keeps its guest instance. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, an unforced `DeleteService` of that service refused while it is scaled above 0 with its task left running, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod whose spec names only a ServiceAccount with the role-arn annotation, where the pod-identity webhook injects the token volume, `AWS_*` environment, STS endpoint and CA bundle and the pod assumes the role, while a pod of an unannotated ServiceAccount is left untouched. The unmodified aws-load-balancer-controller chart 1.11.0 installs with the values an AWS user passes plus the gateway endpoint override, the Shield and WAF switches and the single-subnet feature gate, and reconciles an Ingress into an active ALB. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. The private-endpoint NIC of the cluster's endpoint NLB is disabled through QMP and every worker must be Ready again within ten minutes while the cluster stays `ACTIVE` and the NLB keeps its guest instance. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
### `rds` — managed databases
@@ -149,7 +140,6 @@ The create/describe/connect path against a live cluster: a DB VM that actually b
### Storage and durability
- **`storagefault`** — what a guest does when its storage backend stops answering, and what stop/start does when the seal failed. **The claim is about corruption, not availability.** SIGSTOPs a cluster-wide service, so it always runs last and in its own cell. Three-node, because the RS(2,1) shard floor and cross-node takeover after a failed seal do not exist on one node.
- **No suite reaches a `pending` snapshot.** Every e2e cell runs the default `viperblockd` provider, which finishes a snapshot before `CreateSnapshot` answers, so `single`'s `TestSnapshotLifecycle`, `TestSnapshotBackedLaunch` and `TestCreateImage`, and `rds`'s `TestSnapshotRestore` and `TestDelete`, run the create that writes the snapshot document `pending` before the provider call and settle it `completed` within the request, and the pending-snapshot reaper finds nothing to settle. The `pending` paths are unit-tested against the memory provider answering `pending`: the reaper settling snapshots and AMIs and carrying out deferred deletes, the request's rollback and its ambiguous calls, and `DeleteSnapshot` of a pending snapshot in `domains/ec2/snapshot`; `CreateImage` and the readiness gates in `domains/ec2/image`, `domains/ec2/instance` and `domains/ec2/volume`; DB snapshots and the final snapshot waiting in `creating` in `domains/rds/instance`; and the import's wait in `operator/admin`. The contract's `pending` answer, its filtered `ListSnapshots` and the `state` it reports are in the conformance suite (`providers/ebs/conformance`), which every provider runs.
- **`partialblock`** — concurrent sub-block writes to the same block surviving a cold reread through NBD from a real guest.
- **`storagegrowth`** — volume growth and image pull. Requires `external_mode=pool`; skips on a nat env, which has no EIP and therefore no guest SSH.
- **`reboot`** — a slow guest waited for and keeping what it wrote, and a wedged guest forced off and relaunched once its budget expires. Proves a real reboot by requiring a *new* boot ID.
+30 -30
View File
@@ -1,31 +1,30 @@
module github.com/mulgadc/spinifex
go 1.27.4
go 1.27.3
require (
github.com/aws/aws-sdk-go v1.55.8
github.com/aws/aws-sdk-go-v2 v1.47.7
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.26
github.com/aws/aws-sdk-go-v2/config v1.33.15
github.com/aws/aws-sdk-go-v2/credentials v1.20.15
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.7
github.com/aws/aws-sdk-go-v2/service/lambda v1.114.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.8
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.10
github.com/aws/smithy-go v1.28.8
github.com/aws/aws-sdk-go-v2 v1.47.5
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25
github.com/aws/aws-sdk-go-v2/config v1.33.12
github.com/aws/aws-sdk-go-v2/credentials v1.20.12
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7
github.com/aws/smithy-go v1.28.7
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/containerd/containerd/v2 v2.4.2
github.com/distribution/reference v0.6.0
github.com/fsnotify/fsnotify v1.10.1
github.com/go-chi/chi/v5 v5.5.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82
github.com/jackc/pgx/v5 v5.12.0
github.com/jackc/pgx/v5 v5.11.0
github.com/klauspost/compress v1.20.3
github.com/klauspost/cpuid/v2 v2.4.2
github.com/klauspost/cpuid/v2 v2.4.1
github.com/miekg/dns v1.1.73
github.com/minio/highwayhash v1.0.4
github.com/mulgadc/bluebottle v1.24.0
@@ -34,14 +33,14 @@ require (
github.com/mulgadc/viperblock v1.24.0
github.com/nats-io/nats-server/v2 v2.15.2
github.com/nats-io/nats.go v1.55.0
github.com/nats-io/nkeys v0.4.17
github.com/nats-io/nkeys v0.4.16
github.com/opencontainers/runtime-spec v1.3.0
github.com/oracle/oci-go-sdk/v65 v65.129.0
github.com/ovn-kubernetes/libovsdb v0.9.1
github.com/oracle/oci-go-sdk/v65 v65.128.0
github.com/ovn-kubernetes/libovsdb v0.9.0
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pterm/pterm v0.12.84
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.1.0
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1
go.etcd.io/etcd/api/v3 v3.7.3
@@ -56,7 +55,7 @@ require (
go.uber.org/goleak v1.3.1-0.20260602155401-5f791e3ef83b
go.yaml.in/yaml/v3 v3.0.6
golang.org/x/crypto v0.58.0
golang.org/x/sync v0.25.0
golang.org/x/sync v0.24.0
golang.org/x/sys v0.49.0
golang.org/x/term v0.47.0
golang.org/x/time v0.16.0
@@ -73,17 +72,17 @@ require (
github.com/Microsoft/hcsshim v0.15.0-rc.4 // indirect
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.10 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.39.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boltdb/bolt v1.3.1 // indirect
@@ -120,6 +119,7 @@ require (
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -144,7 +144,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.3 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/jstemmer/go-junit-report/v2 v2.1.0 // indirect
+56 -56
View File
@@ -22,46 +22,46 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.47.7 h1:jBmtzRtGWVAzbfc4RBWo1dTte0ZZH8p9U42YJIw2+Bw=
github.com/aws/aws-sdk-go-v2 v1.47.7/go.mod h1:I8lFSH4MGrZiw2m7miCrRYHsgpC1+HW8uiVGOiPYPcw=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.26 h1:SJNA4XaM8Z09adeTHf/vME93jzl44lJV70pSawIBXac=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.26/go.mod h1:l0ME5pd2QytH8UZzETcrt90x9Bm+3TyBVeWfVEcZ82c=
github.com/aws/aws-sdk-go-v2/config v1.33.15 h1:eSO7lvznhY5nMN1K9xK+pPTwNNH0chVmCIzH3C5as+E=
github.com/aws/aws-sdk-go-v2/config v1.33.15/go.mod h1:QLBxqEJFcgbrsw2dHaWMDW6DcIFWkHplXJMZGB/s2Ew=
github.com/aws/aws-sdk-go-v2/credentials v1.20.15 h1:LUFG3364w8wKBhGxGQ2XUxCCs+yOUrSg0dWUtBJskp8=
github.com/aws/aws-sdk-go-v2/credentials v1.20.15/go.mod h1:Q+tKQpy3sU1MjBqXzhZauN7ffcNRF9p7Rvw293sYwE4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.7 h1:5GGOwjXU738VrsBT0EDFtP5JtLPmJQILp8RS6P+KqLM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.7/go.mod h1:jY/VeijtHNuTiy5XI9HjpLiamtaaLw+KInZULfookJ8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.10 h1:DKtPO+uoekvZX1tIFGVlWypYGSYkHPb7rliUI1wZtEw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.10/go.mod h1:uAi0RDdZZz8IRpXERptWALeCgQG9Ye/HiMLxkxlt8IU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.10 h1:r8p95YELmfmuV9VvAaFcLesVBZduCCCccxpzQKkNThw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.10/go.mod h1:W3M52Mqai5TjgzyL/dEG+UwE+Xz9PykyjXelvbAzWIA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.10 h1:iytL6kFchhbCWWEfLzMGq1+yRecF7+BOXnp70J9zNRM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.10/go.mod h1:OaBcfeWMHPSyFqiHL/KN1zVIricTvYDNiO4XhR781eM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.24 h1:jGzHjc2jDx08Rji1JTBtMxuXUDG7muTEw+ExYXqFzPg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.24/go.mod h1:uJt75pK2cEyY80F5C+DK9YkCkJWsMkJViHxLYZRHl8Y=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.11 h1:+YhA5h9QMjVsOHfkfY/iVU7FfbT25LcGttfVzzed6v8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.11/go.mod h1:Xn22cWokj55agZo3I0w1JFQUiHhA3qLmHi/F5t1COi0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.10 h1:3nCD0vZOTTMypcaEEJW0rAWqEgUXVzfmNn3dDI1IQ7I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.10/go.mod h1:KJbydA7vvlrQSceENaM/C//yI18Rt36x//hG0Bw3uW0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.10 h1:bCz6PRKhO7ErM3F0XqLl8pbQq+r0Fra6R8PpfPnjgbo=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.10/go.mod h1:N8UvNaHipKSUnUFR07OpGdjU8B5aS/XBxgrcKURb2DQ=
github.com/aws/aws-sdk-go-v2/service/lambda v1.114.2 h1:SbfR8t3crViVjf8bCh9ohXwQYDbPJV/6P6CE3d9iKXc=
github.com/aws/aws-sdk-go-v2/service/lambda v1.114.2/go.mod h1:is4sHkblHDcfDUxxZVrDR9hSzf5I4id6qgX6hH0tUDA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.8 h1:0g/xXBWQEyR6Mcxo1ukBYqzeRX0bQ/ZdXrtKkIo+yT0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.8/go.mod h1:g6EsKHPrO5GpWYKwBfenLLtjuoPRE+WDIlLPQOIixLI=
github.com/aws/aws-sdk-go-v2/service/signin v1.12.3 h1:8cQxxirJ0d0tvmS89hYUpnVArXIKlzwRyiaSfdaokvQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.12.3/go.mod h1:cou3YaR7daAzApgGTG9il1c3iCh/bs72w6p4WFvJnaY=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.10 h1:ojdbdSOuwvVqcY+yfFO7Kz9Qow7brLZCALVBhorjUv8=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.10/go.mod h1:zPf8oFenYL6wvZ8izCh+fzFsSwpNNIBQP965awjBaXQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.39.3 h1:r5DHs3x9vC1p3VQh+V9EFfHIN5QmWDdS0yKITZM2kBI=
github.com/aws/aws-sdk-go-v2/service/sso v1.39.3/go.mod h1:u/xiahjixIqyBFiYNeQkmuimZycycBUJEomieSVsrXk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.3 h1:ENjUto22h2MiHmv8WehZ2874SSCh5knHKsNxeyjzKpY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.3/go.mod h1:jmVZvJzjoaStVpr4Nd11mMYCnyrq+sWT6UiDXVnHyco=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.8 h1:lervRo8V4a9tticNT+nPjqDq9olQPDmgqYczjvJbNio=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.8/go.mod h1:NBCcbmgAjvOsYwA6CPgMmipPUxsEep1JxKb3RWBr9rA=
github.com/aws/smithy-go v1.28.8 h1:aEmdAq7gVCoGCnvf1NnELlu3I6D/nhqrC321FyFiQkQ=
github.com/aws/smithy-go v1.28.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.47.5 h1:5yA+A+BiXbDq1ZLbYwPViwhEGDMdR0MEhL8Jh2LnLS4=
github.com/aws/aws-sdk-go-v2 v1.47.5/go.mod h1:iNIkogrRvFwm+1OQIUOP2lPBtlXZboOIUWiCa+pHEMw=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25 h1:MW0Iql1SOTgQPj8mCxrU/eH/K/AIDWi08ItOsXaWRLU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25/go.mod h1:+ucVYmHYB3JqVueY0E1RHahdXAnKQbIHqybSbvOUbOM=
github.com/aws/aws-sdk-go-v2/config v1.33.12 h1:IV1YARaXahwCqUIyP8IlWsP85Lil+5ozTozDQXPumRk=
github.com/aws/aws-sdk-go-v2/config v1.33.12/go.mod h1:h51R06bzMVwNBRZOX5ylHhNDnkB/azZeq/LvxM6q2pQ=
github.com/aws/aws-sdk-go-v2/credentials v1.20.12 h1:6P7Q7yH/GIwHg6MLKv3ySsBqaCoE1z+C0OTzYMenhEY=
github.com/aws/aws-sdk-go-v2/credentials v1.20.12/go.mod h1:Kw/SaoW/wc7IqJErqXJIzKo8b7Aelxfbuc6XlbrRnsc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5 h1:iXjy2SFhGYNB1jWZBhQqq8HbhsbPF23w+/zbMvPXsTg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5/go.mod h1:k0RUiBnyXU0AXRJ9tJmY2jQke6XUIX2PNVnWtvgaPtc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 h1:gzIWc3DTPfPbWKrQnmL6BV6WD7NPD1htxzgdk/bQ/Z4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8/go.mod h1:vvUA5cDAZWGo4R14FZPZfXfJCmgsafOWEq78Kg0D+ug=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 h1:PWv+vG8aCLS8qd+HPdYhK7Vees90aKHWFFQ1AGYD/8E=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8/go.mod h1:xxPZEpflYHJA12366ZGyq66z9dHPCoeCIu99Jck+MPw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 h1:WOp5De9iWdEHDPh2Kb0tBP3vGBm/dVAuRGh4a4tqduo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8/go.mod h1:z8W8YgfhbWl62XqcTtK+CxPrE+iHGRVUOtrrbIOp7ZY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 h1:MCIR6iZaCKO4UE0hUvoWWLI6JVjppopV64JfAJaMjng=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23/go.mod h1:pphWFS+z0MmIPivj8c/6bATd1Z+Av7Ust75DPdEFsPA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9 h1:GACgECI422Xt5n4Pojr3O0aocPv8lfH4PJe2Aon5Wv8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9/go.mod h1:lPfnRkHpn1qWZJLbVEw10rIqSuCee1pNQTyGkUjAYh8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 h1:+PMpWlJYOn2tm7sm6OZ/ZLd4jStl17T7wjNapd1Rczs=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8/go.mod h1:cnG3xZLGQL4c/bt8pjdzlFtZsW7epBBuNS04a5xMjdo=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8 h1:RS1Wt+K0r4X0ci9Vf4JYTUfHzsQihYA2LgGJeEneKpk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8/go.mod h1:adMnTy5kyoUW86jhXnGSVc/mvMQI4GZd1qYWMeaS8M8=
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4 h1:wvAdq1sNfwuedO1BX8GjY+oZWzoyeD4hhdjJI+4EPnI=
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4/go.mod h1:HgkYN4d72Fawg3MAJQepmNnROFoN11XYu14hgFfChTY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6 h1:to7+Vr3rLkpYRwVKJ5QRriI0eE306vrVN/6TnVHgxvY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6/go.mod h1:Jc89ASa2k7HMAskj3bSnt8bf+k+p2IApzkoXnEjkgUw=
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0 h1:3vu/eUqh0rjh7Zy4N4YKcpZX4Aud+zinB3VsLhhlQFw=
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0/go.mod h1:wxAk0D2oVBSjuXEIzvcKxTZfM90zticdAbh/VD42L0Y=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7 h1:Eip+mZ0wFZY/10e5w89lWhF09Pa8tbbGwxePP3T2iMc=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7/go.mod h1:bXp38eBNyvSevFIao60mYcC3pQIAkPYWOddhFVv9z8E=
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0 h1:kIEpD5dUry7VET3Ybl7zj7PPHRB4lb52G9iTz+a1Tqk=
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0/go.mod h1:UGsEYgWz4Gi+46vDQ5zkFLqUgJziyOE2SXjZjgmfmHc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0 h1:WfFev+HgTodggcNon5HieiLxxwWwb8Br8R2+nDkixvk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0/go.mod h1:gJSdIY6YUzDvqQLcMtPVZgBRHrFwzHryvpE8Igzbxps=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 h1:BVjKrtRUtSYKztBCCfE1oAB+d3ViKcW2cKZfserOqMQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6/go.mod h1:eSukFLeLkPMm+1OxDR+F0joauZdyBkF47Ow7MqjZiJw=
github.com/aws/smithy-go v1.28.7 h1:h/n1SHrwVtaDYLz8mR1/Kq4Xh4RaFbFW7bOYckg9mfE=
github.com/aws/smithy-go v1.28.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -234,10 +234,10 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.12.0 h1:hWwax8YiGT0YiN50U1oIDn5kgR7KC2tG3rmavwpPkxY=
github.com/jackc/pgx/v5 v5.12.0/go.mod h1:u2Fi6no03HCSBYE+ZVdLxbwdu3xyi2vU+K/YNJioSds=
github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
@@ -249,8 +249,8 @@ github.com/jstemmer/go-junit-report/v2 v2.1.0 h1:X3+hPYlSczH9IMIpSC9CQSZA0L+BipY
github.com/jstemmer/go-junit-report/v2 v2.1.0/go.mod h1:mgHVr7VUo5Tn8OLVr1cKnLuEy0M92wdRntM99h7RkgQ=
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/cpuid/v2 v2.4.2 h1:xRw7IZEsAgVCp6trS/DgWbTAqpixhmywgVgBjYGg1bQ=
github.com/klauspost/cpuid/v2 v2.4.2/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/klauspost/cpuid/v2 v2.4.1 h1:I9KxadAkvo+QjtcEE2f2tyfEvmIla/T0m+14PrZNl0U=
github.com/klauspost/cpuid/v2 v2.4.1/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/klauspost/reedsolomon v1.14.2 h1:SafJYwpBBQBI6amHUygcjxZjXeN2HpiENHQDwuPWCCQ=
github.com/klauspost/reedsolomon v1.14.2/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -317,8 +317,8 @@ github.com/nats-io/nats-server/v2 v2.15.2 h1:yLsgt2kslgymvxvjFq1WGWck5hkat2YYfbX
github.com/nats-io/nats-server/v2 v2.15.2/go.mod h1:VXHCZgDglV5wO2B8ZE58yG2HzqxQxYUbQqibUh5Xcr8=
github.com/nats-io/nats.go v1.55.0 h1:Ng8QKHdvdHIWoaiq0+d/aH1BWkxvcJxWJ0Y/9BPKx3M=
github.com/nats-io/nats.go v1.55.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.17 h1:JAJrlDhOyQqOt0a1eg4cbIZ1Kc7+vmkCigAltpwQpVA=
github.com/nats-io/nkeys v0.4.17/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -327,10 +327,10 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg=
github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
github.com/oracle/oci-go-sdk/v65 v65.129.0 h1:keTL99Tc9APm7Cm3QQLtiHZ3KU9ZRkiXJzzgjzjpKRk=
github.com/oracle/oci-go-sdk/v65 v65.129.0/go.mod h1:87yiCsoPSaOZkpt5KIEEThoSirgT8WolahE3I5/qz78=
github.com/ovn-kubernetes/libovsdb v0.9.1 h1:+ltjpC2PEObBWqg2UuG83fH+qdnTuAibXqGrWdUJ1Iw=
github.com/ovn-kubernetes/libovsdb v0.9.1/go.mod h1:mbCZzSSa9FQ0YEFgeO2rXW/OYoMh6vqNrPGpsS2XYAo=
github.com/oracle/oci-go-sdk/v65 v65.128.0 h1:QWh0D4VsQ+94XMFy4sapwHVuW5+VOF4dom+h8/cyJsw=
github.com/oracle/oci-go-sdk/v65 v65.128.0/go.mod h1:87yiCsoPSaOZkpt5KIEEThoSirgT8WolahE3I5/qz78=
github.com/ovn-kubernetes/libovsdb v0.9.0 h1:WNQM8oCmh793mKa7PIIsF5JYoCa91lgXxG1nEEDStFU=
github.com/ovn-kubernetes/libovsdb v0.9.0/go.mod h1:mbCZzSSa9FQ0YEFgeO2rXW/OYoMh6vqNrPGpsS2XYAo=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
@@ -375,8 +375,8 @@ github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qq
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.1.0 h1:6W6MbVUrnMb8PEhjkqwsVLY7ryuzRfFHF4mQd7L94ag=
github.com/spf13/pflag v1.1.0/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -485,8 +485,8 @@ golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+18 -39
View File
@@ -16,17 +16,11 @@ type DispatchInventory struct {
Unsupported []string
Opaque bool
Note string
// SDKV1Operations is the operation set of the aws-sdk-go v1 client for the
// service. Left empty, no gap is classed as needing SDK v2.
SDKV1Operations []string
}
// OperationCoverage is a deterministic model-versus-dispatch comparison.
// Implemented contains only modelled operations with a real handler; Extra
// contains registered operations absent from the pinned model. Missing holds
// the unregistered operations aws-sdk-go v1 can call, and RequiresSDKV2 the
// unregistered ones only a newer SDK can, which are not work until it moves.
// contains registered operations absent from the pinned model.
type OperationCoverage struct {
Service Service
APIVersion string
@@ -36,16 +30,13 @@ type OperationCoverage struct {
Stubbed []string
Unsupported []string
Missing []string
// RequiresSDKV2 is modelled, unregistered and absent from aws-sdk-go v1.
RequiresSDKV2 []string
Extra []string
Opaque bool
Note string
Extra []string
Opaque bool
Note string
}
// CoverageJSONSchemaVersion identifies the generated coverage.json schema.
// Increment it only for an incompatible change to the JSON document. Adding
// requires_sdk_v2 is compatible; missing now excludes it.
// Increment it only for an incompatible change to the JSON document.
const CoverageJSONSchemaVersion = 1
// coverageJSON is the generated, machine-readable form of the operation
@@ -79,10 +70,7 @@ type coverageJSONOperations struct {
Stubbed []string `json:"stubbed"`
Unsupported []string `json:"unsupported"`
Missing []string `json:"missing"`
// RequiresSDKV2 lists modelled, unregistered operations absent from the
// aws-sdk-go v1 SDK Spinifex is built on.
RequiresSDKV2 []string `json:"requires_sdk_v2"`
Extra []string `json:"extra"`
Extra []string `json:"extra"`
}
// CompareOperations compares one embedded service model with an authoritative
@@ -133,13 +121,8 @@ func CompareOperations(service Service, dispatch DispatchInventory) (OperationCo
}
modelledSet := toSet(coverage.Modelled)
sdkV1Set := toSet(dispatch.SDKV1Operations)
for _, operation := range coverage.Modelled {
if !registeredSet[operation] {
if len(sdkV1Set) > 0 && !sdkV1Set[operation] {
coverage.RequiresSDKV2 = append(coverage.RequiresSDKV2, operation)
continue
}
coverage.Missing = append(coverage.Missing, operation)
continue
}
@@ -190,9 +173,9 @@ func RenderCoverageSummary(coverages []OperationCoverage) string {
fmt.Fprintf(&report, "%-24s %-12s not enumerable\n", coverage.Service, coverage.APIVersion)
continue
}
fmt.Fprintf(&report, "%-24s %-12s %3d of %3d implemented (%5.1f%%), %3d stubbed, %3d unsupported, %3d need SDK v2, %3d outside the model\n",
fmt.Fprintf(&report, "%-24s %-12s %3d of %3d implemented (%5.1f%%), %3d stubbed, %3d unsupported, %3d outside the model\n",
coverage.Service, coverage.APIVersion, len(coverage.Implemented), len(coverage.Modelled),
coverage.ImplementedPercent(), len(coverage.Stubbed), len(coverage.Unsupported), len(coverage.RequiresSDKV2), len(coverage.Extra))
coverage.ImplementedPercent(), len(coverage.Stubbed), len(coverage.Unsupported), len(coverage.Extra))
}
return report.String()
}
@@ -218,14 +201,13 @@ func RenderCoverageJSON(coverages []OperationCoverage) (string, error) {
Opaque: coverage.Opaque,
Note: coverage.Note,
Operations: coverageJSONOperations{
Modelled: copiedOperations(coverage.Modelled),
Registered: copiedOperations(coverage.Registered),
Implemented: copiedOperations(coverage.Implemented),
Stubbed: copiedOperations(coverage.Stubbed),
Unsupported: copiedOperations(coverage.Unsupported),
Missing: copiedOperations(coverage.Missing),
RequiresSDKV2: copiedOperations(coverage.RequiresSDKV2),
Extra: copiedOperations(coverage.Extra),
Modelled: copiedOperations(coverage.Modelled),
Registered: copiedOperations(coverage.Registered),
Implemented: copiedOperations(coverage.Implemented),
Stubbed: copiedOperations(coverage.Stubbed),
Unsupported: copiedOperations(coverage.Unsupported),
Missing: copiedOperations(coverage.Missing),
Extra: copiedOperations(coverage.Extra),
},
})
}
@@ -265,7 +247,7 @@ func RenderInternalReport(coverages []OperationCoverage, pages PageSet) string {
var report strings.Builder
report.WriteString("# AWS Model Operation Coverage (internal)\n\n")
report.WriteString("Generated by `make aws-model-coverage`. This file is not published: it carries the counts and the unimplemented operations that the pages under this directory deliberately leave out.\n\n")
report.WriteString("**Not implemented** is the candidate work. **Requires SDK v2** is modelled but absent from aws-sdk-go v1, the SDK Spinifex is built and tested on: it is out of scope until the SDK v2 migration, and a stub for it fakes a feature nothing tested can call. **Not applicable** is declared in `pages.json` and is not work: those operations describe something this platform will not have.\n\n")
report.WriteString("**Not implemented** is the candidate work. **Not applicable** is declared in `pages.json` and is not work: those operations describe something this platform will not have.\n\n")
fmt.Fprintf(&report, "Pinned model source: `%s`.\n\n", ModelSourceDescription())
for _, coverage := range sortedCoverages(coverages) {
@@ -277,17 +259,14 @@ func RenderInternalReport(coverages []OperationCoverage, pages PageSet) string {
}
missing, notApplicable := splitNotApplicable(coverage.Missing, page)
requiresSDKV2, declaredV2 := splitNotApplicable(coverage.RequiresSDKV2, page)
notApplicable = append(notApplicable, declaredV2...)
refused, declaredRefusals := splitNotApplicable(coverage.Unsupported, page)
notApplicable = append(notApplicable, declaredRefusals...)
sort.Strings(notApplicable)
fmt.Fprintf(&report, "`%s` — %d of %d modelled operations implemented (%.1f%%), %d stubbed, %d require SDK v2, %d not applicable, %d registered outside the pinned model.\n\n",
fmt.Fprintf(&report, "`%s` — %d of %d modelled operations implemented (%.1f%%), %d stubbed, %d not applicable, %d registered outside the pinned model.\n\n",
coverage.APIVersion, len(coverage.Implemented), len(coverage.Modelled), coverage.ImplementedPercent(),
len(coverage.Stubbed), len(requiresSDKV2), len(notApplicable), len(coverage.Extra))
len(coverage.Stubbed), len(notApplicable), len(coverage.Extra))
writeOperationList(&report, "Not implemented", missing)
writeOperationList(&report, "Requires SDK v2 (not work until the SDK v2 migration)", requiresSDKV2)
writeOperationList(&report, "Stubbed", coverage.Stubbed)
writeOperationList(&report, "Registered and refused", refused)
writeOperationList(&report, "Registered outside the pinned model", coverage.Extra)
-2
View File
@@ -5,5 +5,3 @@ The `*.json.gz` files are the Smithy JSON AST service models from [`aws/api-mode
Do not edit them by hand. To move to a newer commit, run `scripts/sync-aws-models.sh <commit-sha>`, then `make generate-aws-model-coverage` and review the regenerated `docs/coverage` and the `awsmodel` test expectations: a newer model can add operations, required members and enum values, and each resulting finding is a real gap to triage rather than a reason to stay on an older commit.
`ec2/error-codes.json` is separate from the service models because the EC2 model declares no operation errors. It is a curated subset of the official [EC2 error-code reference](https://docs.aws.amazon.com/ec2/latest/devguide/errors-overview.html): all documented common and server codes, plus the action-specific codes that Spinifex currently emits. The catalog records its verification date and must be reviewed against that source when it changes.
`missing` in the generated coverage is not a task list, and `requires_sdk_v2` is the part of the model aws-sdk-go v1 (Spinifex's frozen SDK) cannot call, derived from the SDK itself. Never register or stub an operation without a real handler and a bead. An operation under `requires_sdk_v2` is out of scope until the SDK v2 migration. An unregistered operation already returns the proper unsupported error, which beats a stub that fakes success. `TestNoStubsForOperationsAbsentFromSDKV1` in `cmd/aws-model-coverage` fails on a stub or refusal for an operation absent from SDK v1; fix it by deleting the registration.
+4 -38
View File
@@ -21,7 +21,6 @@ const (
StatusNotApplicable = "⛔ Not applicable"
StatusNotImplemented = "❌ Not implemented"
StatusOutsideModel = "🔒 Outside the pinned model"
StatusRequiresSDKV2 = "⏳ Requires AWS SDK v2"
)
// Frontmatter length rules enforced by the docs site. See docs/README.md.
@@ -66,7 +65,6 @@ func (c OperationCoverage) OperationStatuses(page PageMetadata) ([]OperationStat
stubbed := toSet(c.Stubbed)
unsupported := toSet(c.Unsupported)
modelled := toSet(c.Modelled)
requiresSDKV2 := toSet(c.RequiresSDKV2)
extra := toSet(c.Extra)
notApplicable := page.NotApplicable
@@ -95,8 +93,6 @@ func (c OperationCoverage) OperationStatuses(page PageMetadata) ([]OperationStat
switch {
case stubbed[operation]:
status = StatusStub
case requiresSDKV2[operation]:
status = StatusRequiresSDKV2
// A registered handler that refuses serves nothing, so it reads as the
// gap it is unless the page declares why it is permanent.
case unsupported[operation]:
@@ -335,7 +331,6 @@ func RenderServicePage(coverage OperationCoverage, pages PageSet, intro string)
}
writeOperations(&body, statuses)
writeRequiresSDKV2(&body, statuses)
return body.String(), nil
}
@@ -354,25 +349,6 @@ func writeOperations(body *strings.Builder, statuses []OperationStatus) {
}
}
// writeRequiresSDKV2 lists the modelled operations that need AWS SDK v2. They
// are not gaps to build: the SDK Spinifex is built and tested on cannot call
// them, so the section records what an SDK upgrade would unlock.
func writeRequiresSDKV2(body *strings.Builder, statuses []OperationStatus) {
var operations []string
for _, status := range statuses {
if status.Status == StatusRequiresSDKV2 {
operations = append(operations, status.Operation)
}
}
if len(operations) == 0 {
return
}
fmt.Fprintf(body, "\n### Requires AWS SDK v2\n\nThese %d operations are in the pinned AWS model but not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2.\n\n| Operation |\n|---|\n", len(operations))
for _, operation := range operations {
fmt.Fprintf(body, "| `%s` |\n", operation)
}
}
// RenderIndexPage renders the cross-service summary that links to each page.
func RenderIndexPage(coverages []OperationCoverage, pages PageSet, intro string) (string, error) {
if err := checkIntro("index", intro); err != nil {
@@ -390,26 +366,16 @@ func RenderIndexPage(coverages []OperationCoverage, pages PageSet, intro string)
}
fmt.Fprintf(&body, "The platform serves **%d operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.\n\n", total)
body.WriteString("| Service | Operations | Requires AWS SDK v2 |\n|---|---:|---:|\n")
body.WriteString("| Service | Operations |\n|---|---:|\n")
for _, coverage := range sortedCoverages(coverages) {
page, ok := pages.Services[coverage.Service]
if !ok {
continue
}
fmt.Fprintf(&body, "| [%s](%s/%s) | %d | %d |\n",
page.Name, pages.BasePath, page.Slug, len(coverage.Implemented), len(coverage.RequiresSDKV2))
}
totalV2 := 0
for _, coverage := range coverages {
if _, ok := pages.Services[coverage.Service]; ok {
totalV2 += len(coverage.RequiresSDKV2)
}
}
fmt.Fprintf(&body, "| **Total** | **%d** | **%d** |\n", total, totalV2)
if totalV2 > 0 {
fmt.Fprintf(&body, "\nA further **%d operations** in the pinned models are not in AWS SDK for Go v1, the SDK Spinifex is built and tested on. They are out of scope until Spinifex moves to AWS SDK v2; each service page lists its own under Requires AWS SDK v2.\n", totalV2)
fmt.Fprintf(&body, "| [%s](%s/%s) | %d |\n",
page.Name, pages.BasePath, page.Slug, len(coverage.Implemented))
}
fmt.Fprintf(&body, "| **Total** | **%d** |\n", total)
if intro != "" {
body.WriteString("\n" + strings.TrimSpace(intro) + "\n")
-69
View File
@@ -1,69 +0,0 @@
// Package sdkv1 reports which operations the vendored aws-sdk-go v1 knows.
//
// Spinifex is built on that frozen SDK, so an operation absent from it cannot
// be implemented or exercised with the SDK the repository tests against. The
// set is read from the SDK's own client types, never listed by hand.
package sdkv1
import (
"reflect"
"slices"
"strings"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/service/acm"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/ecr"
"github.com/aws/aws-sdk-go/service/ecs"
"github.com/aws/aws-sdk-go/service/eks"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/rds"
"github.com/aws/aws-sdk-go/service/route53"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/sqs"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/mulgadc/spinifex/internal/awsmodel"
)
var clients = map[awsmodel.Service]reflect.Type{
awsmodel.ACM: reflect.TypeFor[*acm.ACM](),
awsmodel.EC2: reflect.TypeFor[*ec2.EC2](),
awsmodel.ECR: reflect.TypeFor[*ecr.ECR](),
awsmodel.ECS: reflect.TypeFor[*ecs.ECS](),
awsmodel.EKS: reflect.TypeFor[*eks.EKS](),
awsmodel.ElasticLoadBalancingV2: reflect.TypeFor[*elbv2.ELBV2](),
awsmodel.IAM: reflect.TypeFor[*iam.IAM](),
awsmodel.RDS: reflect.TypeFor[*rds.RDS](),
awsmodel.Route53: reflect.TypeFor[*route53.Route53](),
awsmodel.S3: reflect.TypeFor[*s3.S3](),
awsmodel.SQS: reflect.TypeFor[*sqs.SQS](),
awsmodel.STS: reflect.TypeFor[*sts.STS](),
}
var requestType = reflect.TypeFor[*request.Request]()
// Operations returns the sorted operation names the SDK v1 client for service
// exposes, and false when the service has no SDK v1 client registered here.
// Every operation has an XxxRequest method that builds its request.
func Operations(service awsmodel.Service) ([]string, bool) {
client, ok := clients[service]
if !ok {
return nil, false
}
var operations []string
for method := range client.Methods() {
name, isRequest := strings.CutSuffix(method.Name, "Request")
if !isRequest || name == "" {
continue
}
// Receiver plus input, returning the request and the output.
if method.Type.NumIn() != 2 || method.Type.NumOut() != 2 || method.Type.Out(0) != requestType {
continue
}
operations = append(operations, name)
}
slices.Sort(operations)
return operations, true
}
-42
View File
@@ -1,42 +0,0 @@
package sdkv1
import (
"slices"
"testing"
"github.com/mulgadc/spinifex/internal/awsmodel"
)
func TestOperationsComeFromTheSDKClient(t *testing.T) {
ec2, ok := Operations(awsmodel.EC2)
if !ok {
t.Fatal("no ec2 client")
}
if !slices.IsSorted(ec2) {
t.Error("operations are not sorted")
}
if !slices.Contains(ec2, "RunInstances") {
t.Error("RunInstances is missing")
}
if slices.Contains(ec2, "NewRequest") || slices.Contains(ec2, "") {
t.Error("a non-operation method was counted")
}
ecr, _ := Operations(awsmodel.ECR)
if slices.Contains(ecr, "ListRepositories") {
t.Error("ListRepositories is not an ECR operation in aws-sdk-go v1")
}
}
func TestEveryEmbeddedModelHasAnSDKClient(t *testing.T) {
for _, service := range awsmodel.Services() {
if operations, ok := Operations(service); !ok || len(operations) == 0 {
t.Errorf("%s has no aws-sdk-go v1 operations; register its client in sdkv1.clients", service)
}
}
}
func TestUnknownServiceReportsNotFound(t *testing.T) {
if _, ok := Operations(awsmodel.Service("nope")); ok {
t.Error("unknown service reported as found")
}
}
-132
View File
@@ -1,132 +0,0 @@
package awsmodel_test
import (
"slices"
"strings"
"testing"
"github.com/mulgadc/spinifex/internal/awsmodel"
)
func TestCompareOperationsSplitsGapsBySDKV1(t *testing.T) {
model, err := awsmodel.Load(awsmodel.STS)
if err != nil {
t.Fatal(err)
}
modelled := model.Operations()
// Only AssumeRole is registered; the first other operation is callable
// from SDK v1 and the rest are not.
var inV1, onlyV2 string
for _, operation := range modelled {
if operation != "AssumeRole" && inV1 == "" {
inV1 = operation
}
}
for _, operation := range modelled {
if operation != "AssumeRole" && operation != inV1 {
onlyV2 = operation
break
}
}
coverage, err := awsmodel.CompareOperations(awsmodel.STS, awsmodel.DispatchInventory{
Registered: []string{"AssumeRole"},
SDKV1Operations: []string{"AssumeRole", inV1},
})
if err != nil {
t.Fatal(err)
}
if !slices.Contains(coverage.Missing, inV1) || slices.Contains(coverage.RequiresSDKV2, inV1) {
t.Errorf("%s is in SDK v1 so must be missing, got missing=%v requires=%v", inV1, coverage.Missing, coverage.RequiresSDKV2)
}
if !slices.Contains(coverage.RequiresSDKV2, onlyV2) || slices.Contains(coverage.Missing, onlyV2) {
t.Errorf("%s is not in SDK v1 so must require SDK v2, got missing=%v requires=%v", onlyV2, coverage.Missing, coverage.RequiresSDKV2)
}
if slices.Contains(coverage.RequiresSDKV2, "AssumeRole") {
t.Error("a registered operation must never be listed as requiring SDK v2")
}
if len(coverage.Missing)+len(coverage.RequiresSDKV2) != len(modelled)-1 {
t.Errorf("gaps = %d + %d, want %d", len(coverage.Missing), len(coverage.RequiresSDKV2), len(modelled)-1)
}
unknown, err := awsmodel.CompareOperations(awsmodel.STS, awsmodel.DispatchInventory{Registered: []string{"AssumeRole"}})
if err != nil {
t.Fatal(err)
}
if len(unknown.RequiresSDKV2) != 0 {
t.Errorf("without SDK information nothing may be classed as requiring SDK v2, got %v", unknown.RequiresSDKV2)
}
}
func TestRequiresSDKV2IsRenderedInJSONAndPages(t *testing.T) {
coverage := awsmodel.OperationCoverage{
Service: awsmodel.STS,
APIVersion: "2011-06-15",
Modelled: []string{"AssumeRole", "OnlyInV2"},
Registered: []string{"AssumeRole"},
Implemented: []string{"AssumeRole"},
RequiresSDKV2: []string{"OnlyInV2"},
}
contents, err := awsmodel.RenderCoverageJSON([]awsmodel.OperationCoverage{coverage})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(contents, `"requires_sdk_v2": [`+"\n"+` "OnlyInV2"`) || !strings.Contains(contents, `"missing": []`) {
t.Errorf("JSON does not carry the split:\n%s", contents)
}
if !strings.Contains(contents, `"schema_version": 1,`) {
t.Errorf("schema_version must stay 1: capability_profiles.py rejects any other version:\n%s", contents)
}
pages := testPageSet(t)
page, err := awsmodel.RenderServicePage(coverage, pages, "")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(page, "### Requires AWS SDK v2") || !strings.Contains(page, "| `OnlyInV2` |") {
t.Errorf("service page lacks the SDK v2 section:\n%s", page)
}
operations, _, _ := strings.Cut(page, "### Requires AWS SDK v2")
if strings.Contains(operations, "OnlyInV2") {
t.Error("an SDK-v2-only operation must not be listed under Operations")
}
index, err := awsmodel.RenderIndexPage([]awsmodel.OperationCoverage{coverage}, pages, "")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(index, "A further **1 operations**") {
t.Errorf("index lacks the SDK v2 count:\n%s", index)
}
report := awsmodel.RenderInternalReport([]awsmodel.OperationCoverage{coverage}, pages)
if !strings.Contains(report, "### Requires SDK v2") || strings.Contains(report, "### Not implemented") {
t.Errorf("internal report does not separate the SDK v2 list:\n%s", report)
}
}
func TestIndexAndReportCountSDKV2PerService(t *testing.T) {
sts := awsmodel.OperationCoverage{
Service: awsmodel.STS, APIVersion: "2011-06-15",
Modelled: []string{"A", "B", "C"}, Registered: []string{"A"}, Implemented: []string{"A"},
RequiresSDKV2: []string{"B", "C"},
}
iam := awsmodel.OperationCoverage{
Service: awsmodel.IAM, APIVersion: "2010-05-08",
Modelled: []string{"A", "B"}, Registered: []string{"A"}, Implemented: []string{"A"},
RequiresSDKV2: []string{"B"},
}
coverages := []awsmodel.OperationCoverage{sts, iam}
pages := testPageSet(t)
index, err := awsmodel.RenderIndexPage(coverages, pages, "")
if err != nil {
t.Fatal(err)
}
for _, row := range []string{"| [IAM](/coverage/iam) | 1 | 1 |", "| [STS](/coverage/sts) | 1 | 2 |", "| **Total** | **2** | **3** |"} {
if !strings.Contains(index, row) {
t.Errorf("index lacks row %q:\n%s", row, index)
}
}
report := awsmodel.RenderInternalReport(coverages, pages)
if !strings.Contains(report, "0 stubbed, 2 require SDK v2,") || !strings.Contains(report, "0 stubbed, 1 require SDK v2,") {
t.Errorf("internal report summary lacks per-service SDK v2 counts:\n%s", report)
}
}
+2 -9
View File
@@ -321,19 +321,12 @@ echo "[build-microvm-image] building initramfs: $INITRAMFS_OUT"
# Create the static device nodes and pack the archive inside a single fakeroot
# session: mknod/cpio see faked device entries and write real char-device
# records into the cpio, while the host filesystem never gets an actual node.
# Pack to a .partial and list it before renaming, so a pack cut short (fakeroot
# losing its daemon, a full disk) fails here and never leaves a stale archive.
fakeroot bash -c '
set -euo pipefail
fakeroot sh -c '
cd "$1" || exit 1
mknod -m 600 dev/console c 5 1
mknod -m 666 dev/null c 1 3
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
' _ "$CHROOT_DIR" "$INITRAMFS_OUT.partial" \
&& gzip -t "$INITRAMFS_OUT.partial" \
&& zcat "$INITRAMFS_OUT.partial" | cpio -t --quiet >/dev/null \
|| { rm -f "$INITRAMFS_OUT.partial" "$INITRAMFS_OUT"; echo "ERROR: initramfs $INITRAMFS_OUT is incomplete or unreadable" >&2; exit 1; }
mv -f "$INITRAMFS_OUT.partial" "$INITRAMFS_OUT"
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
# --- Log artifact sizes ---
echo ""
-1
View File
@@ -40,7 +40,6 @@ mkdir -p "$STAGING/scripts"
cp "$PROJECT_ROOT/build/scripts/"* "$STAGING/scripts/"
mkdir -p "$STAGING/udev"
cp "$PROJECT_ROOT/build/udev/"* "$STAGING/udev/"
cp -r "$PROJECT_ROOT/build/ocibsd" "$STAGING/ocibsd"
cp "$PROJECT_ROOT/build/logrotate/spinifex" "$STAGING/logrotate-spinifex"
tar czf /tmp/spinifex-local.tar.gz -C "$STAGING" .
+3 -6
View File
@@ -11,16 +11,13 @@
description="K3s Kubernetes agent (Mulga EKS worker)"
command="/usr/local/bin/k3s"
command_args="agent"
command_background="yes"
pidfile="/run/k3s-agent.pid"
# Supervise the agent as k3s.initd supervises the server: k3s exits when flannel
# cannot reach the API, and without a supervisor the node then stays NotReady.
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
output_log="/var/log/k3s-agent.log"
error_log="/var/log/k3s-agent.log"
start_stop_daemon_args="--wait 5000"
depend() {
need net cgroups
after firewall
@@ -42,8 +42,7 @@ select_lines() {
line = $0
gsub(/\r/, "", line)
keep = 0
if (line ~ /level=(warning|error|fatal)/) keep = 1
else if (line ~ /^(panic|fatal error):/) keep = 1
if (line ~ /level=(warning|error)/) keep = 1
else if (line ~ /^[WE][0-9][0-9][0-9][0-9] /) keep = 1
else if (line ~ /NodeNotReady|PLEG|[Ll]ease|[Nn]ode status/) keep = 1
if (!keep) next
@@ -60,16 +60,6 @@ I1103 03:00:03.123456 1234 kubelet.go:1000] SyncLoop (UPDATE, "api")
EOF
check "filter: info and debug lines are dropped" "0" "$(forwarded)"
# --- filter: the lines an agent exit leaves behind must pass, redacted ---
forward filter_exit <<'EOF'
time="2026-11-03T03:00:08Z" level=fatal msg="flannel exited: failed to register flannel network: token K1009f3c2d4e5::server:s3cr3tExit"
panic: runtime error: invalid memory address or nil pointer dereference
fatal error: concurrent map writes
goroutine 1 [running]:
EOF
check "filter: fatal, panic and fatal error lines pass" "3" "$(forwarded)"
check "filter: a fatal line is still redacted" "0" "$(grep -c 's3cr3tExit' "${OUT}" || true)"
# --- redaction: one case per secret shape ---
redact() { # redact <name> <line> <secret-that-must-not-appear>
forward "redact_$1" <<EOF
+2 -19
View File
@@ -50,11 +50,6 @@
# --yes Skip the confirmation prompt
# --dry-run Print what would happen and touch nothing
#
# Environment:
# SPINIFEX_EBS_PROVIDER Passed to spx admin init on the first host, naming
# what backs EBS in place of its detection; joining
# nodes take the provider from formation.
#
# THE HOST FIREWALL
#
# Nodes installed from the ISO boot armed, scoped to the only cluster member
@@ -164,7 +159,7 @@ while [[ $# -gt 0 ]]; do
--no-firewall) MANAGE_FIREWALL=false; shift ;;
--yes|-y) ASSUME_YES=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
-h|--help) sed -n '2,72p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
-h|--help) sed -n '2,67p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
-*) echo "ERROR: unknown option: $1" >&2; exit 2 ;;
*) HOSTS+=("$1"); shift ;;
esac
@@ -215,18 +210,6 @@ else
done
fi
# init_env prints the env prefix that carries SPINIFEX_EBS_PROVIDER to init, or
# nothing when it is unset. It is joined into a remote shell, so the value is
# limited to a provider name's characters rather than quoted.
init_env() {
case "$1" in
"") ;;
*[!a-z]*) echo "SPINIFEX_EBS_PROVIDER must be a provider name such as viperblockd, got: $1"; return 1 ;;
*) printf 'env SPINIFEX_EBS_PROVIDER=%s ' "$1" ;;
esac
}
INIT_ENV="$(init_env "${SPINIFEX_EBS_PROVIDER:-}")" || fail "$INIT_ENV"
case "$IPSEC" in
on) IPSEC_ENABLED=true ;;
off) IPSEC_ENABLED=false ;;
@@ -688,7 +671,7 @@ log "initializing ${HOSTS[0]} as ${NODE_NAMES[0]} (waiting for $((N - 1)) node(s
# init blocks in a formation server until every node has joined, so it runs
# detached and the token is read back out of its output.
on "${HOSTS[0]}" "sudo rm -f $INIT_LOG; \
sudo setsid ${INIT_ENV}sh -c 'umask 077; spx admin init ${init_args[*]} >$INIT_LOG 2>&1' </dev/null >/dev/null 2>&1 & \
sudo setsid sh -c 'umask 077; spx admin init ${init_args[*]} >$INIT_LOG 2>&1' </dev/null >/dev/null 2>&1 & \
exit 0" || fail "${HOSTS[0]}: could not start spx admin init"
TOKEN=""
+3 -14
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Tests remote_join_cmd and init_env from install-node.sh: the join token reaches spx only on
# stdin, so sudo's log never holds it; other values arrive as plain arguments even when they
# hold shell syntax; and a SPINIFEX_EBS_PROVIDER that is not a provider name is refused.
# Tests remote_join_cmd from install-node.sh: the join token must reach spx only on stdin, so
# sudo's log (which holds the command it was given) never contains it, and the other values must
# arrive as plain arguments even when they hold shell syntax.
# preflight runs this with sh
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
set -euo pipefail
@@ -38,15 +38,4 @@ check "spx got the token" "$(grep -o '<--token><[^>]*>' "$FAKE_SPX_ARGV")" "<--t
check "spx got the other values as given" "$(cat "$FAKE_SPX_ARGV")" "<admin><join><--force><--node><node2><--bind><10.0.0.2><--cluster-bind><10.0.0.2><--advertise><203.0.113.2><--host><10.0.0.1:4432;touch PWNED><--token><$TOKEN><--region><us-east-1><--az><az \$(touch PWNED2)>"
check "no value was run as a command" "$(find "$work" -maxdepth 1 -name 'PWNED*' | wc -l | tr -d ' ')" 0
# init_env: its value is joined into a remote sh -c unquoted, so anything beyond
# a provider name's characters is refused, not passed on.
eval "$(sed -n '/^init_env()/,/^}/p' "$here/install-node.sh")"
check "no provider sets nothing" "$(init_env "")" ""
check "a provider is carried to init" "$(init_env viperblockd)" "env SPINIFEX_EBS_PROVIDER=viperblockd "
check "a provider with shell syntax is refused" \
"$(init_env 'ocibsd;touch PWNED3' >/dev/null 2>&1 && echo accepted || echo refused)" refused
check "a provider with a space is refused" \
"$(init_env 'ocibsd $(touch PWNED4)' >/dev/null 2>&1 && echo accepted || echo refused)" refused
check "no refused value was run as a command" "$(find "$work" -maxdepth 1 -name 'PWNED*' | wc -l | tr -d ' ')" 0
[ "$fails" -eq 0 ]
+2 -9
View File
@@ -382,17 +382,10 @@ microvm_pack_initramfs() {
# Create the static device nodes and pack the archive inside a single fakeroot
# session: mknod/cpio see faked device entries and write real char-device
# records into the cpio, while the host filesystem never gets an actual node.
# Pack to a .partial and list it before renaming, so a pack cut short fails
# here and never leaves a stale archive.
fakeroot bash -c '
set -euo pipefail
fakeroot sh -c '
cd "$1" || exit 1
mknod -m 600 dev/console c 5 1
mknod -m 666 dev/null c 1 3
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
' _ "$CHROOT_DIR" "$INITRAMFS_OUT.partial" \
&& gzip -t "$INITRAMFS_OUT.partial" \
&& zcat "$INITRAMFS_OUT.partial" | cpio -t --quiet >/dev/null \
|| { rm -f "$INITRAMFS_OUT.partial" "$INITRAMFS_OUT"; echo "ERROR: initramfs $INITRAMFS_OUT is incomplete or unreadable" >&2; exit 1; }
mv -f "$INITRAMFS_OUT.partial" "$INITRAMFS_OUT"
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
}
-77
View File
@@ -1,77 +0,0 @@
#!/bin/sh
# microvm-image_test.sh — microvm_pack_initramfs publishes only an archive that
# lists completely, removes a rejected one, and succeeds again on a retry.
#
# The real pack step runs against a small chroot; fakeroot and mknod are
# replaced so it needs no root, and cpio is wrapped to simulate a cut pack.
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 "-- microvm_pack_initramfs"
if ! command -v cpio >/dev/null 2>&1 || ! command -v bash >/dev/null 2>&1; then
echo " skip - cpio or bash not installed"
exit 0
fi
REAL_CPIO=$(command -v cpio)
mkdir -p "$TMP/bin" "$TMP/chroot/dev" "$TMP/chroot/etc" "$TMP/stage"
printf '#!/bin/sh\nexec "$@"\n' > "$TMP/bin/fakeroot"
printf '#!/bin/sh\nexit 0\n' > "$TMP/bin/mknod"
chmod +x "$TMP/bin/fakeroot" "$TMP/bin/mknod"
# Large enough that a cut at 64 KiB loses whole entries and the trailer.
head -c 400000 /dev/urandom > "$TMP/chroot/etc/blob"
printf 'init\n' > "$TMP/chroot/init"
# pack <cpio mode>: runs the real step; mode "cut" truncates the archive stream
# and exits 0, mode "fail" writes it whole and exits 1, mode "ok" is untouched.
pack() {
cat > "$TMP/bin/cpio" <<EOF
#!/bin/bash
case " \$* " in *" -o "*) ;; *) exec "$REAL_CPIO" "\$@" ;; esac
case "$1" in
cut) "$REAL_CPIO" "\$@" | head -c 65536; exit 0 ;;
fail) "$REAL_CPIO" "\$@"; exit 1 ;;
*) exec "$REAL_CPIO" "\$@" ;;
esac
EOF
chmod +x "$TMP/bin/cpio"
PATH="$TMP/bin:$PATH" bash -c '
set -euo pipefail
. "$1/lib/microvm-image.sh"
TAG="[test]" CHROOT_DIR="$2" STAGE_DIR="$3"
microvm_pack_initramfs
' _ "$SCRIPTS_DIR" "$TMP/chroot" "$TMP/stage" > "$TMP/log" 2>&1
}
OUT="$TMP/stage/initramfs.cpio.gz"
for mode in cut fail; do
printf 'stale\n' > "$OUT"
if pack "$mode"; then
fail "$mode: pack succeeded, want failure"
elif [ -e "$OUT" ] || [ -e "$OUT.partial" ]; then
fail "$mode: rejected archive left behind"
else
pass "$mode: pack fails and leaves no archive"
fi
done
if ! pack ok; then
fail "retry after failure: $(tr '\n' ' ' < "$TMP/log")"
elif [ -e "$OUT.partial" ]; then
fail "retry after failure: partial file left behind"
elif ! zcat "$OUT" | "$REAL_CPIO" -t --quiet 2>/dev/null | grep -qx 'etc/blob'; then
fail "retry after failure: archive does not list etc/blob"
else
pass "retry after failure publishes a complete archive"
fi
[ "$fails" -eq 0 ] || exit 1
+1 -1
View File
@@ -98,7 +98,7 @@ record() { echo "$1 $( . "$LIB"; sqsw_starttime "$1" )" > "$T/run/${2:-ready}.pi
shutdown_step() {
(
SQSW_RUN="$T/run" SQSW_DATA="$T/data" SQSW_CONSOLE="$T/console" SQSW_MOUNTS="$T/mounts"
UMOUNT="$TMP/bin/umount" SQSW_STOP_BUDGET_TENTHS=5 SQSW_KILL_WAIT_TENTHS=5
UMOUNT="$TMP/bin/umount" SQSW_STOP_BUDGET_TENTHS=5
# shellcheck source=../../build/microvm/sqs-worker/sqs-worker-lib.sh
. "$LIB"
if [ -n "${1:-}" ]; then sqsw_same() { [ "$1" = "$survivor" ]; }; survivor=$1; fi
+3 -98
View File
@@ -17,7 +17,7 @@
# SETUP_STAGES Comma-separated subset of stages to run:
# deps, aws, dns, users, sudoers, firewall, timesync, files,
# directories, env, systemd, sysctl, needrestart, modules,
# logrotate, udev, ocibsd, fixown, migrations
# logrotate, udev, fixown, migrations
# Unset = run every stage appropriate for the current mode.
set -e
@@ -43,13 +43,6 @@ FIREWALL_TRACK="${FIREWALL_DIR}/conntrack.nft"
FIREWALL_MODE_FILE="${SPINIFEX_FIREWALL_MODE_FILE:-${FIREWALL_DIR}/mode}"
FIREWALL_APPLY="/usr/local/lib/spinifex/spinifex-firewall-apply"
# The OCI instance metadata service spx admin init detects ocibsd by. v2 needs
# the bearer header; it is a confused-deputy guard, not a credential.
OCI_IMDS_SHAPE_URL="http://169.254.169.254/opc/v2/instance/shape"
# Where the tarball's ocibsd/ is staged; init and join install it from here.
OCIBSD_STAGE_DIR="/usr/local/share/spinifex/ocibsd"
SPINIFEX_TOML="/etc/spinifex/spinifex.toml"
# The daemon runs with ProtectKernelModules=yes, so every module it needs must
# already be loaded when it starts.
SPINIFEX_KERNEL_MODULES="openvswitch geneve vhost_net tun"
@@ -1358,7 +1351,6 @@ install_deps() {
apt) install_apt_deps ;;
dnf) install_dnf_deps ;;
esac
install_iscsi_initiator "$pm"
fi
# Mask the standalone dhcpcd.service auto-enabled on Debian Trixie when
@@ -1393,53 +1385,6 @@ install_apt_deps() {
fi
}
# is_oci_vm is spx admin init's detection: the OCI metadata service answers
# within a second with a shape that is not bare metal. Asked once per run,
# because off OCI each ask costs the full second.
is_oci_vm() {
if [ -z "${OCI_VM:-}" ]; then
local shape=""
OCI_VM=no
if command -v curl >/dev/null 2>&1 \
&& shape=$(curl -sf -m 1 -H 'Authorization: Bearer Oracle' "$OCI_IMDS_SHAPE_URL" 2>/dev/null); then
case "$shape" in
"" | BM.*) ;;
*) OCI_VM=yes ;;
esac
fi
fi
[ "$OCI_VM" = yes ]
}
# node_runs_ocibsd reports whether this node's existing config names ocibsd in
# a [nodes.<name>.ebs] table, the provider being the same on every node.
node_runs_ocibsd() {
local text
[ -e "$SPINIFEX_TOML" ] || return 1
if [ -r "$SPINIFEX_TOML" ]; then
text=$(cat "$SPINIFEX_TOML") || return 1
else
text=$($SUDO cat "$SPINIFEX_TOML") || return 1
fi
printf '%s\n' "$text" | awk '
/^[[:space:]]*\[/ { ebs = ($0 ~ /^[[:space:]]*\[nodes\.[^].]+\.ebs\][[:space:]]*(#.*)?$/); next }
ebs && /^[[:space:]]*provider[[:space:]]*=[[:space:]]*"ocibsd"/ { found = 1 }
END { exit !found }'
}
# The initiator ocibsd drives, on the only host it can back EBS on.
install_iscsi_initiator() {
is_oci_vm || return 0
info "OCI virtual machine detected, installing the iSCSI initiator..."
case "$1" in
apt) DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq open-iscsi > /dev/null ;;
dnf) $SUDO dnf install -y -q iscsi-initiator-utils > /dev/null ;;
esac
if [ "${ISO_BUILD:-0}" != "1" ]; then
$SUDO systemctl enable --now iscsid.socket
fi
}
# The package table is the other place keyed on the OS ID: dnf alone does not
# say which repositories or package names a host has.
install_dnf_deps() {
@@ -2190,45 +2135,6 @@ install_udev() {
info "udev rules installed"
}
# install_ocibsd stages the tarball's ocibsd/ on an OCI virtual machine, where
# spx admin init or join installs it once the provider resolves to ocibsd. On
# a node whose config already runs ocibsd it also reinstalls the staged files.
install_ocibsd() {
stage "staging the ocibsd files"
local ocibsd_node=false f
node_runs_ocibsd && ocibsd_node=true
if ! $ocibsd_node && ! is_oci_vm; then
return 0
fi
if [ ! -d "$EXTRACT_DIR/ocibsd" ]; then
$ocibsd_node && fatal "This node runs ocibsd and the tarball has no ocibsd/ directory to reinstall it from"
warn "The tarball has no ocibsd/ directory, so OCI Block Volumes cannot back EBS on this node"
return 0
fi
$SUDO rm -rf "$OCIBSD_STAGE_DIR"
$SUDO install -d -m 0755 "$OCIBSD_STAGE_DIR/spinifex-daemon.service.d"
for f in spinifex-ocibsd.service 99-spinifex-ocibsd.rules spinifex-daemon.service.d/ocibsd.conf; do
$SUDO install -m 0644 "$EXTRACT_DIR/ocibsd/$f" "$OCIBSD_STAGE_DIR/$f"
done
info " $OCIBSD_STAGE_DIR (staged)"
$ocibsd_node || return 0
$SUDO install -m 0644 "$OCIBSD_STAGE_DIR/spinifex-ocibsd.service" /etc/systemd/system/spinifex-ocibsd.service
$SUDO install -d -m 0755 /etc/systemd/system/spinifex-daemon.service.d
$SUDO install -m 0644 "$OCIBSD_STAGE_DIR/spinifex-daemon.service.d/ocibsd.conf" \
/etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf
$SUDO install -d /etc/udev/rules.d
$SUDO install -m 0644 "$OCIBSD_STAGE_DIR/99-spinifex-ocibsd.rules" /etc/udev/rules.d/99-spinifex-ocibsd.rules
# install_systemd has just written the viperblock unit over its mask.
$SUDO ln -sf /dev/null /etc/systemd/system/spinifex-viperblock.service
info " ocibsd unit, device drop-in and udev rule reinstalled; spinifex-viperblock.service masked"
if [ "${ISO_BUILD:-0}" != "1" ]; then
$SUDO systemctl daemon-reload
$SUDO systemctl enable spinifex-ocibsd.service
$SUDO udevadm control --reload-rules
fi
}
# --- Upgrade handling ---
handle_upgrade() {
if $SUDO systemctl is-active --quiet spinifex.target 2>/dev/null; then
@@ -2367,12 +2273,12 @@ main() {
detect_os
detect_arch
# Stages that need EXTRACT_DIR: files, directories, systemd, logrotate, udev, ocibsd.
# Stages that need EXTRACT_DIR: files, directories, systemd, logrotate, udev.
# Only download/extract when at least one such stage is enabled, and before
# anything on the host changes, so the hand-off finds it untouched.
if stage_enabled files || stage_enabled directories \
|| stage_enabled systemd || stage_enabled logrotate \
|| stage_enabled udev || stage_enabled ocibsd; then
|| stage_enabled udev; then
download_spinifex
hand_off_to_packaged_setup "${args[@]}"
fi
@@ -2400,7 +2306,6 @@ main() {
stage_enabled modules && install_kernel_modules
stage_enabled logrotate && install_logrotate
stage_enabled udev && install_udev
stage_enabled ocibsd && install_ocibsd
stage_enabled swap && setup_swap
# Migrations are only safe on a live system (need a running NATS and a
+9 -23
View File
@@ -62,10 +62,6 @@ cat > "$TMP/flow.py" << 'PY'
import os, socket, subprocess, sys
srv_ns, cli_ns, script = sys.argv[1], sys.argv[2], sys.argv[3]
# An optional wait applies only to the last step, the one expected to be lost;
# a loss there exits 3, and a loss anywhere else exits 1.
last_wait = float(sys.argv[4]) if len(sys.argv) > 4 else 2
steps = script.split("@@")
def enter(ns):
fd = os.open("/run/netns/" + ns, os.O_RDONLY)
@@ -80,7 +76,7 @@ listener.listen(1)
enter(cli_ns)
cli = srv = None
for i, step in enumerate(steps):
for step in script.split("@@"):
if step == "connect":
cli = socket.create_connection(("10.99.0.1", 6644), timeout=3)
srv, _ = listener.accept()
@@ -88,8 +84,6 @@ for i, step in enumerate(steps):
srv.settimeout(2)
elif step in ("s2c", "c2s"):
tx, rx = (srv, cli) if step == "s2c" else (cli, srv)
last = i == len(steps) - 1
rx.settimeout(last_wait if last else 2)
tx.sendall(b"x" * 300)
got = b""
try:
@@ -97,18 +91,17 @@ for i, step in enumerate(steps):
got += rx.recv(300 - len(got))
except socket.timeout:
print("lost " + step)
sys.exit(3 if last else 1)
sys.exit(1)
else:
subprocess.run(step, shell=True, check=True)
PY
# dial <client source> <server addr> <port> [wait] — a new connection from the
# server namespace to the client, which listens on the port. 0 when it connects.
# dial <client source> <server addr> <port> — a new connection from the server
# namespace to the client, which listens on the port. 0 when it connects.
cat > "$TMP/dial.py" << 'PY'
import os, socket, sys
srv_ns, cli_ns, src, port = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
wait = float(sys.argv[5]) if len(sys.argv) > 5 else 2
fd = os.open("/run/netns/" + cli_ns, os.O_RDONLY)
os.setns(fd, os.CLONE_NEWNET)
listener = socket.socket()
@@ -118,7 +111,7 @@ fd = os.open("/run/netns/" + srv_ns, os.O_RDONLY)
os.setns(fd, os.CLONE_NEWNET)
dialer = socket.socket()
dialer.bind((src, 0))
dialer.settimeout(wait)
dialer.settimeout(2)
try:
dialer.connect(("10.99.0.3", port))
except OSError:
@@ -155,21 +148,14 @@ flow() {
if out=$($SUDO python3 "$TMP/flow.py" "$S" "$C" "$2" 2>&1); then pass "$1"; else fail "$1: $out"; fi
}
# Steps expected to be dropped wait only this long, since a segment on a local
# veth arrives in microseconds; steps expected to arrive keep the 2s default.
DROP_WAIT=0.5
# Control first: with nothing tracking, arming under an open flow must drop the
# server's next segment, or this kernel cannot show the failure and the cases
# below prove nothing.
fresh
rc=0; $SUDO python3 "$TMP/flow.py" "$S" "$C" "connect@@s2c@@c2s@@$arm@@s2c" "$DROP_WAIT" > /dev/null 2>&1 || rc=$?
if [ "$rc" = 3 ]; then
pass "control: a flow opened with no conntrack user loses the server's next segment once armed"
elif [ "$rc" = 0 ]; then
if $SUDO python3 "$TMP/flow.py" "$S" "$C" "connect@@s2c@@c2s@@$arm@@s2c" > /dev/null 2>&1; then
fail "control: a flow opened with no conntrack user survived the arm, so this kernel cannot reproduce the drop"
else
fail "control: the flow failed before the arm (exit $rc), so the control proves nothing"
pass "control: a flow opened with no conntrack user loses the server's next segment once armed"
fi
fresh
@@ -196,12 +182,12 @@ if $SUDO python3 "$TMP/dial.py" "$S" "$C" 10.99.0.1 6644; then
else
fail "armed: a peer cannot reach a cluster port"
fi
if $SUDO python3 "$TMP/dial.py" "$S" "$C" 10.99.0.9 6644 "$DROP_WAIT"; then
if $SUDO python3 "$TMP/dial.py" "$S" "$C" 10.99.0.9 6644; then
fail "armed: a non-peer reaches a cluster port"
else
pass "armed: a non-peer is dropped on a cluster port"
fi
if $SUDO python3 "$TMP/dial.py" "$S" "$C" 10.99.0.1 7777 "$DROP_WAIT"; then
if $SUDO python3 "$TMP/dial.py" "$S" "$C" 10.99.0.1 7777; then
fail "armed: a peer reaches a port the policy does not open"
else
pass "armed: a peer is dropped on a port the policy does not open"
-157
View File
@@ -1,157 +0,0 @@
#!/bin/sh
# setup_ocibsd_test.sh — setup.sh's systemd and udev stages never install the
# tarball's ocibsd/ files; install_ocibsd stages them on an OCI VM, and
# reinstalls them on upgrade only where the node's config already runs ocibsd.
# A stub SUDO records every privileged command and runs the file ones under a
# scratch root, so what each case leaves on disk is checked as well.
set -eu
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
SETUP="$SCRIPT_DIR/setup.sh"
BUILD="$(dirname -- "$SCRIPT_DIR")/build"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
# The tarball layout the release image and dev-install.sh assemble.
EXTRACT="$TMP/extract"
mkdir -p "$EXTRACT/systemd" "$EXTRACT/udev"
cp "$BUILD"/systemd/* "$EXTRACT/systemd/"
cp "$BUILD"/udev/* "$EXTRACT/udev/"
cp -r "$BUILD/ocibsd" "$EXTRACT/ocibsd"
# install, ln, rm and mkdir run with /etc and /usr/local/share/spinifex moved
# under $ROOT; anything else is only recorded.
cat > "$TMP/sudo" <<'EOF'
#!/usr/bin/env bash
echo "$*" >> "$CALLS"
case "$1" in
install | ln | rm | mkdir) ;;
tee) cat > /dev/null; exit 0 ;;
*) exit 0 ;;
esac
args=()
for a in "$@"; do
case "$a" in
/etc/* | /usr/local/share/spinifex/*) a="$ROOT$a" ;;
esac
args+=("$a")
done
exec "${args[@]}"
EOF
chmod +x "$TMP/sudo"
# run_setup <case> <oci_vm yes|no> <config body or -> <functions...>
# runs the named setup.sh functions in a fresh root for one case.
run_setup() {
name="$1" vm="$2" body="$3"
shift 3
ROOT="$TMP/$name/root"
CALLS="$TMP/$name/calls"
mkdir -p "$ROOT/etc/systemd/system" "$ROOT/etc/spinifex" "$ROOT/usr/local/share/spinifex"
: > "$CALLS"
[ "$body" = - ] || printf '%s\n' "$body" > "$ROOT/etc/spinifex/spinifex.toml"
export ROOT CALLS
OUT=$(INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '
. "$1"; SUDO="$2"; EXTRACT_DIR="$3"; OCI_VM="$4"; SPINIFEX_TOML="$ROOT/etc/spinifex/spinifex.toml"
shift 4
for f in "$@"; do $f; done' _ "$SETUP" "$TMP/sudo" "$EXTRACT" "$vm" "$@" 2>&1) \
|| fail "$name: $* failed: $OUT"
}
echo "-- the systemd and udev stages leave the tarball's ocibsd/ alone"
run_setup units yes - install_systemd install_udev
if grep -q " /etc/systemd/system/spinifex-viperblock.service$" "$CALLS" && grep -q " /etc/udev/rules.d/99-spinifex-vfio.rules$" "$CALLS"; then
pass "installs the systemd/ units and udev/ rules"
else
fail "did not install the systemd/ and udev/ files: $(cat "$CALLS")"
fi
if grep -q "ocibsd" "$CALLS"; then
fail "installed an ocibsd/ file: $(grep ocibsd "$CALLS")"
else
pass "installs nothing from ocibsd/"
fi
STAGED="usr/local/share/spinifex/ocibsd"
FILES="spinifex-ocibsd.service 99-spinifex-ocibsd.rules spinifex-daemon.service.d/ocibsd.conf"
installed_count() {
n=0
for p in etc/systemd/system/spinifex-ocibsd.service etc/udev/rules.d/99-spinifex-ocibsd.rules \
etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf; do
[ ! -e "$ROOT/$p" ] || n=$((n + 1))
done
echo "$n"
}
staged_ok() {
for f in $FILES; do
cmp -s "$EXTRACT/ocibsd/$f" "$ROOT/$STAGED/$f" || return 1
done
}
echo "-- a fresh install on an OCI VM stages ocibsd/ and installs none of it"
run_setup fresh yes - install_systemd install_udev install_ocibsd
if staged_ok; then pass "the three files are staged"; else fail "the stage does not match the tarball: $(ls -R "$ROOT/$STAGED" 2>&1)"; fi
[ "$(installed_count)" = 0 ] && pass "nothing is installed" || fail "installed $(installed_count) ocibsd file(s) on a node init has not resolved"
[ -L "$ROOT/etc/systemd/system/spinifex-viperblock.service" ] && fail "masked viperblock" || pass "viperblock is not masked"
echo "-- an upgrade of an ocibsd node replaces the installed files with the new stage"
OCIBSD_TOML='[nodes.node1.ebs]
provider = "ocibsd"
[nodes.node1.ebs.oci]
auth = "instance_principal"'
ROOT="$TMP/upgrade/root"
mkdir -p "$ROOT/$STAGED/spinifex-daemon.service.d" "$ROOT/etc/systemd/system/spinifex-daemon.service.d" "$ROOT/etc/udev/rules.d"
for f in $FILES; do echo "# the previous release" > "$ROOT/$STAGED/$f"; done
echo "# the previous release" > "$ROOT/etc/systemd/system/spinifex-ocibsd.service"
echo "# the previous release" > "$ROOT/etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf"
echo "# the previous release" > "$ROOT/etc/udev/rules.d/99-spinifex-ocibsd.rules"
ln -s /dev/null "$ROOT/etc/systemd/system/spinifex-viperblock.service"
run_setup upgrade yes "$OCIBSD_TOML" install_systemd install_udev install_ocibsd
if staged_ok; then pass "the stage holds the new files"; else fail "the stage kept the previous release"; fi
if cmp -s "$EXTRACT/ocibsd/spinifex-ocibsd.service" "$ROOT/etc/systemd/system/spinifex-ocibsd.service" \
&& cmp -s "$EXTRACT/ocibsd/spinifex-daemon.service.d/ocibsd.conf" "$ROOT/etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf" \
&& cmp -s "$EXTRACT/ocibsd/99-spinifex-ocibsd.rules" "$ROOT/etc/udev/rules.d/99-spinifex-ocibsd.rules"; then
pass "the unit, drop-in and udev rule are the new ones"
else
fail "an installed ocibsd file is not the new one"
fi
if [ "$(readlink "$ROOT/etc/systemd/system/spinifex-viperblock.service")" = /dev/null ]; then
pass "viperblock is masked again after the unit reinstall"
else
fail "the reinstalled viperblock unit is not masked"
fi
grep -q '^systemctl daemon-reload$' "$CALLS" && grep -q '^udevadm control --reload-rules$' "$CALLS" \
&& pass "systemd and udev are reloaded" || fail "no reload: $(cat "$CALLS")"
echo "-- an ocibsd node is refreshed even when the metadata service does not answer"
run_setup upgrade-no-imds no "$OCIBSD_TOML" install_ocibsd
[ "$(installed_count)" = 3 ] && pass "the three files are installed" || fail "installed $(installed_count) of 3"
echo "-- an upgrade of a viperblock node on an OCI VM installs none of them"
run_setup viperblock yes '[nodes.node1.ebs]
provider = "viperblockd"' install_systemd install_udev install_ocibsd
[ "$(installed_count)" = 0 ] && pass "nothing is installed" || fail "installed $(installed_count) ocibsd file(s) on a viperblock node"
[ -f "$ROOT/etc/systemd/system/spinifex-viperblock.service" ] && [ ! -L "$ROOT/etc/systemd/system/spinifex-viperblock.service" ] \
&& pass "the viperblock unit is installed and not masked" || fail "the viperblock unit was masked"
echo "-- off OCI nothing is staged"
run_setup off-oci no '[nodes.node1.daemon]' install_ocibsd
[ ! -e "$ROOT/$STAGED" ] && pass "no stage" || fail "staged ocibsd/ off OCI"
echo "-- the initiator is installed on an OCI VM only"
run_setup initiator-apt yes - "install_iscsi_initiator apt"
grep -q 'apt-get install -y -qq open-iscsi' "$CALLS" && grep -q '^systemctl enable --now iscsid.socket$' "$CALLS" \
&& pass "apt installs open-iscsi and enables iscsid.socket" || fail "apt: $(cat "$CALLS")"
run_setup initiator-dnf yes - "install_iscsi_initiator dnf"
grep -q 'dnf install -y -q iscsi-initiator-utils' "$CALLS" && pass "dnf installs iscsi-initiator-utils" || fail "dnf: $(cat "$CALLS")"
run_setup initiator-off no - "install_iscsi_initiator apt"
[ -s "$CALLS" ] && fail "installed an initiator off OCI: $(cat "$CALLS")" || pass "no initiator off OCI"
if [ "$fails" -gt 0 ]; then
echo "$fails failure(s)"
exit 1
fi
+3 -17
View File
@@ -230,11 +230,11 @@ Create them once per tenancy, then every deployment and rebuild afterwards uses
**`adopt` references the dynamic group by nothing at all.** Its matching rule is `instance.compartment.id`, so it covers every instance in the compartment and names no OCID — which is why adopting needs no read on an identity resource and no tenancy rights. The cost is that a missing policy is invisible at apply time: the node forms, passes every health check, and then refuses every launch wanting a public address. The allocator gate in `validate-topology.sh` is what catches that, by requiring `ocinet credential authorised to allocate` in each node's `spinifex-daemon` journal.
The policy grants `use vnics`, `manage private-ips`, `manage public-ips` and `use subnets` in one compartment for the allocator, and with `ebs_provider = "ocibsd"` also `manage volume-family` and `use instances` for the `ocibsd` EBS provider. `setup-identity.sh --ebs-provider ocibsd` creates it with them; the policy is shared by every deployment in the compartment, so running it without the flag removes them.
The first four are exactly what allocating an external address does and nothing more; the last two are what creating, attaching, backing up and deleting a block volume does.
The policy grants four verbs in one compartment: `use vnics`, `manage private-ips`, `manage public-ips` and `use subnets`.
That is exactly what allocating an external address does and nothing more.
`use subnets` looks unrelated and is not: `CreatePrivateIp` is checked against `SUBNET_ATTACH` and `CreatePrivateIp` is how an address is registered, so a policy without it authorises nothing and every allocation returns a 404.
**An API key is the fallback**, and the default because it needs nothing from a tenancy admin. Grant that user only the operations Spinifex performs on addresses and volumes and nothing else: `terraform output -json api_key_policy_statements` prints the same statements for the group named by `api_key_group_name` (default `spinifex-nodes`), for a tenancy admin to apply; it is not a tenancy admin, and broader rights widen the blast radius of a node compromise for no benefit.
**An API key is the fallback**, and the default because it needs nothing from a tenancy admin. Grant that user only the operations Spinifex performs on addresses and nothing else; it is not a tenancy admin, and broader rights widen the blast radius of a node compromise for no benefit.
`spx-oci-config.sh` installs it on **every** node, as `/etc/spinifex/oci/{oci_api_key.pem,config}` — not a home directory, because the daemon's unit sets `ProtectHome=yes`. Both files are needed: `oci_config_file` names an ordinary OCI SDK config, so a node holding only the PEM forms and then fails every allocation. The profile it writes is `spinifex`, matching `oci_config_profile`:
@@ -275,20 +275,6 @@ The count that later gates read is the number of addresses the `hosts_file` outp
**A clean teardown can still leave public IPs behind, and they count against the tenancy.** The addresses Spinifex allocates for guests are created by the running node, so they are in no Terraform state and `destroy` neither sees nor removes them. The node's own reconcile collects detached ones every ten minutes, which is exactly the sweep a destroy takes away. Twenty accumulated in `spxbm` over four runs and exhausted the tenancy's 50 reserved-public-IP limit, at which point every later launch in every compartment failed. [Check for leftover public IPs afterwards](../../../docs/oci-integration/README.md#check-for-leftover-public-ips-afterwards) has the query and the rule for reading it.
### The ocibsd topologies
`vm-single-ocibsd` and `vm-multi-ocibsd` are `vm-single` and `vm-multi` with EBS backed by OCI Block Volumes. They build with `ebs_provider = "ocibsd"`, which disables the Block Volume Management plugin and attaches each node's data volume paravirtualized, because every iSCSI session on the node is `ocibsd`'s to manage. They need `--distro`, because a published release predates the `ocibsd` files that `setup.sh` stages, and an external pool, because an API-key run gives `ocibsd` the pool's credential.
The ocibsd topologies name no EBS provider. `spx admin init` detects that it is on an OCI virtual machine, reads the compartment, availability domain and region from the instance metadata, and generates the cluster's `cluster_id`. Every other topology sets `SPINIFEX_EBS_PROVIDER=viperblockd`, so its viperblock baseline is not replaced by detection on OCI. The run checks the `EBS provider:` line init prints: `detected` on the ocibsd topologies under the instance principal, and `from SPINIFEX_EBS_PROVIDER` on the rest.
Under the instance principal, `ocibsd` uses the instance's own certificate. An API-key run runs the credential hook before formation instead, and seeds each node's `spinifex.toml` with `provider = "ocibsd"` and the hook's `/etc/spinifex/oci/config`, which init keeps as an existing config, so its `EBS provider:` line reads `from the existing config`.
Every volume and backup `ocibsd` creates is tagged `spinifex-cluster=<cluster_id>`, and none of them is in any Terraform state, so the run records the ID in `.validate-<topology>/ocibsd-clusters` before any volume exists.
The `ebs provider` gate then checks the units, the viperblock mask, the daemon's device drop-in, and that the compartment and availability domain init detected are the ones the state reports on every node, imports an AMI onto OCI and boots from it, and drives stop and start, attach and detach, snapshot and restore with a guest-written marker read back over the serial console. On `vm-multi-ocibsd` it also starts the stopped instance on another node. It ends by terminating everything and polling OCI until nothing tagged with the instance's volumes or snapshot is left.
`e2e-oci.sh` deletes everything tagged with each recorded `cluster_id` once that topology's nodes are destroyed, including from `--sweep-only`, and fails the run if anything tagged remains. A standalone `validate-topology.sh` run does not sweep, so follow it with `./e2e-oci.sh --sweep-only`.
The workbook runs **on the node** against `127.0.0.1`, because the node certificate carries no SAN for its public address — a workbook driven from outside the VCN is still blocked.
### Validate infrastructure Terraform already created
+4 -10
View File
@@ -45,11 +45,6 @@ locals {
# after the boot disk (mulga-poc has oraclevda1 pointing at sdb1).
data_device = "/dev/oracleoci/oraclevdb"
# The Block Volume Management plugin logs iSCSI sessions in and out by itself,
# and on an ocibsd node those sessions are ocibsd's to manage. With the plugin
# off, the node's data volume attaches paravirtualized, which needs no login.
ocibsd_nodes = var.ebs_provider == "ocibsd"
# adopt and create differ only in who creates the dynamic group and policy; a node
# authenticates identically either way, because the matching rule covers the whole
# compartment and so names no instance.
@@ -111,12 +106,11 @@ resource "oci_core_instance" "mulgadc" {
user_data = base64encode(local.user_data)
}
# Required for automatic UHP iSCSI multipath setup and login, and disabled on
# an ocibsd node, where ocibsd owns every iSCSI session (see ocibsd_nodes).
# Required for automatic UHP iSCSI multipath setup and login.
agent_config {
plugins_config {
name = "Block Volume Management"
desired_state = local.ocibsd_nodes ? "DISABLED" : "ENABLED"
desired_state = "ENABLED"
}
}
@@ -171,10 +165,10 @@ resource "oci_core_volume" "mulgadc_data" {
resource "oci_core_volume_attachment" "mulgadc_data" {
count = var.node_count
attachment_type = local.ocibsd_nodes ? "paravirtualized" : "iscsi"
attachment_type = "iscsi"
# Required by OCI for Ultra High Performance iSCSI volume attachments.
device = local.data_device
is_agent_auto_iscsi_login_enabled = local.ocibsd_nodes ? null : true
is_agent_auto_iscsi_login_enabled = true
instance_id = oci_core_instance.mulgadc[count.index].id
volume_id = oci_core_volume.mulgadc_data[count.index].id
}
+5 -64
View File
@@ -8,10 +8,8 @@
#
# SPX_REF Ref to prove. Default: the spinifex checkout's current branch.
# OCI_TOPOLOGIES Space-separated. Default: "vm-single vm-multi bm
# vm-single-principal vm-single-ocibsd vm-multi-ocibsd". Set
# one name to test one thing. The two ocibsd topologies back
# EBS with OCI Block Volumes, need OCI_SOURCE=tree and an
# external pool, and are recorded SKIPPED without them.
# vm-single-principal". Set one name to test one thing: a
# single VM topology is roughly a quarter of the full run.
# OCI_SOURCE tree (build SPX_REF) or release (the published installer).
# OCI_VERSION With OCI_SOURCE=release, install this exact release tag and
# ignore OCI_CHANNEL. Preferred for anything repeatable: a tag
@@ -84,7 +82,7 @@ SPINIFEX_ROOT="$(cd "$HERE/../../.." && pwd)"
# under the workspace. Deriving one from the other is only right in the first case.
MULGA_ROOT="${MULGA_ROOT:-$SPINIFEX_ROOT/..}"
TOPOLOGIES="${OCI_TOPOLOGIES:-vm-single vm-multi bm vm-single-principal vm-single-ocibsd vm-multi-ocibsd}"
TOPOLOGIES="${OCI_TOPOLOGIES:-vm-single vm-multi bm vm-single-principal}"
SOURCE="${OCI_SOURCE:-tree}"
CHANNEL="${OCI_CHANNEL:-latest}"
INSTALL_VERSION="${OCI_VERSION:-}"
@@ -291,35 +289,6 @@ publish_summary() {
fi
}
# ocibsd's volumes and backups live in no Terraform state, so a destroy leaves any
# a failed run made. Each cluster_id a topology recorded is swept by its tag once
# the nodes are gone, and anything still tagged afterwards fails the run.
sweep_ocibsd() {
local dir="$1" record="$1/ocibsd-clusters" cluster compartment region left
[ -s "$record" ] || return 0
left="$(mktemp)"
while IFS=$'\t' read -r cluster compartment region; do
[ -n "$cluster" ] || continue
log "sweep: OCI volumes and backups of ocibsd cluster $cluster"
if OCIBSD_COMPARTMENT="$compartment" python3 "$HERE/scripts/oci_env.py" \
${OCI_PROFILE:+--profile "$OCI_PROFILE"} --region "$region" -- \
python3 "$HERE/scripts/oci_volumes.py" sweep --cluster "$cluster" \
< /dev/null >> "$ARTIFACT_DIR/sweep.log" 2>&1; then
log "sweep: nothing tagged with ocibsd cluster $cluster remains"
else
log "sweep: OCI still holds volumes or backups of ocibsd cluster $cluster. See $ARTIFACT_DIR/sweep.log"
echo "sweep ${dir##*/.validate-} ocibsd cluster $cluster: FAIL" >> "$VERDICT"
printf '%s\t%s\t%s\n' "$cluster" "$compartment" "$region" >> "$left"
fi
done < "$record"
# Kept only for what is left, so the next sweep retries exactly that.
if [ -s "$left" ]; then
mv "$left" "$record"
else
rm -f "$left" "$record"
fi
}
# A topology destroys itself from its own EXIT trap, so this only catches a run
# the trap never reached: a killed process, a runner reclaimed mid-job, a reboot.
# It works from the state files rather than by name filter, so it can only ever
@@ -328,16 +297,12 @@ sweep() {
local found=0 dir topology
local sweep_env=(env)
for dir in "$STATE_ROOT"/.validate-*; do
[ -d "$dir" ] || continue
if [ ! -s "$dir/terraform.tfstate" ]; then
sweep_ocibsd "$dir"
continue
fi
[ -s "$dir/terraform.tfstate" ] || continue
python3 -c '
import json, sys
state = json.load(open(sys.argv[1]))
sys.exit(0 if any(r["instances"] for r in state.get("resources", [])) else 1)
' "$dir/terraform.tfstate" 2>/dev/null || { sweep_ocibsd "$dir"; continue; }
' "$dir/terraform.tfstate" 2>/dev/null || continue
topology="${dir##*/.validate-}"
# The child reads OCI_STATE_ROOT from the environment, so it looks in the
# same place this loop found the state rather than beside its own script.
@@ -354,7 +319,6 @@ sys.exit(0 if any(r["instances"] for r in state.get("resources", [])) else 1)
if "${sweep_env[@]}" "$HERE/validate-topology.sh" --topology "$topology" --destroy-only \
>> "$ARTIFACT_DIR/sweep.log" 2>&1; then
log "sweep: $topology destroyed"
sweep_ocibsd "$dir"
else
log "sweep: $topology WOULD NOT DESTROY -- it is still billing. See $ARTIFACT_DIR/sweep.log"
echo "sweep $topology: FAIL" >> "$VERDICT"
@@ -378,10 +342,6 @@ sweep
if [ "$SWEEP_ONLY" = 1 ]; then
log "--sweep-only: done"
if grep -q FAIL "$VERDICT"; then
cat "$VERDICT"
exit 1
fi
exit 0
fi
@@ -434,22 +394,6 @@ for topology in $TOPOLOGIES; do
build_principal_env
run_env=(env "${PRINCIPAL_ENV[@]}")
fi
# ocibsd's files ship in the distro this checkout builds, and its volumes
# need the credential the pool uses, so neither can be stood in for.
case "$topology" in
*-ocibsd)
if [ "$SOURCE" != tree ]; then
log "$topology: SKIPPED -- the ocibsd files ship only with a tree build"
echo "$topology: SKIPPED (OCI_SOURCE=$SOURCE)" >> "$VERDICT"
continue
fi
if [ "${OCI_NO_EXTERNAL_POOL:-0}" = 1 ]; then
log "$topology: SKIPPED -- ocibsd needs the OCI credential the external pool installs"
echo "$topology: SKIPPED (OCI_NO_EXTERNAL_POOL=1)" >> "$VERDICT"
continue
fi
;;
esac
args=(--topology "$topology" --ssh-public-key "$SSH_PUBLIC_KEY" --ssh-private-key "$SSH_PRIVATE_KEY")
[ "$DRY_RUN" = 1 ] && args+=(--dry-run)
# Two separate choices. --no-external-pool is the one that forms without any
@@ -516,8 +460,5 @@ if [ "$RUN_RC" = 0 ] && ! grep -q FAIL "$VERDICT"; then
else
log "FAIL"
printf 'FAIL\te2e-oci\t%d.000s\n' "$SECONDS"
# A failed sweep is a FAIL in the verdict with every topology passing, and is
# still resources left billing, so it fails the run as well.
RUN_RC=1
fi
exit "$RUN_RC"
+12 -30
View File
@@ -29,44 +29,26 @@ resource "oci_identity_dynamic_group" "nodes" {
}
# Narrower than it looks. The allocator creates, moves and deletes secondary
# private IPs on a node's own VNIC and attaches public IPs to them, and ocibsd
# creates, attaches and backs up EBS volumes. Nothing here grants identity.
locals {
network_grants = [
"use vnics",
"manage private-ips",
"manage public-ips",
# CreatePrivateIp needs SUBNET_ATTACH and DeletePrivateIp needs SUBNET_DETACH,
# both of which only the subnets `use` verb carries. Without it every allocation
# is refused as a 404, which reads as a missing resource rather than a missing grant.
"use subnets",
]
# ocibsd: volumes, backups and attachments, and attaching them to an instance.
# Without these every ocibsd call is refused and its service fails at start.
# A viperblockd deployment touches no OCI volume, so it is not granted them.
ocibsd_grants = [
"manage volume-family",
"use instances",
]
node_grants = concat(local.network_grants, var.ebs_provider == "ocibsd" ? local.ocibsd_grants : [])
}
# private IPs on a node's own VNIC and attaches public IPs to them, which is what
# these four verbs cover; nothing here grants compute, storage or identity.
resource "oci_identity_policy" "nodes" {
provider = oci.home
count = var.instance_principal == "create" ? 1 : 0
compartment_id = var.compartment_ocid
name = "${var.deployment_name}-nodes-network"
description = "Allows Spinifex nodes to manage the IPs backing external addresses and the OCI volumes backing EBS."
description = "Allows Spinifex nodes to manage the private and public IPs backing external addresses."
statements = [
for grant in local.node_grants :
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to ${grant} in compartment id ${var.compartment_ocid}"
]
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to use vnics in compartment id ${var.compartment_ocid}",
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to manage private-ips in compartment id ${var.compartment_ocid}",
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to manage public-ips in compartment id ${var.compartment_ocid}",
lifecycle {
# CreatePrivateIp needs SUBNET_ATTACH and DeletePrivateIp needs SUBNET_DETACH,
# both of which only the subnets `use` verb carries. Without it every allocation
# is refused as a 404, which reads as a missing resource rather than a missing grant.
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to use subnets in compartment id ${var.compartment_ocid}",
]
lifecycle {
precondition {
condition = local.tenancy_home_region != ""
error_message = "OCI did not report a tenancy home region; cannot create instance-principal IAM resources."
-17
View File
@@ -31,20 +31,3 @@ output "nodes" {
output "hosts_file" {
value = join("\n", [for instance in oci_core_instance.mulgadc : instance.public_ip])
}
# The api_key route's equivalent of the instance-principal policy, for a tenancy
# admin to apply to the API user's group. Sensitive only to keep the compartment
# OCID out of apply.log; read it with terraform output api_key_policy_statements.
output "api_key_policy_statements" {
sensitive = true
value = [
for grant in local.node_grants :
"Allow group ${var.api_key_group_name} to ${grant} in compartment id ${var.compartment_ocid}"
]
}
# Where spx admin init should detect the nodes on ocibsd, so the run can check
# what init read from the instance metadata and knows where to sweep afterwards.
output "availability_domain" { value = local.compute_availability_domain }
output "region" { value = var.region }
output "ebs_provider" { value = var.ebs_provider }
@@ -1,206 +0,0 @@
#!/usr/bin/env python3
"""Find and sweep the OCI volumes and backups ocibsd tags, which no Terraform state holds.
Run under oci_env.py for the credential, with the compartment in OCIBSD_COMPARTMENT.
Standard library and the openssl binary only; the key reaches openssl through a pipe.
oci_volumes.py volumes --tag spinifex-volume-id=vol-0abc
oci_volumes.py backups --tag spinifex-snapshot-id=snap-0abc
oci_volumes.py sweep --cluster <cluster_id> [--timeout 900]
"""
from __future__ import annotations
import argparse
import base64
import email.utils
import json
import os
from pathlib import Path
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
API = "/20160918"
# Gone for good; OCI keeps the record for a while and it cannot be deleted again.
GONE = {"TERMINATED"}
# OCI's own answers to a delete it will accept later: an attachment or a backup
# still in progress (409), or a precondition such as a busy lifecycle (412).
RETRY_LATER = {409, 412, 429, 500, 502, 503, 504}
class OCIError(Exception):
def __init__(self, status: int, body: str):
super().__init__(f"OCI answered {status}: {body[:300]}")
self.status = status
class Client:
def __init__(self, env: dict[str, str]):
missing = [k for k in ("TF_VAR_tenancy_ocid", "TF_VAR_user_ocid", "TF_VAR_fingerprint", "TF_VAR_region") if not env.get(k)]
if missing:
raise ValueError(f"run this under oci_env.py: {', '.join(missing)} unset")
self.key_id = f"{env['TF_VAR_tenancy_ocid']}/{env['TF_VAR_user_ocid']}/{env['TF_VAR_fingerprint']}"
self.host = f"iaas.{env['TF_VAR_region']}.oraclecloud.com"
if env.get("TF_VAR_private_key"):
self.key = env["TF_VAR_private_key"].encode()
elif env.get("TF_VAR_private_key_path"):
self.key = Path(env["TF_VAR_private_key_path"]).read_bytes()
else:
raise ValueError("run this under oci_env.py: no private key in TF_VAR_private_key or TF_VAR_private_key_path")
def _sign(self, data: bytes) -> str:
read_end, write_end = os.pipe()
try:
os.write(write_end, self.key)
finally:
os.close(write_end)
try:
result = subprocess.run(
["openssl", "dgst", "-sha256", "-sign", f"/dev/fd/{read_end}"],
input=data,
capture_output=True,
pass_fds=(read_end,),
check=False,
)
finally:
os.close(read_end)
if result.returncode != 0:
raise ValueError(f"openssl could not sign with the OCI key: {result.stderr.decode(errors='replace').strip()}")
return base64.b64encode(result.stdout).decode()
def request(self, method: str, path: str, query: dict[str, str] | None = None) -> tuple[dict[str, str], object]:
target = API + path
if query:
target += "?" + urllib.parse.urlencode(query)
date = email.utils.formatdate(usegmt=True)
headers = {"date": date, "host": self.host}
signed = ["(request-target)", "date", "host"]
lines = [f"(request-target): {method.lower()} {target}", f"date: {date}", f"host: {self.host}"]
# A PUT or POST also signs its body, which this signer does not do.
if method not in ("GET", "DELETE"):
raise ValueError(f"unsupported method {method}")
signature = self._sign("\n".join(lines).encode())
headers["authorization"] = (
f'Signature version="1",keyId="{self.key_id}",algorithm="rsa-sha256",'
f'headers="{" ".join(signed)}",signature="{signature}"'
)
req = urllib.request.Request(f"https://{self.host}{target}", method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
body = resp.read()
return dict(resp.headers), json.loads(body) if body else None
except urllib.error.HTTPError as error:
raise OCIError(error.code, error.read().decode(errors="replace")) from None
def list_all(self, kind: str, compartment: str) -> list[dict]:
items: list[dict] = []
query = {"compartmentId": compartment, "limit": "100"}
while True:
headers, page = self.request("GET", f"/{kind}", query)
items.extend(page or [])
next_page = {k.lower(): v for k, v in headers.items()}.get("opc-next-page")
if not next_page:
return items
query["page"] = next_page
def matching(items: list[dict], tags: dict[str, str]) -> list[dict]:
"""The live items carrying every tag, with no OCID beyond the item's own."""
found = []
for item in items:
freeform = item.get("freeformTags") or {}
if item.get("lifecycleState") in GONE:
continue
if all(freeform.get(k) == v for k, v in tags.items()):
found.append(
{
"id": item["id"],
"displayName": item.get("displayName"),
"lifecycleState": item.get("lifecycleState"),
"freeformTags": freeform,
}
)
return found
def parse_tags(pairs: list[str]) -> dict[str, str]:
tags = {}
for pair in pairs:
key, sep, value = pair.partition("=")
if not sep or not key:
raise ValueError(f"--tag wants key=value, got {pair!r}")
tags[key] = value
return tags
def sweep(client: Client, compartment: str, cluster: str, timeout: int, interval: int) -> int:
"""Delete every live volume and backup tagged with this cluster until none is left.
OCI refuses both deletes while a backup is CREATING, so a refusal is retried
until the deadline, which is what makes a run killed mid-snapshot sweepable.
"""
tags = {"spinifex-cluster": cluster}
deadline = time.monotonic() + timeout
while True:
left = []
for kind in ("volumeBackups", "volumes"):
for item in matching(client.list_all(kind, compartment), tags):
left.append((kind, item))
if item["lifecycleState"] == "TERMINATING":
continue
try:
client.request("DELETE", f"/{kind}/{item['id']}")
print(f"sweep: deleting {kind[:-1]} {item['displayName']} ({item['lifecycleState']})")
except OCIError as error:
if error.status == 404:
continue
if error.status not in RETRY_LATER:
raise
print(f"sweep: {kind[:-1]} {item['displayName']} not deletable yet: {error.status}")
if not left:
print(f"sweep: nothing tagged spinifex-cluster={cluster} is left")
return 0
if time.monotonic() >= deadline:
for kind, item in left:
print(f"sweep: STILL PRESENT {kind[:-1]} {item['displayName']} {item['lifecycleState']}")
print(f"sweep: {len(left)} tagged resource(s) left after {timeout}s")
return 1
time.sleep(interval)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True)
for name in ("volumes", "backups"):
listing = sub.add_parser(name, help=f"print the live {name} carrying every --tag, as JSON")
listing.add_argument("--tag", action="append", default=[], required=True)
sweeping = sub.add_parser("sweep", help="delete every live volume and backup tagged with --cluster")
sweeping.add_argument("--cluster", required=True)
sweeping.add_argument("--timeout", type=int, default=900)
sweeping.add_argument("--interval", type=int, default=15)
args = parser.parse_args()
compartment = os.environ.get("OCIBSD_COMPARTMENT", "")
if not compartment.startswith("ocid1."):
raise ValueError("OCIBSD_COMPARTMENT is not set to a compartment OCID")
client = Client(dict(os.environ))
if args.command == "sweep":
if not args.cluster.strip():
raise ValueError("--cluster is empty, which would match nothing and report clean")
return sweep(client, compartment, args.cluster, args.timeout, args.interval)
kind = "volumes" if args.command == "volumes" else "volumeBackups"
print(json.dumps(matching(client.list_all(kind, compartment), parse_tags(args.tag))))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OCIError, ValueError, OSError) as error:
print(f"oci_volumes.py: {error}", file=sys.stderr)
raise SystemExit(1)
+4 -13
View File
@@ -21,7 +21,6 @@ STATE_DIR="${OCI_STATE_ROOT:-$HERE}/.identity"
SSH_PUBLIC_KEY="${OCI_SSH_PUBLIC_KEY:-$HOME/.ssh/oci-spx.pub}"
DESTROY=0
DRY_RUN=0
EBS_PROVIDER=viperblockd
log() { printf '[setup-identity] %s\n' "$*"; }
die() {
@@ -31,17 +30,14 @@ die() {
usage() {
cat >&2 <<EOF
usage: ${0##*/} [--dry-run] [--destroy] [--ebs-provider viperblockd|ocibsd]
usage: ${0##*/} [--dry-run] [--destroy]
Creates the OCI dynamic group and policy for instance-principal authentication.
Needs a tenancy-admin OCI credential; the compartment-scoped user the rest of this
configuration runs as cannot create either resource.
--dry-run Show the plan, change nothing.
--destroy Remove them again. Every node using "adopt" loses its credential.
--ebs-provider ocibsd also grants manage volume-family and use instances. The
policy is shared by every deployment in the compartment, so a
later run without it removes those grants. Default viperblockd.
--dry-run Show the plan, change nothing.
--destroy Remove them again. Every node using "adopt" loses its credential.
EOF
exit 2
}
@@ -50,16 +46,11 @@ while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--destroy) DESTROY=1; shift ;;
--ebs-provider) EBS_PROVIDER="${2:?}"; shift 2 ;;
-h | --help) usage ;;
*) die "unknown option $1" ;;
esac
done
case "$EBS_PROVIDER" in
viperblockd | ocibsd) ;;
*) die "--ebs-provider must be viperblockd or ocibsd, got '$EBS_PROVIDER'" ;;
esac
[ -r "$SSH_PUBLIC_KEY" ] || die "no readable SSH public key at $SSH_PUBLIC_KEY (set OCI_SSH_PUBLIC_KEY)"
mkdir -p "$STATE_DIR"
@@ -84,7 +75,7 @@ if [ "$DESTROY" = 1 ]; then
exit 0
fi
args=(-var 'instance_principal=create' -var "ebs_provider=$EBS_PROVIDER" -state "$STATE_DIR/terraform.tfstate" "${targets[@]}")
args=(-var 'instance_principal=create' -state "$STATE_DIR/terraform.tfstate" "${targets[@]}")
if [ "$DRY_RUN" = 1 ]; then
log "dry run: planning the dynamic group and policy only"
+34 -653
View File
@@ -49,7 +49,7 @@ WORKBOOKS=""
# set -u an unset array is an error rather than an empty one.
HOSTS=()
TOPOLOGIES="bm vm-single vm-multi vm-single-principal vm-single-ocibsd vm-multi-ocibsd"
TOPOLOGIES="bm vm-single vm-multi vm-single-principal"
# What a topology is worth when nothing says otherwise, so a CI run of a named
# topology is the same every time. Supplied as TF_VAR_, which terraform.auto.tfvars
@@ -61,14 +61,8 @@ TOPOLOGIES="bm vm-single vm-multi vm-single-principal vm-single-ocibsd vm-multi-
#
# A case rather than an associative array: macOS ships bash 3.2, where declare -A
# is an indexed assignment and the key is read as an arithmetic variable.
#
# TOPO_EBS_PROVIDER is what backs EBS. The -ocibsd topologies are their viperblock
# twins on OCI Block Volumes, so a difference between a pair is the provider's.
# They set no provider and rely on init's detection on an OCI VM; every other
# topology names viperblockd in SPINIFEX_EBS_PROVIDER to keep its baseline.
topo_defaults() {
TOPO_FORCE_PRINCIPAL=0
TOPO_EBS_PROVIDER=viperblockd
case "$1" in
bm) TOPO_DEFAULT_SHAPE="BM.Standard.E2.64"; TOPO_DEFAULT_NODES=1 ;;
vm-single) TOPO_DEFAULT_SHAPE="VM.Standard.E6.Flex"; TOPO_DEFAULT_NODES=1 ;;
@@ -80,10 +74,6 @@ topo_defaults() {
TOPO_DEFAULT_SHAPE="${OCI_PRINCIPAL_SHAPE:-VM.Standard.E6.Flex}"
TOPO_DEFAULT_NODES=1
TOPO_FORCE_PRINCIPAL=1 ;;
vm-single-ocibsd)
TOPO_DEFAULT_SHAPE="VM.Standard.E6.Flex"; TOPO_DEFAULT_NODES=1; TOPO_EBS_PROVIDER=ocibsd ;;
vm-multi-ocibsd)
TOPO_DEFAULT_SHAPE="VM.Standard.E6.Flex"; TOPO_DEFAULT_NODES=3; TOPO_EBS_PROVIDER=ocibsd ;;
*) return 1 ;;
esac
}
@@ -96,7 +86,7 @@ die() {
usage() {
cat >&2 <<EOF
usage: ${0##*/} --topology <bm|vm-single|vm-multi|vm-single-principal|vm-single-ocibsd|vm-multi-ocibsd> [options]
usage: ${0##*/} --topology <bm|vm-single|vm-multi|vm-single-principal> [options]
Builds a topology, or installs on existing OCI hosts, then installs Spinifex,
forms the cluster, and verifies the OCI address allocator.
@@ -105,13 +95,6 @@ forms the cluster, and verifies the OCI address allocator.
topology is the easiest expensive mistake here.
vm-single-principal is vm-single authenticating as the
instance, and implies --instance-principal.
vm-single-ocibsd and vm-multi-ocibsd set no EBS
provider, so init detects ocibsd on the OCI VM, and
add the ebs provider gate; the others set
SPINIFEX_EBS_PROVIDER=viperblockd. The ocibsd pair
need --distro. Their OCI volumes and backups are
in no Terraform state: the destroy here leaves any a
failed run made, and e2e-oci.sh sweeps them by tag.
--ssh-public-key PATH Public half installed on each node. Default $SSH_PUBLIC_KEY
--ssh-private-key PATH Private half used to reach them. Default $SSH_PRIVATE_KEY
--instance-principal Configure the pool with oci_auth="instance_principal"
@@ -123,9 +106,7 @@ forms the cluster, and verifies the OCI address allocator.
Create the dynamic group and policy before deployment.
Needs a tenancy-admin profile. Opt-in because these are
tenancy-level resources. Use --identity-oci-profile to
use a different profile for this one-time setup. The
policy carries the volume grants only on an ocibsd
topology, and it is shared, so run it from one of those.
use a different profile for this one-time setup.
--identity-oci-profile NAME
Tenancy-admin profile for --setup-instance-principal.
Defaults to --oci-profile / OCI_PROFILE.
@@ -228,19 +209,6 @@ fi
[ -z "$EXISTING_HOSTS_FILE" ] || [ -z "$EXISTING_STATE_FILE" ] \
|| die "--existing-hosts-file and --existing-state-file are mutually exclusive"
# ocibsd needs a node credential, which a run with no pool never installs, and the
# compartment and region from the state, which a hosts file does not carry, to
# sweep and check its volumes. setup.sh stages its files from the tarball, and a
# published release predates them.
if [ "$TOPO_EBS_PROVIDER" = ocibsd ] && [ "$DESTROY_ONLY" = 0 ]; then
[ "$SKIP_POOL" != 1 ] \
|| die "--no-external-pool installs no OCI credential, so ocibsd on $TOPOLOGY could not reach the OCI API"
[ -z "$EXISTING_HOSTS_FILE" ] \
|| die "$TOPOLOGY reads its compartment and availability domain from Terraform state; use --existing-state-file"
[ -n "$DISTRO" ] || [ "$DRY_RUN" = 1 ] \
|| die "$TOPOLOGY needs --distro: a published release predates the ocibsd files setup.sh stages and init installs"
fi
if [ -n "$EXISTING_HOSTS_FILE" ] || [ -n "$EXISTING_STATE_FILE" ]; then
EXISTING_INFRA=1
[ "$DESTROY_ONLY" = 0 ] \
@@ -313,7 +281,7 @@ tf() {
# deployment sizes itself in that file and the topology only supplies the
# default a CI run wants.
TF_VAR_compute_shape="$TOPO_DEFAULT_SHAPE" TF_VAR_node_count="$TOPO_DEFAULT_NODES" \
TF_VAR_ebs_provider="$TOPO_EBS_PROVIDER" python3 "$HERE/scripts/oci_env.py" --ssh-public-key-path "$SSH_PUBLIC_KEY" \
python3 "$HERE/scripts/oci_env.py" --ssh-public-key-path "$SSH_PUBLIC_KEY" \
${OCI_PROFILE:+--profile "$OCI_PROFILE"} -- \
terraform -chdir="$HERE" "$@"
}
@@ -415,18 +383,18 @@ identity_resources() {
"$HERE/setup-identity.sh" --destroy
elif [ "$DRY_RUN" = 1 ]; then
OCI_CLI_PROFILE="$identity_profile" OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \
"$HERE/setup-identity.sh" --dry-run --ebs-provider "$TOPO_EBS_PROVIDER"
"$HERE/setup-identity.sh" --dry-run
else
OCI_CLI_PROFILE="$identity_profile" OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \
"$HERE/setup-identity.sh" --ebs-provider "$TOPO_EBS_PROVIDER"
"$HERE/setup-identity.sh"
fi
else
if [ "$action" = destroy ]; then
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh" --destroy
elif [ "$DRY_RUN" = 1 ]; then
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh" --dry-run --ebs-provider "$TOPO_EBS_PROVIDER"
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh" --dry-run
else
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh" --ebs-provider "$TOPO_EBS_PROVIDER"
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh"
fi
fi
}
@@ -451,19 +419,11 @@ capture_journals() {
# the empty array is an unbound-variable error rather than zero passes.
[ "${#HOSTS[@]}" -gt 0 ] || return 0
for host in "${HOSTS[@]}"; do
# Per-request access lines are most of the volume, so they are dropped and
# the rest kept for the whole run rather than its last few minutes.
ssh_node "$host" '
echo "=== spinifex, warning and above ==="
sudo journalctl -u "spinifex-*" --since -6h --priority=warning --no-pager | tail -2000
echo "=== resolver drop-in and status ==="
sudo stat -c "%A %U:%G %n" /etc/systemd/resolved.conf.d /etc/systemd/resolved.conf.d/spinifex-dns.conf 2>&1
sudo cat /etc/systemd/resolved.conf.d/spinifex-dns.conf 2>&1
resolvectl status 2>&1 | head -40
echo "=== spinifex and resolved, all priorities, access lines dropped, last 40000 lines ==="
sudo journalctl -u "spinifex-*" -u systemd-resolved --since -6h --no-pager \
| grep -avE "\"msg\":\"(request|Request|Service request completed|IMDS: serving metadata request|IMDS: issued IMDSv2 token|Processing DescribeInstances request from this node|DescribeInstances completed|DescribeInstances: Aggregated response)\"" \
| tail -40000
sudo journalctl -u "spinifex-*" --since -4h --priority=warning --no-pager | tail -2000
echo "=== spinifex, all priorities, last 2000 lines ==="
sudo journalctl -u "spinifex-*" --since -4h --no-pager | tail -2000
' > "$STATE_DIR/journal-$host.log" 2>&1 || log "could not collect the journal from $host"
done
}
@@ -483,10 +443,8 @@ mark_stage() { : > "$STATE_DIR/stage-$1.done"; }
formation_complete() {
local host
stage_done formation || return 1
# A line only init writes, not a non-empty file: an ocibsd API-key run seeds the
# file with the credential before init, so a fresh node already has one.
for host in "${HOSTS[@]}"; do
ssh_node "$host" "grep -q '^data_dir = ' /etc/spinifex/spinifex.toml" || return 1
ssh_node "$host" 'test -s /etc/spinifex/spinifex.toml' || return 1
done
}
@@ -676,7 +634,6 @@ if [ "$DRY_RUN" = 1 ]; then
| sed -E 's/ +=/ =/' | sort -u | sed 's/^/ /'
log "full plan in $STATE_DIR/plan.txt"
plan="install Spinifex on $NODES node(s), form the cluster"
[ "$TOPO_EBS_PROVIDER" = ocibsd ] && plan="$plan on ocibsd, run the ebs provider gate"
[ "$SKIP_POOL" = 1 ] && plan="$plan, configure no external pool"
if [ "$SKIP_WORKLOAD" = 1 ]; then
plan="$plan, launch no guests"
@@ -712,29 +669,6 @@ else
|| die "read instance_principal='$PRINCIPAL_MODE' from the tfvars and the apply built '$built_principal'; set it in one place"
fi
# Where ocibsd should keep the cluster's volumes, read back from what was built
# rather than from this run's inputs, to check what init detected and to sweep.
# Never logged: the compartment is an account's OCID.
topo_output() {
if [ "$EXISTING_INFRA" = 1 ]; then
state_output "$1"
else
tf output -raw "${tf_state[@]}" "$1"
fi
}
OCIBSD_COMPARTMENT=""
OCIBSD_AD=""
OCIBSD_REGION=""
if [ "$TOPO_EBS_PROVIDER" = ocibsd ]; then
built_provider="$(topo_output ebs_provider 2>/dev/null)" \
|| die "the state has no ebs_provider output; it predates ocibsd, so rebuild it"
[ "$built_provider" = ocibsd ] \
|| die "$TOPOLOGY needs nodes built for ocibsd and the state says ebs_provider='$built_provider'; a tfvars override wins over the topology"
OCIBSD_COMPARTMENT="$(topo_output compartment_ocid)" || die "could not read compartment_ocid from the state"
OCIBSD_AD="$(topo_output availability_domain)" || die "could not read availability_domain from the state"
OCIBSD_REGION="$(topo_output region)" || die "could not read region from the state"
fi
# Written for every topology, not just the multi-node one that passes it to
# install-node.sh: it is also how an operator gets the addresses afterwards
# without a terraform invocation and the right -state path.
@@ -795,42 +729,17 @@ for host in "${HOSTS[@]}"; do
fi
# cloud-init being finished does not mean apt is: the apt-daily timers and
# unattended-upgrades run on their own schedule and hold the dpkg lock, which
# the installer then fails on. The timers are stopped so no new run starts, and
# the locks must stay free for three polls, since a run frees them between phases.
# the installer then fails on. Wait for the lock rather than fight it.
ssh_node "$host" "DISTRO_NAME='$(basename "${DISTRO:-}")' SETUP_NAME='$(basename "${SETUP_SH:-}")' CHANNEL='$CHANNEL' INSTALL_VERSION='$INSTALL_VERSION' bash -s" \
> "$STATE_DIR/install-$host.log" 2>&1 <<'REMOTE' \
|| die "install failed on $host; see $STATE_DIR/install-$host.log"
set -e
sudo systemctl stop apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true
free=0
for i in $(seq 1 120); do
# The Oracle Cloud Agent runs its own apt update at boot, which can hang for
# longer than this wait. A list refresh is safe to stop; a dpkg run is not.
if [ "$i" -ge 36 ] && ! pgrep -x dpkg >/dev/null && pgrep -f '^/usr/bin/apt update$' >/dev/null; then
echo "stopping an apt update still running after 3 minutes:"
ps -o pid,etime,args -p "$(pgrep -d, -f '^/usr/bin/apt update$')" || true
sudo pkill -f '^/usr/bin/apt update$' || true
fi
if systemctl is-active --quiet apt-daily.service apt-daily-upgrade.service \
|| sudo fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock >/dev/null 2>&1; then
free=0
else
free=$((free + 1))
[ "$free" -lt 3 ] || break
fi
for _ in $(seq 1 120); do
sudo fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock >/dev/null 2>&1 || break
sleep 5
done
[ "$free" -ge 3 ] || { echo "apt was still busy after 10 minutes:"; ps -eo pid,etime,args | grep -E '[a]pt|[d]pkg|[u]nattended'; exit 1; }
# The image's regional clouds.archive.ubuntu.com alias and the country mirror
# have each gone unreachable for hours, so apt falls through a list instead.
for f in $(sudo grep -rlE '[a-z0-9-]+\.clouds\.archive\.ubuntu\.com' /etc/apt/sources.list /etc/apt/sources.list.d 2>/dev/null); do
regional=$(grep -oE 'https?://[a-z0-9-]+\.clouds\.archive\.ubuntu\.com/ubuntu/?' "$f" | head -1)
printf '%s\tpriority:1\n%s\tpriority:2\n%s\tpriority:3\n' \
http://archive.ubuntu.com/ubuntu/ http://au.archive.ubuntu.com/ubuntu/ "${regional%/}/" \
| sudo tee /etc/apt/spx-mirrors.txt >/dev/null
sudo sed -i -E 's#https?://[a-z0-9-]+\.clouds\.archive\.ubuntu\.com/ubuntu/?#mirror+file:/etc/apt/spx-mirrors.txt#g' "$f"
done
echo 'Acquire::Retries "3";' | sudo tee /etc/apt/apt.conf.d/80-spx-retries >/dev/null
sudo fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
&& { echo "the dpkg lock was still held after 10 minutes"; exit 1; }
if [ -n "${DISTRO_NAME:-}" ]; then
# The production setup.sh against the ref's own tarball, which is what the
# release and the ISO both run, so there is no separate dev install layout.
@@ -859,42 +768,6 @@ REMOTE
done
record install PASS "$INSTALLED_VERSION"
# spx admin init's EBS choice, written to every node once (ADR-0018:S3). The ocibsd
# topologies set nothing, so init detects ocibsd on the OCI VM; the rest name
# viperblockd, which keeps the viperblock baseline on OCI.
INIT_EBS_PROVIDER=""
[ "$TOPO_EBS_PROVIDER" = ocibsd ] || INIT_EBS_PROVIDER=viperblockd
# ocibsd defaults to the instance principal. An API-key run gives init the key
# instead: the hook installs it first, and each node's config is seeded with
# provider and credential, which init keeps as an existing config's values.
API_KEY_EBS=0
[ "$TOPO_EBS_PROVIDER" = ocibsd ] && [ "$PRINCIPAL_MODE" = off ] && API_KEY_EBS=1
PRESEEDED_EBS=0
if [ "$API_KEY_EBS" = 1 ] && ! formation_complete; then
log "running the credential hook before formation, so init can check the ocibsd credential"
"$CREDENTIAL_HOOK" "$SSH_PRIVATE_KEY" "${HOSTS[@]}" > "$STATE_DIR/credential-hook.log" 2>&1 \
|| die "the credential hook failed; see $STATE_DIR/credential-hook.log"
for i in "${!HOSTS[@]}"; do
# Named as init and install-node.sh name them: the host on one node,
# node1..nodeN on several.
if [ "$NODES" = 1 ]; then name='$(hostname -s)'; else name="node$((i + 1))"; fi
ssh_node "${HOSTS[$i]}" "sed \"s/@NODE@/$name/\" | sudo tee /etc/spinifex/spinifex.toml >/dev/null" <<'TOML' \
|| die "could not seed the ocibsd credential on ${HOSTS[$i]}"
[nodes.@NODE@.ebs]
provider = "ocibsd"
[nodes.@NODE@.ebs.oci]
auth = "config_file"
config_file = "/etc/spinifex/oci/config"
config_profile = "spinifex"
TOML
done
PRESEEDED_EBS=1
record "ocibsd credential" PASS "API key installed and seeded before init"
fi
# A single node is its own documented path: install-node.sh refuses fewer than two
# hosts, because there is nothing to join. Both branches use the flags the guide
# names, so what is validated is what the guide tells a customer to type.
@@ -903,34 +776,19 @@ if formation_complete; then
record formation SKIP "checkpoint verified"
elif [ "$NODES" = 1 ]; then
log "initializing the single node"
ssh_node "${HOSTS[0]}" "INIT_EBS_PROVIDER=$INIT_EBS_PROVIDER PRESEEDED_EBS=$PRESEEDED_EBS bash -s" \
> "$STATE_DIR/form.log" 2>&1 <<'REMOTE' \
ssh_node "${HOSTS[0]}" '
set -e
sudo spx admin init --node "$(hostname -s)" --nodes 1 \
--region ap-southeast-2 --az ap-southeast-2a \
--external-mode=nat --ipsec=false
sudo systemctl start spinifex.target
' > "$STATE_DIR/form.log" 2>&1 \
|| die "single-node init failed; see $STATE_DIR/form.log"
set -e
ebs_env=()
[ -z "$INIT_EBS_PROVIDER" ] || ebs_env=("SPINIFEX_EBS_PROVIDER=$INIT_EBS_PROVIDER")
# The seeded config is the only one there, so replacing it needs --force.
force=()
[ "$PRESEEDED_EBS" = 0 ] || force=(--force)
sudo env -u SPINIFEX_EBS_PROVIDER ${ebs_env[@]+"${ebs_env[@]}"} spx admin init ${force[@]+"${force[@]}"} \
--node "$(hostname -s)" --nodes 1 \
--region ap-southeast-2 --az ap-southeast-2a \
--external-mode=nat --ipsec=false
sudo systemctl start spinifex.target
REMOTE
mark_stage formation
record formation PASS
else
log "forming the cluster"
# OCI reuses public addresses, and install-node.sh checks host keys against this
# runner's known_hosts, so a key an earlier run saw at an address would refuse it.
for host in "${HOSTS[@]}"; do
ssh-keygen -R "$host" >/dev/null 2>&1 || true
done
# env -u first, so a SPINIFEX_EBS_PROVIDER in this shell cannot stand in for
# detection on the ocibsd topologies.
env -u SPINIFEX_EBS_PROVIDER ${INIT_EBS_PROVIDER:+SPINIFEX_EBS_PROVIDER=$INIT_EBS_PROVIDER} \
"$REPO_ROOT/scripts/install-node.sh" \
"$REPO_ROOT/scripts/install-node.sh" \
--hosts-file "$STATE_DIR/hosts" \
--user ubuntu \
--identity "$SSH_PRIVATE_KEY" \
@@ -942,53 +800,6 @@ else
record formation PASS
fi
# What init printed about its EBS choice, from the log formation left: init's own
# output on one node, the init log install-node.sh keeps on the first host on several.
init_ebs_line() {
if [ "$NODES" = 1 ]; then
grep -m1 '^EBS provider: ' "$STATE_DIR/form.log"
else
ssh_node "${HOSTS[0]}" "sudo grep -m1 '^EBS provider: ' /var/log/spx-init.log"
fi
}
# The provider has to be the topology's for the reason the topology names: the
# ocibsd pair detected on the VM, the rest told viperblockd by the environment,
# so a baseline flipped by detection or an ocibsd run that was configured fails.
case "$TOPO_EBS_PROVIDER:$API_KEY_EBS" in
ocibsd:0) want_ebs='EBS provider: ocibsd (detected: OCI virtual machine ' ;;
ocibsd:1) want_ebs='EBS provider: ocibsd (from the existing config)' ;;
*) want_ebs='EBS provider: viperblockd (from SPINIFEX_EBS_PROVIDER)' ;;
esac
ebs_line="$(init_ebs_line)" || die "init printed no EBS provider line; see $STATE_DIR/form.log"
case "$ebs_line" in
"$want_ebs"*) record "ebs provider choice" PASS "${ebs_line#EBS provider: }" ;;
*) die "init chose '$ebs_line', and $TOPOLOGY needs '$want_ebs'" ;;
esac
# ocibsd tags every volume and backup with the cluster_id spx admin init generated,
# and no Terraform state holds them, so e2e-oci.sh sweeps by that tag after the
# destroy. Recorded before any volume exists, so a killed run is still sweepable.
OCIBSD_CLUSTERS="$STATE_DIR/ocibsd-clusters"
OCIBSD_CLUSTER_ID=""
if [ "$TOPO_EBS_PROVIDER" = ocibsd ]; then
OCIBSD_CLUSTER_ID="$(ssh_node "${HOSTS[0]}" 'sudo python3 -' <<'PY'
import sys, tomllib
nodes = tomllib.load(open("/etc/spinifex/spinifex.toml", "rb")).get("nodes", {})
# A leader's config also lists the other nodes, without their EBS tables.
ids = {n["ebs"]["oci"].get("cluster_id", "") for n in nodes.values() if "oci" in n.get("ebs", {})}
if len(ids) != 1 or "" in ids:
sys.exit("expected one cluster_id across [nodes.*.ebs.oci], found %r" % sorted(ids))
print(ids.pop())
PY
)" || die "could not read the ocibsd cluster_id from ${HOSTS[0]}"
[[ "$OCIBSD_CLUSTER_ID" =~ ^[0-9a-f]{32}$ ]] \
|| die "${HOSTS[0]} reports cluster_id '$OCIBSD_CLUSTER_ID', which is not the 128-bit hex value init writes"
# Appended: a record a failed sweep left behind must survive this run's.
line="$(printf '%s\t%s\t%s' "$OCIBSD_CLUSTER_ID" "$OCIBSD_COMPARTMENT" "$OCIBSD_REGION")"
grep -qxF "$line" "$OCIBSD_CLUSTERS" 2>/dev/null || printf '%s\n' "$line" >> "$OCIBSD_CLUSTERS"
log "ocibsd cluster_id $OCIBSD_CLUSTER_ID, recorded for the sweep"
fi
# Runs after formation and before the pool is configured, which is the only window
# where a node has /etc/spinifex but has not yet started the allocator. Nothing in
# this repository knows what it does: an API-key deployment needs a credential on
@@ -998,7 +809,7 @@ if pool_complete; then
log "OCI pool configuration already completed (resuming; pool setup skipped)"
record pool SKIP "checkpoint verified"
else
if [ "$SKIP_POOL" != 1 ] && [ "$PRINCIPAL_MODE" = off ] && [ "$PRESEEDED_EBS" = 0 ]; then
if [ "$SKIP_POOL" != 1 ] && [ "$PRINCIPAL_MODE" = off ]; then
log "running the credential hook"
# Arguments, not a file: the hook is told where the nodes are and how to reach
# them, and decides for itself what to put there.
@@ -1125,9 +936,9 @@ fi
# Parse the STATUS column, never grep for Ready: NotReady contains it, and that
# read passed a single-node run whose only node was NotReady on 0.0.0.0. The table
# is colourised and drawn with box characters, so both come off first.
# is colourised, so the escapes come off first.
count_ready() {
sed -e 's/\x1b\[[0-9;]*m//g' -e 's/│/|/g' "$STATE_DIR/nodes.txt" \
sed 's/\x1b\[[0-9;]*m//g' "$STATE_DIR/nodes.txt" \
| awk -F'|' 'NR > 1 { gsub(/^[ \t]+|[ \t]+$/, "", $2); if ($2 == "Ready") n++ } END { print n + 0 }'
}
@@ -1151,429 +962,10 @@ fi
if [ "$SKIP_WORKLOAD" = 1 ]; then
log "--skip-workload: stopping before the workbook"
if [ "$TOPO_EBS_PROVIDER" = ocibsd ]; then
record "ebs provider" SKIPPED "--skip-workload"
fi
record workbooks SKIPPED "--skip-workload"
exit 0
fi
# The ebs provider gate, ocibsd only. The workbooks reach EBS only through RDS,
# ECS and EKS, so this boots from an imported AMI, stops, attaches, snapshots,
# restores and terminates, checking each against OCI's own view of the volumes.
GATE_IMAGE=""
GATE_LOG="$STATE_DIR/ebs-gate.log"
# The AWS CLI on node 1, as the workbook driver runs it. Each argument is quoted
# for the remote shell, so a JMESPath expression arrives as one word.
node_aws() {
ssh_node "${HOSTS[0]}" "AWS_PROFILE=spinifex aws --output text $(printf '%q ' "$@")"
}
# OCI's live volumes or backups carrying every key=value given, as JSON, read from
# this runner with the credential the apply used; nothing on a node is trusted.
oci_tagged() {
local kind="$1" tag tags=()
shift
for tag in "$@"; do tags+=(--tag "$tag"); done
OCIBSD_COMPARTMENT="$OCIBSD_COMPARTMENT" python3 "$HERE/scripts/oci_env.py" \
${OCI_PROFILE:+--profile "$OCI_PROFILE"} --region "$OCIBSD_REGION" -- \
python3 "$HERE/scripts/oci_volumes.py" "$kind" "${tags[@]}" 2>>"$GATE_LOG"
}
oci_count() {
oci_tagged "$@" | python3 -c 'import json, sys; print(len(json.load(sys.stdin)))'
}
# get-console-output is base64 on the wire and the CLI may or may not decode it.
console_text() {
node_aws ec2 get-console-output --instance-id "$1" --query Output 2>/dev/null \
| python3 -c '
import base64, binascii, sys
raw = sys.stdin.read().strip()
try:
sys.stdout.write(base64.b64decode(raw, validate=True).decode("utf-8", "replace"))
except (binascii.Error, ValueError):
sys.stdout.write(raw)
' | tr -d '\r'
}
# wait_console <instance> <ERE> <seconds> prints the last console line matching.
# On a timeout the guest's last gate reports go to the gate log, so a failure
# shows what the guest saw instead.
wait_console() {
local id="$1" pattern="$2" deadline=$((SECONDS + $3)) line
while [ "$SECONDS" -lt "$deadline" ]; do
line="$(console_text "$id" | grep -E "$pattern" | tail -1 || true)"
if [ -n "$line" ]; then
printf '%s\n' "$line"
return 0
fi
sleep 10
done
{
printf '\n=== %s console, last gate reports, waiting for: %s ===\n' "$id" "$pattern"
console_text "$id" | grep 'SPXGATE' | tail -20 || true
} >> "$GATE_LOG" 2>&1
return 1
}
# wait_new_boot <instance> <token> <boots seen> <seconds> prints the boot ID of
# the first boot not yet seen that found the root marker, proving a restart kept it.
wait_new_boot() {
local id="$1" tok="$2" seen="$3" deadline=$((SECONDS + $4)) boot
while [ "$SECONDS" -lt "$deadline" ]; do
for boot in $(console_text "$id" \
| sed -n "s/.*SPXGATE boot=\([0-9a-f-]*\) root-present serial=[^ ]* $tok\$/\1/p"); do
case " $seen " in
*" $boot "*) ;;
*) printf '%s\n' "$boot"; return 0 ;;
esac
done
sleep 10
done
return 1
}
# The node whose QEMU runs the instance, by process, as the multinode suite finds it.
hosting_node() {
local host found=""
for host in "${HOSTS[@]}"; do
if ssh_node "$host" "ps -eo args | grep -F -- '$1' | grep -q '[q]emu-system'"; then
[ -z "$found" ] || die "$1 runs on both $found and $host"
found="$host"
fi
done
[ -n "$found" ] || die "no node runs a QEMU for $1"
printf '%s\n' "$found"
}
wait_gone_from_oci() {
local deadline=$((SECONDS + 600)) left vol
while :; do
left=0
for vol in "$@"; do
case "$vol" in
snap-*) left=$((left + $(oci_count backups "spinifex-snapshot-id=$vol"))) ;;
*) left=$((left + $(oci_count volumes "spinifex-volume-id=$vol"))) ;;
esac
done
[ "$left" = 0 ] && return 0
[ "$SECONDS" -lt "$deadline" ] || return 1
sleep 15
done
}
# Reports on the serial console, which get-console-output reads: the root marker
# at each boot, then each Spinifex disk (serial vol...) as it appears, writing a
# marker to a blank one and reading one back from a restored one.
write_gate_user_data() {
cat > "$1" <<'USERDATA'
#cloud-config
write_files:
# Ubuntu 26.04's dd (uutils) fails O_DIRECT reads with EINVAL, so the gate's
# direct I/O uses a page-aligned buffer of its own.
- path: /usr/local/sbin/spx-gate-io
permissions: "0755"
content: |
#!/usr/bin/python3
import mmap, os, sys
mode, dev = sys.argv[1], sys.argv[2]
buf = mmap.mmap(-1, 4096)
if mode == "read":
fd = os.open(dev, os.O_RDONLY | os.O_DIRECT)
n = os.readv(fd, [buf])
if n != 4096:
sys.exit(f"short read: {n} bytes")
sys.stdout.buffer.write(buf[:])
else:
buf.write((sys.argv[3] + "\n").encode())
fd = os.open(dev, os.O_WRONLY | os.O_DIRECT | os.O_SYNC)
if os.writev(fd, [buf]) != 4096:
sys.exit("short write")
os.fsync(fd)
- path: /usr/local/sbin/spx-ebs-gate
permissions: "0755"
content: |
#!/bin/sh
BOOT=$(cat /proc/sys/kernel/random/boot_id)
say() { echo "SPXGATE boot=$BOOT $*" > /dev/ttyS0; }
serial() { cat "/sys/block/$1/serial" 2>/dev/null || cat "/sys/block/$1/device/serial" 2>/dev/null || true; }
src=$(findmnt -no SOURCE /)
rootdisk=$(lsblk -no PKNAME "$src" | head -1)
[ -n "$rootdisk" ] || rootdisk=${src##*/}
mark=/var/lib/spx-ebs-gate/root
if [ -s "$mark" ]; then
say "root-present serial=$(serial "$rootdisk") $(cat "$mark")"
else
mkdir -p /var/lib/spx-ebs-gate
cat /proc/sys/kernel/random/uuid > "$mark.tmp" && sync && mv "$mark.tmp" "$mark" && sync
say "root-written serial=$(serial "$rootdisk") $(cat "$mark")"
fi
seen=" "
while :; do
next=" "
for sys in /sys/block/*; do
d=${sys##*/}
[ "$d" != "$rootdisk" ] || continue
s=$(serial "$d")
case "$s" in vol*) ;; *) continue ;; esac
case "$seen" in *" $d "*) next="$next$d "; continue ;; esac
# A failed or short read is not a blank disk: overwriting it would destroy the marker.
if ! err=$(spx-gate-io read "/dev/$d" 2>&1 >/run/spx-gate-head); then
say "data-unreadable serial=$s $(printf '%s' "$err" | tr '\n' ' ' | tail -c 200)"
continue
fi
head=$(head -c 64 /run/spx-gate-head | tr -d '\000')
case "$head" in
"SPXGATE-DATA "*)
say "data-present serial=$s ${head#SPXGATE-DATA }"; next="$next$d " ;;
"")
if blkid -p "/dev/$d" >/dev/null 2>&1; then
say "data-foreign serial=$s"; next="$next$d "
else
tok=$(cat /proc/sys/kernel/random/uuid)
if spx-gate-io write "/dev/$d" "SPXGATE-DATA $tok" 2>/dev/null; then
say "data-written serial=$s $tok"; next="$next$d "
fi
fi ;;
*) say "data-foreign serial=$s"; next="$next$d " ;;
esac
done
seen=$next
sleep 3
done
- path: /etc/systemd/system/spx-ebs-gate.service
content: |
[Unit]
Description=Spinifex EBS provider gate reporter
After=local-fs.target
[Service]
ExecStart=/usr/local/sbin/spx-ebs-gate
Restart=always
[Install]
WantedBy=multi-user.target
runcmd:
- [systemctl, daemon-reload]
- [systemctl, enable, --now, spx-ebs-gate.service]
USERDATA
}
# What the gate leaves for a failure it cannot explain from its own output: each
# node's iSCSI sessions and records, block devices and their holders, and the
# provider's and kernel's recent log. Best effort, so a failed read never masks
# the gate's own error.
gate_diagnose() {
local host
for host in "${HOSTS[@]}"; do
{
printf '\n=== diagnostics from %s ===\n' "$host"
ssh_node "$host" 'sudo bash -s' <<'DIAG' 2>&1
for c in "iscsiadm -m session -P 3" "iscsiadm -m node" "lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,HCTL" "ls -l /dev/disk/by-path"; do
printf -- '--- %s\n' "$c"; $c 2>&1
done
printf -- '--- holders of iSCSI devices\n'
for d in /dev/disk/by-path/*iqn.2015-12.com.oracleiaas:*; do
[ -e "$d" ] || continue
dev=$(readlink -f "$d"); printf '%s -> %s\n' "$d" "$dev"
ls /sys/class/block/"${dev##*/}"/holders 2>&1; fuser -v "$dev" 2>&1
done
printf -- '--- journalctl -u spinifex-ocibsd\n'; journalctl -u spinifex-ocibsd -n 300 --no-pager 2>&1
printf -- '--- journalctl -k\n'; journalctl -k -n 150 --no-pager 2>&1
DIAG
} >> "$GATE_LOG" || true
done
}
gate_die() {
gate_diagnose
die "$@"
}
ebs_provider_gate() {
local host out ami snap_ami itype subnet iid az root_vol dot tok boot boots line
local data_vol data_serial data_tok snap restored from to
: > "$GATE_LOG"
log "ebs provider gate: units, masks, drop-ins and the detected placement on every node"
for host in "${HOSTS[@]}"; do
# The placement init read from the instance metadata must be where this run
# built the nodes; compared on the node, so the compartment is never printed.
ssh_node "$host" "WANT_COMPARTMENT='$OCIBSD_COMPARTMENT' WANT_AD='$OCIBSD_AD' bash -s" >> "$GATE_LOG" 2>&1 <<'REMOTE' \
|| gate_die "$host is not an ocibsd node as installed; see $GATE_LOG"
fail() { echo "FAIL $(hostname -s): $*"; exit 1; }
[ "$(systemctl is-enabled spinifex-viperblock.service 2>&1)" = masked ] \
|| fail "spinifex-viperblock.service is not masked"
if systemctl is-active --quiet spinifex-viperblock.service; then fail "spinifex-viperblock.service is running"; fi
systemctl is-active --quiet spinifex-ocibsd.service || fail "spinifex-ocibsd.service is not active"
[ "$(systemctl is-enabled spinifex-ocibsd.service)" = enabled ] || fail "spinifex-ocibsd.service is not enabled"
systemctl show -p DropInPaths --value spinifex-daemon.service \
| grep -q /etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf \
|| fail "the daemon's ocibsd device drop-in is not loaded"
systemctl show -p DeviceAllow --value spinifex-daemon.service | grep -q 'block-sd rw' \
|| fail "the daemon's device cgroup does not allow block-sd"
test -f /etc/udev/rules.d/99-spinifex-ocibsd.rules || fail "no ocibsd udev rule"
sudo WANT_COMPARTMENT="$WANT_COMPARTMENT" WANT_AD="$WANT_AD" python3 - <<"PY" \
|| fail "a node in spinifex.toml is not on ocibsd where the nodes were built"
import os, sys, tomllib
nodes = tomllib.load(open("/etc/spinifex/spinifex.toml", "rb")).get("nodes", {})
# Only tables with an ebs section: a leader also lists the other nodes, bare.
# Each node's own table always has one, and this runs on every node.
nodes = {n: v for n, v in nodes.items() if "ebs" in v}
other = {n: v["ebs"].get("provider") for n, v in nodes.items() if v["ebs"].get("provider") != "ocibsd"}
if not nodes or other:
sys.exit("providers other than ocibsd: %r" % other)
for n, v in sorted(nodes.items()):
oci = v["ebs"].get("oci", {})
if oci.get("compartment_id") != os.environ["WANT_COMPARTMENT"]:
sys.exit("%s: compartment_id is not the compartment the nodes were built in" % n)
if oci.get("availability_domain") != os.environ["WANT_AD"]:
sys.exit("%s: availability_domain %r, and the nodes were built in %r" % (n, oci.get("availability_domain"), os.environ["WANT_AD"]))
print("ebs providers:", ", ".join("%s=ocibsd" % n for n in sorted(nodes)))
PY
echo "ok $(hostname -s)"
REMOTE
done
record "ebs provider units" PASS "ocibsd active, viperblock masked, device drop-in, placement from metadata on $NODES node(s)"
GATE_IMAGE=ubuntu-26.04-x86_64
log "ebs provider gate: importing $GATE_IMAGE onto an OCI volume"
out="$(ssh_node "${HOSTS[0]}" "sudo spx admin images import --name $GATE_IMAGE --config /etc/spinifex/spinifex.toml" 2>&1)" \
|| { printf '%s\n' "$out" >> "$GATE_LOG"; gate_die "importing $GATE_IMAGE failed; see $GATE_LOG"; }
printf '%s\n' "$out" >> "$GATE_LOG"
ami="$(printf '%s\n' "$out" | sed -n 's/.*Image-ID (AMI): \(ami-[0-9a-f]*\).*/\1/p' | tail -1)"
[ -n "$ami" ] || gate_die "the import printed no AMI ID; see $GATE_LOG"
snap_ami="$(node_aws ec2 describe-images --image-ids "$ami" --query 'Images[0].BlockDeviceMappings[0].Ebs.SnapshotId')"
[ "$(oci_count backups "spinifex-snapshot-id=$snap_ami" "spinifex-cluster=$OCIBSD_CLUSTER_ID")" = 1 ] \
|| gate_die "$ami's snapshot $snap_ami is not one OCI backup tagged with this cluster"
record "ami import" PASS "$ami, snapshot $snap_ami is an OCI backup"
itype="$(node_aws ec2 describe-instance-types \
--query 'sort_by(InstanceTypes[?VCpuInfo.DefaultVCpus>=`2` && MemoryInfo.SizeInMiB>=`2048`], &MemoryInfo.SizeInMiB)[0].InstanceType')"
{ [ -n "$itype" ] && [ "$itype" != None ]; } || gate_die "no instance type with 2 vCPU and 2 GiB on this cluster"
subnet="$(node_aws ec2 describe-subnets --filters Name=default-for-az,Values=true \
--query 'Subnets[0].SubnetId' 2>>"$GATE_LOG")" || subnet=""
[ "$subnet" != None ] || subnet=""
write_gate_user_data "$STATE_DIR/ebs-gate-user-data.yaml"
scp -i "$SSH_PRIVATE_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -q "$STATE_DIR/ebs-gate-user-data.yaml" "ubuntu@${HOSTS[0]}:/tmp/ebs-gate-user-data.yaml" \
|| gate_die "could not copy the gate's user-data"
log "ebs provider gate: booting $itype from $ami"
iid="$(node_aws ec2 run-instances --image-id "$ami" --instance-type "$itype" --count 1 \
${subnet:+--subnet-id "$subnet"} --user-data file:///tmp/ebs-gate-user-data.yaml \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=ebs-provider-gate}]' \
--query 'Instances[0].InstanceId')" || gate_die "run-instances from $ami failed"
node_aws ec2 wait instance-running --instance-ids "$iid" || gate_die "$iid never reached running"
read -r az root_vol dot <<EOF
$(node_aws ec2 describe-instances --instance-ids "$iid" \
--query 'Reservations[0].Instances[0].[Placement.AvailabilityZone,BlockDeviceMappings[0].Ebs.VolumeId,BlockDeviceMappings[0].Ebs.DeleteOnTermination]')
EOF
[ "$dot" = True ] || gate_die "$iid's root volume $root_vol is not delete-on-termination"
line="$(wait_console "$iid" 'SPXGATE boot=[0-9a-f-]+ root-written ' 900)" \
|| gate_die "$iid never reported its root marker on the console"
boot="$(printf '%s\n' "$line" | sed -n 's/.*boot=\([0-9a-f-]*\) .*/\1/p')"
tok="${line##* }"
boots="$boot"
[ "$(oci_count volumes "spinifex-volume-id=$root_vol" "spinifex-cluster=$OCIBSD_CLUSTER_ID")" = 1 ] \
|| gate_die "$iid's root volume $root_vol is not one OCI volume tagged with this cluster"
record "boot from ocibsd" PASS "$iid on $root_vol, an OCI volume tagged with this cluster"
log "ebs provider gate: stop and start $iid"
node_aws ec2 stop-instances --instance-ids "$iid" >/dev/null || gate_die "stop-instances $iid failed"
node_aws ec2 wait instance-stopped --instance-ids "$iid" || gate_die "$iid never stopped"
node_aws ec2 start-instances --instance-ids "$iid" >/dev/null || gate_die "start-instances $iid failed"
node_aws ec2 wait instance-running --instance-ids "$iid" || gate_die "$iid never ran again"
boot="$(wait_new_boot "$iid" "$tok" "$boots" 900)" \
|| gate_die "$iid did not find its root marker after a stop and start"
boots="$boots $boot"
record "stop and start" PASS "the root marker survived"
log "ebs provider gate: attach and detach a data volume"
data_vol="$(node_aws ec2 create-volume --availability-zone "$az" --size 1 --volume-type gp3 \
--query VolumeId)" || gate_die "create-volume failed"
node_aws ec2 wait volume-available --volume-ids "$data_vol" || gate_die "$data_vol never became available"
node_aws ec2 attach-volume --volume-id "$data_vol" --instance-id "$iid" --device /dev/sdf >/dev/null \
|| gate_die "attach-volume $data_vol failed"
node_aws ec2 wait volume-in-use --volume-ids "$data_vol" || gate_die "$data_vol never attached"
data_serial="${data_vol//-/}"
line="$(wait_console "$iid" "SPXGATE boot=[0-9a-f-]+ data-written serial=$data_serial " 600)" \
|| gate_die "the guest never wrote to $data_vol"
data_tok="${line##* }"
[ "$(oci_count volumes "spinifex-volume-id=$data_vol" "spinifex-cluster=$OCIBSD_CLUSTER_ID")" = 1 ] \
|| gate_die "$data_vol is not one OCI volume tagged with this cluster"
node_aws ec2 detach-volume --volume-id "$data_vol" >/dev/null || gate_die "detach-volume $data_vol failed"
node_aws ec2 wait volume-available --volume-ids "$data_vol" || gate_die "$data_vol never detached"
record "attach and detach" PASS "$data_vol written in the guest"
# Read back before the snapshot, so a lost marker is told apart from a lost restore.
node_aws ec2 attach-volume --volume-id "$data_vol" --instance-id "$iid" --device /dev/sdf >/dev/null \
|| gate_die "reattach-volume $data_vol failed"
node_aws ec2 wait volume-in-use --volume-ids "$data_vol" || gate_die "$data_vol never reattached"
wait_console "$iid" "SPXGATE boot=[0-9a-f-]+ data-present serial=$data_serial $data_tok\$" 600 >/dev/null \
|| gate_die "the guest did not read its marker back from $data_vol after a detach"
node_aws ec2 detach-volume --volume-id "$data_vol" >/dev/null || gate_die "detach-volume $data_vol failed"
node_aws ec2 wait volume-available --volume-ids "$data_vol" || gate_die "$data_vol never detached again"
record "reattach" PASS "$data_vol kept its marker across a detach"
log "ebs provider gate: snapshot, restore and read back"
snap="$(node_aws ec2 create-snapshot --volume-id "$data_vol" --query SnapshotId)" \
|| gate_die "create-snapshot $data_vol failed"
node_aws ec2 wait snapshot-completed --snapshot-ids "$snap" || gate_die "$snap never completed"
[ "$(oci_count backups "spinifex-snapshot-id=$snap" "spinifex-cluster=$OCIBSD_CLUSTER_ID")" = 1 ] \
|| gate_die "$snap is not one OCI backup tagged with this cluster"
restored="$(node_aws ec2 create-volume --availability-zone "$az" --snapshot-id "$snap" \
--volume-type gp3 --query VolumeId)" || gate_die "create-volume from $snap failed"
node_aws ec2 wait volume-available --volume-ids "$restored" || gate_die "$restored never became available"
node_aws ec2 attach-volume --volume-id "$restored" --instance-id "$iid" --device /dev/sdg >/dev/null \
|| gate_die "attach-volume $restored failed"
node_aws ec2 wait volume-in-use --volume-ids "$restored" || gate_die "$restored never attached"
wait_console "$iid" "SPXGATE boot=[0-9a-f-]+ data-present serial=${restored//-/} $data_tok\$" 600 >/dev/null \
|| gate_die "the guest did not read $data_vol's marker back from $restored"
node_aws ec2 detach-volume --volume-id "$restored" >/dev/null || gate_die "detach-volume $restored failed"
node_aws ec2 wait volume-available --volume-ids "$restored" || gate_die "$restored never detached"
record "snapshot and restore" PASS "$snap restored to $restored with the marker intact"
# A stopped instance restarts on its last node unless that node's daemon does
# not answer, so the daemon is stopped there for the start. The guest is down
# first, so no write is in flight; this is not a fence test.
if [ "$NODES" -gt 1 ]; then
from="$(hosting_node "$iid")"
log "ebs provider gate: moving $iid off $from"
node_aws ec2 stop-instances --instance-ids "$iid" >/dev/null || gate_die "stop-instances $iid failed"
node_aws ec2 wait instance-stopped --instance-ids "$iid" || gate_die "$iid never stopped"
ssh_node "$from" 'sudo systemctl stop spinifex-daemon.service' || gate_die "could not stop the daemon on $from"
node_aws ec2 start-instances --instance-ids "$iid" >/dev/null || gate_die "start-instances $iid failed"
node_aws ec2 wait instance-running --instance-ids "$iid" || gate_die "$iid never ran on another node"
ssh_node "$from" 'sudo systemctl start spinifex-daemon.service' || gate_die "could not restart the daemon on $from"
to="$(hosting_node "$iid")"
[ "$to" != "$from" ] || gate_die "$iid started on $from again, so nothing moved"
boot="$(wait_new_boot "$iid" "$tok" "$boots" 900)" \
|| gate_die "$iid did not find its root marker on $to"
record "cross-node start" PASS "$iid moved from $from to $to with its root marker"
fi
log "ebs provider gate: terminate and delete"
node_aws ec2 terminate-instances --instance-ids "$iid" >/dev/null || gate_die "terminate-instances $iid failed"
node_aws ec2 wait instance-terminated --instance-ids "$iid" || gate_die "$iid never terminated"
# Spinifex refuses DeleteVolume on a volume with a snapshot and DeleteSnapshot on
# one a live volume came from, so the restore goes first and the source last.
node_aws ec2 delete-volume --volume-id "$restored" || gate_die "delete-volume $restored failed"
node_aws ec2 delete-snapshot --snapshot-id "$snap" || gate_die "delete-snapshot $snap failed"
local deadline=$((SECONDS + 300))
until node_aws ec2 delete-volume --volume-id "$data_vol" 2>>"$GATE_LOG"; do
[ "$SECONDS" -lt "$deadline" ] || gate_die "delete-volume $data_vol still refused 300s after $snap was deleted"
sleep 10
done
wait_gone_from_oci "$root_vol" "$data_vol" "$restored" "$snap" \
|| gate_die "OCI still holds a live volume or backup for $root_vol, $data_vol, $restored or $snap after 600s"
record "terminate" PASS "no OCI volume or backup left for the instance, its volumes or the snapshot"
}
if [ "$TOPO_EBS_PROVIDER" = ocibsd ]; then
ebs_provider_gate
fi
# The same driver the GitHub nightly runs (cell 17), on the same published
# workbooks, so a workbook that passes on a hypervisor and fails on OCI is a
# difference in OCI and not in the test. It owns its own per-workbook assertions
@@ -1610,23 +1002,12 @@ scp -i "$SSH_PRIVATE_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev
# to absorb parallel uploads. Three are appliances rather than distros: RDS, ECS
# and EKS each boot their own, found by a spinifex:managed-by tag and never by name.
log "importing the images the workbooks need"
# The ebs provider gate has already imported its own, and is not asked twice.
WORKBOOK_IMAGES=""
for img in ubuntu-26.04-x86_64 spinifex-rds-postgres spinifex-ecs-node spinifex-eks-node; do
[ "$img" = "$GATE_IMAGE" ] || WORKBOOK_IMAGES="$WORKBOOK_IMAGES $img"
done
ssh_node "${HOSTS[0]}" "WORKBOOK_IMAGES='$WORKBOOK_IMAGES' bash -s" > "$STATE_DIR/images.log" 2>&1 <<'REMOTE' \
|| die "image import failed; see $STATE_DIR/images.log"
out=$(mktemp)
for img in $WORKBOOK_IMAGES; do
# The import reports some failures on stdout, so a failure prints all of it.
if ! sudo spx admin images import --name "$img" --config /etc/spinifex/spinifex.toml < /dev/null > "$out" 2>&1; then
echo "importing $img failed:"
tr '\r' '\n' < "$out" | grep -v '/100%)' | tail -40
exit 1
fi
done
REMOTE
ssh_node "${HOSTS[0]}" '
set -e
for img in ubuntu-26.04-x86_64 spinifex-rds-postgres spinifex-ecs-node spinifex-eks-node; do
sudo spx admin images import --name "$img" --config /etc/spinifex/spinifex.toml >/dev/null
done
' > "$STATE_DIR/images.log" 2>&1 || die "image import failed; see $STATE_DIR/images.log"
# Every workbook that is a workbook. The shared driver's own default is five, which
# leaves ECS and all three EKS variants untested on every platform -- their
-22
View File
@@ -68,28 +68,6 @@ variable "instance_principal" {
}
}
# What backs EBS on these nodes. ocibsd attaches an OCI block volume per EBS
# volume at runtime, so the Oracle Cloud Agent's Block Volume Management plugin
# is disabled and the node's own data volume attaches paravirtualized instead.
variable "ebs_provider" {
description = "EBS provider the nodes are installed with: viperblockd, or ocibsd for OCI Block Volumes."
type = string
default = "viperblockd"
validation {
condition = contains(["viperblockd", "ocibsd"], var.ebs_provider)
error_message = "ebs_provider must be viperblockd or ocibsd."
}
}
# Named only in the api_key_policy_statements output, which a tenancy admin
# applies to the group holding the API user that spx-oci-config.sh installs.
variable "api_key_group_name" {
description = "IAM group of the API-key user the nodes authenticate as when instance_principal is off."
type = string
default = "spinifex-nodes"
}
variable "vcn_cidr" {
description = "VCN address space. A /22 leaves room for the per-node secondary private IPs Spinifex allocates for external addresses."
type = string
+1 -2
View File
@@ -349,8 +349,7 @@ rm_path /etc/systemd/system/spinifex-*.service \
/etc/systemd/system/spinifex.slice \
/etc/systemd/system/spinifex-system.slice \
/etc/systemd/system/spinifex-guests.slice \
/etc/systemd/system/system.slice.d/spinifex-reserve.conf \
/etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf
/etc/systemd/system/system.slice.d/spinifex-reserve.conf
# Drop-ins we wrote into *other people's* units. These are the dangerous ones:
# they are not in a spinifex-named path, so a namespace glob misses them, and
-1
View File
@@ -42,7 +42,6 @@ ASSUME Capabilities \in [online_expansion : BOOLEAN,
read_only_publish : BOOLEAN,
volume_enumeration : BOOLEAN,
snapshot_enumeration : BOOLEAN,
snapshot_copy : BOOLEAN,
owner_routing : BOOLEAN,
exclusion : [scope : {"none", "node", "cluster"},
claim_ttl_seconds : Nat,
+6 -5
View File
@@ -17,8 +17,6 @@ The contract that every implementation of the `EBSProvider` interface must meet.
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.
A block export arrives at contract version 2: a PublishVolume call below it, for a volume that only a host block device can export, is refused with `unsupported_version`.
A snapshot's `state` in a ListSnapshots ref, and the `snapshot_id` filter, arrive at contract version 4: a ref carries no state below it, and a filtered call below it is refused with `unsupported_version` rather than answered with every snapshot.
- 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.
@@ -102,10 +100,13 @@ The contract that every implementation of the `EBSProvider` interface must meet.
### 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 a provider forget a snapshot's source?
The control plane mints every snapshot ID and records it before the first call, so the ID alone names the request. A provider that no longer knows a snapshot's source, such as qemu-nbd after a restart, answers a retry with the snapshot, and refuses with `already_exists` only when it knows the source differs. The specification models a provider that always knows the source.
- 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?
@@ -132,7 +133,7 @@ The contract that every implementation of the `EBSProvider` interface must meet.
- 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, while OCI refuses to delete a backup that is still being created, so the control plane never asks: it defers a customer's delete of a pending snapshot until the snapshot settles.
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`?
+17 -51
View File
@@ -13,9 +13,8 @@ CreateSnapshotRequests == [snapshot_id : Optional(SnapshotIDs),
CreateSnapshot(req, val, err) ==
(*******************************************************************************************************************)
(* A volume's data is frozen as a snapshot, from which new volumes can be restored. The snapshot holds the *)
(* volume's data as it was when the call returned, even while it is pending, and a pending snapshot settles later *)
(* without a call (SettleSnapshot). A retry of a call that already succeeded returns the same snapshot. *)
(* 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
@@ -70,14 +69,13 @@ CopySnapshot(req, val, err) ==
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),
unsupported_capability |-> ~Capabilities.snapshot_copy]
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.
@@ -99,45 +97,27 @@ CopySnapshot(req, val, err) ==
ListSnapshotsRequests == [max_results : -1..(MaxListResults + 1),
starting_token : Optional(ListTokens),
snapshot_id : Optional(SnapshotIDs)]
\* The ref that ListSnapshots returns for a snapshot, carrying its current state.
SnapshotRef(id) == [id |-> id,
source_volume_id |-> snapshots[id].source_volume_id,
handle |-> snapshots[id].handle,
state |-> snapshots[id].state]
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. A call that names a snapshot returns that snapshot in its current *)
(* state, or nothing when it does not exist, so an accepted snapshot is never reported absent while it is pending. *)
(* returns no next token lists every snapshot. *)
(*******************************************************************************************************************)
LET snapID == req.snapshot_id
Errors ==
\* A call that names a snapshot reads only that one, so it needs no enumeration.
LET checks == [unsupported_capability |-> snapID = None /\ ~Capabilities.snapshot_enumeration]
IN {e \in DOMAIN checks : checks[e]}
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 named one snapshot, so paging is ignored.
\/ /\ Errors = {}
/\ snapID /= None
/\ val = [snapshots |-> IF KeyExists(snapshots, snapID) THEN {SnapshotRef(snapID)} ELSE {},
next_token |-> None]
/\ err = None
/\ UNCHANGED vars
\* The request was accepted.
\/ /\ Errors = {}
/\ snapID = None
/\ LET refs == {SnapshotRef(id) : id \in DOMAIN snapshots}
/\ 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)
@@ -176,18 +156,4 @@ DeleteSnapshot(req, err) ==
/\ err = None
/\ UNCHANGED <<volumes, publications>>
-----------------------------------------------------------------------------------------------------------------------
\* Steps without a call
SettleSnapshot(snapID) ==
(*******************************************************************************************************************)
(* A pending snapshot settles as completed or error, as the provider finishes it. Its data is unchanged, since it *)
(* was fixed when CreateSnapshot returned, and a snapshot that has settled never changes state again. *)
(*******************************************************************************************************************)
/\ KeyExists(snapshots, snapID)
/\ snapshots[snapID].state = "pending"
/\ \E s \in {"completed", "error"} :
snapshots' = [snapshots EXCEPT ![snapID].state = s]
/\ UNCHANGED <<volumes, publications>>
=======================================================================================================================
+19 -102
View File
@@ -322,27 +322,14 @@ type ViperblockConfig struct {
}
// EBS provider selectors. Viperblockd routes EBS calls to the viperblockd
// daemon, qemunbd to the qemunbdd daemon and ocibsd to the ocibsd daemon, all
// over the versioned ebs.provider.v1.* NATS contract. Embedded named the removed in-process
// daemon and qemunbd to the qemunbdd daemon, both over the versioned
// ebs.provider.v1.* NATS contract. Embedded named the removed in-process
// engine and survives solely so a config that still selects it can be
// rejected by name.
const (
EBSProviderEmbedded = "embedded"
EBSProviderViperblockd = "viperblockd"
EBSProviderQEMUNBD = "qemunbd"
EBSProviderOCIBSD = "ocibsd"
)
// OCI credential selections, as in oci_auth on an external pool and auth in
// [nodes.<name>.ebs.oci]. An unset auth reads a config file.
const (
// OCIAuthConfigFile authenticates from an API-key config file. The default,
// because it is what an operator already has from `oci setup config`.
OCIAuthConfigFile = "config_file"
// OCIAuthInstancePrincipal authenticates as the instance, from the
// certificate its metadata service serves. No key material on any node and
// nothing to rotate, at the cost of a dynamic group and an IAM policy.
OCIAuthInstancePrincipal = "instance_principal"
)
// Per-volume export tunables. Both are applied per nbdkit process, and
@@ -366,13 +353,10 @@ const (
// boundary, not one provider's settings, so a second provider never needs a
// rename.
type EBSConfig struct {
// Provider is "viperblockd", "qemunbd" or "ocibsd" and may be left unset.
// Volumes are persisted in ebsmetadata.
// Provider is "viperblockd" or "qemunbd" and may be left unset. Volumes
// are persisted in ebsmetadata.
Provider string `json:"Provider" mapstructure:"provider"`
// OCI is the [nodes.<name>.ebs.oci] table, read only by ocibsd.
OCI EBSOCIConfig `json:"OCI" mapstructure:"oci"`
// DefaultThreads is nbdkit's -t: worker threads per NBD connection, which
// bounds how many requests are in flight inside viperblock for one volume.
// QEMU opens a single connection per volume, so this is the whole
@@ -386,27 +370,9 @@ type EBSConfig struct {
CacheSizeMB *int `json:"CacheSizeMB" mapstructure:"cache_size_mb"`
}
// EBSOCIConfig is where ocibsd keeps the cluster's volumes and how it
// authenticates. Auth, ConfigFile and ConfigProfile mean what oci_auth,
// oci_config_file and oci_config_profile mean on an oci external pool.
type EBSOCIConfig struct {
// CompartmentID and AvailabilityDomain are the same on every node: a
// volume is created there whichever node answers, and attached anywhere.
CompartmentID string `json:"CompartmentID" mapstructure:"compartment_id"`
AvailabilityDomain string `json:"AvailabilityDomain" mapstructure:"availability_domain"`
// ClusterID is generated once by spx admin init and tags every OCI
// resource ocibsd creates, so one compartment can hold several clusters.
ClusterID string `json:"ClusterID" mapstructure:"cluster_id"`
Auth string `json:"Auth" mapstructure:"auth"`
ConfigFile string `json:"ConfigFile" mapstructure:"config_file"`
ConfigProfile string `json:"ConfigProfile" mapstructure:"config_profile"`
}
func (c EBSOCIConfig) isZero() bool { return c == EBSOCIConfig{} }
// SupportedEBSProvider reports whether name is a provider a node may run.
func SupportedEBSProvider(name string) bool {
return name == EBSProviderViperblockd || name == EBSProviderQEMUNBD || name == EBSProviderOCIBSD
return name == EBSProviderViperblockd || name == EBSProviderQEMUNBD
}
// ResolvedProvider normalizes an empty Provider to EBSProviderViperblockd,
@@ -923,63 +889,6 @@ func validateSingleEBSProvider(cc *ClusterConfig) error {
return nil
}
// validateOCIAuth checks an OCI credential selection, where prefix is what
// the keys are named with ("oci_" on an external pool, "" in [ebs.oci]).
func validateOCIAuth(prefix, auth, configFile, configProfile string) error {
// Named explicitly rather than inferred, so a node that was meant to
// use its own identity and has no dynamic group fails at config load
// instead of falling back to a key file nobody installed.
switch auth {
case "", OCIAuthConfigFile, OCIAuthInstancePrincipal:
default:
return fmt.Errorf("%sauth must be %q or %q, got %q", prefix, OCIAuthConfigFile, OCIAuthInstancePrincipal, auth)
}
// Credentials come from the metadata service under instance principal,
// so a config file named beside it is one of two sources of truth and
// the one that would be silently ignored.
if auth == OCIAuthInstancePrincipal && (configFile != "" || configProfile != "") {
return fmt.Errorf("%[1]sconfig_file/%[1]sconfig_profile are not valid with %[1]sauth=\"instance_principal\" (the instance's own certificate is the credential)", prefix)
}
return nil
}
// validateEBSOCI checks every node's [ebs.oci] table: present and complete
// exactly when the node runs ocibsd, and naming one compartment and one AD
// across the cluster, since any node may create a volume another attaches.
func validateEBSOCI(cc *ClusterConfig) error {
var first string
var want EBSOCIConfig
for _, name := range slices.Sorted(maps.Keys(cc.Nodes)) {
ebs := cc.Nodes[name].EBS
if ebs.ResolvedProvider() != EBSProviderOCIBSD {
if !ebs.OCI.isZero() {
return fmt.Errorf("config: [nodes.%s.ebs.oci] is only valid with provider = %q", name, EBSProviderOCIBSD)
}
continue
}
oci := ebs.OCI
if oci.CompartmentID == "" || oci.AvailabilityDomain == "" {
return fmt.Errorf("config: [nodes.%s.ebs.oci] compartment_id and availability_domain are required with provider = %q", name, EBSProviderOCIBSD)
}
if err := validateOCIAuth("", oci.Auth, oci.ConfigFile, oci.ConfigProfile); err != nil {
return fmt.Errorf("config: [nodes.%s.ebs.oci] %w", name, err)
}
if first == "" {
first, want = name, oci
continue
}
if oci.ClusterID != want.ClusterID {
return fmt.Errorf("config: [nodes.%s.ebs.oci] cluster_id %q differs from [nodes.%s.ebs.oci] cluster_id %q; every node must carry the cluster's one ID",
name, oci.ClusterID, first, want.ClusterID)
}
if oci.CompartmentID != want.CompartmentID || oci.AvailabilityDomain != want.AvailabilityDomain {
return fmt.Errorf("config: [nodes.%s.ebs.oci] names compartment %q in %q but [nodes.%s.ebs.oci] names %q in %q; every node must name the same compartment and availability domain",
first, want.CompartmentID, want.AvailabilityDomain, name, oci.CompartmentID, oci.AvailabilityDomain)
}
}
return nil
}
func validateClusterConfig(cc *ClusterConfig) error {
if viper.IsSet("network.external_dhcp") {
return fmt.Errorf("config: [network] external_dhcp is no longer supported; remove the key (static WAN-pool allocation only)")
@@ -996,7 +905,7 @@ func validateClusterConfig(cc *ClusterConfig) error {
// ebsmetadata one-way without the operator ever asking for it.
return fmt.Errorf("config: [nodes.%s.ebs] provider=%q has been removed; set provider = %q or remove the key (this is a one-way switch: volumes move to ebsmetadata)", nodeName, EBSProviderEmbedded, EBSProviderViperblockd)
default:
return fmt.Errorf("config: [nodes.%s.ebs] provider=%q unsupported; use %q, %q or %q, or remove the key", nodeName, nodeCfg.EBS.Provider, EBSProviderViperblockd, EBSProviderQEMUNBD, EBSProviderOCIBSD)
return fmt.Errorf("config: [nodes.%s.ebs] provider=%q unsupported; use %q or %q, or remove the key", nodeName, nodeCfg.EBS.Provider, EBSProviderViperblockd, EBSProviderQEMUNBD)
}
// Range checks only. Both settings are deliberately unbounded above by
// anything host-aware: they are operator tunables, and the host cost is
@@ -1014,9 +923,6 @@ func validateClusterConfig(cc *ClusterConfig) error {
if err := validateSingleEBSProvider(cc); err != nil {
return err
}
if err := validateEBSOCI(cc); err != nil {
return err
}
if err := validateNorthstarCluster(cc); err != nil {
return err
@@ -1087,8 +993,19 @@ func validateClusterConfig(cc *ClusterConfig) error {
if (p.OCIVNICID == "") == (p.OCIVNICIface == "") {
return fmt.Errorf("config: [[network.external_pools]] %q: source=\"oci\" requires exactly one of oci_vnic_id or oci_vnic_iface", p.Name)
}
if err := validateOCIAuth("oci_", p.OCIAuth, p.OCIConfigFile, p.OCIConfigProfile); err != nil {
return fmt.Errorf("config: [[network.external_pools]] %q: %w", p.Name, err)
// Named explicitly rather than inferred, so a node that was meant to
// use its own identity and has no dynamic group fails at config load
// instead of falling back to a key file nobody installed.
switch p.OCIAuth {
case "", "config_file", "instance_principal":
default:
return fmt.Errorf("config: [[network.external_pools]] %q: oci_auth must be \"config_file\" or \"instance_principal\", got %q", p.Name, p.OCIAuth)
}
// Credentials come from the metadata service under instance principal,
// so a config file named beside it is one of two sources of truth and
// the one that would be silently ignored.
if p.OCIAuth == "instance_principal" && (p.OCIConfigFile != "" || p.OCIConfigProfile != "") {
return fmt.Errorf("config: [[network.external_pools]] %q: oci_config_file/oci_config_profile are not valid with oci_auth=\"instance_principal\" (the instance's own certificate is the credential)", p.Name)
}
if p.BindBridge != "" || p.DHCPMAC != "" {
return fmt.Errorf("config: [[network.external_pools]] %q: bind_bridge/dhcp_mac are only valid with source=\"dhcp\"", p.Name)

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