Compare commits

...

56 Commits

Author SHA1 Message Date
tomnewton-mulga a1331b3785 release: update go.mod for v1.14.0 2026-07-28 16:14:13 +10:00
Julian Sommer 341f36a432 chore: bump alpine image to 3.24.1 2026-07-28 11:05:50 +10:00
Julian Sommer 5800f39e42 fix(ec2): drain a snapshot's volume on the node hosting it (#589) 2026-07-27 15:40:29 +10:00
Julian Sommer 7e356b5b9b test: cover DescribeInstanceStatus and the SDK instance-status-ok waiter (#588) 2026-07-27 13:46:16 +10:00
Julian Sommer 331e46951f chore: rebuild frontend with new deps 2026-07-27 12:51:36 +10:00
dependabot[bot] 6ba9e11461 chore: bump the npm-dependencies group (#587) 2026-07-27 12:49:48 +10:00
dependabot[bot] 1d1b1f6a51 chore: bump the go-dependencies group with 3 updates (#586) 2026-07-27 12:48:17 +10:00
Julian Sommer 5b8f20b89f test: assert ModifyVolume resize reaches the guest block device (#585) 2026-07-27 12:47:42 +10:00
tomnewton-mulga 6389b37433 fix(gpu): Blackwell GPU support fixes for EKS/ECS image builds and VFIO (#559)
* fix(eks): GPU node AMI declares uefi boot mode

The spinifex-eks-node-gpu catalog entry declared BootMode: bios, but
the Ubuntu 26.04 GPU appliance image is built GPT/EFI-only. Under
legacy BIOS no OVMF/pflash firmware is attached, so the guest hangs at
firmware with zero serial console output.

* fix(images): install open-kernel NVIDIA DKMS driver for Blackwell GPUs

Blackwell-generation GPUs (e.g. RTX Pro 6000 Blackwell) refuse to
initialize under the closed/proprietary kernel module: dmesg shows
RmInitAdapter failing with "requires use of the NVIDIA open kernel
modules" (NVRM error 0x22). Switch from nvidia-dkms-<ver>-server to
nvidia-dkms-<ver>-server-open in both the shared ubuntu-gpu-nvidia
recipe and its verbatim duplicate in eks-node-gpu. nvidia-utils has no
separate open variant (userspace tools only), so it is unchanged.

* fix(eks): tolerate CP and GPU taints in ebs-csi-controller/lbc addons

A cluster with a GPU-only nodegroup has no untainted node: the CP
carries CriticalAddonsOnly (EKS parity keeps user workloads off it)
and every worker carries nvidia.com/gpu. Without tolerations for both,
ebs-csi-controller and aws-load-balancer-controller have nowhere to
schedule and stay Pending forever. Real EKS never needs this since its
control plane isn't a schedulable node, so the stock upstream
manifests carry no such toleration.

* fix(images): give the libguestfs appliance a working resolver

The guest's /etc/resolv.conf symlinks to systemd-resolved's stub
(127.0.0.53), which isn't running inside the appliance chroot, so DNS
is dead until package installs need it. Point it at a real resolver
before package installs, then restore the stub symlink afterward so
the shipped AMI still gets its DNS from systemd-resolved at boot like
a stock cloud image.

* fix(gpu): skip upstream PCIe bridge devices during VFIO bind/unbind

A GPU's IOMMU group can include an upstream PCIe bridge when the
platform lacks ACS isolation between them. vfio-pci refuses to bind
non-endpoint bridge devices, so Claim/ReclaimByAddress must filter
bridge-class (0x06) members out of the group before touching driver
bindings, rather than failing the whole claim or leaving the bridge
rebound incorrectly.

* fix(ecs): GPU node AMI declares uefi boot mode

The spinifex-ecs-node-gpu catalog entry declared BootMode: bios, but the
Ubuntu 26.04 GPU appliance image is built GPT/EFI-only. Under legacy BIOS
no OVMF/pflash firmware is attached, so the guest hangs at firmware with
zero serial console output.

Same defect and same fix as the eks-node-gpu entry: both images are built
by the same script from the same stock Ubuntu cloudimg, so both were
EFI-only and only one was corrected.

* fix(images): open-kernel NVIDIA module on ecs-agent-gpu, pin driver branch

ecs-agent-gpu still installed nvidia-dkms-<N>-server, the proprietary
kernel module. NVIDIA ships no proprietary module for Blackwell (GB202)
and later, so ECS GPU container-instances could not bind those cards on
any driver branch. It is the third verbatim copy of the routine and was
missed when the other two were fixed.

Also pins the driver branch across all three copies. The branch was
resolved at build time via apt-cache search | sort -rn | head -1, which
sorts on the number in the package name — but that number is a branch
alias, not a driver version (nvidia-dkms-535-server installs 580.126.20),
so the ordering key need not match what installs. Resolving also let
identical source produce different drivers on different days.

The pin lives in setup.sh rather than manifest.conf because manifest
variables are not passed into the guest: the setup script is uploaded and
executed by virt-customize --run, with no environment carried across.

* fix(eks): tolerate CP and GPU taints in argocd addon

All six argocd Deployments and its StatefulSet carried no tolerations. A
cluster with a GPU-only nodegroup has no untainted node to place them on:
the control plane carries CriticalAddonsOnly and every worker carries
nvidia.com/gpu, so the pods stay Pending forever.

Same defect the ebs-csi-controller and load-balancer-controller addons
already had. Real EKS never hits it because its control plane is not a
schedulable node, so the stock upstream manifests carry no such
toleration and the divergence is ours to maintain.

---------

Co-authored-by: joshsiv-mulga <mrk@mulgadc.com>
2026-07-27 12:11:18 +10:00
Julian Sommer 0708ac4816 chore: setup react-you-might-not-need-an-effect 2026-07-27 11:36:16 +10:00
Julian Sommer 9cec5a50c6 fix: pin isos to /latest so they stop getting pruned 2026-07-27 09:33:44 +10:00
Julian Sommer d96bc3c095 fix: bump grpc 2026-07-27 09:15:36 +10:00
Julian Sommer 04c7efde19 chore: lint config 2026-07-27 09:08:53 +10:00
Julian Sommer 95973eeb3c refactor: migrate spinifex off legacy nats.JetStreamContext onto nats.go/jetstream (#584) 2026-07-24 16:40:57 +10:00
Julian Sommer e79ca59d4b fix: configure host DNS from spx admin init/join (#582) 2026-07-24 10:02:11 +10:00
Josh Siviour 61319ba4a4 fix: terminate/detach volume-wedge — detach-before-delete, bounded detach, vanished-QEMU + stuck-terminate backstops (#583)
* fix: detach before delete on instance terminate to stop root-volume leak

TerminateStoppedInstance and the running-instance terminate path both
deleted a DeleteOnTermination root volume without first clearing its
attachment, so DeleteVolume's in-use guard rejected it and the error was
swallowed (stopped path) or only alarmed (running path), leaking the
volume permanently.

Add VolumeServiceImpl.DeleteVolumeOnTerminate, which clears the stale
attachment via UpdateVolumeState (terminate implies detach) before
deleting, and route both terminate paths through it. Stamp
Teardown[TeardownVolumes] on the stopped path so a delete failure is
visible to the volume leak reaper instead of being lost.

* fix: detach non-DeleteOnTermination root on terminate; cover teardown self-heal

A DeleteOnTermination=false root volume stayed attached to the terminated
instance on both terminate paths: Stop's Unmount deliberately skips Boot
volumes, so the root kept its attachment across stop and neither terminate
path detached it, stranding it attached to an instance that no longer
exists. Add VolumeServiceImpl.DetachVolumeOnTerminate (metadata-only
attachment clear, no delete) and route the DeleteOnTermination=false Boot
volume through it on both paths, matching AWS terminate-implies-detach.

Also add a regression test proving the TerminatedTeardownReaper re-drives a
failed volumes-teardown through the now-detach-first cleaner and self-heals
the leak, so a transient delete failure at terminate does not strand a
volume permanently.

* fix: bound detach chain and reconcile vanished-QEMU wedge

DetachVolume gains a bounded aggregate context deadline over the QMP
hot-unplug chain and a confirmed-dead-QEMU short-circuit that skips the
QMP steps (which would only hang on the wedged process) and seals
directly, so a detach can no longer pin attachMu forever when nbdkit or
QEMU die mid-flight.

OrphanQEMUReaper now also finalizes shutting-down instances whose QEMU
has vanished: a terminate that wedged downstream and never reached
finalizeTerminated is driven to terminated, with outstanding teardown
stamped failed so TerminatedTeardownReaper completes it idempotently.
Gated on a dead process, so a live in-progress terminate is never
stolen.

* feat: add delete-authorized stuck-terminate backstop

A terminate that wedges with QEMU still alive is invisible to the
OrphanQEMUReaper dead-QEMU reconcile and would sit shutting-down forever.
StuckTerminateReaper force-completes it: past stuckTerminateTimeout it
force-kills the wedged process, reclaims DeleteOnTermination volume space
through the cleaner, stamps remaining teardown for TerminatedTeardownReaper,
and drives the record to terminated.

It is the one reaper explicitly authorized to delete volume data, but only
for a DeleteOnTermination volume on an already-terminating instance — a far
narrower licence than ADR-0005 §3 denies VolumeLeakReaper, which stays
mark-and-alarm-only. A new ShuttingDownAt timestamp, stamped once on the
transition, bounds how long a terminate may wedge; a missing timestamp reads
as not-yet-stuck so the backstop never acts on an unbounded record.
2026-07-23 23:30:12 +10:00
joshsiv-mulga 6e13a9d9f4 test: add continuous single-open churn e2e for viperblock 503/GC
TestNPassOverwrite detaches after every pass, so viperblock's drain and GC
only ever run at reopen — it cannot show whether GC reclaims superseded
chunks while a volume stays open, and its serial write rate never nears the
predastore rate limiter's burst. TestContinuousChurn attaches once and drives
concurrent 4 MiB-aligned raw overwrites of per-writer regions for several
minutes without detaching, so a sustained superseded-chunk backlog builds
under one long open, spanning multiple GC ticks.

The workload is bounded and self-contained: each dd writes one chunk and the
clock is re-checked after every write, the loop runs as root under a single
sudo bash with a process-group kill trap, and it is wrapped in a guest-side
timeout. Root ownership is load-bearing — the writers open a raw device, so
only a root-owned process group can reap them; a best-effort guest pkill runs
before detach as a backstop so a stray writer cannot wedge the detach.
2026-07-23 14:49:01 +10:00
Julian Sommer 12085f029c feat: render import progress bars in human-readable units (#581) 2026-07-23 14:40:37 +10:00
Julian Sommer 4803469037 fix: e2e test assertion 2026-07-23 13:56:01 +10:00
joshsiv-mulga eae776b2f8 chore: bump viperblock to dev 8a2df56 (503-not-ErrNoSpace + latch logging) 2026-07-23 13:14:04 +10:00
Julian Sommer f50f69caff chore: fix viperblock logs to not log the data 2026-07-23 12:17:22 +10:00
Julian Sommer f682d09a72 fix: nil panics 2026-07-23 12:04:59 +10:00
Julian Sommer 1d7d5e1750 fix: disable debug log 2026-07-23 11:56:41 +10:00
joshsiv-mulga e26a480e54 chore: repin viperblock to dev with chunk GC on its own goroutine
Picks up viperblock 3dcfbb7 (#49): the chunk GC sweep now runs on a
dedicated goroutine instead of sharing the chunk-uploader select, so
sustained overwrite churn no longer starves GC and wedges the volume at
the internal space cap.
2026-07-23 11:41:18 +10:00
Julian Sommer b505887b36 chore: add nilaway 2026-07-23 11:28:02 +10:00
Julian Sommer 1d55404a73 revert: host DNS setup on resolved/resolvconf hosts (#580)
This reverts commit f9c9f0b38d.

#580 switched setup.sh to read the northstar :53 listener and zones from
northstar.toml, so the resolved stage now defers at install time (before
formation) and must be re-run afterward. Only firstboot re-runs it; the
manual setup.sh + spx admin init install and CI never do, so those hosts
get no host DNS and cluster-zone names (ELB/EKS) fail to resolve from the node.

Reverting restores the pre-#580 behaviour: systemd-resolved is configured
at install time from the WAN IP, with no post-formation re-run needed. This
reintroduces the original gap where resolvconf hosts get no host DNS. The
proper fix configures host DNS from spx admin init/join for both resolver
managers (mulga-x30yv).
2026-07-23 11:23:21 +10:00
Julian Sommer 5001a04b6a chore: lint config 2026-07-23 10:50:18 +10:00
Julian Sommer f595129c7a chore: enable SA1019 2026-07-23 10:26:03 +10:00
Julian Sommer a36c6d819e chore: bump image versions 2026-07-23 10:19:18 +10:00
Josh Siviour 528bb1e483 fix(lbagent): quiet expected startup heartbeat failures below ERROR (#579)
A freshly launched LB agent begins heartbeating the instant the VM boots,
but the gateway node fielding that first heartbeat may not yet have the LB
record — it is still replicating from the node that created it — so the
gateway answers 400 LoadBalancerNotFound. The IMDS credential chain is
likewise not always warm on the first tick. Both self-heal within a few
ticks, yet every early failure was logged at ERROR, flooding the console
telemetry with startup noise (46 such lines across 8 agents in one run).

Bound these to a registration grace window: before any heartbeat has ever
succeeded, and only within the window, a failure logs at INFO. Past the
window with no success — the fingerprint of a genuinely blackholed agent —
or after any prior success, a failure logs at ERROR as before, so the
serial-console telemetry still surfaces a real blackhole. The only change
for a persistent failure is a bounded delay before it escalates.
2026-07-23 10:07:06 +10:00
Josh Siviour 8989fc9ba8 fix: make the seal-failure unmount test deterministic (#578)
* fix: make the seal-failure unmount test deterministic

The test forced the seal to fail by pointing S3Host at an unreachable
endpoint, so the handler only returned once the S3 client had exhausted
its jittered backoff. Measured handler time spanned 1.8s to 5.5s against
a 5s request deadline, which failed 2-3 runs in 10 on an idle machine and
worse on a loaded CI runner.

Config gains a sealVolume seam, nil meaning the real seal, and the test
injects an immediate failure instead. It now runs in 0.53s, 50 for 50,
and still asserts what it was written to assert -- plus that the handler
actually attempted the seal, which the old form could not distinguish.

* chore: bump viperblock to dev tip (GC observability + scratch sweep)

Picks up viperblock #47 (every chunk GC sweep logs its outcome at Info)
and #48 (TestMain reclaims scratch dirs abandoned by a panicking run).
2026-07-23 10:02:26 +10:00
Julian Sommer a55544b7d4 chore: remove integration from e2e 2026-07-23 09:49:51 +10:00
Julian Sommer f9c9f0b38d fix: configure host DNS for Spinifex zones on resolved and resolvconf hosts (#580) 2026-07-23 09:43:38 +10:00
joshsiv-mulga d6b356b658 Stop GPU E2E on push and merge 2026-07-23 09:14:20 +10:00
joshsiv-mulga e0259b7638 update preflight 2026-07-23 08:03:08 +10:00
Josh Siviour 88b57b1805 chore: give the integration tier its own CI job and drop it from preflight (#577)
The tier ran inside the Build and Test job, serialised ahead of test-cover, and
again in preflight. Neither placement bought signal that a parallel job does not:
it provisions nothing and finishes in seconds, so the only effect was wall time
on the two paths developers wait for most.

It stays PR-blocking as its own job, which is what distinguishes it from
e2e.yml's front gate — that one is push-triggered on a self-hosted runner and so
never gates a PR before merge.
2026-07-23 07:56:07 +10:00
Josh Siviour 318e43c66f fix: allocate mgmt IPs cluster-wide via NATS KV CAS (#576)
MgmtIPAllocator allocated from a process-local map, but br-mgmt is
trunked onto a shared physical NIC making the /24 one flat L2 segment
across the whole cluster. Every node independently allocated from
.10, causing collisions and ARP-race losers being blackholed.

Rewrite the allocator on the spinifex-cluster-state NATS KV bucket
using compare-and-swap, keyed by mgmt-ipam.<subnet>, modeled on IPAM.
Bind the KV handle in startCluster (not startLocal) to respect the
DDIL boundary. Serve already-allocated instance IDs from the local
cache when KV is unhealthy, but refuse new allocations until it
recovers. Rebuild now reconciles this node's running VMs into the
cluster-wide record instead of just refreshing the local cache.
2026-07-22 22:27:07 +10:00
Josh Siviour 7e8cbeef6e feat(config): enable viperblock chunk GC in rendered node configs (#563)
* feat(config): enable viperblock chunk GC in rendered node configs

The gc_enabled toggle was plumbed through every VB construction site but
nothing ever set it. GCEnabled is a *bool that every consumer treats as
off when nil, and the config template rendered shardwal while omitting
gc_enabled, so no deployed node ever carried the key and chunk GC was off
everywhere by construction. Superseded chunks were never reclaimed under
overwrite churn.

Render gc_enabled = true alongside shardwal, and cover it with a test that
loads the rendered file through config.LoadConfig rather than matching the
template text. A string match would still pass if the value failed to parse
into a non-nil pointer, which is the exact way the setting reaches its
consumers.

Existing deployments keep the old rendering until their config is
regenerated; the template governs install and bootstrap only.

* chore: pin viperblock to the build with the cross-process GC snapshot marker

Enabling chunk GC in rendered node configs is only safe once the sweeping
engine can see a snapshot taken by another process. viperblock 3b56d61 adds the
per-volume snapshot marker that closes that window, and 2a34add fixes the
concurrent-stop panic in the background goroutine lifecycle that GC's own ticker
would otherwise widen. Pin both before turning GC on.
2026-07-22 22:12:13 +10:00
Josh Siviour d2efbee043 fix(daemon): fail the system-instance launch when the VM never reaches running (#574)
LaunchRunInstances is a best-effort batch launcher that reports failure only
through VM state: volume preparation, GPU claim and vmMgr.Run errors each call
MarkFailed and continue. launchAMISystemInstance ignored that and always
returned a SystemInstanceOutput, so an EKS control-plane server, ELB or Bedrock
serving VM whose launch failed handed its caller an instance ID for a VM that
was already being torn down.

Check the instance reached running state before returning. Cleanup stays with
the MarkFailed chain LaunchRunInstances has already run.
2026-07-22 21:08:08 +10:00
Josh Siviour 344a490a70 fix(vm): give relaunched data volumes named block nodes so detach can complete (#575)
buildDrives emitted every non-boot data volume as a bare -drive with no
id= and no node-name=, so a stop/start (or reboot/resize) relaunch left
the volume anonymous: device_del/blockdev-del had nothing to address,
QEMU kept the NBD socket open, and DetachVolume stalled through
nbdkit's full kill grace before giving up.

Add -blockdev support to the QEMU config builder and use it for
non-boot, non-EFI volumes, sharing the node-name/device-id/iothread-id
naming and virtio-blk-pci device arguments with AttachVolume's hot-plug
path so cold-boot and hot-attach can no longer drift apart. A data
volume with no persisted HotplugPort now gets one allocated and written
back during buildDrives instead of landing on an unplugged bus.

Also raise unmountSealTimeout past viperblockd's kill grace (it runs
the seal after the SIGKILL, so matching the grace meant an unmount
that used the whole grace could never be observed as successful), and
bound the e2e harness's forced teardown detach by the teardown
deadline so a slow detach is reported as slow rather than as an
unexplained leak.
2026-07-22 20:38:44 +10:00
Josh Siviour 62e7f983ee test: assert the gateway->vpcd->OVN crossing in the integration tier (#572)
* test: assert the gateway->vpcd->OVN crossing in the integration tier

The tier already ran the real VPCServiceImpl for the AWS-facing half, but
nothing consumed the vpc.* events it published: the SG topics were answered
by a canned {"success":true} and the fire-and-forget topology topics were
dropped. Both sides of that wire were tested only against stubs, so the join
was asserted nowhere below the live e2e tier.

Wire a real subscribers.Subscriber over a real in-process OVN Northbound DB
(ovntest.StartNB), opt-in per test so only tests that assert OVN state pay
the startup cost. StartDaemonLite gains WithRealVPCD to suppress its stubs,
since a stub and the subscriber both answering one request-reply would race.

Scope is the crossing, not NB row shapes — those stay with the reconcile
scenario tests. Everything host-dependent (ovn-controller, OVS, taps, netns,
nftables, packet paths) remains the live tier's claim.

* fix: make the integration tier re-runnable and quiet the ensure probe

Two defects the vpcd/OVN work surfaced.

The tier was green at -count=1 and red package-wide at -count=2. Test
accounts were keyed by t.Name(), which is unique among tests but repeats
across passes in one TestMain, so a second pass inherited the first pass's
JetStream namespace — including an ECR signing key sealed with a master key
StartGateway regenerates per call. Key each account off a counter instead.
TestRunInstances_ClientTokenIdempotency then collided with itself for the
same reason: its dedup store outlives a pass, so its fixed ClientToken made
the second pass's opening launch look like a replay. Suffix the tokens per
invocation.

ensureNamedRowOps' wait-op is built to fail fast when the row it guards
already exists, which is the ordinary idempotent path, but transactOps
reported it as "OVSDB operation failed" at ERROR. Every re-ensure emitted
one, drowning real faults in the log sink. Classify that one result as the
probe outcome it is and log it at Debug.

* test: cover transactOps' failed-result paths

The classifier split one log line into two branches and neither was
exercised: transactOps sat at 31% because EnsureLogicalRouter's cache
lookup absorbs a re-ensure before it ever transacts, so no unit test
reached a failed operation result.

Drive both kinds directly against a real NB — the guard probe finding an
existing row, and two same-named inserts violating the schema's name
index — since only the second is a fault and they now log at different
levels. transactOps goes to 94%; the remainder is the transport-error
return, which needs a broken connection to reach.
2026-07-22 18:17:24 +10:00
Josh Siviour 68abc0bfb3 fix(ci): scope e2e telemetry per cell/job and define suite topology eligibility once (#573)
* feat(ci): extend per-cell telemetry scoping to every e2e workflow

ci.cell only reached e2e-nightly and e2e-suites, so a run of e2e, e2e-ddil
or gpu-e2e still landed in the sink as one undifferentiated bucket. Stamp it
on the remaining workflows: single/multi on e2e's two provisioning jobs, ddil
and gpu on their single-job workflows.

The best-effort numeric job id lookup moves out of e2e-nightly into a shared
composite action, so the four other provisioning jobs get ci.job_id without
copying the gh/jq incantation. The lookup now matches on env.JOB_NAME rather
than interpolating the name into the jq filter, which broke on names carrying
quotes or non-ASCII separators, and no-ops when telemetry is disabled.

gpu-e2e gets ci.cell but deliberately no job id lookup: nothing in that job
bakes the attrs into a daemon, so the API call would be paid for an env var
no exporter reads.

* fix(ci): define e2e suite topology eligibility once

Three workflows carried three different answers to which suites a topology
can run. e2e-suites and e2e-nightly ran lb on their single-node leg, where
every internet-facing subtest skips for want of a peer, so a fraction of the
suite reported as a green lb run. e2e-suites' multi leg ran multinode alone,
dropping the lb and cert coverage that only a multi-node env can complete —
so full lb coverage never ran there at all.

.github/scripts/suite-sets.sh is now the single definition, sourced by all
three. e2e.yml's two allow-list regexes derive from it, e2e-suites derives
both matrices from it (multi_provision gains the suite-matrix step
single_provision already had), and e2e-nightly's deliberately narrower
per-cell lists are declared there too, guarded by an assert that fails the
cell if a subset ever names a suite its topology cannot fully exercise.

The nightly stays narrow on purpose: its cells have a 35-minute budget and
exist to prove each install/network/host-OS permutation boots and serves.
Suite breadth is the PR gate's job and already runs on every push. That is
now stated in one place rather than implied by a third divergent list.
2026-07-22 17:18:55 +10:00
Josh Siviour cae76ba1b6 test: complete the e2e live-vs-mock rebalance (epic mulga-8oyg3) (#566)
* test(volume): assert chunk GC stays wired on in CreateVolume

GCEnabled was zero-valued at every viperblock construction site for a long
time and no test noticed. It is not part of viperblock.VBState, so it never
round-trips through a saved config.json and cannot be observed by reading
volume state back.

Extract the viperblock.VB assembly out of CreateVolume into buildVBConfig so
the GC wiring is assertable directly against the returned config, without a
live viperblock or S3 backend. The config is returned by pointer because
viperblock.VB carries atomic.Uint64 fields that vet's copylocks check
rightly refuses to see copied.

* test(e2e): snapshot backend byte accounting before volume purge

DeleteVolume purges a volume's entire backend prefix, including any chunks a
leak left behind, before anything measures it. That is the same masking that
hid the storage-growth leak in production: a passing E2E cannot fail on
backend growth when the evidence is deleted first.

Snapshot the volume's backend chunk footprint in EnsureVolume's cleanup
closure, immediately before DeleteVolume, so every volume the shared teardown
path purges is measured rather than silently discarded. The snapshot is
always taken and logged; the hard ratio check additionally runs for callers
that registered a guest-written-bytes baseline, since most callers never
attach or write to the volume they create and so have no baseline to compare
against.

The tolerance is the RS(2,1) erasure floor widened for per-chunk header and
tail-chunk overhead. It is sized to distinguish unbounded growth from a
footprint settling at the floor, not to catch bounded residual garbage.

The check is non-fatal inside the cleanup callback: t.Fatalf's runtime.Goexit
would abort the DeleteVolume call queued behind it and turn a reported leak
into a real one.

* test(e2e): add memory-constrained concurrent-write durability regression

The existing durability tests write one 4MiB sentinel, sequentially, on a
comfortably sized guest, which is the exact inverse of the known-open
data-loss bug: acked writes read back zero-filled after a cold reread when a
guest is memory-starved during concurrent writeback. No happy-path test can
reach it, because the fault lives in the intersection of memory pressure,
write concurrency and cache-cold reread.

Add a suite that encodes those three conditions together: a guest discovered
at or below the working set, N parallel large writers launched as background
jobs in a single command so they genuinely overlap, swap disabled, then sync
and drop_caches before rereading. Assertions are per stream rather than
aggregate so a failure names the losing writer instead of leaving whoever
reads it to re-derive which of N writers lost data from one combined digest.

GuestExec gains a timeout-parameterised variant. Its fixed two-minute default
is sized for sub-second payloads, and a multi-gigabyte write to a
network-backed volume would exceed it, so the suite would have failed by
timeout while looking like it had caught the corruption. The budget scales
with the working set, and a deadline is reported distinctly from a data
mismatch so the two red states are never confused.

The suite is dispatchable but deliberately absent from the default SUITES:
the bug it reproduces is still open, so it is expected to fail and must not
sit in a merge-gating path. It is registered in the workflow allow-list so it
stays discoverable rather than being buried behind a skip.

Also record on the live IAM instance-profile test why its assertions were not
moved down to the integration tier, so the trace is not repeated.

* test(e2e): drop backend byte accounting, assert a node-wide delta instead

The accounting helper listed the internal predastore bucket to attribute
bytes per volume. That listing is refused unconditionally: predastore
authorises bucket access by same-account ownership, and the bucket belongs
to the service account, so a test caller gets 403 no matter what it holds.
Granting the test a service credential would invert the trust boundary to
buy a measurement, which is the wrong trade.

Replace it with what the S3 surface actually permits: sample node-wide
allocated bytes either side of the write and bound the delta against the
bytes the guest wrote. Coarser, but it measures something real rather than
depending on a call that cannot succeed.

The tolerance moves into the suite that uses it. It encodes RS(2,1)'s 1.5x
expansion floor plus headroom; a cluster running the recommended RS(3,2)
has a 1.667x floor and only ~12% of margin left, so the constant needs
revisiting if the encoding changes.

* revert: drop memdurability e2e suite

The suite asserted the guest read back what it wrote after a
memory-constrained concurrent write, but the assertion could not fail for
the reason it claimed: it compared the guest's own read against the
guest's own write with the page cache still warm, so it passed whether or
not the data reached the backend. A test that is green on a build with a
known-open durability bug is worse than no test — it advertises coverage
that does not exist.

Removes the suite and its Makefile/workflow wiring, plus
DiscoverInstanceTypeAtLeastMemory, which had no other caller.

GuestExecTimeout stays: GuestExec now delegates to it, and the old
two-minute hard cap it replaced was a latent trap for any long guest
command.

* test(e2e): add partial-block concurrent-write durability suite

Replaces the removed memdurability suite, which passed on a build that
still carried the bug it claimed to test.

The engine reaches its read-modify-write path only for a partial block;
a fully covered block takes a full-overwrite branch that reads nothing
and can lose nothing. The old workload wrote through ext4 with 1 MiB
buffers, which the filesystem coalesced into aligned full-block
requests: block-layer tracing recorded zero unaligned and zero sub-4096
requests, so the path under test never ran, and writers 4 MiB apart
could not collide on a block even in principle.

This suite writes the shape the defect needs. Two writers issue
concurrent O_DIRECT writes into disjoint halves of the same 4096-byte
blocks on a raw, unformatted device, stamping every half with the round
number, then drop the page cache and reread with O_DIRECT asserting
every half carries the final round. One half reading back at an earlier
round is the signature of the lost update.

The old suite also could not fail even had it reached the path: its
write phase ended in 'wait; sync; sha256sum' and reported sha256sum's
exit status, while bare wait returns 0 unconditionally, so writer errors
were invisible and a backend ENOSPC presented as a pass. Here each
writer's exit status and exact byte count are asserted before any
durability claim is made.

Working set is 8 MiB rather than 2048 MiB, and the volume is deleted on
teardown; the predecessor left roughly 3 GiB of backend per run and
twice drove a node under the nearfull watermark.

Dispatchable but not in the default suite list until a green run and a
demonstrated fail-first against a pre-fix engine exist.

* test(e2e): let the fixture own the working-set volume delete

The suite deleted the volume in its own cleanup and the fixture deleted
it again on close, so every run ended with an InvalidVolume.NotFound
from the second attempt. Detach stays — DeleteVolume against a still
attached volume is what leaks volumes on teardown — and the delete is
left to the fixture so the volume is released exactly once.

* test(e2e): gate merges on the partial-block durability suite

Verified on one node with the same test binary and workload, varying
only the engine: 1758 of 8192 halves reverted a generation against the
pre-fix build, 0 of 8192 against the fixed one. That is the fail-first
evidence the suite needed before it could be trusted to gate anything.

Adding it to the default suite list rather than leaving it dispatchable
is the point. Its predecessor sat outside every path that runs, which
is how it survived long enough to be discovered green on a build that
still lost data.

* test(integration): cover the ECR data plane in the front gate

The live ecr/ suite drove docker, crane and skopeo against a real
registry, yet none of it boots a guest: it needs a registry, an object
store and an HTTP client. The in-process gateway already serves the OCI
Distribution v2 surface on an ephemeral port with a memory-backed object
store, so these run here in 0.18s instead of behind full provisioning.

Covers the three claims the live tests made beyond authorization, which
is already down here: a push/pull round trip asserting blobs and
manifests read back byte-identical, refusal to auto-create a repository
on push, and retagging an existing manifest without disturbing the
original tag.

Content is compared rather than status codes alone. A registry that
stores a blob under the wrong digest, truncates it, or serves a
different one answers 200 to every request in the sequence.

The capacity/load leg deliberately stays live: it exists to put real
parallel pressure on a real predastore, and a memory-backed object store
cannot make that claim.

* fix(e2e): make volume teardown release the volume before deleting it

Fixture cleanups drain LIFO, so a suite that creates its instance before its
volume tears the volume down first, while it is still attached. DeleteVolume
answered VolumeInUse, nothing retried, and the volume was stranded on the node.

Reordering the chain cannot fix this — registration order has no way to express
the instance/volume dependency. Instead teardown is now self-sufficient for any
creation order: it force-detaches, waits for the volume to reach available, and
only then deletes. Instance teardown waits for terminated for the same reason,
since TerminateInstances returns as soon as the request is accepted.

Two things kept the leak invisible. A failing cleanup was logged and forgotten,
so a run stayed green with resources left behind; failures are now reported as
leaks, failing the bound test or surfacing through Close() for TestMain to fail
the run. And a panic in one cleanup aborted the whole chain, stranding every
resource behind it; each callback is now isolated.

RegisterVolumeTeardown and the fixture had grown separate teardown paths, only
one of which detached. Both now share it.
2026-07-22 17:02:20 +10:00
Julian Sommer c054b0213e fix(ec2): render ED25519 key fingerprints as EC2 does (#571) 2026-07-22 16:42:38 +10:00
Julian Sommer a3885db916 fix(ec2): fingerprint key pairs over the encodings AWS hashes (#570) 2026-07-22 16:14:39 +10:00
Julian Sommer 369c237f01 refactor(gateway): parse AWS query bodies with url.ParseQuery (#569) 2026-07-22 15:41:09 +10:00
Julian Sommer 2119981924 refactor(ec2): use x/crypto/ssh for key parsing and fingerprints (#567) 2026-07-22 15:14:08 +10:00
Josh Siviour 5fd74a8212 fix(eks): don't baseline a nodegroup's Ready-gate on its prior incarnation (#568)
Re-creating a nodegroup of the same name could never reach ACTIVE. The
Ready-gate took its baseline from the live Ready count for that name, so
a terraform replace after CREATE_FAILED — which deletes then re-creates
— captured the previous incarnation's workers, demanded old+new, then
watched the count fall as the old workers were terminated. The target
was unreachable by construction.

Observed on env19 with a perfectly healthy cluster: three new workers
all joined and the create still failed with

  reports 3 Ready nodes, want >= 5 (baseline 2 + 3 workers)

and each retry raised the bar again (baseline 3 => want 6), so the only
way out was untainting the resource by hand.

A create means every worker under that name is new, so the gate is just
"desired Ready nodes carrying this nodegroup label" — baseline 0. The
trade-off is stated in the comment: a stale incarnation's Ready nodes
can briefly inflate the count and satisfy the gate early, but they go
NotReady within about a minute of termination, whereas baselining on
them fails every replacement permanently.

The test models the real sequence — delete, stale Ready count still
reported, re-create — and fails without the fix with the same shape as
the field error.
2026-07-22 15:08:51 +10:00
Josh Siviour 291e550e8a fix(dhcp): honour the caller's retransmission budget and encode option 61 correctly (#565)
* fix(dhcp): derive nclient4 read deadline from the caller's context

nclient4 races its own read deadline against the caller's context and the
shorter one ends the attempt. Acquire and Renew passed a fixed 5s, so every
window in Manager's 4s/8s/16s/32s retransmission schedule past the first was
truncated to 5s: the client listened for ~19s of its 90s budget and a server
that went quiet for longer was never waited out.

Measured in the sink, the split was total — attempt 1 (jittered under 5s)
always failed with the caller's context deadline, attempts 2 to 4 always
failed with nclient4's ErrNoResponse — and only 27% of clients that lost
attempt 1 were rescued by attempt 2.

Track the context's remaining time instead, keeping the configured value as
the fallback its doc comment already describes.

* fix(dhcp): keep the socket deadline strictly behind the caller's

Setting the nclient4 read deadline to exactly the context's remaining time
makes the two expire together, so a timed-out attempt reports either
context.DeadlineExceeded or nclient4's ErrNoResponse depending on which
goroutine wins. Observed live on a settled bridge: three attempts reported the
context deadline and the fourth reported ErrNoResponse, which reads as a
packet-matching fault rather than the plain timeout it is.

Give the socket a second of grace so ctx.Done() is always what fires.

* fix(dhcp): encode option 61 with its hardware-type byte

The client identifier was sent as a bare string, so the server read the
identifier's first character as the hardware type — "dhcp-gw-lrp-vpc-x"
arrived as type 100 ('d') with the identifier truncated. Since that is not
Ethernet, the server recorded no hardware address, and the upstream lease
table listed our leases with a MAC of all zeroes.

Send the chaddr typed as Ethernet, falling back to a type-0 identifier when
no hardware address is available. RELEASE now encodes identically, since the
server matches a release to a lease by client identifier.

Option 61 no longer carries the readable id, so restore it via hostname and
vendor class, which were specified but never populated. dhcptest builds its
options through the same helper rather than its own copy, which had drifted
into reproducing the broken encoding.

* fix(dhcptest): derive the probe MAC the way vpcd does

The probe carried its own copy of the MAC derivation, still on the 24-bit
02:00:00:xx:xx:xx hash that vpcd moved off when widening it to a SHA-256
prefix. So --spinifex-id leased under a MAC the daemon would never use:
"gateway-wan1" resolved to 02:00:00:7c:b7:b6 here and 02:54:df:71:22:5c in
vpcd, which makes the probe unable to reproduce a daemon lease at all.

Call DeriveMAC instead and drop the local copy.
2026-07-22 14:19:16 +10:00
Julian Sommer 826a441beb chore: use stdlib sort + ip conversion 2026-07-22 14:15:41 +10:00
Josh Siviour 2e7136c893 feat(ci): stamp per-cell scoping labels on e2e telemetry (#564)
The nightly matrix runs every cell under a single ci.run_id, and each cell
names its hosts node1/node2/node3, so telemetry from all cells collapses into
one undifferentiated bucket in the ES sink. Root-causing a single failed cell
means aggregating across every other cell in the run.

Stamp ci.cell and ci.matrix alongside the existing ci.run_id. Workflow-level
env cannot reference the matrix context, so the attrs are overridden in each
matrixed job's own env block, leaving the workflow-level value as the
non-matrix fallback.

The numeric GitHub job id is not exposed in any context (github.job is the
YAML key, not the id), so resolve it best-effort via the jobs API and append
it if found. The lookup never fails the job.

e2e-suites can only distinguish single from multi: daemon telemetry is baked
into the VM at provision time, before the suite matrix exists.

Baremetal shipped no telemetry at all — neither the endpoint nor the attrs
crossed the workflow_call boundary. Pass them through as an explicit input so
the cell reaches the sink for the first time.
2026-07-22 14:08:52 +10:00
Josh Siviour 4793b5ac09 fix(eks): node route MTU blackholing image pulls, and teardown wedged in DELETING (#562)
* fix(eks-node): stop the DHCP MTU overriding the pinned link MTU

Pinning eth0 to 1320 was never enough. This image runs dhcpcd, which
honours DHCP option 26 and stamps the advertised MTU onto every route it
installs:

  default via 10.32.101.1 dev eth0 proto dhcp src 10.32.101.7 \
    metric 1002 mtu 1442

Linux prefers a route's MTU metric over the device MTU, so the node kept
advertising an MSS derived from 1442 while the real path MTU through the
NAT gateway is 1408. Small packets pass and full-MSS inbound segments —
a TLS ServerHello with its certificate chain, image layers — are
silently dropped. It presents as `net/http: TLS handshake timeout` on
image pulls, not as anything MTU-shaped, and it only affects the host
netns, so containerd pulls fail while pod traffic sized off cni0 is
fine. Per-destination PMTU exceptions expiring in and out of the route
cache made it look intermittent and host-specific.

Three changes, innermost first:

  - dhcpcd.conf gets `nooption interface_mtu`, so no lease can stamp a
    route MTU. mulga-vpc-mtu is the single source of truth for the MTU
    of these nodes.
  - mulga-vpc-mtu re-stamps any route that already carries a different
    MTU, covering an image built before this fix or a dhcpcd already
    running. Routes with no MTU metric inherit the device MTU and are
    left alone.
  - the subnet DHCP option drops 1442 to 1408. 1442 is geneve-only and
    ignores the IPsec ESP overhead on the NAT egress path, so it
    overstates the usable MTU for every guest, not just these nodes.

Also corrects the header comment, which claimed Alpine's udhcpc ignores
option 26 — this image runs dhcpcd, which honours it. That wrong premise
is why the route metric was never considered.

Adds a stubbed-`ip` test covering the rewrite, the dev token that
`ip route show dev X` omits, idempotency, and routes without an MTU
metric.

* fix(eks): sweep the CP VPC by identity so teardown can finish

DeleteClusterCPVPC finds the managed control-plane VPC's subnets and SGs
by role tag. A create that fails partway — the first control-plane
server failing to launch, say — leaves resources behind that never got
tagged, so the sweep cannot see them.

DeleteVpc rejects while any subnet or non-default SG remains, and
teardown is re-driven on a timer, so the cluster sits in DELETING
forever retrying the same doomed delete every couple of minutes:

  eks-deleting: re-driving wedged DELETING teardown cluster=toc
  eks-deleting: re-drive teardown failed
    err="delete managed CP VPC: eks: delete cp vpc vpc-...: DependencyViolation"

Nothing converges: the cluster name cannot be reused and terraform's
delete wait can never pass. Observed on env19, wedged for ~11 hours
until this change went in, at which point the next re-drive cleared it.

After the tagged sweep, clear anything still in the VPC scoped by
vpc-id: subnets, and non-default SGs (the default SG goes with the VPC
cascade). Teardown had no SG surface, so CPVPCDeps gains an optional SG
provisioner — nil is tolerated, since only teardown uses it.

* chore: pin viperblock + predastore to the merged storage fixes

viperblock f8cfd77 (#42) makes read-modify-write atomic per block,
removing the lost update that silently reverted a 4096-byte block to a
previous generation of itself. Confirmed as the env19 corruption cause
by an A/B: the pre-fix build corrupted image layers within ~15 minutes
(failed to extract layer / invalid tar header / unpigz: corrupted) while
the fixed build ran 5+ hours clean, absorbing 6407 same-block RMW
conflicts on the worker root disks.

predastore c0a90d2 (#63) batches FSM.Restore. Without it, any predastore
restart on a store whose metadata set outgrew one badger transaction
fails with "Txn is too big to fit into one request" on every node at
once, taking the metadata plane — and with it the AMI catalogue and
cluster creation — down permanently.

Both were verified in the built artifact rather than from go.mod alone:
go version -m reports the new pseudo-versions, and the rmw_conflicts
counter plus the batched-restore error strings are present in the
binary. Pinning by go.mod while the submodule pointer says otherwise is
how an earlier analysis ended up reading dead code.

* test: record the corrected DHCP MTU in the kitchen-sink golden

The NB golden pins the DHCP options published per subnet, including the
advertised MTU. Dropping the subnet option from 1442 to 1408 changes
what the reconciler writes, so the golden diverged and the live scenario
test failed — the test doing exactly its job.

Regenerated with UPDATE_GOLDEN=1. The diff is only mtu:1442 -> mtu:1408
on the two subnet DHCP records; nothing else in the snapshot moves.
2026-07-22 12:57:36 +10:00
Julian Sommer 3e0aab174a refactor: migrate instance describe handlers (#561) 2026-07-22 12:34:43 +10:00
Julian Sommer 10ce0fba8f fix: resolve wrapped AWS error codes (#560) 2026-07-22 11:36:59 +10:00
Julian Sommer 77c0e337a6 fix: return Placement and Spot lineage for stopped and terminated instances (#558) 2026-07-22 10:58:47 +10:00
Julian Sommer 4274184ba4 refactor(daemon): collapse one-line NATS handler wrappers into the subscription table (#557) 2026-07-22 10:21:17 +10:00
540 changed files with 18452 additions and 10464 deletions
+52
View File
@@ -0,0 +1,52 @@
name: Resolve OTEL job id
description: >-
Append ci.job_id to SPINIFEX_OTEL_ATTRS so telemetry emitted by this job can
be pivoted straight back to its GitHub job log. Must run before the step that
bakes SPINIFEX_OTEL_ATTRS into a VM's telemetry.env, and requires
`actions: read` on the calling job.
inputs:
job-name:
description: >-
Rendered name of the calling job, exactly as it appears in the Actions UI.
github.job is only the YAML key and no context exposes the numeric id, so
the id has to be looked up by name.
required: true
token:
description: Token used for the Actions jobs API lookup.
required: false
default: ${{ github.token }}
runs:
using: composite
steps:
- name: Resolve CI job id (best-effort)
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
JOB_NAME: ${{ inputs.job-name }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: |
# Never fail the caller: on any lookup error or name mismatch ci.job_id
# is simply omitted and the run proceeds with the labels it already has.
set +e
if [ -z "${SPINIFEX_OTEL_ATTRS:-}" ]; then
echo "SPINIFEX_OTEL_ATTRS is unset or empty — telemetry is disabled, nothing to label"
exit 0
fi
# env.JOB_NAME rather than shell interpolation into the jq filter: job
# names carry quotes, slashes and non-ASCII separators that would
# otherwise break the expression.
JOB_ID=$(gh api "repos/${REPO}/actions/runs/${RUN_ID}/jobs" --paginate \
--jq '.jobs[] | select(.name == env.JOB_NAME) | .id' 2>/dev/null | head -n1)
if [ -n "$JOB_ID" ]; then
echo "SPINIFEX_OTEL_ATTRS=${SPINIFEX_OTEL_ATTRS},ci.job_id=${JOB_ID}" >> "$GITHUB_ENV"
echo "resolved ci.job_id=${JOB_ID} for job '${JOB_NAME}'"
else
echo "::warning::could not resolve numeric job id for '${JOB_NAME}' — continuing without ci.job_id"
fi
exit 0
+58
View File
@@ -0,0 +1,58 @@
# shellcheck shell=bash
# shellcheck disable=SC2034 # every variable here is consumed by the sourcing workflow
# Source from a workflow `run:` block:
# . "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
#
# Which e2e suites each topology can run. Sourced by every workflow that drives
# suites (e2e.yml, e2e-suites.yml, e2e-nightly.yml) so the lists cannot drift
# apart — they previously disagreed three ways.
#
# Eligibility is a property of the suite, not of the workflow: a suite belongs
# to a topology when all of its assertions are reachable there. lb is
# multi-only for exactly that reason — on a single node every internet-facing
# subtest skips for want of a peer, so a single-node lb run reports green
# having exercised a fraction of the suite.
# Suites runnable against a one-node environment.
#
# partialblock gates merges because it earned it, not because it is new: on one
# node, same binary and workload, it fails against the pre-fix engine (1758 of
# 8192 halves reverted a generation) and passes against the fixed one (0 of
# 8192). A suite that has never been shown to go red for the reason it claims
# does not belong here.
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock"
# Suites runnable against a multi-node environment.
E2E_SUITES_MULTI="multinode lb cert"
# grep -xE alternation form, for narrowing a requested set to the eligible one.
E2E_SUITES_SINGLE_RE="$(printf '%s' "${E2E_SUITES_SINGLE}" | tr ' ' '|')"
E2E_SUITES_MULTI_RE="$(printf '%s' "${E2E_SUITES_MULTI}" | tr ' ' '|')"
# e2e-nightly runs a narrower set than the two sets above, deliberately: its
# ~19 cells each have a 35-minute budget and exist to prove that every
# install / network / host-OS permutation boots and serves. Suite breadth is
# the PR gate's job, and it already runs on every push. These are subsets, and
# e2e_suites_assert_subset below enforces that.
E2E_SUITES_NIGHTLY_SINGLE="single cert"
E2E_SUITES_NIGHTLY_MULTI="multinode cert lb"
# Fail loudly when a narrowed list names a suite its topology cannot run. This
# is the guard that stops the lists drifting again: a suite added to a nightly
# subset without also being declared eligible above stops the job here rather
# than silently skipping half its assertions in CI.
# e2e_suites_assert_subset <label> <subset> <eligible-set>
e2e_suites_assert_subset() {
local label="$1" subset="$2" eligible="$3" suite found eligible_suite
for suite in ${subset}; do
found=0
for eligible_suite in ${eligible}; do
if [ "${suite}" = "${eligible_suite}" ]; then found=1; break; fi
done
if [ "${found}" -ne 1 ]; then
echo "::error::${label} names suite '${suite}', which is not eligible for that topology (eligible: ${eligible}). Fix the list in .github/scripts/suite-sets.sh." >&2
return 1
fi
done
return 0
}
+12
View File
@@ -9,6 +9,11 @@ on:
required: false
type: string
default: ""
otel-attrs:
description: "OTEL_RESOURCE_ATTRIBUTES value for this run, e.g. ci.run_id=123,ci.cell=20 (caller-specific, so it's an input rather than a workflow-level default)"
required: false
type: string
default: ""
permissions:
contents: read
@@ -16,6 +21,10 @@ permissions:
env:
GIT_BRANCH: ${{ github.ref_name }}
# Same central ES/APM sink as the other four e2e workflows. Bare-metal had
# no default here before — this cell's telemetry never reached the sink at
# all (not just missing ci.cell), see bm-bootstrap.sh's telemetry.env step.
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
jobs:
e2e_baremetal:
@@ -28,6 +37,9 @@ jobs:
env:
BM_ISO_CACHE: /var/cache/spinifex-iso-builder
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-20-${{ github.run_id }}
# Caller-supplied (workflow_call input); empty for a manual
# workflow_dispatch run, which bm-bootstrap.sh treats as "no attrs".
SPINIFEX_OTEL_ATTRS: ${{ inputs.otel-attrs }}
steps:
- name: Clean Workspace
run: |
+14
View File
@@ -13,6 +13,9 @@ on:
permissions:
contents: read
# actions: read lets the otel-job-id action look up this job's numeric id via
# the Actions API — github.job is only the YAML key, no context exposes it.
actions: read
env:
GIT_BRANCH: ${{ github.ref_name }}
@@ -28,6 +31,10 @@ jobs:
timeout-minutes: 60
env:
TOFU_LOG: /tmp/e2e-tofu-ddil-${{ github.run_id }}.log
# Job-level env wins over the workflow-level default. ci.cell=ddil marks
# this workflow's telemetry so a DDIL run's partition-induced errors are
# never mistaken for a healthy multi-node run's in the sink.
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=ddil
steps:
- name: Clean Workspace
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
@@ -72,6 +79,13 @@ jobs:
echo "${BIN_DIR}" >> "${GITHUB_PATH}"
"${BIN_DIR}/gotestsum" --version
# Adds ci.job_id alongside ci.cell, before Bootstrap bakes the attrs into
# the VMs, so a telemetry doc points back at this job's log directly.
- name: Resolve CI job id
uses: ./spinifex/.github/actions/otel-job-id
with:
job-name: DDIL Real Multi-Node
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
run: |
+37 -2
View File
@@ -11,6 +11,10 @@ on:
permissions:
contents: read
# actions: read lets the "Resolve CI job id" step below look up this job's
# numeric id via the Actions API — github.job is only the YAML key, no
# context exposes the id itself.
actions: read
# Scheduled runs are loaded from the default branch (main), so the schedule
# trigger must stay in sync on main for cron to fire.
@@ -46,6 +50,11 @@ jobs:
env:
TOFU_LOG: /tmp/e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}.log
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}
# Job-level env wins over the workflow-level default above, so telemetry
# from this cell carries ci.cell/ci.matrix. Nine cells run under one
# run id and every cell names its hosts node1/node2/node3, so without
# this every cell's telemetry collapses into one unattributable bucket.
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=${{ matrix.cell }},ci.matrix=${{ matrix.topo }}/${{ matrix.net }}/${{ matrix.host }}
steps:
# blank or scheduled run = full matrix.
@@ -92,6 +101,16 @@ jobs:
with:
path: spinifex
# Nine cells share one run id and every cell names its hosts
# node1/node2/node3, so ci.cell above separates them and ci.job_id here
# links each cell's telemetry to the job log that produced it. Runs
# before Bootstrap bakes SPINIFEX_OTEL_ATTRS into the VM's telemetry.env.
- name: Resolve CI job id
if: steps.gate.outputs.selected == 'true'
uses: ./spinifex/.github/actions/otel-job-id
with:
job-name: "cell-${{ matrix.cell }} (${{ matrix.topo }}/${{ matrix.net }}/${{ matrix.host }}${{ matrix.extra && format('/{0}', matrix.extra) || '' }})"
- name: Build Install Artifacts
if: steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
@@ -147,13 +166,20 @@ jobs:
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'single' && matrix.extra == ''
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
# Nightly runs a documented subset of the single-node set; the assert
# fails the cell if that subset ever names a suite this topology
# cannot fully exercise.
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
e2e_suites_assert_subset "e2e-nightly single" "${E2E_SUITES_NIGHTLY_SINGLE}" "${E2E_SUITES_SINGLE}"
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; FAIL=0; \
for SUITE in single cert lb; do \
for SUITE in ${E2E_SUITES_NIGHTLY_SINGLE}; do \
echo \"::group::suite \$SUITE\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
@@ -190,6 +216,11 @@ jobs:
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'real-multi'
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
# See the single-node step: same subset guard, multi-node set.
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
e2e_suites_assert_subset "e2e-nightly multi" "${E2E_SUITES_NIGHTLY_MULTI}" "${E2E_SUITES_MULTI}"
INVENTORY=$(tofu output -json inventory)
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
@@ -204,7 +235,7 @@ jobs:
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; FAIL=0; \
for SUITE in multinode cert lb; do \
for SUITE in ${E2E_SUITES_NIGHTLY_MULTI}; do \
echo \"::group::suite \$SUITE\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \
@@ -373,4 +404,8 @@ jobs:
uses: ./.github/workflows/e2e-baremetal.yml
with:
ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
# A reusable-workflow call gets none of this workflow's context (env:
# above, matrix.cell) for free — cell-20 needs its own attrs string.
# There's no matrix leg here, just the one fixed baremetal cell.
otel-attrs: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=20,ci.matrix=baremetal/pxe/real-hw
secrets: inherit
+79 -10
View File
@@ -3,11 +3,14 @@
# Restructures the looped e2e.yml into env-grouped job blocks so each suite
# surfaces as its own node in the Actions graph (mulga-siv-283):
#
# single_provision ─┬─ single_suite (matrix: single, cert, lb, eks, iam)
# single_provision ─┬─ single_suite (matrix: the single-node-eligible suites)
# └─ single_teardown (if: always)
# multi_provision ─┬─ multi_suite (matrix: multinode)
# multi_provision ─┬─ multi_suite (matrix: the multi-node-eligible suites)
# └─ multi_teardown (if: always)
#
# Both matrices come from .github/scripts/suite-sets.sh, the one definition of
# which suites a topology can run, shared with e2e.yml and e2e-nightly.yml.
#
# Constraint: tofu state is runner-filesystem-local, so *_provision and
# *_teardown must execute on the same self-hosted runner (one runner per label
# today). Teardown reuses the persisted mulga/ tofu-cluster workspace and does
@@ -25,9 +28,13 @@ on:
workflow_dispatch:
inputs:
single_suites:
description: "Single-node suites to run as matrix legs"
description: "Single-node suites to run as matrix legs (empty = every single-node-eligible suite, per .github/scripts/suite-sets.sh)"
required: false
default: "single cert lb eks iam"
default: ""
multi_suites:
description: "Multi-node suites to run as matrix legs (empty = every multi-node-eligible suite, per .github/scripts/suite-sets.sh)"
required: false
default: ""
run_multinode:
description: "Also run the multinode env block"
type: boolean
@@ -40,6 +47,10 @@ on:
permissions:
contents: read
# actions: read lets the otel-job-id action look up a provision job's numeric
# id via the Actions API — github.job is only the YAML key, no context
# exposes it.
actions: read
env:
GIT_BRANCH: ${{ github.ref_name }}
@@ -64,6 +75,13 @@ jobs:
matrix: ${{ steps.suites.outputs.matrix }}
env:
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
# This job (not single_suite) is where "Bootstrap via Binary Install"
# bakes SPINIFEX_OTEL_ATTRS into the VM's telemetry.env, before the
# suite matrix even exists — single_suite's legs share this one
# already-booted VM, so daemon telemetry can't be scoped any finer
# than "single env" per suite leg. ci.cell=single distinguishes it
# from multi_provision's VMs within the same run id.
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=single
steps:
- name: Reap Orphan Procs
run: |
@@ -115,12 +133,22 @@ jobs:
- name: Re-enable Go toolchain auto-download
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
# Default and allow-list both come from the shared definition, so this
# workflow cannot drift from e2e.yml about what a single node can run.
- name: Compute suite matrix
id: suites
env:
REQUESTED: ${{ inputs.single_suites }}
run: |
LIST="${{ inputs.single_suites || 'single cert lb eks iam' }}"
set -euo pipefail
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
LIST="${REQUESTED:-${E2E_SUITES_SINGLE}}"
e2e_suites_assert_subset "single_suites" "${LIST}" "${E2E_SUITES_SINGLE}"
echo "list=${LIST}" >> "$GITHUB_OUTPUT"
JSON=$(printf '%s\n' $LIST | jq -R . | jq -cs .)
# Unquoted on purpose: the space-separated list must word-split into
# one JSON element per suite.
# shellcheck disable=SC2086
JSON=$(printf '%s\n' ${LIST} | jq -R . | jq -cs .)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
- name: Reclaim docker build cache
@@ -130,6 +158,14 @@ jobs:
docker buildx prune -af --filter "until=24h" || true
docker system df || true
# ci.job_id names the provision job, not the suite job that later drives
# the env: the suite legs share this env and its baked telemetry.env, so
# the provisioner is the only job the daemon telemetry can belong to.
- name: Resolve CI job id
uses: ./spinifex/.github/actions/otel-job-id
with:
job-name: Single env · provision
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
run: |
@@ -497,8 +533,13 @@ jobs:
node1_ip: ${{ steps.cluster.outputs.node1_ip }}
ssh_key: ${{ steps.cluster.outputs.ssh_key }}
nodes_csv: ${{ steps.cluster.outputs.nodes_csv }}
suites: ${{ steps.suites.outputs.list }}
matrix: ${{ steps.suites.outputs.matrix }}
env:
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
# See single_provision above — daemon telemetry is baked in here, at
# provision time, before the multi_suite matrix exists.
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=multi
steps:
- name: Reap Orphan Procs
run: |
@@ -554,6 +595,12 @@ jobs:
docker buildx prune -af --filter "until=24h" || true
docker system df || true
# See the matching note on single_provision.
- name: Resolve CI job id
uses: ./spinifex/.github/actions/otel-job-id
with:
job-name: Multi env · provision
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
run: |
@@ -601,14 +648,36 @@ jobs:
echo "ssh_key=${SSH_KEY}" >> "$GITHUB_OUTPUT"
echo "nodes_csv=${NODES_CSV}" >> "$GITHUB_OUTPUT"
- name: Build multinode binary on runner
# Mirrors single_provision's step: the multi leg previously ran multinode
# alone, silently dropping the lb and cert coverage that only a
# multi-node env can complete.
- name: Compute suite matrix
id: suites
env:
REQUESTED: ${{ inputs.multi_suites }}
run: |
set -euo pipefail
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
LIST="${REQUESTED:-${E2E_SUITES_MULTI}}"
e2e_suites_assert_subset "multi_suites" "${LIST}" "${E2E_SUITES_MULTI}"
echo "list=${LIST}" >> "$GITHUB_OUTPUT"
# Unquoted on purpose: the space-separated list must word-split into
# one JSON element per suite.
# shellcheck disable=SC2086
JSON=$(printf '%s\n' ${LIST} | jq -R . | jq -cs .)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
- name: Build multinode binaries on runner
working-directory: spinifex/tests/e2e
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
banner "build multinode, teardown"
# teardown always builds: the run-scoped sweep binary runs in the
# teardown job, it is never a selectable suite leg.
SUITES_BUILD="${{ steps.suites.outputs.list }} teardown"
banner "build ${SUITES_BUILD// /, }"
group_open "go test -c"
make e2e-build SUITES_BUILD="multinode teardown"
make e2e-build SUITES_BUILD="${SUITES_BUILD}"
group_close
ok "$(basename _bin/multinode.test) $(stat -c '%s' _bin/multinode.test | numfmt --to=iec)"
@@ -652,7 +721,7 @@ jobs:
strategy:
fail-fast: false
matrix:
suite: [multinode]
suite: ${{ fromJSON(needs.multi_provision.outputs.matrix) }}
env:
NODE1_IP: ${{ needs.multi_provision.outputs.node1_ip }}
SSH_KEY: ${{ needs.multi_provision.outputs.ssh_key }}
+48 -72
View File
@@ -26,10 +26,13 @@ on:
permissions:
contents: read
statuses: write
# actions: read lets the otel-job-id action look up a job's numeric id via
# the Actions API — github.job is only the YAML key, no context exposes it.
actions: read
env:
GIT_BRANCH: ${{ github.ref_name }}
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam' }}
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam e2e-partialblock' }}
TEST_RUN: ${{ inputs.test_run || '' }}
# OTLP export to the central Elastic sink (wattle). Repo variable overrides;
# empty string disables telemetry entirely.
@@ -37,57 +40,28 @@ env:
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
jobs:
# In-process integration front gate. Provisions nothing -- no tofu, no
# docker, no Spinifex daemons, no fixed ports -- so it runs directly on the
# runner host rather than inside a provisioned VM, and completes in seconds.
#
# It runs on ci-single rather than a GitHub-hosted runner so it shares the
# toolchain and sibling-repo build of the live suites, and so the run stays
# entirely on the LAN that hosts the Elastic sink. Holding a ci-single slot
# for seconds is negligible against the 50-minute e2e_single that follows.
#
# e2e_single and e2e_multi both require this job to succeed, so a red
# integration run is caught before either provisions a tofu env or pays the
# Spinifex install -- which is the expensive half, not the tofu apply.
integration:
name: Integration (front gate)
runs-on: [self-hosted, ci-single]
timeout-minutes: 10
steps:
# This runner is shared with e2e_single, which leaves root-owned tofu and
# mount artifacts behind; a plain checkout would fail on them. Unmount
# anything still bound under the workspace before wiping it.
- name: Clean Workspace
run: |
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: false
- name: Run in-process integration tests
run: make test-integration
e2e_single:
name: Single-Node E2E
needs: [integration]
# The integration front gate must be green: a red integration run stops
# here, before this job provisions a tofu env or pays the Spinifex install.
#
# The dispatch check gates on exactly the suites this job's Run step
# allow-lists (single|iam|cert|eks|ecs|storagegrowth). Gating on a suite the
# job won't run — lb, which only executes in the multinode job — would
# allow-lists, i.e. E2E_SUITES_SINGLE in .github/scripts/suite-sets.sh —
# an `if:` expression cannot source that file, so this one list is
# hand-mirrored and must be updated with it. Gating on a suite the job
# won't run — lb, which only executes in the multinode job — would
# provision a tofu VM and pay the Spinifex install only to run nothing and
# tear down. Push triggers keep running via the null-check on inputs.suites.
if: needs.integration.result == 'success' && (github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-iam') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-eks') || contains(github.event.inputs.suites, 'e2e-ecs') || contains(github.event.inputs.suites, 'e2e-storagegrowth'))
# tear down. Push triggers keep running via the null-check on
# inputs.suites.
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-iam') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-eks') || contains(github.event.inputs.suites, 'e2e-ecs') || contains(github.event.inputs.suites, 'e2e-storagegrowth') || contains(github.event.inputs.suites, 'e2e-partialblock')
runs-on: [self-hosted, ci-single]
timeout-minutes: 50
env:
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
ARTIFACT_DIR: /tmp/spinifex-e2e-artifacts-${{ github.run_id }}
# Job-level env wins over the workflow-level default. This job and
# e2e_multi share one run id and both name their hosts node1/node2/node3,
# so without ci.cell their telemetry collapses into one bucket. This job
# is where "Bootstrap via Binary Install" bakes SPINIFEX_OTEL_ATTRS into
# the VM's telemetry.env, so the label has to be set here.
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=single
steps:
- name: Reap Orphan Procs
run: |
@@ -152,6 +126,13 @@ jobs:
docker buildx prune -af --filter "until=24h" || true
docker system df || true
# Adds ci.job_id alongside ci.cell, before Bootstrap bakes the attrs into
# the VM, so a telemetry doc points back at this job's log directly.
- name: Resolve CI job id
uses: ./spinifex/.github/actions/otel-job-id
with:
job-name: Single-Node E2E
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
run: |
@@ -317,8 +298,11 @@ jobs:
set +e
# Allow-list, not a deny-list: a suite runs here only if named. Deny-lists
# silently fan a newly added suite out to every job, which is how lb ended up
# running twice per e2e run.
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE 'single|iam|cert|eks|ecs|storagegrowth' | tr '\n' ' ')
# running twice per e2e run. The allow-list itself is shared with the other
# e2e workflows so the three cannot disagree about what a topology can run;
# suite-sets.sh also carries the per-suite rationale.
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE "${E2E_SUITES_SINGLE_RE}" | tr '\n' ' ')
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
echo "no in-VM suites requested (SUITES=${SUITES}); skipping"
echo "rc=0" >> "$GITHUB_OUTPUT"
@@ -495,19 +479,19 @@ jobs:
e2e_multi:
name: Multi-Node E2E
needs: [integration]
# Gated on the integration front gate for the same reason as e2e_single:
# the 3-node env is never stood up if the control plane is already broken.
# The dispatch check gates on exactly the suites this job's Run step
# allow-lists (multinode|lb|cert) so lb — which only runs here — still
# provisions the 3-node env when requested on its own, and the env stays
# unprovisioned when none of them are asked for.
if: needs.integration.result == 'success' && (github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-multinode') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-cert'))
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-multinode') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-cert')
runs-on: [self-hosted, ci-multi]
timeout-minutes: 60
env:
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
ARTIFACT_DIR: /tmp/spinifex-e2e-multi-artifacts-${{ github.run_id }}
# See the matching note on e2e_single: ci.cell is what separates this
# job's 3-node telemetry from the single-node job's under one run id.
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=multi
steps:
- name: Reap Orphan Procs
run: |
@@ -572,6 +556,12 @@ jobs:
docker buildx prune -af --filter "until=24h" || true
docker system df || true
# See the matching note on e2e_single.
- name: Resolve CI job id
uses: ./spinifex/.github/actions/otel-job-id
with:
job-name: Multi-Node E2E
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
run: |
@@ -714,7 +704,8 @@ jobs:
run: |
set +e
# Allow-list, not a deny-list — see the single-node job for why.
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE 'multinode|lb|cert' | tr '\n' ' ')
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE "${E2E_SUITES_MULTI_RE}" | tr '\n' ' ')
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
echo "no multinode-eligible suites requested (SUITES=${SUITES}); skipping"
echo "rc=0" >> "$GITHUB_OUTPUT"
@@ -930,7 +921,7 @@ jobs:
e2e_status:
name: Report E2E Status
if: always()
needs: [integration, e2e_single, e2e_multi]
needs: [e2e_single, e2e_multi]
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
@@ -939,22 +930,15 @@ jobs:
GH_TOKEN: ${{ github.token }}
REPORT_SHA: ${{ github.sha }}
run: |
# integration must have actually succeeded -- unlike e2e_single/multi,
# "skipped" is never an acceptable result for it (it has no `if:` of
# its own), so a red or skipped integration run fails the gate here
# even though e2e_single/e2e_multi themselves report "skipped" (their
# `if:` never let them start).
integration_ok=true
case "${{ needs.integration.result }}" in success) ;; *) integration_ok=false ;; esac
single_ok=true; multi_ok=true
case "${{ needs.e2e_single.result }}" in success|skipped) ;; *) single_ok=false ;; esac
case "${{ needs.e2e_multi.result }}" in success|skipped) ;; *) multi_ok=false ;; esac
if $integration_ok && $single_ok && $multi_ok; then
if $single_ok && $multi_ok; then
STATE="success"
DESC="E2E suites passed"
else
STATE="failure"
DESC="E2E failed: integration=${{ needs.integration.result }} single=${{ needs.e2e_single.result }} multi=${{ needs.e2e_multi.result }}"
DESC="E2E failed: single=${{ needs.e2e_single.result }} multi=${{ needs.e2e_multi.result }}"
fi
gh api "repos/${{ github.repository }}/statuses/${REPORT_SHA}" \
-f state="$STATE" \
@@ -971,7 +955,7 @@ jobs:
# status-report check.
e2e_es_summary:
name: ES CI Run Summary
needs: [integration, e2e_single, e2e_multi]
needs: [e2e_single, e2e_multi]
if: always()
runs-on: [self-hosted, ci-single]
timeout-minutes: 5
@@ -1011,7 +995,6 @@ jobs:
BRANCH: ${{ github.head_ref || github.ref_name }}
SHA: ${{ github.sha }}
SERVER_URL: ${{ github.server_url }}
INTEGRATION_RESULT: ${{ needs.integration.result }}
SINGLE_RESULT: ${{ needs.e2e_single.result }}
MULTI_RESULT: ${{ needs.e2e_multi.result }}
SINGLE_DIR: ${{ github.workspace }}/es-summary-single
@@ -1040,20 +1023,15 @@ jobs:
FAILED_JSON=$(printf '%s' "$FAILED_SUITES" | jq -R -s 'split("\n") | map(select(length > 0))')
FAILED_COUNT=$(printf '%s' "$FAILED_JSON" | jq 'length')
# Mirror e2e_status's integration_ok/single_ok/multi_ok logic exactly
# so the PR status and this ES doc can never disagree on overall
# pass/fail. Like e2e_status, "skipped" is only acceptable for
# single/multi (their `if:` may legitimately not select them); a
# skipped or failed integration always fails the gate.
integration_ok=true
case "$INTEGRATION_RESULT" in success) ;; *) integration_ok=false ;; esac
# Mirror e2e_status's single_ok/multi_ok logic exactly so the PR
# status and this ES doc can never disagree on overall pass/fail.
single_ok=true
case "$SINGLE_RESULT" in success | skipped) ;; *) single_ok=false ;; esac
multi_ok=true
case "$MULTI_RESULT" in success | skipped) ;; *) multi_ok=false ;; esac
STATE=success
if [ "$integration_ok" != true ] || [ "$single_ok" != true ] || [ "$multi_ok" != true ] || [ "$FAILED_COUNT" -gt 0 ]; then
if [ "$single_ok" != true ] || [ "$multi_ok" != true ] || [ "$FAILED_COUNT" -gt 0 ]; then
STATE=failure
fi
@@ -1070,7 +1048,6 @@ jobs:
--arg sha "${SHA}" \
--arg run_attempt "${RUN_ATTEMPT}" \
--arg state "${STATE}" \
--arg integration_result "${INTEGRATION_RESULT}" \
--arg single_result "${SINGLE_RESULT}" \
--arg multi_result "${MULTI_RESULT}" \
--argjson duration "${DURATION}" \
@@ -1081,7 +1058,6 @@ jobs:
labels: { ci_run_id: $run_id },
ci: { workflow: $workflow, branch: $branch, sha: $sha, run_attempt: $run_attempt },
state: $state,
integration_result: $integration_result,
single_result: $single_result,
multi_result: $multi_result,
failed_suites: $failed_suites,
+5 -27
View File
@@ -44,32 +44,6 @@ on:
description: "Optional -test.run regex scoped to tests/e2e/gpu (empty = run all GPU tests)"
required: false
default: ""
push:
branches:
- main
- dev
paths: &gpu_paths
- "spinifex/gpu/**"
- "spinifex/instancetypes/**"
- "spinifex/daemon/gpu_adapter.go"
- "spinifex/daemon/gpu_pool_reconciler.go"
- "spinifex/handlers/ecs/gpu_report.go"
- "spinifex/handlers/eks/nodegroup.go"
- "spinifex/handlers/eks/nodegroup_userdata.go"
- "spinifex/vm/vm.go"
- "spinifex/vm/lifecycle.go"
- "cmd/ecs-agent/gpu_discovery.go"
- "cmd/ecs-agent/gpu_ledger.go"
- "cmd/spinifex/cmd/admin_gpu.go"
- "tests/e2e/gpu/**"
- "scripts/images/eks-node-gpu/**"
- "scripts/images/ecs-agent-gpu/**"
- ".github/workflows/gpu-e2e.yml"
pull_request:
branches:
- main
- dev
paths: *gpu_paths
permissions:
contents: read
@@ -88,7 +62,11 @@ env:
# the GPU suite's own daemon-side spans are emitted by wattle's already-
# configured obs-agent, not by this job.
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
# ci.cell=gpu keeps this workflow's telemetry separable from the tofu-env
# workflows'. Nothing in this job bakes these attrs into a daemon (see the
# note above), so the label only takes effect once wattle's obs-agent is
# given them; there is deliberately no ci.job_id lookup for the same reason.
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=gpu
WATTLE_SSH_HOST: ${{ vars.WATTLE_SSH_HOST || '192.168.1.13' }}
WATTLE_SSH_USER: ${{ vars.WATTLE_SSH_USER || 'tf-user' }}
TEST_RUN: ${{ inputs.test_run || '' }}
+49 -9
View File
@@ -68,15 +68,6 @@ jobs:
- name: Manifest check + drift lint
run: make -C spinifex manifest-check manifest-lint
# In-process integration tier (gateway.SetupRoutes over embedded NATS
# JetStream). Runs here, on every PR, rather than only relying on
# e2e.yml's "Integration (front gate)" job: that job is push-triggered
# against main/dev on a self-hosted runner, so it never blocks a PR
# before merge. This step provisions nothing and completes in seconds,
# so it belongs as an ordinary required PR check, not a separate job.
- name: Run Integration Tests
run: make -C spinifex test-integration
- name: Run Tests with Coverage
run: make -C spinifex test-cover
@@ -86,6 +77,55 @@ jobs:
- name: Check Diff Coverage
run: cd spinifex && scripts/diff-coverage.sh coverage.out --base HEAD~1
# In-process integration tier (gateway.SetupRoutes over embedded NATS
# JetStream). Its own job so it runs in parallel with the unit tiers rather
# than adding to their wall time, and it still blocks a PR — unlike e2e.yml's
# "Integration (front gate)" job, which is push-triggered against main/dev on
# a self-hosted runner and so never gates a PR before merge.
integration_tests:
name: Integration Tests
runs-on: ubuntu-26.04
steps:
- name: Checkout Spinifex
uses: actions/checkout@v7
with:
path: spinifex
fetch-depth: 2
- name: Resolve sibling refs
id: siblings
env:
BRANCH: ${{ github.head_ref || github.ref_name }}
run: bash .github/resolve-sibling-refs.sh
working-directory: spinifex
- name: Checkout Viperblock
uses: actions/checkout@v7
with:
repository: mulgadc/viperblock
ref: ${{ steps.siblings.outputs.viperblock }}
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v7
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
- name: Setup Go
uses: actions/setup-go@v7
with:
cache-dependency-path: "spinifex/go.sum"
go-version-file: "spinifex/go.mod"
- name: Setup Go Workspace
run: go work init ./spinifex ./viperblock ./predastore
- name: Run Integration Tests
run: make -C spinifex test-integration
race_detection:
name: Race Detection
runs-on: ubuntu-26.04
+56 -38
View File
@@ -1,5 +1,8 @@
version: "2"
issues:
max-same-issues: 10
formatters:
enable:
- gofmt
@@ -14,7 +17,6 @@ linters:
- bidichk # checks for dangerous unicode bidi character sequences
- bodyclose # checks whether HTTP response body is closed successfully
- canonicalheader # checks whether net/http.Header uses canonical header keys
- decorder # checks declaration order of types, consts, vars, and funcs
- depguard # blocks imports of packages with a preferred replacement
- durationcheck # checks for two durations multiplied together
- embeddedstructfieldcheck # checks embedded sync.Mutex/RWMutex in structs
@@ -24,17 +26,20 @@ linters:
- errorlint # catches ==/!= on errors, type assertions on errors, and %v instead of %w — all break on wrapped errors
- exptostd # detects golang.org/x/exp/ functions replaceable by stdlib
- fatcontext # detects nested contexts in loops
- forbidigo # blocks the legacy JetStream/KV symbols
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
- godot # checks that declaration comments end in a period
- goprintffuncname # checks that printf-like functions are named with f at the end
- gosec # inspects source code for security problems
- govet # reports suspicious constructs (Printf alignment, shadow, etc.)
- iface # detects interface pollution — duplicate interface declarations
- ineffassign # detects assignments to existing variables that are not used
- iotamixing # flags const blocks that mix iota with non-iota declarations
- makezero # finds slice declarations with non-zero initial length followed by append
- mirror # catches wrong mirror patterns in bytes/strings usage
- misspell # finds commonly misspelled English words
- musttag # checks structs passed to marshal/unmarshal have proper tags
- modernize # modernizes code to use newer Go idioms (go fix analyzers)
- musttag # checks structs passed to marshal/unmarshal have proper tags
- nakedret # finds naked returns in functions
- nilerr # catches code that returns nil even after checking err != nil
- nilnesserr # catches returning a different nil error variable after err != nil check
@@ -55,9 +60,6 @@ linters:
- whitespace # detects leading and trailing whitespace in functions
settings:
embeddedstructfieldcheck:
forbid-mutex: true
depguard:
rules:
main:
@@ -67,11 +69,32 @@ linters:
- pkg: math/rand$
desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2
embeddedstructfieldcheck:
forbid-mutex: true
errcheck:
check-type-assertions: true # replaces forcetypeassert
exclude-functions:
- (*github.com/nats-io/nats.go.Subscription).Unsubscribe # cleanup-only, errors not actionable
forbidigo:
analyze-types: true
forbid:
- pattern: '\.JetStream$'
pkg: '^github\.com/nats-io/nats\.go$'
msg: legacy nc.JetStream() is superseded; construct with jetstream.New(nc).
- pattern: '^nats\.(JetStreamContext|KeyValue.*|KeyWatcher|Err(KeyNotFound|KeyExists|BucketNotFound|NoKeysFound|StreamNotFound))$'
pkg: '^github\.com/nats-io/nats\.go$'
msg: legacy nats KV types/sentinels are superseded; use github.com/nats-io/nats.go/jetstream.
gosec:
excludes:
- G104 # unchecked errors — errcheck is stricter
- G116 # bidi chars — bidichk is more thorough
- G117 # use of net/http serve functions (we handle TLS separately)
- G703 # path traversal taint analysis — false positives on config file loading
- G705 # XSS taint analysis — false positives on internal error responses
govet:
enable-all: true
disable:
@@ -79,6 +102,28 @@ linters:
- shadow # too noisy to retrofit — all 50 findings are err re-declarations
- unusedwrite # false positives on struct field assignments before marshal/send
modernize:
disable:
- newexpr # don't replace aws.String() with new() — AWS SDK idiom
- stringsbuilder # don't replace small string concatenations with strings.Builder
nakedret:
max-func-lines: 0
nolintlint:
require-explanation: true
require-specific: true
perfsprint:
concat-loop: false
err-error: false # don't flag err.Error()
errorf: false # don't convert fmt.Errorf to errors.New
strconcat: false
reassign:
patterns:
- ".*"
staticcheck:
checks:
- all
@@ -90,40 +135,8 @@ linters:
- -ST1020 # godoc on exported types — not enforcing yet
- -ST1021 # godoc on exported type groups — not enforcing yet
- -ST1022 # godoc on exported vars/consts — not enforcing yet
- -SA1019 # deprecated usage (handle separately)
- -SA4006 # value overwritten — ineffassign + wastedassign cover this better
- -SA9005 # ineffective break (staticcheck false positives)
gosec:
excludes:
- G104 # unchecked errors — errcheck is stricter
- G116 # bidi chars — bidichk is more thorough
- G117 # use of net/http serve functions (we handle TLS separately)
- G703 # path traversal taint analysis — false positives on config file loading
- G705 # XSS taint analysis — false positives on internal error responses
- G706 # log injection taint analysis — false positives on structured slog usage
modernize:
disable:
- newexpr # don't replace aws.String() with new() — AWS SDK idiom
- stringsbuilder # don't replace small string concatenations with strings.Builder
perfsprint:
concat-loop: false
err-error: false # don't flag err.Error()
errorf: false # don't convert fmt.Errorf to errors.New
strconcat: false
nakedret:
max-func-lines: 0
nolintlint:
require-explanation: true
require-specific: true
reassign:
patterns:
- ".*"
- -SA9005 # marshaling structs with no exported fields — intended {} for empty API-response types
testifylint:
disable:
@@ -153,3 +166,8 @@ linters:
linters: [gosec] # subprocess execution — legitimate in VM orchestration
- text: "G304"
linters: [gosec] # file path from variable — necessary for config loading
# aws-sdk-go v1 is deprecated wholesale (EOL 2025-07-31); the v1->v2
# migration is tracked separately, so suppress its SA1019 noise for now.
- text: "aws-sdk-go is deprecated"
linters: [staticcheck]
+20 -8
View File
@@ -120,10 +120,15 @@ install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share
sudo install -m 0644 $(MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/vmlinuz
sudo install -m 0644 $(MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/initramfs.cpio.gz
# Preflight — runs the same checks as GitHub Actions (lint + vuln + tests).
# Use this before committing to catch CI failures locally.
# Preflight — the pre-commit gate: manifest checks, lint, vuln, and the unit,
# race and e2e-harness tiers.
#
# The integration tier is deliberately NOT here. CI runs it as its own parallel
# job, so it is still gated before merge, and keeping it out leaves preflight
# the fast common-path check — run `make test-integration` directly when a
# change touches the gateway router or the NATS subjects it drives.
preflight:
@$(MAKE) --no-print-directory QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-race test-harness test-integration
@$(MAKE) --no-print-directory QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-race test-harness
@echo -e "\n ✅ Preflight passed — safe to commit."
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
@@ -137,10 +142,10 @@ test-harness:
# JetStream, with only the daemon-side NATS subjects stubbed. Build-tagged
# `integration` so it's skipped by the default `go test ./spinifex/...` and by
# `test-cover`/`test-race`. Nothing provisioned — no tofu, no docker, no
# Spinifex daemons, so the whole package runs in well under a minute — part
# of `preflight` (and its own PR-blocking CI step) rather than the
# self-hosted, push-triggered live e2e tiers, so a regression here is caught
# before it can be merged, not just after.
# Spinifex daemons, so the whole package runs in well under a minute. It gets
# its own PR-blocking CI job rather than the self-hosted, push-triggered live
# e2e tiers, so a regression here is caught before it can be merged, not just
# after.
test-integration:
@echo -e "\n....Running in-process integration tests...."
$(_Q)LOG_IGNORE=1 go test -tags=integration -timeout 60s ./tests/integration/... $(_RACEQ)
@@ -266,6 +271,13 @@ govulncheck:
$(_Q)go tool govulncheck ./...
@echo " govulncheck ok"
# NilAway — advisory nil-panic analysis. Not in preflight: it has a known
# false-positive rate, so findings are triaged by hand rather than gating commits.
nilaway:
@echo "Running nilaway..."
$(_Q)go tool nilaway -include-pkgs=github.com/mulgadc/spinifex -exclude-test-files ./...
@echo " nilaway ok"
# Build release tarballs — use distro-ARCH for single arch, distro for both
distro: distro-amd64 distro-arm64
@echo ""
@@ -312,5 +324,5 @@ distro-clean:
.PHONY: build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench test-actions test-harness test-integration manifest-check manifest-lint manifest-lint-update \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck \
lint fix govulncheck nilaway \
distro distro-amd64 distro-arm64 distro-clean
+9 -25
View File
@@ -6,7 +6,7 @@
// Usage (must run as root — needs AF_PACKET):
//
// sudo dhcptest --iface=br-wan
// sudo dhcptest --iface=br-wan --mac=02:00:00:7c:b7:b9 --promisc
// sudo dhcptest --iface=br-wan --mac=02:54:df:71:22:5c --promisc
// sudo dhcptest --iface=br-wan --spinifex-id=gateway-wan1
package main
@@ -21,8 +21,8 @@ import (
"strings"
"time"
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
)
func main() {
@@ -44,9 +44,11 @@ func main() {
switch {
case *spinifexID != "":
hwAddr, err = net.ParseMAC(generateMAC(*spinifexID))
// vpcd's own derivation, so the probe leases under the MAC the daemon
// would have used for this id.
hwAddr, err = dhcp.DeriveMAC(*spinifexID)
if err != nil {
slog.Error("Could not parse generated MAC", "spinifex-id", *spinifexID, "error", err)
slog.Error("Could not derive MAC", "spinifex-id", *spinifexID, "error", err)
os.Exit(1)
}
fmt.Printf("spinifex virtual MAC for %q: %s\n", *spinifexID, hwAddr)
@@ -109,17 +111,9 @@ func main() {
}
defer client.Close()
// Identity modifiers — client-id / hostname / vendor-class for upstream lease tagging.
var mods []dhcpv4.Modifier
if cid != "" {
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier([]byte(cid))))
}
if *hostname != "" {
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptHostName(*hostname)))
}
if *vendorClass != "" {
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClassIdentifier(*vendorClass)))
}
// Identity modifiers — client-id / hostname / vendor-class for upstream lease
// tagging. Built by the vpcd code path so the probe cannot drift from it.
mods := dhcp.IdentityModifiers(cid, *hostname, *vendorClass, hwAddr)
fmt.Println("Sending DHCP Discover...")
start := time.Now()
@@ -153,16 +147,6 @@ func (slogPrintfer) Printf(format string, v ...any) {
slog.Info(fmt.Sprintf(format, v...), "component", "nclient4")
}
// generateMAC is the same deterministic hash used by vpcd's topology.go.
// It produces a locally-administered MAC (02:00:00:xx:xx:xx) from a resource ID.
func generateMAC(resourceID string) string {
h := uint32(0)
for _, c := range resourceID {
h = h*31 + uint32(c)
}
return fmt.Sprintf("02:00:00:%02x:%02x:%02x", (h>>16)&0xff, (h>>8)&0xff, h&0xff)
}
func ifaceMAC(name string) (net.HardwareAddr, error) {
ifc, err := net.InterfaceByName(name)
if err != nil {
+167 -48
View File
@@ -34,6 +34,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/gpu"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/hostdns"
"github.com/mulgadc/spinifex/spinifex/network/host"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/utils"
@@ -339,6 +340,7 @@ func init() {
adminInitCmd.Flags().Int("external-prefix-len", 24, "External pool subnet prefix length (auto-detected)")
adminInitCmd.Flags().Bool("gpu-passthrough", false, "Enable VFIO GPU passthrough (sets gpu_passthrough = true in daemon config)")
adminInitCmd.Flags().Bool("ipsec", true, "Encrypt intra-AZ Geneve via OVN native IPsec (cluster-wide); disable only for trusted single-rack lab")
adminInitCmd.Flags().Bool("skip-host-dns", false, "Do not point this node's host resolver at its local northstar (LB/EKS names then won't resolve from the node)")
// Flags for admin join
adminJoinCmd.Flags().String("region", "ap-southeast-2", "Region for this node")
@@ -355,6 +357,7 @@ func init() {
adminJoinCmd.Flags().StringSlice("services", nil, "Services this node runs (default: all)")
adminJoinCmd.Flags().Bool("no-telemetry", false, "Disable telemetry metrics sent during join (default: enabled)")
adminJoinCmd.Flags().String("email", "", "Operator email address (used for update and security notifications)")
adminJoinCmd.Flags().Bool("skip-host-dns", false, "Do not point this node's host resolver at its local northstar (LB/EKS names then won't resolve from the node)")
adminJoinCmd.Flags().Int("predastore-compaction-interval", 0, "Predastore compactor interval in seconds (0 = unset, uses built-in default). Test clusters set a short interval.")
adminJoinCmd.MarkFlagRequired("node")
adminJoinCmd.MarkFlagRequired("host")
@@ -707,7 +710,25 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
// command, which would collapse the path to a relative config/ca.pem.
bakeCACertIntoImage(extractedImagePath, filepath.Join(baseDir, "config", "ca.pem"))
err = v_utils.ImportDiskImage(&s3Config, &vbConfig, extractedImagePath)
// Render the flush bar here rather than inside viperblock, which stays a
// pure storage library. The bar is built lazily on the first update so it is
// never drawn if the import fails before any bytes flush; viperblock
// throttles the callback to ≤101 invocations, so the human-readable title
// renders without the per-block render-frequency regression.
var flushBar *pterm.ProgressbarPrinter
var flushUpdate func(current uint64)
progress := func(current, total uint64) {
if flushBar == nil {
flushBar, flushUpdate = utils.NewByteProgressBar("Flushing image to storage", total)
}
flushUpdate(current)
}
err = v_utils.ImportDiskImage(&s3Config, &vbConfig, extractedImagePath, progress)
if flushBar != nil {
_, _ = flushBar.Stop()
}
if err != nil {
fmt.Fprintf(os.Stderr, "Could not import image to predastore: %v\n", err)
@@ -1190,10 +1211,31 @@ func runAdminInit(cmd *cobra.Command, args []string) {
os.Exit(1)
}
// Detect DNS servers from the host for VM DHCP
// Validate IP address format
if net.ParseIP(bindIP) == nil {
fmt.Fprintf(os.Stderr, "❌ Error: Invalid IP address for --bind: %s\n", bindIP)
os.Exit(1)
}
// Resolve the off-host advertise IP before detecting DNS. DetectNetwork may
// have failed earlier, so retry lazily when the listener still needs a WAN IP.
if advertiseFlag == "" && (bindIP == "0.0.0.0" || bindIP == "127.0.0.1") && detectedNet == nil {
if d, derr := admin.DetectNetwork(); derr == nil {
detectedNet = d
}
}
advertiseIP, err := resolveAdvertiseIP(bindIP, advertiseFlag, detectedNet)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err)
os.Exit(1)
}
// Exclude the node-local Northstar listener from its own upstreams. On a
// forced re-init, resolvconf may still list this address first; forwarding it
// back to Northstar creates a recursive DNS loop.
var dnsServers []string
if externalMode != "" {
dnsServers = detectDNSServers(externalIface)
dnsServers = detectDNSServers(externalIface, advertiseIP)
if len(dnsServers) > 0 {
fmt.Printf(" DNS servers: %s\n", strings.Join(dnsServers, ", "))
}
@@ -1229,25 +1271,6 @@ func runAdminInit(cmd *cobra.Command, args []string) {
})
}
// Validate IP address format
if net.ParseIP(bindIP) == nil {
fmt.Fprintf(os.Stderr, "❌ Error: Invalid IP address for --bind: %s\n", bindIP)
os.Exit(1)
}
// Resolve the off-host advertise IP. DetectNetwork may have failed earlier;
// detect lazily if we still need the WAN IP.
if advertiseFlag == "" && (bindIP == "0.0.0.0" || bindIP == "127.0.0.1") && detectedNet == nil {
if d, derr := admin.DetectNetwork(); derr == nil {
detectedNet = d
}
}
advertiseIP, err := resolveAdvertiseIP(bindIP, advertiseFlag, detectedNet)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err)
os.Exit(1)
}
// Validate port range
if port < 1 || port > 65535 {
fmt.Fprintf(os.Stderr, "❌ Error: Port must be between 1 and 65535, got: %d\n", port)
@@ -1633,6 +1656,9 @@ func runAdminInit(cmd *cobra.Command, args []string) {
finalizeNodeSetup(spxRoot, certPath, adminAccessKey, adminSecretKey, region, bindIP)
skipHostDNS, _ := cmd.Flags().GetBool("skip-host-dns")
configureHostDNS(configSettings, skipHostDNS)
// Write node.conf so spx admin banner works on source installs (not just ISO).
nodeHostname, _ := os.Hostname()
if nodeHostname == "" {
@@ -1877,6 +1903,9 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
finalizeNodeSetup(spxRoot, certPath, adminAccessKey, adminSecretKey, region, bindIP)
skipHostDNS, _ := cmd.Flags().GetBool("skip-host-dns")
configureHostDNS(configSettings, skipHostDNS)
// Keep formation server running briefly so joining nodes can fetch complete status
fmt.Println("\n⏳ Waiting for joining nodes to fetch cluster data...")
time.Sleep(15 * time.Second)
@@ -2344,6 +2373,9 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
finalizeNodeSetup(dataDir, caCertPath, creds.AdminAccessKey, creds.AdminSecretKey, creds.Region, bindIP)
skipHostDNS, _ := cmd.Flags().GetBool("skip-host-dns")
configureHostDNS(configSettings, skipHostDNS)
// Print cluster summary
fmt.Println("\n🎉 Node successfully joined cluster!")
fmt.Printf(" Cluster: %s (%d nodes)\n", creds.ClusterName, len(statusResp.Nodes))
@@ -2434,7 +2466,9 @@ func initIAMServiceFromConfig() (*handlers_iam.IAMServiceImpl, *config.ClusterCo
return nil, nil, nil, nil, fmt.Errorf("load master key: %w", err)
}
svc, err := handlers_iam.NewIAMServiceImpl(nc, masterKey, len(cfg.Nodes))
// Background: this runs at CLI top level, where there is no request to
// inherit a deadline from and the process exits after the one command.
svc, err := handlers_iam.NewIAMServiceImpl(context.Background(), nc, masterKey, len(cfg.Nodes))
if err != nil {
nc.Close()
return nil, nil, nil, nil, fmt.Errorf("init IAM service: %w", err)
@@ -2468,7 +2502,7 @@ func runAccountCreate(cmd *cobra.Command, args []string) {
// Create default VPC for the new account (belt-and-suspenders: daemon also
// does this via iam.account.created event, but daemon may not be running).
nodeConfig := cfg.Nodes[cfg.Node]
vpcSvc, vpcErr := handlers_ec2_vpc.NewVPCServiceImplWithNATS(&nodeConfig, nc)
vpcSvc, vpcErr := handlers_ec2_vpc.NewVPCServiceImplWithNATS(context.Background(), &nodeConfig, nc)
if vpcErr != nil {
fmt.Fprintf(os.Stderr, "Warning: could not create default VPC service: %v\n", vpcErr)
} else if _, vpcErr = vpcSvc.EnsureDefaultVPC(accountID); vpcErr != nil {
@@ -2704,6 +2738,45 @@ func generateAndWriteConfigs(dirs configDirs, spinifexTomlPath string, settings
return admin.GenerateConfigFiles(configs, settings)
}
// hostDNSParams derives the resolver target from the settings just rendered into
// northstar.toml and reports whether host DNS should be configured at all. Host
// DNS is pointed at the node's own northstar only when a northstar config was
// rendered (scoped DNS credentials present) and the operator did not opt out.
// The resolver is the node AdvertiseIP, falling back to BindIP.
func hostDNSParams(settings admin.ConfigSettings, skip bool) (hostdns.Params, bool) {
northstarEnabled := settings.NorthstarAccessKey != "" && settings.NorthstarSecretKey != ""
if !northstarEnabled || skip {
return hostdns.Params{}, false
}
resolverIP := settings.AdvertiseIP
if resolverIP == "" {
resolverIP = settings.BindIP
}
return hostdns.Params{
ResolverIP: resolverIP,
BaseDomain: settings.NorthstarDefaultDomain,
InternalDomain: settings.NorthstarInternalDomain,
}, true
}
// configureHostDNS points this node's host resolver at its own northstar so the
// Spinifex authoritative zones (ELB/EKS names) resolve from the node itself on
// every install path. Failures are surfaced loudly but never abort a formed
// cluster, mirroring installCACertificate.
func configureHostDNS(settings admin.ConfigSettings, skip bool) {
params, ok := hostDNSParams(settings, skip)
if !ok {
return
}
fmt.Println("\n🔧 Configuring host DNS for Spinifex zones...")
if err := hostdns.Configure(params); err != nil {
fmt.Fprintf(os.Stderr, "⚠️ Warning: could not configure host DNS: %v\n", err)
fmt.Fprintf(os.Stderr, " LB/EKS names may not resolve from this node until the host resolver points at %s:53.\n", params.ResolverIP)
return
}
fmt.Printf("✅ Host DNS: %s + %s -> %s:53 (northstar)\n", params.BaseDomain, params.InternalDomain, params.ResolverIP)
}
// finalizeNodeSetup configures AWS credentials, creates service directories,
// and sets ownership when running as root.
func finalizeNodeSetup(dataDir, certPath, adminAccessKey, adminSecretKey, region, bindIP string) {
@@ -3009,15 +3082,36 @@ func bridgeModeFor(externalMode string) string {
return ""
}
// detectDNSServers auto-detects DNS servers from the host for the specified
// interface. Uses resolvectl (systemd-resolved) first, then falls back to
// /etc/resolv.conf. Returns up to 3 servers. Falls back to public DNS if none found.
func detectDNSServers(iface string) []string {
// dnsDetectionCommandTimeout prevents a wedged resolver manager from blocking init.
const dnsDetectionCommandTimeout = 5 * time.Second
// dnsDetectionSources isolates host resolver discovery for deterministic tests.
type dnsDetectionSources struct {
queryResolvectl func(args ...string) (string, error)
readResolvConf func() (string, error)
}
// detectDNSServers auto-detects up to three host DNS servers, preferring
// systemd-resolved before resolv.conf and public fallbacks.
func detectDNSServers(iface string, excludedIPs ...string) []string {
return detectDNSServersWithSources(iface, excludedIPs, dnsDetectionSources{
queryResolvectl: func(args ...string) (string, error) {
return utils.RunCommandWithTimeout(dnsDetectionCommandTimeout, "resolvectl", args...)
},
readResolvConf: func() (string, error) {
data, err := os.ReadFile("/etc/resolv.conf")
return string(data), err
},
})
}
// detectDNSServersWithSources finds usable upstreams in resolver-manager order.
func detectDNSServersWithSources(iface string, excludedIPs []string, sources dnsDetectionSources) []string {
// Try resolvectl for the specific link first (most reliable on modern systems)
if iface != "" {
out, err := exec.Command("resolvectl", "dns", iface).CombinedOutput()
out, err := sources.queryResolvectl("dns", iface)
if err == nil {
servers := parseDNSFromResolvectl(string(out))
servers := filterDNSServers(parseDNSFromResolvectl(out), excludedIPs...)
if len(servers) > 0 {
return servers
}
@@ -3025,38 +3119,66 @@ func detectDNSServers(iface string) []string {
}
// Try resolvectl global
out, err := exec.Command("resolvectl", "dns").CombinedOutput()
out, err := sources.queryResolvectl("dns")
if err == nil {
servers := parseDNSFromResolvectl(string(out))
servers := filterDNSServers(parseDNSFromResolvectl(out), excludedIPs...)
if len(servers) > 0 {
return servers
}
}
// Fall back to /etc/resolv.conf
data, err := os.ReadFile("/etc/resolv.conf")
resolvConf, err := sources.readResolvConf()
if err == nil {
var servers []string
for line := range strings.SplitSeq(string(data), "\n") {
for line := range strings.SplitSeq(resolvConf, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "nameserver ") {
ip := strings.TrimSpace(strings.TrimPrefix(line, "nameserver"))
// Skip localhost (systemd-resolved stub)
if ip != "127.0.0.53" && ip != "127.0.0.1" && net.ParseIP(ip) != nil {
servers = append(servers, ip)
}
servers = append(servers, strings.TrimSpace(strings.TrimPrefix(line, "nameserver")))
}
}
if len(servers) > 0 {
if len(servers) > 3 {
servers = servers[:3]
}
if servers = filterDNSServers(servers, excludedIPs...); len(servers) > 0 {
return servers
}
}
// Fallback to well-known public DNS
return []string{"8.8.8.8", "1.1.1.1"}
return filterDNSServers([]string{"8.8.8.8", "1.1.1.1"}, excludedIPs...)
}
// filterDNSServers removes local, duplicate, and invalid resolver addresses and
// caps the upstream list at the three entries supported by generated configs.
func filterDNSServers(servers []string, excludedIPs ...string) []string {
excluded := make(map[string]struct{}, len(excludedIPs)+2)
excluded["127.0.0.1"] = struct{}{}
excluded["127.0.0.53"] = struct{}{}
for _, value := range excludedIPs {
if ip := net.ParseIP(strings.TrimSpace(value)); ip != nil {
excluded[ip.String()] = struct{}{}
}
}
filtered := make([]string, 0, min(len(servers), 3))
seen := make(map[string]struct{}, len(servers))
for _, value := range servers {
ip := net.ParseIP(strings.TrimSpace(value))
if ip == nil {
continue
}
normalized := ip.String()
if _, skip := excluded[normalized]; skip {
continue
}
if _, duplicate := seen[normalized]; duplicate {
continue
}
seen[normalized] = struct{}{}
filtered = append(filtered, normalized)
if len(filtered) == 3 {
break
}
}
return filtered
}
// parseDNSFromResolvectl extracts IP addresses from resolvectl dns output.
@@ -3071,14 +3193,11 @@ func parseDNSFromResolvectl(output string) []string {
}
fields := strings.FieldsSeq(after)
for f := range fields {
if net.ParseIP(f) != nil && f != "127.0.0.53" && f != "127.0.0.1" {
if net.ParseIP(f) != nil {
servers = append(servers, f)
}
}
}
if len(servers) > 3 {
servers = servers[:3]
}
return servers
}
+156
View File
@@ -14,6 +14,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/formation"
handlers_dns "github.com/mulgadc/spinifex/spinifex/handlers/dns"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/hostdns"
"github.com/mulgadc/spinifex/spinifex/utils"
toml "github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
@@ -545,6 +546,32 @@ func TestSpinifexTomlTemplate_EncryptionKeyFile(t *testing.T) {
assert.NotContains(t, string(data2), "encryption_key_file", "blank key must omit the field")
}
// A rendered node config must turn chunk GC on. Asserting the template text
// alone is not enough: the value has to survive parsing into a non-nil true
// pointer, because every consumer treats nil as off. Rendering and loading
// together is the only check that covers the whole path.
func TestSpinifexTomlTemplate_GCEnabled(t *testing.T) {
dir := t.TempDir()
settings := admin.ConfigSettings{
Node: "node1", Az: "ap-southeast-2a", Port: "4432", Region: "ap-southeast-2",
BindIP: "0.0.0.0", AccessKey: "AKIATEST", SecretKey: "SECRET",
AccountID: "123456789012", NatsToken: "token",
OVNNBAddr: "tcp:127.0.0.1:6641", OVNSBAddr: "tcp:127.0.0.1:6642",
ConfigDir: dir,
}
path := filepath.Join(dir, "spinifex.toml")
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
cfg, err := config.LoadConfig(path)
require.NoError(t, err)
node, ok := cfg.Nodes["node1"]
require.True(t, ok, "rendered config must define the node")
require.NotNil(t, node.Viperblock.GCEnabled, "gc_enabled must be rendered; nil leaves chunk GC off on every deployed node")
assert.True(t, *node.Viperblock.GCEnabled, "chunk GC must be enabled in a freshly rendered node config")
}
// Joiners run `spx admin join` against a configDir that doesn't yet contain
// a predastore/ subdir. The helper must create it (predastore subdir is
// owned by spinifex-storage in prod, but the helper just needs the dir to
@@ -1057,6 +1084,135 @@ func TestAdminInitFlag_CompactionIntervalReachesRenderedConfig(t *testing.T) {
assert.Contains(t, out, "interval_seconds = 45")
}
func TestFilterDNSServersExcludesLocalNorthstar(t *testing.T) {
servers := []string{
"10.0.0.5",
"127.0.0.53",
"1.1.1.1",
"1.1.1.1",
"not-an-ip",
"2001:0db8::1",
"8.8.8.8",
"9.9.9.9",
"4.4.4.4",
}
got := filterDNSServers(servers, "10.0.0.5", "2001:db8::1")
assert.Equal(t, []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"}, got)
}
func TestDetectDNSServersContinuesAfterExcludedSource(t *testing.T) {
var queries []string
sources := dnsDetectionSources{
queryResolvectl: func(args ...string) (string, error) {
query := strings.Join(args, " ")
queries = append(queries, query)
switch query {
case "dns br-wan":
return "Link 2 (br-wan): 10.0.0.5 127.0.0.53\n", nil
case "dns":
return "Global: 10.0.0.5 10.0.0.5 127.0.0.53 10.20.0.53 8.8.8.8\n", nil
default:
return "", fmt.Errorf("unexpected query %q", query)
}
},
readResolvConf: func() (string, error) {
t.Fatal("resolv.conf read despite usable global resolvers")
return "", nil
},
}
got := detectDNSServersWithSources("br-wan", []string{"10.0.0.5"}, sources)
assert.Equal(t, []string{"10.20.0.53", "8.8.8.8"}, got)
assert.Equal(t, []string{"dns br-wan", "dns"}, queries)
}
func TestDetectDNSServersFallsBackToResolvConf(t *testing.T) {
sources := dnsDetectionSources{
queryResolvectl: func(_ ...string) (string, error) {
return "", errors.New("resolver manager unavailable")
},
readResolvConf: func() (string, error) {
return "nameserver 10.0.0.5\nnameserver 10.20.0.53\n", nil
},
}
got := detectDNSServersWithSources("br-wan", []string{"10.0.0.5"}, sources)
assert.Equal(t, []string{"10.20.0.53"}, got)
}
func TestHostDNSParams(t *testing.T) {
// northstarEnabled requires both credentials; the zones and IPs mirror what
// admin init/join hold in memory after rendering northstar.toml.
enabled := admin.ConfigSettings{
NorthstarAccessKey: "AK",
NorthstarSecretKey: "SK",
NorthstarDefaultDomain: "spx3.net",
NorthstarInternalDomain: "compute.internal",
AdvertiseIP: "10.0.0.5",
BindIP: "0.0.0.0",
}
tests := []struct {
name string
settings admin.ConfigSettings
skip bool
wantOK bool
wantParams hostdns.Params
}{
{
name: "enabled selects AdvertiseIP over BindIP",
settings: enabled,
wantOK: true,
wantParams: hostdns.Params{
ResolverIP: "10.0.0.5",
BaseDomain: "spx3.net",
InternalDomain: "compute.internal",
},
},
{
name: "falls back to BindIP when AdvertiseIP is unset",
settings: func() admin.ConfigSettings {
s := enabled
s.AdvertiseIP = ""
s.BindIP = "10.0.0.9"
return s
}(),
wantOK: true,
wantParams: hostdns.Params{
ResolverIP: "10.0.0.9",
BaseDomain: "spx3.net",
InternalDomain: "compute.internal",
},
},
{
name: "opt-out skips even when enabled",
settings: enabled,
skip: true,
wantOK: false,
},
{
name: "northstar disabled skips",
settings: func() admin.ConfigSettings {
s := enabled
s.NorthstarSecretKey = ""
return s
}(),
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := hostDNSParams(tt.settings, tt.skip)
assert.Equal(t, tt.wantOK, ok)
if tt.wantOK {
assert.Equal(t, tt.wantParams, got)
}
})
}
}
// The image is written into a root volume of exactly the size this returns, so
// anything less than the image size truncates the image and the guest never
// finds its root. The old flooring conversion undersized every non-round image.
+2 -1
View File
@@ -92,7 +92,7 @@ gpu_passthrough = true
host = "{{.BindIP}}:9999"
tlskey = "config/server.key"
tlscert = "config/server.pem"
debug = true
debug = false
config = "awsgw/awsgw.toml"
[nodes.{{.Node}}.nats]
@@ -106,6 +106,7 @@ token = "{{.NatsToken}}"
[nodes.{{.Node}}.viperblock]
shardwal = false
gc_enabled = true
{{- if .EncryptionKeyFile}}
encryption_key_file = "{{.EncryptionKeyFile}}"
{{- end}}
+2 -2
View File
@@ -57,8 +57,8 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; add `--external-pool start-end --external-gateway <ip>` or `--external-source=dhcp [--external-bind-bridge <iface>]` for a public pool with full EIP support; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/`. `--force` on an already-initialized node is idempotent for crypto: it preserves the existing keys (master.key, predastore/viperblock encryption keys), system + admin credentials, and CA, and refreshes only the config files and the CA-signed server certificate (so a changed bind IP / SANs is picked up without breaking already-joined nodes or CA-baked AMIs). A genuine clean slate requires removing the data dirs first. |
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations |
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--skip-host-dns`, `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; add `--external-pool start-end --external-gateway <ip>` or `--external-source=dhcp [--external-bind-bridge <iface>]` for a public pool with full EIP support; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` → points the host resolver at this node's local northstar so the Spinifex zones (ELB/EKS names) resolve from the node itself (opt out with `--skip-host-dns`). `--force` on an already-initialized node is idempotent for crypto: it preserves the existing keys (master.key, predastore/viperblock encryption keys), system + admin credentials, and CA, and refreshes only the config files and the CA-signed server certificate (so a changed bind IP / SANs is picked up without breaking already-joined nodes or CA-baked AMIs). A genuine clean slate requires removing the data dirs first. |
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services`, `--skip-host-dns` | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations → points the host resolver at this node's local northstar so the Spinifex zones resolve from the node itself (opt out with `--skip-host-dns`) |
### Version
+1 -1
View File
@@ -75,7 +75,7 @@ Mirror the cloud image you intend to run as guest VMs:
```bash
mkdir -p images
curl -fsSL "https://cloud.debian.org/images/cloud/trixie/20260518-2482/debian-13-genericcloud-amd64-20260518-2482.qcow2" \
curl -fsSL "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2" \
-o images/debian-13-amd64.qcow2
```
+5 -3
View File
@@ -107,8 +107,10 @@ Spinifex nodes initiate a small, fixed set of outbound connections.
| Destination | Purpose | Protocol | Verification |
|-------------|---------|----------|--------------|
| `https://cloud.debian.org/images/cloud/trixie/` | Debian 13 cloud image download | HTTPS | TLS + checksum verification |
| `https://cloud-images.ubuntu.com/resolute/` | Ubuntu 26.04 LTS 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 |
| `https://d2yp8ipz5jfqcw.cloudfront.net` | Alpine image for managed HAProxy load-balancer | HTTPS | TLS + checksum verification |
| `https://install.mulgadc.com/install` | One-shot install telemetry POST on `spx admin init` / `join`. | HTTPS | TLS |
@@ -116,7 +118,7 @@ Spinifex nodes initiate a small, fixed set of outbound connections.
**Update checks and metadata.** Spinifex does not check for updates and does not consume a cloud metadata service (`169.254.169.254` is served *by* the cluster to guest VMs). Node software updates come from the operator's OS package channel. The install-telemetry endpoint above is the only vendor-operated destination contacted by a node; closed-egress deployments should disable it and record the opt-out in the security plan.
**Air-gapped deployments.** The three image URLs are the only destinations needed for the standard image catalogue. Mirror them locally and use `spx admin images import --file` with pre-staged files. Telemetry must also be disabled. See [Air-Gapped Install](/docs/install-airgapped).
**Air-gapped deployments.** The image URLs above are the only destinations needed for the standard image catalogue. Mirror them locally and use `spx admin images import --file` with pre-staged files. Telemetry must also be disabled. See [Air-Gapped Install](/docs/install-airgapped).
## 3. Cross-Node (Internal) Connections
+27 -22
View File
@@ -4,10 +4,10 @@ go 1.26.5
require (
github.com/aws/aws-sdk-go v1.55.8
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2 v1.43.0
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14
github.com/aws/aws-sdk-go-v2/config v1.32.30
github.com/aws/aws-sdk-go-v2/credentials v1.19.29
github.com/aws/aws-sdk-go-v2/config v1.32.31
github.com/aws/aws-sdk-go-v2/credentials v1.19.30
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
@@ -20,9 +20,9 @@ require (
github.com/insomniacslk/dhcp v0.0.0-20260407060928-11b94ed970f2
github.com/klauspost/cpuid/v2 v2.4.0
github.com/miekg/dns v1.1.72
github.com/mulgadc/northstar v1.13.0
github.com/mulgadc/predastore v1.13.0
github.com/mulgadc/viperblock v1.13.0
github.com/mulgadc/northstar v1.14.0
github.com/mulgadc/predastore v1.14.0
github.com/mulgadc/viperblock v1.14.0
github.com/nats-io/nats-server/v2 v2.14.3
github.com/nats-io/nats.go v1.52.0
github.com/opencontainers/runtime-spec v1.3.0
@@ -62,19 +62,19 @@ require (
github.com/antithesishq/antithesis-sdk-go v0.7.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect
github.com/aws/smithy-go v1.27.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -104,7 +104,7 @@ require (
github.com/containerd/ttrpc v1.2.8 // indirect
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgraph-io/badger/v4 v4.9.4 // indirect
github.com/dgraph-io/badger/v4 v4.9.5 // indirect
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
@@ -172,7 +172,7 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/quic-go/quic-go v0.61.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
@@ -187,7 +187,9 @@ require (
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect
@@ -198,7 +200,7 @@ require (
golang.org/x/vuln v1.4.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
@@ -215,4 +217,7 @@ require (
// stop requiring the monolith.
replace google.golang.org/genproto => google.golang.org/genproto v0.0.0-20260622175928-b703f567277d
tool golang.org/x/vuln/cmd/govulncheck
tool (
go.uber.org/nilaway/cmd/nilaway
golang.org/x/vuln/cmd/govulncheck
)
+47 -41
View File
@@ -524,72 +524,72 @@ github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZw
github.com/aws/aws-sdk-go-v2 v1.17.5/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw=
github.com/aws/aws-sdk-go-v2 v1.25.2/go.mod h1:Evoc5AsmtveRt1komDwIsjHFyrP5tDuF1D1U+6z6pNo=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI=
github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
github.com/aws/aws-sdk-go-v2/config v1.18.14/go.mod h1:0pI6JQBHKwd0JnwAZS3VCapLKMO++UL2BOkWwyyzTnA=
github.com/aws/aws-sdk-go-v2/config v1.27.4/go.mod h1:zq2FFXK3A416kiukwpsd+rD4ny6JC7QSkp4QdN1Mp2g=
github.com/aws/aws-sdk-go-v2/config v1.29.12/go.mod h1:xse1YTjmORlb/6fhkWi8qJh3cvZi4JoVNhc+NbJt4kI=
github.com/aws/aws-sdk-go-v2/config v1.32.30 h1:XwsEzpTJfQYJbFicz/QMLwAZdyeNVVoOEkbF7R3gPJk=
github.com/aws/aws-sdk-go-v2/config v1.32.30/go.mod h1:Ud32SuMc+/9BGxfpSVld7HrE2o05JwKmXY4M3jOQNZU=
github.com/aws/aws-sdk-go-v2/config v1.32.31 h1:n4nY9O3QKoHIkL85EX+V8RcMFtOhlpTFhGArg915PXk=
github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs=
github.com/aws/aws-sdk-go-v2/credentials v1.13.14/go.mod h1:85ckagDuzdIOnZRwws1eLKnymJs3ZM1QwVC1XcuNGOY=
github.com/aws/aws-sdk-go-v2/credentials v1.17.4/go.mod h1:+30tpwrkOgvkJL1rUZuRLoxcJwtI/OkeBLYnHxJtVe0=
github.com/aws/aws-sdk-go-v2/credentials v1.17.65/go.mod h1:4zyjAuGOdikpNYiSGpsGz8hLGmUzlY8pc8r9QQ/RXYQ=
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 h1:WHZGssHH887cO0ox07SIQZsFx3MKD4ps6w0xUEmnKYQ=
github.com/aws/aws-sdk-go-v2/credentials v1.19.29/go.mod h1:Mhl0xR6zjguiuj00XRx2wMx22sAltk7oya39sT7fdg8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.23/go.mod h1:mOtmAg65GT1HIL/HT/PynwPbS+UG0BgCZ6vhkPqnxWo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.2/go.mod h1:iRlGzMix0SExQEviAyptRWRGdYNo3+ufW/lCzvKVTUc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.29/go.mod h1:Dip3sIGv485+xerzVv24emnjX5Sg88utCL8fwGmCeWg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.2/go.mod h1:wRQv0nN6v9wDXuWThpovGQjqF1HFdcgWjporw14lS8k=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.23/go.mod h1:mr6c4cHC+S/MMkrjtSlG4QA36kOznDep+0fga5L/fGQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.2/go.mod h1:tyF5sKccmDz0Bv4NrstEr+/9YkSPJHrcO7UsUKf7pWM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.30/go.mod h1:vsbq62AOBwQ1LJ/GWKFxX8beUEYeRp/Agitrxee2/qM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1/go.mod h1:JKpmtYhhPs7D97NL/ltqz7yCkERFW5dOlHyVl66ZYF8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 h1:mdPwDQPqxlw9Sc62Nt15yjEcARaDbPXkjRYtXsUripo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24/go.mod h1:ls5ytnwLTcQaUu32fMYXFI3MjpKuTwL840PAm9iqyEg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.23/go.mod h1:9uPh+Hrz2Vn6oMnQYiUi/zbh3ovbnQk19YKINkQny44=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.2/go.mod h1:Ru7vg1iQ7cR4i7SZ/JTLYN9kaXtbL69UdgG0OQWQxW0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 h1:5C00eQYpTrgQXnp6V3P6P7zPElna3AXvlukbANE6nJI=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 h1:V7ZZ300WPXGjvkyore5DGe0ljVPOxCXie/thWdtSBXE=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 h1:jWXtZdCnhXa9sGFixRaU2AxT4DIVse9HS4E2f+/KwV0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32/go.mod h1:9JS1UpfVvyD/ZPX8GsKb/Pq8scEM+7GP5fqh9SwH7po=
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 h1:7QZWVJZWzHivHWIa+5TELLaBBkbuoj0GPwQtMlJ0sqk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0/go.mod h1:fcvq5L7dK+5cQFicEJwpI6e6Wn8NY2i6yT5wRLYVc7s=
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4=
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs=
github.com/aws/aws-sdk-go-v2/service/sso v1.12.3/go.mod h1:jtLIhd+V+lft6ktxpItycqHqiVXrPIRjWIsFIlzMriw=
github.com/aws/aws-sdk-go-v2/service/sso v1.20.1/go.mod h1:RsYqzYr2F2oPDdpy+PdhephuZxTfjHQe7SOBcZGoAU8=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.2/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 h1:gYFYh4iLLcAOJRLNPY2aD2g9DIhKn4eof8UkIrr1rTk=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A=
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.3/go.mod h1:zVwRrfdSmbRZWkUkWjOItY7SOalnFnq/Yg2LVPqDjwc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.1/go.mod h1:YjAPFn4kGFqKC54VsHs5fn5B6d+PCY2tziEa3U/GB5Y=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 h1:arjT9Cm3/WYbGmD5TUZHk4UQn4Lle1fUNZs5FC6CtF0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg=
github.com/aws/aws-sdk-go-v2/service/sts v1.18.4/go.mod h1:1mKZHLLpDMHTNSYPJ7qrcnCQdHCWsNQaT0xRvq2u80s=
github.com/aws/aws-sdk-go-v2/service/sts v1.28.1/go.mod h1:uQ7YYKZt3adCRrdCBREm1CD3efFLOUNH77MrUCvx5oA=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSIm93W2zhx10Xtydw=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78vpjFZ7PRUj44mJc=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg=
github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
@@ -735,8 +735,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v4 v4.9.4 h1:bcw+waCpzRZ2nmcSPbnPvDVhiEsn98TKmvnAhK7r7LM=
github.com/dgraph-io/badger/v4 v4.9.4/go.mod h1:nJjaJTUOSsQEBhsq209FmwCvMJzEA3e74RjZw6V2pQI=
github.com/dgraph-io/badger/v4 v4.9.5 h1:zT46OMrF3ntqsfI3ynKp7hUkQrGlcK2CX5psQmH0iW0=
github.com/dgraph-io/badger/v4 v4.9.5/go.mod h1:Xa9dAupjbwAacupWFCpa6YEn9E1PjBXkfZYr2I/8aWg=
github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU=
github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
@@ -1317,12 +1317,12 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mulgadc/northstar v1.13.0 h1:L9PyYZ9Klg7dv2kE5+DHOcgPHelaZRDIvnwBsbHsdGI=
github.com/mulgadc/northstar v1.13.0/go.mod h1:erQE/PzK9UMtgH8Y+uI5RdiboO/nqe1CUJbIpkae/FM=
github.com/mulgadc/predastore v1.13.0 h1:jDPpLJP/GA4WK2t+9lksTSFDuQiV8vgDl6ISnPJRUuo=
github.com/mulgadc/predastore v1.13.0/go.mod h1:38YSuk9zvYmbmgyM13WTkIQPJtvo2KmGJBYJul6NeAE=
github.com/mulgadc/viperblock v1.13.0 h1:NuqmPRm26/j58GKbzcyshm0uZwdobzdjKK1qEsb2pCY=
github.com/mulgadc/viperblock v1.13.0/go.mod h1:sW0hxZjN7WDQQZ39L5mE58fVLbSv3mBP3/ifFPdwJtU=
github.com/mulgadc/northstar v1.14.0 h1:8JzDcS4k3SN5PF3jBdB8x9FOWC24eITCClmOdwpoY8Q=
github.com/mulgadc/northstar v1.14.0/go.mod h1:6EArbWIQUrCFQ+XZ5ZJrGS/ep/u/rF6l+dKZh5vVIRs=
github.com/mulgadc/predastore v1.14.0 h1:vG+egKIP08SLql4hDUwke31ritValUYaDbyH39Ta6qw=
github.com/mulgadc/predastore v1.14.0/go.mod h1:C/mlefzQgqRAXNMbKc/rmW5BVSoB3R0OPHsgOi/islI=
github.com/mulgadc/viperblock v1.14.0 h1:Q6REPTPHh+3fnkjbyBSfLXWSppgiLsEcxwuvaHow0LY=
github.com/mulgadc/viperblock v1.14.0/go.mod h1:5ENQb0OrjTplIRiLShRW/oNWOzbFvSCFgo+PkNH4858=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
@@ -1475,8 +1475,8 @@ github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA=
github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@@ -1546,6 +1546,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -1812,6 +1813,8 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a h1:CsRd824LAzMmlpu9nYv2PHOqPQYv4Zjd4kGM+g0ahS0=
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a/go.mod h1:i9Hi8GeZFLBtUCXP73ZNSHKZ4K2JtxT0pLmo3sbJzlo=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
@@ -1911,6 +1914,8 @@ golang.org/x/exp/shiny v0.0.0-20230801115018-d63ba01acd4b/go.mod h1:UH99kUObWAZk
golang.org/x/exp/shiny v0.0.0-20230817173708-d852ddb80c63/go.mod h1:UH99kUObWAZkDnWqppdQe5ZhPYESUw8I0zVV1uWBR+0=
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
golang.org/x/exp/shiny v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976 h1:GTD/WuaexTazIG/SxLOz4rEKZPDVilmVVC2nz4xhwfE=
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -3094,8 +3099,9 @@ google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0/go.mod h1:Dk1tviKTvMCz5tvh7t+fh94dhmQVHuCt2OzJB3CTW9Y=
google.golang.org/grpc/examples v0.0.0-20201112215255-90f1b3ee835b/go.mod h1:IBqQ7wSUJ2Ep09a8rMWFsg4fmI2r38zwsq8a0GgxXpM=
+15
View File
@@ -265,6 +265,15 @@ else
CUST+=(--memsize 2048)
fi
# The guest's /etc/resolv.conf symlinks to systemd-resolved's stub
# (127.0.0.53), which isn't running inside the appliance chroot, so DNS is
# dead until package installs need it. Point it at a real resolver first, then
# restore the stub symlink after package installs so the shipped AMI still
# gets its DNS from systemd-resolved at boot like a stock cloud image.
if [[ -n "${APK_PACKAGES:-}${APT_PACKAGES:-}" ]]; then
CUST+=(--run-command 'rm -f /etc/resolv.conf; printf "nameserver 1.1.1.1\nnameserver 8.8.8.8\n" > /etc/resolv.conf')
fi
# Packages
if [[ "$DISTRO" == "alpine" ]] && [[ -n "${APK_PACKAGES:-}" ]]; then
echo "Will install packages: ${APK_PACKAGES}"
@@ -338,6 +347,12 @@ else
CUST+=(--run-command 'export DEBIAN_FRONTEND=noninteractive; apt-get clean; rm -rf /var/lib/apt/lists/* /tmp/* 2>/dev/null || true')
fi
# Restore the stock systemd-resolved stub symlink so the shipped AMI gets its
# DNS from the instance's own resolver at boot, not the static appliance one.
if [[ "$DISTRO" == "ubuntu" ]] && [[ -n "${APK_PACKAGES:-}${APT_PACKAGES:-}${SETUP_SCRIPT:-}" ]]; then
CUST+=(--run-command 'rm -f /etc/resolv.conf; ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf')
fi
echo "Customizing image (libguestfs appliance)..."
"${CUST[@]}"
+14 -11
View File
@@ -71,18 +71,21 @@ EOF
apt-get install -y --no-install-recommends "linux-headers-${KVER}"
# Ubuntu 26.04+ no longer ships unversioned meta-packages; detect the
# latest available versioned NVIDIA server driver.
NVIDIA_VER=$(apt-cache search '^nvidia-dkms-[0-9]+-server$' 2>/dev/null \
| grep -oP '(?<=nvidia-dkms-)\d+(?=-server)' | sort -rn | head -1)
if [ -z "${NVIDIA_VER}" ]; then
echo "ERROR: No versioned nvidia-dkms-*-server package found in apt cache"
apt-cache search nvidia-dkms || true
exit 1
fi
echo "[nvidia-gpu-stack] installing NVIDIA server driver version: ${NVIDIA_VER}"
# Ubuntu 26.04+ no longer ships unversioned meta-packages, so the branch is
# named explicitly. Pinned rather than resolved at build time: the number in
# the package name is a branch alias, not a driver version (nvidia-dkms-535-server
# installs 580.126.20), so picking the highest number sorts on a value that
# need not match what gets installed. Resolving also let identical source
# produce different drivers on different days. Bumping is a deliberate edit.
NVIDIA_VER=595
echo "[nvidia-gpu-stack] installing NVIDIA server driver branch: ${NVIDIA_VER}"
# The -open (GSP/open-kernel-module) DKMS variant, not the closed/proprietary
# one: Blackwell-generation GPUs (e.g. RTX Pro 6000 Blackwell) refuse to
# initialize under the closed kernel module — RmInitAdapter fails with
# "requires use of the NVIDIA open kernel modules" (dmesg NVRM error 0x22).
# nvidia-utils has no separate open variant (userspace tools only).
apt-get install -y --no-install-recommends \
"nvidia-dkms-${NVIDIA_VER}-server" \
"nvidia-dkms-${NVIDIA_VER}-server-open" \
"nvidia-utils-${NVIDIA_VER}-server"
NVIDIA_DKMS_NAME=$(dkms status 2>/dev/null | awk -F'[,/ ]+' '/nvidia/{print $1; exit}')
+14 -11
View File
@@ -90,18 +90,21 @@ EOF
apt-get install -y --no-install-recommends "linux-headers-${KVER}"
# Ubuntu 26.04+ no longer ships unversioned meta-packages; detect the
# latest available versioned NVIDIA server driver.
NVIDIA_VER=$(apt-cache search '^nvidia-dkms-[0-9]+-server$' 2>/dev/null \
| grep -oP '(?<=nvidia-dkms-)\d+(?=-server)' | sort -rn | head -1)
if [ -z "${NVIDIA_VER}" ]; then
echo "ERROR: No versioned nvidia-dkms-*-server package found in apt cache"
apt-cache search nvidia-dkms || true
exit 1
fi
echo "[nvidia-gpu-stack] installing NVIDIA server driver version: ${NVIDIA_VER}"
# Ubuntu 26.04+ no longer ships unversioned meta-packages, so the branch is
# named explicitly. Pinned rather than resolved at build time: the number in
# the package name is a branch alias, not a driver version (nvidia-dkms-535-server
# installs 580.126.20), so picking the highest number sorts on a value that
# need not match what gets installed. Resolving also let identical source
# produce different drivers on different days. Bumping is a deliberate edit.
NVIDIA_VER=595
echo "[nvidia-gpu-stack] installing NVIDIA server driver branch: ${NVIDIA_VER}"
# The -open (GSP/open-kernel-module) DKMS variant, not the closed/proprietary
# one: Blackwell-generation GPUs (e.g. RTX Pro 6000 Blackwell) refuse to
# initialize under the closed kernel module — RmInitAdapter fails with
# "requires use of the NVIDIA open kernel modules" (dmesg NVRM error 0x22).
# nvidia-utils has no separate open variant (userspace tools only).
apt-get install -y --no-install-recommends \
"nvidia-dkms-${NVIDIA_VER}-server" \
"nvidia-dkms-${NVIDIA_VER}-server-open" \
"nvidia-utils-${NVIDIA_VER}-server"
NVIDIA_DKMS_NAME=$(dkms status 2>/dev/null | awk -F'[,/ ]+' '/nvidia/{print $1; exit}')
@@ -24939,6 +24939,17 @@ spec:
labels:
app.kubernetes.io/name: argocd-applicationset-controller
spec:
# Local delta over the vendored upstream manifest: a cluster with a
# GPU-only nodegroup has no untainted node — the CP carries
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
# these the pod stays Pending forever. Upstream never needs it because
# real EKS control planes are not schedulable nodes.
tolerations:
- key: CriticalAddonsOnly
operator: Exists
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
containers:
- args:
- /usr/local/bin/argocd-applicationset-controller
@@ -25180,6 +25191,17 @@ spec:
labels:
app.kubernetes.io/name: argocd-dex-server
spec:
# Local delta over the vendored upstream manifest: a cluster with a
# GPU-only nodegroup has no untainted node — the CP carries
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
# these the pod stays Pending forever. Upstream never needs it because
# real EKS control planes are not schedulable nodes.
tolerations:
- key: CriticalAddonsOnly
operator: Exists
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
@@ -25304,6 +25326,17 @@ spec:
labels:
app.kubernetes.io/name: argocd-notifications-controller
spec:
# Local delta over the vendored upstream manifest: a cluster with a
# GPU-only nodegroup has no untainted node — the CP carries
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
# these the pod stays Pending forever. Upstream never needs it because
# real EKS control planes are not schedulable nodes.
tolerations:
- key: CriticalAddonsOnly
operator: Exists
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
containers:
- args:
- /usr/local/bin/argocd-notifications
@@ -25403,6 +25436,17 @@ spec:
labels:
app.kubernetes.io/name: argocd-redis
spec:
# Local delta over the vendored upstream manifest: a cluster with a
# GPU-only nodegroup has no untainted node — the CP carries
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
# these the pod stays Pending forever. Upstream never needs it because
# real EKS control planes are not schedulable nodes.
tolerations:
- key: CriticalAddonsOnly
operator: Exists
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
@@ -25486,6 +25530,17 @@ spec:
labels:
app.kubernetes.io/name: argocd-repo-server
spec:
# Local delta over the vendored upstream manifest: a cluster with a
# GPU-only nodegroup has no untainted node — the CP carries
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
# these the pod stays Pending forever. Upstream never needs it because
# real EKS control planes are not schedulable nodes.
tolerations:
- key: CriticalAddonsOnly
operator: Exists
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
@@ -25840,6 +25895,17 @@ spec:
labels:
app.kubernetes.io/name: argocd-server
spec:
# Local delta over the vendored upstream manifest: a cluster with a
# GPU-only nodegroup has no untainted node — the CP carries
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
# these the pod stays Pending forever. Upstream never needs it because
# real EKS control planes are not schedulable nodes.
tolerations:
- key: CriticalAddonsOnly
operator: Exists
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
@@ -26251,6 +26317,17 @@ spec:
labels:
app.kubernetes.io/name: argocd-application-controller
spec:
# Local delta over the vendored upstream manifest: a cluster with a
# GPU-only nodegroup has no untainted node — the CP carries
# CriticalAddonsOnly and every worker carries nvidia.com/gpu, so without
# these the pod stays Pending forever. Upstream never needs it because
# real EKS control planes are not schedulable nodes.
tolerations:
- key: CriticalAddonsOnly
operator: Exists
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
@@ -44,6 +44,18 @@ spec:
kubernetes.io/os: linux
serviceAccountName: ebs-csi-controller-sa
priorityClassName: system-cluster-critical
# A cluster with a GPU-only nodegroup has no untainted node: the CP carries
# CriticalAddonsOnly (EKS parity keeps user workloads off it) and every
# worker carries nvidia.com/gpu (nodegroup_userdata.go). Without these
# tolerations the controller has nowhere to schedule and stays Pending
# forever. Real EKS never needs this: its control plane isn't a schedulable
# node, so the stock upstream manifest carries no such toleration.
tolerations:
- key: CriticalAddonsOnly
operator: Exists
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
@@ -348,6 +348,18 @@ spec:
readOnly: true
{{IRSA_VOLUME_MOUNT}}
priorityClassName: system-cluster-critical
# A cluster with a GPU-only nodegroup has no untainted node: the CP carries
# CriticalAddonsOnly (EKS parity keeps user workloads off it) and every
# worker carries nvidia.com/gpu (nodegroup_userdata.go). Without these
# tolerations the controller has nowhere to schedule and stays Pending
# forever. Real EKS never needs this: its control plane isn't a schedulable
# node, so the stock upstream manifest carries no such toleration.
tolerations:
- key: CriticalAddonsOnly
operator: Exists
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
securityContext:
fsGroup: 1337
serviceAccountName: aws-load-balancer-controller
+59 -7
View File
@@ -4,13 +4,27 @@
#
# Spinifex carries tenant traffic over an OVN geneve underlay (~1442B usable on a
# 1500B host link). The EKS node's primary ENI (eth0) comes up at the default
# 1500 — cloud-init's Ec2 datasource has no MTU leaf and Alpine's udhcpc ignores
# DHCP option 26 — so flannel derives flannel.1 = 1500-50 = 1450. VXLAN frames up
# to 1500 then exceed the underlay and are silently dropped: small packets (TCP
# handshakes) pass while large ones (TLS, kubelet streams, pod-to-pod payloads)
# blackhole. Pinning eth0 down to 1320 (flannel.1 = 1270) leaves margin under the
# underlay and restores the datapath. This must run before k3s starts flannel,
# which reads the iface MTU once at startup.
# 1500 — cloud-init's Ec2 datasource has no MTU leaf — so flannel derives
# flannel.1 = 1500-50 = 1450. VXLAN frames up to 1500 then exceed the underlay and
# are silently dropped: small packets (TCP handshakes) pass while large ones (TLS,
# kubelet streams, pod-to-pod payloads) blackhole. Pinning eth0 down to 1320
# (flannel.1 = 1270) leaves margin under the underlay and restores the datapath.
# This must run before k3s starts flannel, which reads the iface MTU once at
# startup.
#
# Pinning the LINK is not sufficient on its own. This image runs dhcpcd, not
# udhcpc (an earlier version of this comment had that backwards), and dhcpcd
# honours DHCP option 26 by stamping the advertised MTU onto every route it
# installs:
#
# default via 10.32.101.1 dev eth0 proto dhcp src 10.32.101.7 metric 1002 mtu 1442
#
# Linux prefers a route's MTU metric over the device MTU (dst_mtu()), so the node
# still advertises an MSS derived from 1442 no matter what the link says, and
# large inbound segments blackhole in the HOST netns — which is where containerd
# pulls run, so image pulls fail with `TLS handshake timeout` while pod traffic
# (correctly sized off cni0) is fine. Pod netns working while the node netns does
# not is the signature. Re-stamp the routes to match the pinned link.
#
# eth0 is always the primary data ENI (cloud-init set-name; mgmt0, when present,
# is a separate NIC and is left at its default). The default-route interface is
@@ -30,9 +44,47 @@ pin() {
fi
}
# Rewrite the MTU metric dhcpcd stamped onto the routes of one interface, so no
# route can advertise a larger MSS than the pinned link carries. Routes with no
# MTU metric inherit the device MTU and are left alone.
#
# `ip route show dev X` omits the "dev X" token from each line, so it is added
# back before replacing; a "default" line already carries its own dev.
restamp() {
iface="$1"
[ -n "$iface" ] || return 0
ip -4 route show dev "$iface" 2>/dev/null | while read -r line; do
case "$line" in
*" mtu "*) ;;
*) continue ;;
esac
cur=$(echo "$line" | sed -n 's/.* mtu \([0-9]*\).*/\1/p')
[ -n "$cur" ] || continue
[ "$cur" != "$MTU" ] || continue
spec=$(echo "$line" | sed "s/ mtu $cur/ mtu $MTU/")
case "$spec" in
*" dev "*) ;;
*) spec="$spec dev $iface" ;;
esac
# Word splitting is intended: $spec is a route specification, not a path.
# shellcheck disable=SC2086
if ip route replace $spec; then
echo "[mulga-vpc-mtu] $iface route MTU $cur -> $MTU ($line)"
else
echo "[mulga-vpc-mtu] WARNING: failed to restamp $iface route: $line" >&2
fi
done
}
pin eth0
route_iface=$(ip -4 route show default 2>/dev/null | awk '{print $5; exit}')
[ "$route_iface" = "eth0" ] || pin "$route_iface"
# setup.sh writes `nooption interface_mtu` to dhcpcd.conf so no future lease can
# re-stamp a route MTU; this pass fixes routes installed before that took effect
# (an already-running dhcpcd, or an image built before the fix).
restamp eth0
[ "$route_iface" = "eth0" ] || restamp "$route_iface"
exit 0
@@ -0,0 +1,118 @@
#!/bin/sh
# Self-contained POSIX test for mulga-vpc-mtu.sh route re-stamping. No root or
# real interfaces needed: `ip` is stubbed on PATH, serving fixture route output
# and recording every mutating call.
#
# Covers the fault this script exists to prevent: dhcpcd stamps the DHCP MTU
# (option 26) onto its routes, Linux prefers a route's MTU metric over the device
# MTU, so pinning only the link leaves the node advertising an MSS too large for
# the path and large inbound segments blackhole in the host netns.
#
# Run: sh scripts/images/eks-node/mulga-vpc-mtu_test.sh
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PINNER="${SCRIPT_DIR}/mulga-vpc-mtu.sh"
WORK=$(mktemp -d)
trap 'rm -rf "${WORK}"' EXIT
STUBBIN="${WORK}/bin"
mkdir -p "${STUBBIN}"
# Stub `ip`: serves ${ROUTES_DEFAULT} / ${ROUTES_DEV} for show, records mutations
# ("link set …" / "route replace …") to ${CALLS}. Anything else is a no-op.
cat > "${STUBBIN}/ip" <<'STUB'
#!/bin/sh
args="$*"
case "$args" in
*"route show default"*) cat "${ROUTES_DEFAULT}"; exit 0 ;;
*"route show dev "*) cat "${ROUTES_DEV}"; exit 0 ;;
"link show dev "*) exit 0 ;;
"link set dev "*) echo "$args" >> "${CALLS}"; exit 0 ;;
"route replace "*) echo "$args" >> "${CALLS}"; exit 0 ;;
esac
exit 0
STUB
chmod +x "${STUBBIN}/ip"
PASS=0
FAIL=0
check() { # check <desc> <expected> <actual>
if [ "$2" = "$3" ]; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
echo "FAIL: $1"
echo " expected: [$2]"
echo " actual: [$3]"
fi
}
# run_case <name> <default-routes> <dev-routes>: runs the pinner against fixture
# route tables and leaves the recorded mutations in ${CALLS}.
run_case() {
CASE="${WORK}/$1"
mkdir -p "${CASE}"
CALLS="${CASE}/calls"
ROUTES_DEFAULT="${CASE}/default"
ROUTES_DEV="${CASE}/dev"
: > "${CALLS}"
printf '%s\n' "$2" > "${ROUTES_DEFAULT}"
printf '%s\n' "$3" > "${ROUTES_DEV}"
export CALLS ROUTES_DEFAULT ROUTES_DEV
PATH="${STUBBIN}:${PATH}" sh "${PINNER}" >/dev/null 2>&1
}
replaces() { grep -c '^route replace' "${CALLS}" 2>/dev/null | head -1; }
# 1. The real-world fault: dhcpcd's 1442 on the default route is rewritten to the
# pinned link MTU, and the rest of the route spec is preserved verbatim.
run_case dhcpcd-1442 \
'default via 10.32.101.1 dev eth0 proto dhcp src 10.32.101.7 metric 1002 mtu 1442' \
'default via 10.32.101.1 proto dhcp src 10.32.101.7 metric 1002 mtu 1442'
check "link is pinned to 1320" \
"link set dev eth0 mtu 1320" \
"$(grep '^link set' "${CALLS}" | head -1)"
check "route MTU rewritten to the pinned link MTU" \
"route replace default via 10.32.101.1 proto dhcp src 10.32.101.7 metric 1002 mtu 1320 dev eth0" \
"$(grep '^route replace' "${CALLS}" | head -1)"
# 2. `ip route show dev X` omits the dev token; it must be added back or the
# replace would be rejected (and the fault would silently persist).
run_case dev-token-restored \
'default via 10.32.101.1 dev eth0 proto dhcp metric 1002 mtu 1442' \
'10.32.101.0/24 proto kernel scope link src 10.32.101.7 metric 1002 mtu 1442'
check "dev token re-added to a dev-scoped route" \
"route replace 10.32.101.0/24 proto kernel scope link src 10.32.101.7 metric 1002 mtu 1320 dev eth0" \
"$(grep '^route replace' "${CALLS}" | head -1)"
# 3. Idempotent: a route already at the pinned MTU is left alone, so a re-run (or
# a boot after the dhcpcd.conf fix) issues no route changes.
run_case already-pinned \
'default via 10.32.101.1 dev eth0 proto dhcp metric 1002 mtu 1320' \
'default via 10.32.101.1 proto dhcp metric 1002 mtu 1320'
check "no replace when the route MTU already matches" "0" "$(replaces)"
# 4. A route with no MTU metric inherits the device MTU — leave it alone rather
# than pinning a value the link may later change.
run_case no-mtu-metric \
'default via 10.32.101.1 dev eth0 proto dhcp metric 1002' \
'default via 10.32.101.1 proto dhcp metric 1002'
check "no replace when the route carries no MTU metric" "0" "$(replaces)"
# 5. The pinned value is overridable, and the override reaches the routes too.
run_case custom-mtu \
'default via 10.32.101.1 dev eth0 proto dhcp metric 1002 mtu 1442' \
'default via 10.32.101.1 proto dhcp metric 1002 mtu 1442'
CASE="${WORK}/custom-mtu-run"
mkdir -p "${CASE}"
CALLS="${CASE}/calls"
: > "${CALLS}"
export CALLS
PATH="${STUBBIN}:${PATH}" MULGA_VPC_NIC_MTU=1408 sh "${PINNER}" >/dev/null 2>&1
check "MULGA_VPC_NIC_MTU overrides both link and route" \
"route replace default via 10.32.101.1 proto dhcp metric 1002 mtu 1408 dev eth0" \
"$(grep '^route replace' "${CALLS}" | head -1)"
echo "pass=${PASS} fail=${FAIL}"
[ "${FAIL}" -eq 0 ]
+12
View File
@@ -142,4 +142,16 @@ if ! grep -qxF 'noipv4ll' /etc/dhcpcd.conf 2>/dev/null; then
echo 'noipv4ll' >> /etc/dhcpcd.conf
fi
# Ignore the DHCP MTU (option 26). dhcpcd honours it by stamping the advertised
# value onto every route it installs, and Linux prefers a route's MTU metric over
# the device MTU — so the subnet's advertised MTU wins over the link MTU that
# mulga-vpc-mtu pins, and the node advertises an MSS too large for the real path.
# Large inbound segments then blackhole in the host netns (image pulls fail with
# `TLS handshake timeout`) while pod traffic, sized off cni0, is unaffected.
# mulga-vpc-mtu is the single source of truth for this node's MTU; this stops
# dhcpcd re-stamping a different one on every lease renewal.
if ! grep -qxF 'nooption interface_mtu' /etc/dhcpcd.conf 2>/dev/null; then
echo 'nooption interface_mtu' >> /etc/dhcpcd.conf
fi
echo "[eks-node-setup] done"
+15 -12
View File
@@ -60,19 +60,22 @@ EOF
apt-get install -y --no-install-recommends \
"linux-headers-${KVER}"
# Detect the latest available versioned NVIDIA server driver. Ubuntu 26.04+
# no longer ships unversioned meta-packages (nvidia-dkms-server); the packages
# are now versioned (e.g. nvidia-dkms-570-server).
NVIDIA_VER=$(apt-cache search '^nvidia-dkms-[0-9]+-server$' 2>/dev/null \
| grep -oP '(?<=nvidia-dkms-)\d+(?=-server)' | sort -rn | head -1)
if [[ -z "${NVIDIA_VER}" ]]; then
echo "ERROR: No versioned nvidia-dkms-*-server package found in apt cache"
apt-cache search nvidia-dkms || true
exit 1
fi
echo "Installing NVIDIA server driver version: ${NVIDIA_VER}"
# Ubuntu 26.04+ no longer ships unversioned meta-packages (nvidia-dkms-server);
# the packages are versioned (e.g. nvidia-dkms-570-server), so the branch is
# named explicitly. Pinned rather than resolved at build time: the number in the
# package name is a branch alias, not a driver version (nvidia-dkms-535-server
# installs 580.126.20), so picking the highest number sorts on a value that need
# not match what gets installed. Resolving also let identical source produce
# different drivers on different days. Bumping is a deliberate edit.
NVIDIA_VER=595
echo "Installing NVIDIA server driver branch: ${NVIDIA_VER}"
# The -open (GSP/open-kernel-module) DKMS variant, not the closed/proprietary
# one: Blackwell-generation GPUs (e.g. RTX Pro 6000 Blackwell) refuse to
# initialize under the closed kernel module — RmInitAdapter fails with
# "requires use of the NVIDIA open kernel modules" (dmesg NVRM error 0x22).
# nvidia-utils has no separate open variant (userspace tools only).
apt-get install -y --no-install-recommends \
"nvidia-dkms-${NVIDIA_VER}-server" \
"nvidia-dkms-${NVIDIA_VER}-server-open" \
"nvidia-utils-${NVIDIA_VER}-server"
# Detect the installed NVIDIA DKMS module name + version for explicit build.
+1 -75
View File
@@ -15,7 +15,7 @@
# VERBOSE Set to 1 to echo "[setup] <stage>" before each top-level step.
# SETUP_STAGES Comma-separated subset of stages to run:
# deps, aws, users, sudoers, files, directories,
# env, systemd, logrotate, udev, fixown, resolved,
# env, systemd, logrotate, udev, fixown,
# migrations
# Unset = run every stage appropriate for the current mode.
@@ -794,79 +794,6 @@ EOF
info "Swap enabled: $swapfile (${size_mb}MiB), vm.swappiness=10"
}
# --- Configure host split DNS for Spinifex zones (systemd-resolved) ---
# When systemd-resolved manages the host resolver, route the Spinifex
# authoritative zones (default_domain + compute.internal) to the local northstar
# resolver on the node WAN IP, while every other name keeps using the existing
# link/DHCP DNS. The "~" routing-domain prefix restricts the DNS= server to
# exactly those zones (resolved.conf(5)). northstar binds <wan>:53 (not
# 127.0.0.1), so DNS= must be the WAN IP. Idempotent; no-op without resolved.
setup_resolved_dns() {
stage "configuring systemd-resolved split DNS for Spinifex zones"
# ISO chroot has no live resolver to reconfigure; firstboot handles it.
if [ "${ISO_BUILD:-0}" = "1" ]; then
info "Resolved split DNS skipped (ISO_BUILD=1; firstboot configures it)"
return
fi
# Only act when systemd-resolved is the active resolver; otherwise the
# operator must point their resolver at the node :53 manually.
if ! $SUDO systemctl is-active --quiet systemd-resolved 2>/dev/null; then
info "systemd-resolved not active — skipping host split DNS"
info " To resolve the Spinifex zones, forward them to the node WAN IP on :53"
return
fi
# northstar binds :53 on the node WAN IP. Detect the default-route source.
local wan_ip
wan_ip=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | awk '{print $2}')
[ -z "$wan_ip" ] && wan_ip=$(ip -4 route show default 2>/dev/null | grep -oE 'src [0-9.]+' | awk '{print $2}')
if [ -z "$wan_ip" ]; then
warn "Could not detect a WAN IP — skipping resolved split DNS"
warn " Re-run once the WAN is up: sudo SETUP_STAGES=resolved $0"
return
fi
# Default zones match `spx admin init` defaults; honour a custom
# default_domain if northstar.toml already exists (post-init re-run).
local base_domain="spx3.net" internal_domain="compute.internal"
local ns_toml=/etc/spinifex/northstar/northstar.toml
if $SUDO test -f "$ns_toml"; then
local cfg_domain
cfg_domain=$($SUDO grep -E '^[[:space:]]*default_domain[[:space:]]*=' "$ns_toml" 2>/dev/null \
| head -1 | sed -E 's/.*=[[:space:]]*"?([^"#]*[^"# ])"?.*/\1/')
[ -n "$cfg_domain" ] && base_domain="$cfg_domain"
fi
local dropin_dir=/etc/systemd/resolved.conf.d
local dropin="$dropin_dir/spinifex-dns.conf"
local tmp
tmp=$(mktemp)
cat > "$tmp" << EOF
# Generated by setup.sh — route the Spinifex authoritative zones to the local
# northstar resolver on the node WAN IP. The "~" routing-domain prefix restricts
# this DNS= server to exactly these zones; all other names use the link DNS.
# northstar binds <wan>:53 (not 127.0.0.1), so DNS= is the WAN IP.
[Resolve]
DNS=${wan_ip}
Domains=~${base_domain} ~${internal_domain}
EOF
# Skip the resolved restart when nothing changed (idempotent re-runs).
if $SUDO test -f "$dropin" && $SUDO cmp -s "$tmp" "$dropin"; then
rm -f "$tmp"
info "Host split DNS already current: ${base_domain} + ${internal_domain} -> ${wan_ip}:53"
return
fi
$SUDO mkdir -p "$dropin_dir"
$SUDO install -m 0644 "$tmp" "$dropin"
rm -f "$tmp"
$SUDO systemctl restart systemd-resolved 2>/dev/null || true
info "Host split DNS: ${base_domain} + ${internal_domain} -> ${wan_ip}:53 (northstar)"
}
# --- Main ---
main() {
info "Spinifex installer"
@@ -903,7 +830,6 @@ main() {
stage_enabled logrotate && install_logrotate
stage_enabled udev && install_udev
stage_enabled swap && setup_swap
stage_enabled resolved && setup_resolved_dns
# Migrations are only safe on a live system (need a running NATS and a
# persisted config file). Skip under ISO_BUILD and under any explicit
+32
View File
@@ -522,6 +522,38 @@ func ValidErrorCode(code string) string {
return ErrorServerInternal
}
// ResolveErrorCode returns the first registered AWS error code in err's unwrap tree.
func ResolveErrorCode(err error) (string, bool) {
if err == nil {
return "", false
}
code := err.Error()
if _, ok := ErrorLookup[code]; ok {
return code, true
}
if joined, ok := err.(interface{ Unwrap() []error }); ok {
for _, inner := range joined.Unwrap() {
if code, found := ResolveErrorCode(inner); found {
return code, true
}
}
return "", false
}
if wrapped, ok := err.(interface{ Unwrap() error }); ok {
return ResolveErrorCode(wrapped.Unwrap())
}
return "", false
}
// ValidErrorCodeFromError resolves err or returns ErrorServerInternal.
func ValidErrorCodeFromError(err error) string {
if code, ok := ResolveErrorCode(err); ok {
return code
}
return ErrorServerInternal
}
// HasErrorCode reports whether s is exactly a registered AWS error code.
// Use it to decide whether an error string may be surfaced to clients verbatim
// (mapped to its real HTTP status) rather than wrapped/sanitized to 500.
+41
View File
@@ -70,6 +70,47 @@ func TestValidErrorCode(t *testing.T) {
}
}
func TestResolveErrorCode(t *testing.T) {
known := errors.New(ErrorInsufficientAddressCapacity)
tests := []struct {
name string
err error
want string
wantOK bool
}{
{name: "nil", err: nil},
{name: "exact code", err: known, want: ErrorInsufficientAddressCapacity, wantOK: true},
{name: "single wrapper", err: fmt.Errorf("allocate public address: %w", known), want: ErrorInsufficientAddressCapacity, wantOK: true},
{name: "nested wrappers", err: fmt.Errorf("launch on node: %w", fmt.Errorf("prepare instance: %w", known)), want: ErrorInsufficientAddressCapacity, wantOK: true},
{name: "joined errors", err: errors.Join(errors.New("opaque"), known), want: ErrorInsufficientAddressCapacity, wantOK: true},
{name: "first joined code wins", err: errors.Join(errors.New(ErrorAuthFailure), known), want: ErrorAuthFailure, wantOK: true},
{name: "unknown error", err: errors.New("opaque")},
{name: "code substring is not exact", err: errors.New("launch failed: " + ErrorInsufficientAddressCapacity)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := ResolveErrorCode(tt.err)
if got != tt.want || ok != tt.wantOK {
t.Errorf("ResolveErrorCode() = (%q, %v), want (%q, %v)", got, ok, tt.want, tt.wantOK)
}
})
}
}
func TestValidErrorCodeFromError(t *testing.T) {
wrapped := fmt.Errorf("launch: %w", errors.New(ErrorInsufficientInstanceCapacity))
if got := ValidErrorCodeFromError(wrapped); got != ErrorInsufficientInstanceCapacity {
t.Errorf("ValidErrorCodeFromError() = %q, want %q", got, ErrorInsufficientInstanceCapacity)
}
if got := ValidErrorCodeFromError(errors.New("opaque")); got != ErrorServerInternal {
t.Errorf("ValidErrorCodeFromError() = %q, want %q", got, ErrorServerInternal)
}
if got := ValidErrorCodeFromError(nil); got != ErrorServerInternal {
t.Errorf("ValidErrorCodeFromError(nil) = %q, want %q", got, ErrorServerInternal)
}
}
func TestHasErrorCode(t *testing.T) {
tests := []struct {
name string
+278 -268
View File
@@ -57,6 +57,7 @@ import (
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/instancetypes"
"github.com/mulgadc/spinifex/spinifex/kvutil"
"github.com/mulgadc/spinifex/spinifex/network/external"
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
"github.com/mulgadc/spinifex/spinifex/network/host"
@@ -66,6 +67,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// ResourceManager handles the allocation and tracking of system resources.
@@ -770,123 +772,125 @@ func (d *Daemon) subscribeAll() error {
subs := []natsSub{
// ec2.RunInstances is handled by dynamic per-instance-type subscriptions
// managed by ResourceManager.initSubscriptions()
{"ec2.CreateKeyPair", d.handleEC2CreateKeyPair, "spinifex-workers"},
{"ec2.DeleteKeyPair", d.handleEC2DeleteKeyPair, "spinifex-workers"},
{"ec2.DescribeKeyPairs", d.handleEC2DescribeKeyPairs, "spinifex-workers"},
{"ec2.ImportKeyPair", d.handleEC2ImportKeyPair, "spinifex-workers"},
{"ec2.CreateKeyPair", handleNATSRequest(d.keyService.CreateKeyPair), "spinifex-workers"},
{"ec2.DeleteKeyPair", handleNATSRequest(d.keyService.DeleteKeyPair), "spinifex-workers"},
{"ec2.DescribeKeyPairs", handleNATSRequest(d.keyService.DescribeKeyPairs), "spinifex-workers"},
{"ec2.ImportKeyPair", handleNATSRequest(d.keyService.ImportKeyPair), "spinifex-workers"},
{"imds.ec2.get_public_key", d.handleIMDSGetPublicKey, "spinifex-workers"},
{"ec2.DescribeImages", d.handleEC2DescribeImages, "spinifex-workers"},
{"ec2.DescribeImages", handleNATSRequest(d.imageService.DescribeImages), "spinifex-workers"},
{"ec2.CreateImage", d.handleEC2CreateImage, ""},
{"ec2.DeregisterImage", d.handleEC2DeregisterImage, "spinifex-workers"},
{"ec2.RegisterImage", d.handleEC2RegisterImage, "spinifex-workers"},
{"ec2.CopyImage", d.handleEC2CopyImage, "spinifex-workers"},
{"ec2.DescribeImageAttribute", d.handleEC2DescribeImageAttribute, "spinifex-workers"},
{"ec2.ModifyImageAttribute", d.handleEC2ModifyImageAttribute, "spinifex-workers"},
{"ec2.ResetImageAttribute", d.handleEC2ResetImageAttribute, "spinifex-workers"},
{"ec2.CreateVolume", d.handleEC2CreateVolume, "spinifex-workers"},
{"ec2.DescribeVolumes", d.handleEC2DescribeVolumes, "spinifex-workers"},
{"ec2.DeregisterImage", handleNATSRequest(d.imageService.DeregisterImage), "spinifex-workers"},
{"ec2.RegisterImage", handleNATSRequest(d.imageService.RegisterImage), "spinifex-workers"},
{"ec2.CopyImage", handleNATSRequest(d.imageService.CopyImage), "spinifex-workers"},
{"ec2.DescribeImageAttribute", handleNATSRequest(d.imageService.DescribeImageAttribute), "spinifex-workers"},
{"ec2.ModifyImageAttribute", handleNATSRequest(d.imageService.ModifyImageAttribute), "spinifex-workers"},
{"ec2.ResetImageAttribute", handleNATSRequest(d.imageService.ResetImageAttribute), "spinifex-workers"},
{"ec2.CreateVolume", handleNATSRequest(d.volumeService.CreateVolume), "spinifex-workers"},
{"ec2.DescribeVolumes", handleNATSRequest(d.volumeService.DescribeVolumes), "spinifex-workers"},
{"ec2.ModifyVolume", d.handleEC2ModifyVolume, "spinifex-workers"},
{"ec2.DeleteVolume", d.handleEC2DeleteVolume, "spinifex-workers"},
{"ec2.DescribeVolumeStatus", d.handleEC2DescribeVolumeStatus, "spinifex-workers"},
{"ec2.DescribeVolumesModifications", d.handleEC2DescribeVolumesModifications, "spinifex-workers"},
{"ec2.CreateSnapshot", d.handleEC2CreateSnapshot, "spinifex-workers"},
{"ec2.DescribeSnapshots", d.handleEC2DescribeSnapshots, "spinifex-workers"},
{"ec2.DeleteSnapshot", d.handleEC2DeleteSnapshot, "spinifex-workers"},
{"ec2.CopySnapshot", d.handleEC2CopySnapshot, "spinifex-workers"},
{"ec2.CreateTags", d.handleEC2CreateTags, "spinifex-workers"},
{"ec2.DeleteTags", d.handleEC2DeleteTags, "spinifex-workers"},
{"ec2.DescribeTags", d.handleEC2DescribeTags, "spinifex-workers"},
{"ec2.CreateEgressOnlyInternetGateway", d.handleEC2CreateEgressOnlyInternetGateway, "spinifex-workers"},
{"ec2.DeleteEgressOnlyInternetGateway", d.handleEC2DeleteEgressOnlyInternetGateway, "spinifex-workers"},
{"ec2.DescribeEgressOnlyInternetGateways", d.handleEC2DescribeEgressOnlyInternetGateways, "spinifex-workers"},
{"ec2.CreateInternetGateway", d.handleEC2CreateInternetGateway, "spinifex-workers"},
{"ec2.DeleteInternetGateway", d.handleEC2DeleteInternetGateway, "spinifex-workers"},
{"ec2.DescribeInternetGateways", d.handleEC2DescribeInternetGateways, "spinifex-workers"},
{"ec2.AttachInternetGateway", d.handleEC2AttachInternetGateway, "spinifex-workers"},
{"ec2.DetachInternetGateway", d.handleEC2DetachInternetGateway, "spinifex-workers"},
{"ec2.CreatePlacementGroup", d.handleEC2CreatePlacementGroup, "spinifex-workers"},
{"ec2.DeletePlacementGroup", d.handleEC2DeletePlacementGroup, "spinifex-workers"},
{"ec2.DescribePlacementGroups", d.handleEC2DescribePlacementGroups, "spinifex-workers"},
{"ec2.ReserveSpreadNodes", d.handleEC2ReserveSpreadNodes, "spinifex-workers"},
{"ec2.FinalizeSpreadInstances", d.handleEC2FinalizeSpreadInstances, "spinifex-workers"},
{"ec2.ReleaseSpreadNodes", d.handleEC2ReleaseSpreadNodes, "spinifex-workers"},
{"ec2.RemoveInstanceFromPlacementGroup", d.handleEC2RemoveInstanceFromPlacementGroup, "spinifex-workers"},
{"ec2.ReserveClusterNode", d.handleEC2ReserveClusterNode, "spinifex-workers"},
{"ec2.FinalizeClusterInstances", d.handleEC2FinalizeClusterInstances, "spinifex-workers"},
{"ec2.CreateLaunchTemplate", d.handleEC2CreateLaunchTemplate, "spinifex-workers"},
{"ec2.CreateLaunchTemplateVersion", d.handleEC2CreateLaunchTemplateVersion, "spinifex-workers"},
{"ec2.DeleteLaunchTemplate", d.handleEC2DeleteLaunchTemplate, "spinifex-workers"},
{"ec2.DeleteLaunchTemplateVersions", d.handleEC2DeleteLaunchTemplateVersions, "spinifex-workers"},
{"ec2.ModifyLaunchTemplate", d.handleEC2ModifyLaunchTemplate, "spinifex-workers"},
{"ec2.DescribeLaunchTemplates", d.handleEC2DescribeLaunchTemplates, "spinifex-workers"},
{"ec2.DescribeLaunchTemplateVersions", d.handleEC2DescribeLaunchTemplateVersions, "spinifex-workers"},
{"ec2.PutSpotInstanceRequests", d.handleEC2PutSpotInstanceRequests, "spinifex-workers"},
{"ec2.DescribeSpotInstanceRequests", d.handleEC2DescribeSpotInstanceRequests, "spinifex-workers"},
{"ec2.CancelSpotInstanceRequests", d.handleEC2CancelSpotInstanceRequests, "spinifex-workers"},
{"ec2.DeleteVolume", handleNATSRequest(d.volumeService.DeleteVolume), "spinifex-workers"},
{"ec2.DescribeVolumeStatus", handleNATSRequest(d.volumeService.DescribeVolumeStatus), "spinifex-workers"},
{"ec2.DescribeVolumesModifications", handleNATSRequest(d.volumeService.DescribeVolumesModifications), "spinifex-workers"},
{"ec2.CreateSnapshot", handleNATSRequest(d.snapshotService.CreateSnapshot), "spinifex-workers"},
{"ec2.DescribeSnapshots", handleNATSRequest(d.snapshotService.DescribeSnapshots), "spinifex-workers"},
{"ec2.DeleteSnapshot", handleNATSRequest(d.snapshotService.DeleteSnapshot), "spinifex-workers"},
{"ec2.CopySnapshot", handleNATSRequest(d.snapshotService.CopySnapshot), "spinifex-workers"},
{"ec2.CreateTags", handleNATSRequest(d.createTags), "spinifex-workers"},
{"ec2.DeleteTags", handleNATSRequest(d.deleteTags), "spinifex-workers"},
{"ec2.DescribeTags", handleNATSRequest(d.tagsService.DescribeTags), "spinifex-workers"},
{"ec2.CreateEgressOnlyInternetGateway", handleNATSRequest(d.eigwService.CreateEgressOnlyInternetGateway), "spinifex-workers"},
{"ec2.DeleteEgressOnlyInternetGateway", handleNATSRequest(d.eigwService.DeleteEgressOnlyInternetGateway), "spinifex-workers"},
{"ec2.DescribeEgressOnlyInternetGateways", handleNATSRequest(d.eigwService.DescribeEgressOnlyInternetGateways), "spinifex-workers"},
{"ec2.CreateInternetGateway", handleNATSRequest(d.igwService.CreateInternetGateway), "spinifex-workers"},
{"ec2.DeleteInternetGateway", handleNATSRequest(d.igwService.DeleteInternetGateway), "spinifex-workers"},
{"ec2.DescribeInternetGateways", handleNATSRequest(d.igwService.DescribeInternetGateways), "spinifex-workers"},
{"ec2.AttachInternetGateway", handleNATSRequest(d.igwService.AttachInternetGateway), "spinifex-workers"},
{"ec2.DetachInternetGateway", handleNATSRequest(d.igwService.DetachInternetGateway), "spinifex-workers"},
{"ec2.CreatePlacementGroup", handleNATSRequest(d.placementGroupService.CreatePlacementGroup), "spinifex-workers"},
{"ec2.DeletePlacementGroup", handleNATSRequest(d.placementGroupService.DeletePlacementGroup), "spinifex-workers"},
{"ec2.DescribePlacementGroups", handleNATSRequest(d.placementGroupService.DescribePlacementGroups), "spinifex-workers"},
{"ec2.ReserveSpreadNodes", handleNATSRequest(d.placementGroupService.ReserveSpreadNodes), "spinifex-workers"},
{"ec2.FinalizeSpreadInstances", handleNATSRequest(d.placementGroupService.FinalizeSpreadInstances), "spinifex-workers"},
{"ec2.ReleaseSpreadNodes", handleNATSRequest(d.placementGroupService.ReleaseSpreadNodes), "spinifex-workers"},
{"ec2.RemoveInstanceFromPlacementGroup", handleNATSRequest(d.placementGroupService.RemoveInstance), "spinifex-workers"},
{"ec2.ReserveClusterNode", handleNATSRequest(d.placementGroupService.ReserveClusterNode), "spinifex-workers"},
{"ec2.FinalizeClusterInstances", handleNATSRequest(d.placementGroupService.FinalizeClusterInstances), "spinifex-workers"},
{"ec2.CreateLaunchTemplate", handleNATSRequest(d.launchTemplateService.CreateLaunchTemplate), "spinifex-workers"},
{"ec2.CreateLaunchTemplateVersion", handleNATSRequest(d.launchTemplateService.CreateLaunchTemplateVersion), "spinifex-workers"},
{"ec2.DeleteLaunchTemplate", handleNATSRequest(d.launchTemplateService.DeleteLaunchTemplate), "spinifex-workers"},
{"ec2.DeleteLaunchTemplateVersions", handleNATSRequest(d.launchTemplateService.DeleteLaunchTemplateVersions), "spinifex-workers"},
{"ec2.ModifyLaunchTemplate", handleNATSRequest(d.launchTemplateService.ModifyLaunchTemplate), "spinifex-workers"},
{"ec2.DescribeLaunchTemplates", handleNATSRequest(d.launchTemplateService.DescribeLaunchTemplates), "spinifex-workers"},
{"ec2.DescribeLaunchTemplateVersions", handleNATSRequest(d.launchTemplateService.DescribeLaunchTemplateVersions), "spinifex-workers"},
{"ec2.PutSpotInstanceRequests", handleNATSRequest(d.spotInstanceService.PutSpotInstanceRequests), "spinifex-workers"},
{"ec2.DescribeSpotInstanceRequests", handleNATSRequest(d.spotInstanceService.DescribeSpotInstanceRequests), "spinifex-workers"},
{"ec2.CancelSpotInstanceRequests", handleNATSRequest(d.spotInstanceService.CancelSpotInstanceRequests), "spinifex-workers"},
// Capacity reservations: Create is node-targeted (gateway pins one node);
// Describe fans out and Cancel broadcasts, so both use plain Subscribe.
{fmt.Sprintf("ec2.CreateCapacityReservation.%s", d.node), d.handleEC2CreateCapacityReservation, ""},
{"ec2.DescribeCapacityReservations", d.handleEC2DescribeCapacityReservations, ""},
{"ec2.CancelCapacityReservation", d.handleEC2CancelCapacityReservation, ""},
{"ec2.CreateNatGateway", d.handleEC2CreateNatGateway, "spinifex-workers"},
{"ec2.DeleteNatGateway", d.handleEC2DeleteNatGateway, "spinifex-workers"},
{"ec2.DescribeNatGateways", d.handleEC2DescribeNatGateways, "spinifex-workers"},
{"ec2.CreateRouteTable", d.handleEC2CreateRouteTable, "spinifex-workers"},
{"ec2.DeleteRouteTable", d.handleEC2DeleteRouteTable, "spinifex-workers"},
{"ec2.DescribeRouteTables", d.handleEC2DescribeRouteTables, "spinifex-workers"},
{"ec2.CreateRoute", d.handleEC2CreateRoute, "spinifex-workers"},
{"ec2.DeleteRoute", d.handleEC2DeleteRoute, "spinifex-workers"},
{"ec2.ReplaceRoute", d.handleEC2ReplaceRoute, "spinifex-workers"},
{"ec2.AssociateRouteTable", d.handleEC2AssociateRouteTable, "spinifex-workers"},
{"ec2.DisassociateRouteTable", d.handleEC2DisassociateRouteTable, "spinifex-workers"},
{"ec2.ReplaceRouteTableAssociation", d.handleEC2ReplaceRouteTableAssociation, "spinifex-workers"},
{"ec2.CreateVpc", d.handleEC2CreateVpc, "spinifex-workers"},
{"ec2.DeleteVpc", d.handleEC2DeleteVpc, "spinifex-workers"},
{"ec2.DescribeVpcs", d.handleEC2DescribeVpcs, "spinifex-workers"},
{"ec2.CreateSubnet", d.handleEC2CreateSubnet, "spinifex-workers"},
{"ec2.DeleteSubnet", d.handleEC2DeleteSubnet, "spinifex-workers"},
{"ec2.DescribeSubnets", d.handleEC2DescribeSubnets, "spinifex-workers"},
{"ec2.ModifySubnetAttribute", d.handleEC2ModifySubnetAttribute, "spinifex-workers"},
{"ec2.ModifyVpcAttribute", d.handleEC2ModifyVpcAttribute, "spinifex-workers"},
{"ec2.DescribeVpcAttribute", d.handleEC2DescribeVpcAttribute, "spinifex-workers"},
{"ec2.CreateNetworkInterface", d.handleEC2CreateNetworkInterface, "spinifex-workers"},
{"ec2.DeleteNetworkInterface", d.handleEC2DeleteNetworkInterface, "spinifex-workers"},
{"ec2.DescribeNetworkInterfaces", d.handleEC2DescribeNetworkInterfaces, "spinifex-workers"},
{"ec2.ModifyNetworkInterfaceAttribute", d.handleEC2ModifyNetworkInterfaceAttribute, "spinifex-workers"},
{"ec2.CreateSecurityGroup", d.handleEC2CreateSecurityGroup, "spinifex-workers"},
{"ec2.DeleteSecurityGroup", d.handleEC2DeleteSecurityGroup, "spinifex-workers"},
{"ec2.DescribeSecurityGroups", d.handleEC2DescribeSecurityGroups, "spinifex-workers"},
{"ec2.DescribeSecurityGroupRules", d.handleEC2DescribeSecurityGroupRules, "spinifex-workers"},
{"ec2.AuthorizeSecurityGroupIngress", d.handleEC2AuthorizeSecurityGroupIngress, "spinifex-workers"},
{"ec2.AuthorizeSecurityGroupEgress", d.handleEC2AuthorizeSecurityGroupEgress, "spinifex-workers"},
{"ec2.RevokeSecurityGroupIngress", d.handleEC2RevokeSecurityGroupIngress, "spinifex-workers"},
{"ec2.RevokeSecurityGroupEgress", d.handleEC2RevokeSecurityGroupEgress, "spinifex-workers"},
{"ec2.ModifyInstanceAttribute", d.handleEC2ModifyInstanceAttribute, "spinifex-workers"},
{"ec2.ModifyInstanceMetadataOptions", d.handleEC2ModifyInstanceMetadataOptions, "spinifex-workers"},
{"ec2.CreateNatGateway", handleNATSRequest(d.natGatewayService.CreateNatGateway), "spinifex-workers"},
{"ec2.DeleteNatGateway", handleNATSRequest(d.natGatewayService.DeleteNatGateway), "spinifex-workers"},
{"ec2.DescribeNatGateways", handleNATSRequest(d.natGatewayService.DescribeNatGateways), "spinifex-workers"},
{"ec2.CreateRouteTable", handleNATSRequest(d.routeTableService.CreateRouteTable), "spinifex-workers"},
{"ec2.DeleteRouteTable", handleNATSRequest(d.routeTableService.DeleteRouteTable), "spinifex-workers"},
{"ec2.DescribeRouteTables", handleNATSRequest(d.routeTableService.DescribeRouteTables), "spinifex-workers"},
{"ec2.CreateRoute", handleNATSRequest(d.routeTableService.CreateRoute), "spinifex-workers"},
{"ec2.DeleteRoute", handleNATSRequest(d.routeTableService.DeleteRoute), "spinifex-workers"},
{"ec2.ReplaceRoute", handleNATSRequest(d.routeTableService.ReplaceRoute), "spinifex-workers"},
{"ec2.AssociateRouteTable", handleNATSRequest(d.routeTableService.AssociateRouteTable), "spinifex-workers"},
{"ec2.DisassociateRouteTable", handleNATSRequest(d.routeTableService.DisassociateRouteTable), "spinifex-workers"},
{"ec2.ReplaceRouteTableAssociation", handleNATSRequest(d.routeTableService.ReplaceRouteTableAssociation), "spinifex-workers"},
{"ec2.CreateVpc", handleNATSRequest(d.vpcService.CreateVpc), "spinifex-workers"},
{"ec2.DeleteVpc", handleNATSRequest(d.vpcService.DeleteVpc), "spinifex-workers"},
{"ec2.DescribeVpcs", handleNATSRequest(d.vpcService.DescribeVpcs), "spinifex-workers"},
{"ec2.CreateSubnet", handleNATSRequest(d.vpcService.CreateSubnet), "spinifex-workers"},
{"ec2.DeleteSubnet", handleNATSRequest(d.vpcService.DeleteSubnet), "spinifex-workers"},
{"ec2.DescribeSubnets", handleNATSRequest(d.vpcService.DescribeSubnets), "spinifex-workers"},
{"ec2.ModifySubnetAttribute", handleNATSRequest(d.vpcService.ModifySubnetAttribute), "spinifex-workers"},
{"ec2.ModifyVpcAttribute", handleNATSRequest(d.vpcService.ModifyVpcAttribute), "spinifex-workers"},
{"ec2.DescribeVpcAttribute", handleNATSRequest(d.vpcService.DescribeVpcAttribute), "spinifex-workers"},
{"ec2.CreateNetworkInterface", handleNATSRequest(d.vpcService.CreateNetworkInterface), "spinifex-workers"},
{"ec2.DeleteNetworkInterface", handleNATSRequest(d.vpcService.DeleteNetworkInterface), "spinifex-workers"},
{"ec2.DescribeNetworkInterfaces", handleNATSRequest(d.vpcService.DescribeNetworkInterfaces), "spinifex-workers"},
{"ec2.ModifyNetworkInterfaceAttribute", handleNATSRequest(d.vpcService.ModifyNetworkInterfaceAttribute), "spinifex-workers"},
{"ec2.CreateSecurityGroup", handleNATSRequest(d.vpcService.CreateSecurityGroup), "spinifex-workers"},
{"ec2.DeleteSecurityGroup", handleNATSRequest(d.vpcService.DeleteSecurityGroup), "spinifex-workers"},
{"ec2.DescribeSecurityGroups", handleNATSRequest(d.vpcService.DescribeSecurityGroups), "spinifex-workers"},
{"ec2.DescribeSecurityGroupRules", handleNATSRequest(d.vpcService.DescribeSecurityGroupRules), "spinifex-workers"},
{"ec2.AuthorizeSecurityGroupIngress", handleNATSRequest(d.vpcService.AuthorizeSecurityGroupIngress), "spinifex-workers"},
{"ec2.AuthorizeSecurityGroupEgress", handleNATSRequest(d.vpcService.AuthorizeSecurityGroupEgress), "spinifex-workers"},
{"ec2.RevokeSecurityGroupIngress", handleNATSRequest(d.vpcService.RevokeSecurityGroupIngress), "spinifex-workers"},
{"ec2.RevokeSecurityGroupEgress", handleNATSRequest(d.vpcService.RevokeSecurityGroupEgress), "spinifex-workers"},
{"ec2.ModifyInstanceAttribute", handleNATSRequest(d.instanceService.ModifyInstanceAttribute), "spinifex-workers"},
{"ec2.ModifyInstanceMetadataOptions", handleNATSRequest(d.instanceService.ModifyInstanceMetadataOptions), "spinifex-workers"},
{"ec2.start", d.handleEC2StartStoppedInstance, "spinifex-workers"},
{fmt.Sprintf("ec2.start.%s", d.node), d.handleEC2StartStoppedInstanceDirect, ""},
{"ec2.terminate", d.handleEC2TerminateStoppedInstance, "spinifex-workers"},
{"ec2.DescribeStoppedInstances", d.handleEC2DescribeStoppedInstances, "spinifex-workers"},
{"ec2.DescribeTerminatedInstances", d.handleEC2DescribeTerminatedInstances, "spinifex-workers"},
// ec2.start.{node} is the node-targeted variant: it always starts locally
// and never re-forwards, so it goes straight to the service (no routing loop).
{fmt.Sprintf("ec2.start.%s", d.node), handleNATSRequest(d.instanceService.StartStoppedInstance), ""},
{"ec2.terminate", handleNATSRequest(d.instanceService.TerminateStoppedInstance), "spinifex-workers"},
{"ec2.DescribeStoppedInstances", handleNATSRequest(d.instanceService.DescribeStoppedInstances), "spinifex-workers"},
{"ec2.DescribeTerminatedInstances", handleNATSRequest(d.instanceService.DescribeTerminatedInstances), "spinifex-workers"},
// these fan out to all nodes and gateway aggregates the results. The
// handler only sees per-daemon local state (vmMgr/stoppedStore), so
// any queue-grouped routing produces 1/N false NotFound responses.
{"ec2.DescribeInstances", d.handleEC2DescribeInstances, ""},
{"ec2.DescribeInstanceStatus", d.handleEC2DescribeInstanceStatus, ""},
{"ec2.DescribeInstanceTypes", d.handleEC2DescribeInstanceTypes, ""},
{"ec2.DescribeInstanceAttribute", d.handleEC2DescribeInstanceAttribute, ""},
{"ec2.DescribeInstances", handleNATSRequest(d.instanceService.DescribeInstances), ""},
{"ec2.DescribeInstanceStatus", handleNATSRequest(d.instanceService.DescribeInstanceStatus), ""},
{"ec2.DescribeInstanceTypes", handleNATSRequest(d.instanceService.DescribeInstanceTypes), ""},
{"ec2.DescribeInstanceAttribute", handleNATSRequest(d.instanceService.DescribeInstanceAttribute), ""},
// IAM instance profile associations: Disassociate/Replace mutate the
// owning daemon's vm.VM (non-owners NoOp with Found=false); Describe
// returns per-daemon matches that the gateway concatenates.
{"ec2.IamProfileAssociation.disassociate", d.handleIamProfileDisassociate, ""},
{"ec2.IamProfileAssociation.replace", d.handleIamProfileReplace, ""},
{"ec2.IamProfileAssociation.describe", d.handleIamProfileDescribe, ""},
{"ec2.EnableEbsEncryptionByDefault", d.handleEC2EnableEbsEncryptionByDefault, "spinifex-workers"},
{"ec2.DisableEbsEncryptionByDefault", d.handleEC2DisableEbsEncryptionByDefault, "spinifex-workers"},
{"ec2.GetEbsEncryptionByDefault", d.handleEC2GetEbsEncryptionByDefault, "spinifex-workers"},
{"ec2.GetSerialConsoleAccessStatus", d.handleEC2GetSerialConsoleAccessStatus, "spinifex-workers"},
{"ec2.EnableSerialConsoleAccess", d.handleEC2EnableSerialConsoleAccess, "spinifex-workers"},
{"ec2.DisableSerialConsoleAccess", d.handleEC2DisableSerialConsoleAccess, "spinifex-workers"},
{"ec2.IamProfileAssociation.disassociate", handleNATSRequest(d.instanceService.DisassociateIamProfileAssociation), ""},
{"ec2.IamProfileAssociation.replace", handleNATSRequest(d.instanceService.ReplaceIamProfileAssociation), ""},
{"ec2.IamProfileAssociation.describe", handleNATSRequest(d.instanceService.DescribeIamProfileAssociations), ""},
{"ec2.EnableEbsEncryptionByDefault", handleNATSRequest(d.accountService.EnableEbsEncryptionByDefault), "spinifex-workers"},
{"ec2.DisableEbsEncryptionByDefault", handleNATSRequest(d.accountService.DisableEbsEncryptionByDefault), "spinifex-workers"},
{"ec2.GetEbsEncryptionByDefault", handleNATSRequest(d.accountService.GetEbsEncryptionByDefault), "spinifex-workers"},
{"ec2.GetSerialConsoleAccessStatus", handleNATSRequest(d.accountService.GetSerialConsoleAccessStatus), "spinifex-workers"},
{"ec2.EnableSerialConsoleAccess", handleNATSRequest(d.accountService.EnableSerialConsoleAccess), "spinifex-workers"},
{"ec2.DisableSerialConsoleAccess", handleNATSRequest(d.accountService.DisableSerialConsoleAccess), "spinifex-workers"},
{fmt.Sprintf("spinifex.admin.%s.health", d.node), d.handleHealthCheck, ""},
{"spinifex.nodes.discover", d.handleNodeDiscover, ""},
{"spinifex.node.status", d.handleNodeStatus, ""},
@@ -907,41 +911,41 @@ func (d *Daemon) subscribeAll() error {
// Without a subscriber the gateway returns nats.ErrNoResponders → ServiceUnavailable.
if d.elbv2Service.GatewayURL != "" {
subs = append(subs,
natsSub{"elbv2.CreateLoadBalancer", d.handleELBv2CreateLoadBalancer, "spinifex-workers"},
natsSub{"elbv2.DeleteLoadBalancer", d.handleELBv2DeleteLoadBalancer, "spinifex-workers"},
natsSub{"elbv2.DescribeLoadBalancers", d.handleELBv2DescribeLoadBalancers, "spinifex-workers"},
natsSub{"elbv2.CreateTargetGroup", d.handleELBv2CreateTargetGroup, "spinifex-workers"},
natsSub{"elbv2.ModifyTargetGroup", d.handleELBv2ModifyTargetGroup, "spinifex-workers"},
natsSub{"elbv2.DeleteTargetGroup", d.handleELBv2DeleteTargetGroup, "spinifex-workers"},
natsSub{"elbv2.DescribeTargetGroups", d.handleELBv2DescribeTargetGroups, "spinifex-workers"},
natsSub{"elbv2.RegisterTargets", d.handleELBv2RegisterTargets, "spinifex-workers"},
natsSub{"elbv2.DeregisterTargets", d.handleELBv2DeregisterTargets, "spinifex-workers"},
natsSub{"elbv2.DescribeTargetHealth", d.handleELBv2DescribeTargetHealth, "spinifex-workers"},
natsSub{"elbv2.CreateListener", d.handleELBv2CreateListener, "spinifex-workers"},
natsSub{"elbv2.DeleteListener", d.handleELBv2DeleteListener, "spinifex-workers"},
natsSub{"elbv2.ModifyListener", d.handleELBv2ModifyListener, "spinifex-workers"},
natsSub{"elbv2.DescribeListeners", d.handleELBv2DescribeListeners, "spinifex-workers"},
natsSub{"elbv2.CreateRule", d.handleELBv2CreateRule, "spinifex-workers"},
natsSub{"elbv2.ModifyRule", d.handleELBv2ModifyRule, "spinifex-workers"},
natsSub{"elbv2.DeleteRule", d.handleELBv2DeleteRule, "spinifex-workers"},
natsSub{"elbv2.DescribeRules", d.handleELBv2DescribeRules, "spinifex-workers"},
natsSub{"elbv2.SetRulePriorities", d.handleELBv2SetRulePriorities, "spinifex-workers"},
natsSub{"elbv2.DescribeTags", d.handleELBv2DescribeTags, "spinifex-workers"},
natsSub{"elbv2.AddTags", d.handleELBv2AddTags, "spinifex-workers"},
natsSub{"elbv2.RemoveTags", d.handleELBv2RemoveTags, "spinifex-workers"},
natsSub{"elbv2.LBAgentHeartbeat", d.handleELBv2LBAgentHeartbeat, "spinifex-workers"},
natsSub{"elbv2.GetLBConfig", d.handleELBv2GetLBConfig, "spinifex-workers"},
natsSub{"elbv2.ModifyTargetGroupAttributes", d.handleELBv2ModifyTargetGroupAttributes, "spinifex-workers"},
natsSub{"elbv2.DescribeTargetGroupAttributes", d.handleELBv2DescribeTargetGroupAttributes, "spinifex-workers"},
natsSub{"elbv2.ModifyLoadBalancerAttributes", d.handleELBv2ModifyLoadBalancerAttributes, "spinifex-workers"},
natsSub{"elbv2.DescribeLoadBalancerAttributes", d.handleELBv2DescribeLoadBalancerAttributes, "spinifex-workers"},
natsSub{"elbv2.SetSecurityGroups", d.handleELBv2SetSecurityGroups, "spinifex-workers"},
natsSub{"elbv2.SetIpAddressType", d.handleELBv2SetIpAddressType, "spinifex-workers"},
natsSub{"elbv2.SetSubnets", d.handleELBv2SetSubnets, "spinifex-workers"},
natsSub{"elbv2.AddListenerCertificates", d.handleELBv2AddListenerCertificates, "spinifex-workers"},
natsSub{"elbv2.RemoveListenerCertificates", d.handleELBv2RemoveListenerCertificates, "spinifex-workers"},
natsSub{"elbv2.DescribeListenerCertificates", d.handleELBv2DescribeListenerCertificates, "spinifex-workers"},
natsSub{"elbv2.DescribeSSLPolicies", d.handleELBv2DescribeSSLPolicies, "spinifex-workers"},
natsSub{"elbv2.CreateLoadBalancer", handleNATSRequest(d.elbv2Service.CreateLoadBalancer), "spinifex-workers"},
natsSub{"elbv2.DeleteLoadBalancer", handleNATSRequest(d.elbv2Service.DeleteLoadBalancer), "spinifex-workers"},
natsSub{"elbv2.DescribeLoadBalancers", handleNATSRequest(d.elbv2Service.DescribeLoadBalancers), "spinifex-workers"},
natsSub{"elbv2.CreateTargetGroup", handleNATSRequest(d.elbv2Service.CreateTargetGroup), "spinifex-workers"},
natsSub{"elbv2.ModifyTargetGroup", handleNATSRequest(d.elbv2Service.ModifyTargetGroup), "spinifex-workers"},
natsSub{"elbv2.DeleteTargetGroup", handleNATSRequest(d.elbv2Service.DeleteTargetGroup), "spinifex-workers"},
natsSub{"elbv2.DescribeTargetGroups", handleNATSRequest(d.elbv2Service.DescribeTargetGroups), "spinifex-workers"},
natsSub{"elbv2.RegisterTargets", handleNATSRequest(d.elbv2Service.RegisterTargets), "spinifex-workers"},
natsSub{"elbv2.DeregisterTargets", handleNATSRequest(d.elbv2Service.DeregisterTargets), "spinifex-workers"},
natsSub{"elbv2.DescribeTargetHealth", handleNATSRequest(d.elbv2Service.DescribeTargetHealth), "spinifex-workers"},
natsSub{"elbv2.CreateListener", handleNATSRequest(d.elbv2Service.CreateListener), "spinifex-workers"},
natsSub{"elbv2.DeleteListener", handleNATSRequest(d.elbv2Service.DeleteListener), "spinifex-workers"},
natsSub{"elbv2.ModifyListener", handleNATSRequest(d.elbv2Service.ModifyListener), "spinifex-workers"},
natsSub{"elbv2.DescribeListeners", handleNATSRequest(d.elbv2Service.DescribeListeners), "spinifex-workers"},
natsSub{"elbv2.CreateRule", handleNATSRequest(d.elbv2Service.CreateRule), "spinifex-workers"},
natsSub{"elbv2.ModifyRule", handleNATSRequest(d.elbv2Service.ModifyRule), "spinifex-workers"},
natsSub{"elbv2.DeleteRule", handleNATSRequest(d.elbv2Service.DeleteRule), "spinifex-workers"},
natsSub{"elbv2.DescribeRules", handleNATSRequest(d.elbv2Service.DescribeRules), "spinifex-workers"},
natsSub{"elbv2.SetRulePriorities", handleNATSRequest(d.elbv2Service.SetRulePriorities), "spinifex-workers"},
natsSub{"elbv2.DescribeTags", handleNATSRequest(d.elbv2Service.DescribeTags), "spinifex-workers"},
natsSub{"elbv2.AddTags", handleNATSRequest(d.elbv2Service.AddTags), "spinifex-workers"},
natsSub{"elbv2.RemoveTags", handleNATSRequest(d.elbv2Service.RemoveTags), "spinifex-workers"},
natsSub{"elbv2.LBAgentHeartbeat", handleNATSRequest(d.elbv2Service.LBAgentHeartbeat), "spinifex-workers"},
natsSub{"elbv2.GetLBConfig", handleNATSRequest(d.elbv2Service.GetLBConfig), "spinifex-workers"},
natsSub{"elbv2.ModifyTargetGroupAttributes", handleNATSRequest(d.elbv2Service.ModifyTargetGroupAttributes), "spinifex-workers"},
natsSub{"elbv2.DescribeTargetGroupAttributes", handleNATSRequest(d.elbv2Service.DescribeTargetGroupAttributes), "spinifex-workers"},
natsSub{"elbv2.ModifyLoadBalancerAttributes", handleNATSRequest(d.elbv2Service.ModifyLoadBalancerAttributes), "spinifex-workers"},
natsSub{"elbv2.DescribeLoadBalancerAttributes", handleNATSRequest(d.elbv2Service.DescribeLoadBalancerAttributes), "spinifex-workers"},
natsSub{"elbv2.SetSecurityGroups", handleNATSRequest(d.elbv2Service.SetSecurityGroups), "spinifex-workers"},
natsSub{"elbv2.SetIpAddressType", handleNATSRequest(d.elbv2Service.SetIpAddressType), "spinifex-workers"},
natsSub{"elbv2.SetSubnets", handleNATSRequest(d.elbv2Service.SetSubnets), "spinifex-workers"},
natsSub{"elbv2.AddListenerCertificates", handleNATSRequest(d.elbv2Service.AddListenerCertificates), "spinifex-workers"},
natsSub{"elbv2.RemoveListenerCertificates", handleNATSRequest(d.elbv2Service.RemoveListenerCertificates), "spinifex-workers"},
natsSub{"elbv2.DescribeListenerCertificates", handleNATSRequest(d.elbv2Service.DescribeListenerCertificates), "spinifex-workers"},
natsSub{"elbv2.DescribeSSLPolicies", handleNATSRequest(d.elbv2Service.DescribeSSLPolicies), "spinifex-workers"},
)
}
@@ -950,97 +954,97 @@ func (d *Daemon) subscribeAll() error {
// stable while real bodies land.
if d.eksService != nil {
subs = append(subs,
natsSub{"eks.CreateCluster", d.handleEKSCreateCluster, "spinifex-workers"},
natsSub{"eks.DescribeCluster", d.handleEKSDescribeCluster, "spinifex-workers"},
natsSub{"eks.ListClusters", d.handleEKSListClusters, "spinifex-workers"},
natsSub{"eks.UpdateClusterConfig", d.handleEKSUpdateClusterConfig, "spinifex-workers"},
natsSub{"eks.UpdateClusterVersion", d.handleEKSUpdateClusterVersion, "spinifex-workers"},
natsSub{"eks.DeleteCluster", d.handleEKSDeleteCluster, "spinifex-workers"},
natsSub{"eks.CreateNodegroup", d.handleEKSCreateNodegroup, "spinifex-workers"},
natsSub{"eks.DescribeNodegroup", d.handleEKSDescribeNodegroup, "spinifex-workers"},
natsSub{"eks.ListNodegroups", d.handleEKSListNodegroups, "spinifex-workers"},
natsSub{"eks.UpdateNodegroupConfig", d.handleEKSUpdateNodegroupConfig, "spinifex-workers"},
natsSub{"eks.UpdateNodegroupVersion", d.handleEKSUpdateNodegroupVersion, "spinifex-workers"},
natsSub{"eks.DeleteNodegroup", d.handleEKSDeleteNodegroup, "spinifex-workers"},
natsSub{"eks.CreateAccessEntry", d.handleEKSCreateAccessEntry, "spinifex-workers"},
natsSub{"eks.DescribeAccessEntry", d.handleEKSDescribeAccessEntry, "spinifex-workers"},
natsSub{"eks.ListAccessEntries", d.handleEKSListAccessEntries, "spinifex-workers"},
natsSub{"eks.UpdateAccessEntry", d.handleEKSUpdateAccessEntry, "spinifex-workers"},
natsSub{"eks.DeleteAccessEntry", d.handleEKSDeleteAccessEntry, "spinifex-workers"},
natsSub{"eks.AssociateAccessPolicy", d.handleEKSAssociateAccessPolicy, "spinifex-workers"},
natsSub{"eks.DisassociateAccessPolicy", d.handleEKSDisassociateAccessPolicy, "spinifex-workers"},
natsSub{"eks.ListAssociatedAccessPolicies", d.handleEKSListAssociatedAccessPolicies, "spinifex-workers"},
natsSub{"eks.ListAccessPolicies", d.handleEKSListAccessPolicies, "spinifex-workers"},
natsSub{"eks.ListAddons", d.handleEKSListAddons, "spinifex-workers"},
natsSub{"eks.DescribeAddonVersions", d.handleEKSDescribeAddonVersions, "spinifex-workers"},
natsSub{"eks.CreateAddon", d.handleEKSCreateAddon, "spinifex-workers"},
natsSub{"eks.DeleteAddon", d.handleEKSDeleteAddon, "spinifex-workers"},
natsSub{"eks.DescribeAddon", d.handleEKSDescribeAddon, "spinifex-workers"},
natsSub{"eks.UpdateAddon", d.handleEKSUpdateAddon, "spinifex-workers"},
natsSub{"eks.ListStagedAddonManifests", d.handleEKSListStagedAddonManifests, "spinifex-workers"},
natsSub{"eks.GetRecoveryDirective", d.handleEKSGetRecoveryDirective, "spinifex-workers"},
natsSub{"eks.SetRecoveryDirective", d.handleEKSSetRecoveryDirective, "spinifex-workers"},
natsSub{"eks.RestoreSnapshot", d.handleEKSRestoreSnapshot, "spinifex-workers"},
natsSub{"eks.AssociateIdentityProviderConfig", d.handleEKSAssociateIdentityProviderConfig, "spinifex-workers"},
natsSub{"eks.DescribeIdentityProviderConfig", d.handleEKSDescribeIdentityProviderConfig, "spinifex-workers"},
natsSub{"eks.ListIdentityProviderConfigs", d.handleEKSListIdentityProviderConfigs, "spinifex-workers"},
natsSub{"eks.DisassociateIdentityProviderConfig", d.handleEKSDisassociateIdentityProviderConfig, "spinifex-workers"},
natsSub{"eks.TagResource", d.handleEKSTagResource, "spinifex-workers"},
natsSub{"eks.UntagResource", d.handleEKSUntagResource, "spinifex-workers"},
natsSub{"eks.ListTagsForResource", d.handleEKSListTagsForResource, "spinifex-workers"},
natsSub{"eks.CreateCluster", handleNATSRequestWithPrincipal(d.eksService.CreateCluster), "spinifex-workers"},
natsSub{"eks.DescribeCluster", handleNATSRequest(d.eksService.DescribeCluster), "spinifex-workers"},
natsSub{"eks.ListClusters", handleNATSRequest(d.eksService.ListClusters), "spinifex-workers"},
natsSub{"eks.UpdateClusterConfig", handleNATSRequest(d.eksService.UpdateClusterConfig), "spinifex-workers"},
natsSub{"eks.UpdateClusterVersion", handleNATSRequest(d.eksService.UpdateClusterVersion), "spinifex-workers"},
natsSub{"eks.DeleteCluster", handleNATSRequest(d.eksService.DeleteCluster), "spinifex-workers"},
natsSub{"eks.CreateNodegroup", handleNATSRequest(d.eksService.CreateNodegroup), "spinifex-workers"},
natsSub{"eks.DescribeNodegroup", handleNATSRequest(d.eksService.DescribeNodegroup), "spinifex-workers"},
natsSub{"eks.ListNodegroups", handleNATSRequest(d.eksService.ListNodegroups), "spinifex-workers"},
natsSub{"eks.UpdateNodegroupConfig", handleNATSRequest(d.eksService.UpdateNodegroupConfig), "spinifex-workers"},
natsSub{"eks.UpdateNodegroupVersion", handleNATSRequest(d.eksService.UpdateNodegroupVersion), "spinifex-workers"},
natsSub{"eks.DeleteNodegroup", handleNATSRequest(d.eksService.DeleteNodegroup), "spinifex-workers"},
natsSub{"eks.CreateAccessEntry", handleNATSRequest(d.eksService.CreateAccessEntry), "spinifex-workers"},
natsSub{"eks.DescribeAccessEntry", handleNATSRequest(d.eksService.DescribeAccessEntry), "spinifex-workers"},
natsSub{"eks.ListAccessEntries", handleNATSRequest(d.eksService.ListAccessEntries), "spinifex-workers"},
natsSub{"eks.UpdateAccessEntry", handleNATSRequest(d.eksService.UpdateAccessEntry), "spinifex-workers"},
natsSub{"eks.DeleteAccessEntry", handleNATSRequest(d.eksService.DeleteAccessEntry), "spinifex-workers"},
natsSub{"eks.AssociateAccessPolicy", handleNATSRequest(d.eksService.AssociateAccessPolicy), "spinifex-workers"},
natsSub{"eks.DisassociateAccessPolicy", handleNATSRequest(d.eksService.DisassociateAccessPolicy), "spinifex-workers"},
natsSub{"eks.ListAssociatedAccessPolicies", handleNATSRequest(d.eksService.ListAssociatedAccessPolicies), "spinifex-workers"},
natsSub{"eks.ListAccessPolicies", handleNATSRequest(d.eksService.ListAccessPolicies), "spinifex-workers"},
natsSub{"eks.ListAddons", handleNATSRequest(d.eksService.ListAddons), "spinifex-workers"},
natsSub{"eks.DescribeAddonVersions", handleNATSRequest(d.eksService.DescribeAddonVersions), "spinifex-workers"},
natsSub{"eks.CreateAddon", handleNATSRequest(d.eksService.CreateAddon), "spinifex-workers"},
natsSub{"eks.DeleteAddon", handleNATSRequest(d.eksService.DeleteAddon), "spinifex-workers"},
natsSub{"eks.DescribeAddon", handleNATSRequest(d.eksService.DescribeAddon), "spinifex-workers"},
natsSub{"eks.UpdateAddon", handleNATSRequest(d.eksService.UpdateAddon), "spinifex-workers"},
natsSub{"eks.ListStagedAddonManifests", handleNATSRequest(d.eksService.ListStagedAddonManifests), "spinifex-workers"},
natsSub{"eks.GetRecoveryDirective", handleNATSRequest(d.eksService.GetRecoveryDirective), "spinifex-workers"},
natsSub{"eks.SetRecoveryDirective", handleNATSRequest(d.eksService.SetRecoveryDirective), "spinifex-workers"},
natsSub{"eks.RestoreSnapshot", handleNATSRequest(d.eksService.RestoreSnapshot), "spinifex-workers"},
natsSub{"eks.AssociateIdentityProviderConfig", handleNATSRequest(d.eksService.AssociateIdentityProviderConfig), "spinifex-workers"},
natsSub{"eks.DescribeIdentityProviderConfig", handleNATSRequest(d.eksService.DescribeIdentityProviderConfig), "spinifex-workers"},
natsSub{"eks.ListIdentityProviderConfigs", handleNATSRequest(d.eksService.ListIdentityProviderConfigs), "spinifex-workers"},
natsSub{"eks.DisassociateIdentityProviderConfig", handleNATSRequest(d.eksService.DisassociateIdentityProviderConfig), "spinifex-workers"},
natsSub{"eks.TagResource", handleNATSRequest(d.eksService.TagResource), "spinifex-workers"},
natsSub{"eks.UntagResource", handleNATSRequest(d.eksService.UntagResource), "spinifex-workers"},
natsSub{"eks.ListTagsForResource", handleNATSRequest(d.eksService.ListTagsForResource), "spinifex-workers"},
)
}
// ECS gateway → daemon subscriptions (control plane; per-account KV).
if d.ecsService != nil {
subs = append(subs,
natsSub{"ecs.CreateCluster", d.handleECSCreateCluster, "spinifex-workers"},
natsSub{"ecs.DeleteCluster", d.handleECSDeleteCluster, "spinifex-workers"},
natsSub{"ecs.DescribeClusters", d.handleECSDescribeClusters, "spinifex-workers"},
natsSub{"ecs.ListClusters", d.handleECSListClusters, "spinifex-workers"},
natsSub{"ecs.RegisterTaskDefinition", d.handleECSRegisterTaskDefinition, "spinifex-workers"},
natsSub{"ecs.DeregisterTaskDefinition", d.handleECSDeregisterTaskDefinition, "spinifex-workers"},
natsSub{"ecs.DescribeTaskDefinition", d.handleECSDescribeTaskDefinition, "spinifex-workers"},
natsSub{"ecs.ListTaskDefinitions", d.handleECSListTaskDefinitions, "spinifex-workers"},
natsSub{"ecs.RegisterContainerInstance", d.handleECSRegisterContainerInstance, "spinifex-workers"},
natsSub{"ecs.DeregisterContainerInstance", d.handleECSDeregisterContainerInstance, "spinifex-workers"},
natsSub{"ecs.UpdateContainerInstancesState", d.handleECSUpdateContainerInstancesState, "spinifex-workers"},
natsSub{"ecs.DescribeContainerInstances", d.handleECSDescribeContainerInstances, "spinifex-workers"},
natsSub{"ecs.ListContainerInstances", d.handleECSListContainerInstances, "spinifex-workers"},
natsSub{"ecs.RunTask", d.handleECSRunTask, "spinifex-workers"},
natsSub{"ecs.StartTask", d.handleECSStartTask, "spinifex-workers"},
natsSub{"ecs.StopTask", d.handleECSStopTask, "spinifex-workers"},
natsSub{"ecs.DescribeTasks", d.handleECSDescribeTasks, "spinifex-workers"},
natsSub{"ecs.ListTasks", d.handleECSListTasks, "spinifex-workers"},
natsSub{"ecs.CreateService", d.handleECSCreateService, "spinifex-workers"},
natsSub{"ecs.UpdateService", d.handleECSUpdateService, "spinifex-workers"},
natsSub{"ecs.DeleteService", d.handleECSDeleteService, "spinifex-workers"},
natsSub{"ecs.DescribeServices", d.handleECSDescribeServices, "spinifex-workers"},
natsSub{"ecs.ListServices", d.handleECSListServices, "spinifex-workers"},
natsSub{"ecs.SubmitTaskStateChange", d.handleECSSubmitTaskStateChange, "spinifex-workers"},
natsSub{"ecs.PollAssignments", d.handleECSPollAssignments, "spinifex-workers"},
natsSub{"ecs.ReportTaskGPU", d.handleECSReportTaskGPU, "spinifex-workers"},
natsSub{"ecs.ProvisionCapacity", d.handleECSProvisionCapacity, "spinifex-workers"},
natsSub{"ecs.TagResource", d.handleECSTagResource, "spinifex-workers"},
natsSub{"ecs.UntagResource", d.handleECSUntagResource, "spinifex-workers"},
natsSub{"ecs.ListTagsForResource", d.handleECSListTagsForResource, "spinifex-workers"},
natsSub{"ecs.PutClusterCapacityProviders", d.handleECSPutClusterCapacityProviders, "spinifex-workers"},
natsSub{"ecs.CreateCapacityProvider", d.handleECSCreateCapacityProvider, "spinifex-workers"},
natsSub{"ecs.DescribeCapacityProviders", d.handleECSDescribeCapacityProviders, "spinifex-workers"},
natsSub{"ecs.DeleteCapacityProvider", d.handleECSDeleteCapacityProvider, "spinifex-workers"},
natsSub{"ecs.CreateCluster", handleNATSRequest(d.ecsService.CreateCluster), "spinifex-workers"},
natsSub{"ecs.DeleteCluster", handleNATSRequest(d.ecsService.DeleteCluster), "spinifex-workers"},
natsSub{"ecs.DescribeClusters", handleNATSRequest(d.ecsService.DescribeClusters), "spinifex-workers"},
natsSub{"ecs.ListClusters", handleNATSRequest(d.ecsService.ListClusters), "spinifex-workers"},
natsSub{"ecs.RegisterTaskDefinition", handleNATSRequest(d.ecsService.RegisterTaskDefinition), "spinifex-workers"},
natsSub{"ecs.DeregisterTaskDefinition", handleNATSRequest(d.ecsService.DeregisterTaskDefinition), "spinifex-workers"},
natsSub{"ecs.DescribeTaskDefinition", handleNATSRequest(d.ecsService.DescribeTaskDefinition), "spinifex-workers"},
natsSub{"ecs.ListTaskDefinitions", handleNATSRequest(d.ecsService.ListTaskDefinitions), "spinifex-workers"},
natsSub{"ecs.RegisterContainerInstance", handleNATSRequest(d.ecsService.RegisterContainerInstance), "spinifex-workers"},
natsSub{"ecs.DeregisterContainerInstance", handleNATSRequest(d.ecsService.DeregisterContainerInstance), "spinifex-workers"},
natsSub{"ecs.UpdateContainerInstancesState", handleNATSRequest(d.ecsService.UpdateContainerInstancesState), "spinifex-workers"},
natsSub{"ecs.DescribeContainerInstances", handleNATSRequest(d.ecsService.DescribeContainerInstances), "spinifex-workers"},
natsSub{"ecs.ListContainerInstances", handleNATSRequest(d.ecsService.ListContainerInstances), "spinifex-workers"},
natsSub{"ecs.RunTask", handleNATSRequest(d.ecsService.RunTask), "spinifex-workers"},
natsSub{"ecs.StartTask", handleNATSRequest(d.ecsService.StartTask), "spinifex-workers"},
natsSub{"ecs.StopTask", handleNATSRequest(d.ecsService.StopTask), "spinifex-workers"},
natsSub{"ecs.DescribeTasks", handleNATSRequest(d.ecsService.DescribeTasks), "spinifex-workers"},
natsSub{"ecs.ListTasks", handleNATSRequest(d.ecsService.ListTasks), "spinifex-workers"},
natsSub{"ecs.CreateService", handleNATSRequest(d.ecsService.CreateService), "spinifex-workers"},
natsSub{"ecs.UpdateService", handleNATSRequest(d.ecsService.UpdateService), "spinifex-workers"},
natsSub{"ecs.DeleteService", handleNATSRequest(d.ecsService.DeleteService), "spinifex-workers"},
natsSub{"ecs.DescribeServices", handleNATSRequest(d.ecsService.DescribeServices), "spinifex-workers"},
natsSub{"ecs.ListServices", handleNATSRequest(d.ecsService.ListServices), "spinifex-workers"},
natsSub{"ecs.SubmitTaskStateChange", handleNATSRequest(d.ecsService.SubmitTaskStateChange), "spinifex-workers"},
natsSub{"ecs.PollAssignments", handleNATSRequest(d.ecsService.PollAssignments), "spinifex-workers"},
natsSub{"ecs.ReportTaskGPU", handleNATSRequest(d.ecsService.ReportTaskGPU), "spinifex-workers"},
natsSub{"ecs.ProvisionCapacity", handleNATSRequest(d.ecsService.ProvisionCapacity), "spinifex-workers"},
natsSub{"ecs.TagResource", handleNATSRequest(d.ecsService.TagResource), "spinifex-workers"},
natsSub{"ecs.UntagResource", handleNATSRequest(d.ecsService.UntagResource), "spinifex-workers"},
natsSub{"ecs.ListTagsForResource", handleNATSRequest(d.ecsService.ListTagsForResource), "spinifex-workers"},
natsSub{"ecs.PutClusterCapacityProviders", handleNATSRequest(d.ecsService.PutClusterCapacityProviders), "spinifex-workers"},
natsSub{"ecs.CreateCapacityProvider", handleNATSRequest(d.ecsService.CreateCapacityProvider), "spinifex-workers"},
natsSub{"ecs.DescribeCapacityProviders", handleNATSRequest(d.ecsService.DescribeCapacityProviders), "spinifex-workers"},
natsSub{"ecs.DeleteCapacityProvider", handleNATSRequest(d.ecsService.DeleteCapacityProvider), "spinifex-workers"},
)
}
// ACM gateway → daemon subscriptions (minimal certificate store).
if d.acmService != nil {
subs = append(subs,
natsSub{"acm.ImportCertificate", d.handleACMImportCertificate, "spinifex-workers"},
natsSub{"acm.DescribeCertificate", d.handleACMDescribeCertificate, "spinifex-workers"},
natsSub{"acm.ListCertificates", d.handleACMListCertificates, "spinifex-workers"},
natsSub{"acm.DeleteCertificate", d.handleACMDeleteCertificate, "spinifex-workers"},
natsSub{"acm.ListTagsForCertificate", d.handleACMListTagsForCertificate, "spinifex-workers"},
natsSub{"acm.AddTagsToCertificate", d.handleACMAddTagsToCertificate, "spinifex-workers"},
natsSub{"acm.RemoveTagsFromCertificate", d.handleACMRemoveTagsFromCertificate, "spinifex-workers"},
natsSub{"acm.ImportCertificate", handleNATSRequest(d.acmService.ImportCertificate), "spinifex-workers"},
natsSub{"acm.DescribeCertificate", handleNATSRequest(d.acmService.DescribeCertificate), "spinifex-workers"},
natsSub{"acm.ListCertificates", handleNATSRequest(d.acmService.ListCertificates), "spinifex-workers"},
natsSub{"acm.DeleteCertificate", handleNATSRequest(d.acmService.DeleteCertificate), "spinifex-workers"},
natsSub{"acm.ListTagsForCertificate", handleNATSRequest(d.acmService.ListTagsForCertificate), "spinifex-workers"},
natsSub{"acm.AddTagsToCertificate", handleNATSRequest(d.acmService.AddTagsToCertificate), "spinifex-workers"},
natsSub{"acm.RemoveTagsFromCertificate", handleNATSRequest(d.acmService.RemoveTagsFromCertificate), "spinifex-workers"},
)
}
@@ -1048,28 +1052,28 @@ func (d *Daemon) subscribeAll() error {
// JetStream KV metadata; blob/manifest bytes never traverse these subjects.
if d.ecrMetaService != nil {
subs = append(subs,
natsSub{handlers_ecr.SubjectRepoCreate, d.handleECRRepoCreate, "spinifex-workers"},
natsSub{handlers_ecr.SubjectRepoDescribe, d.handleECRRepoDescribe, "spinifex-workers"},
natsSub{handlers_ecr.SubjectRepoList, d.handleECRRepoList, "spinifex-workers"},
natsSub{handlers_ecr.SubjectRepoDelete, d.handleECRRepoDelete, "spinifex-workers"},
natsSub{handlers_ecr.SubjectPolicyPut, d.handleECRPolicyPut, "spinifex-workers"},
natsSub{handlers_ecr.SubjectPolicyGet, d.handleECRPolicyGet, "spinifex-workers"},
natsSub{handlers_ecr.SubjectPolicyDelete, d.handleECRPolicyDelete, "spinifex-workers"},
natsSub{handlers_ecr.SubjectLifecyclePut, d.handleECRLifecyclePut, "spinifex-workers"},
natsSub{handlers_ecr.SubjectLifecycleGet, d.handleECRLifecycleGet, "spinifex-workers"},
natsSub{handlers_ecr.SubjectLifecycleDelete, d.handleECRLifecycleDelete, "spinifex-workers"},
natsSub{handlers_ecr.SubjectTagPut, d.handleECRTagPut, "spinifex-workers"},
natsSub{handlers_ecr.SubjectTagGet, d.handleECRTagGet, "spinifex-workers"},
natsSub{handlers_ecr.SubjectTagList, d.handleECRTagList, "spinifex-workers"},
natsSub{handlers_ecr.SubjectTagDelete, d.handleECRTagDelete, "spinifex-workers"},
natsSub{handlers_ecr.SubjectManifestPut, d.handleECRManifestPut, "spinifex-workers"},
natsSub{handlers_ecr.SubjectManifestDescribe, d.handleECRManifestDescribe, "spinifex-workers"},
natsSub{handlers_ecr.SubjectManifestList, d.handleECRManifestList, "spinifex-workers"},
natsSub{handlers_ecr.SubjectManifestDelete, d.handleECRManifestDelete, "spinifex-workers"},
natsSub{handlers_ecr.SubjectUploadCreate, d.handleECRUploadCreate, "spinifex-workers"},
natsSub{handlers_ecr.SubjectUploadGet, d.handleECRUploadGet, "spinifex-workers"},
natsSub{handlers_ecr.SubjectUploadUpdate, d.handleECRUploadUpdate, "spinifex-workers"},
natsSub{handlers_ecr.SubjectUploadDelete, d.handleECRUploadDelete, "spinifex-workers"},
natsSub{handlers_ecr.SubjectRepoCreate, handleNATSRequest(d.ecrMetaService.RepoCreate), "spinifex-workers"},
natsSub{handlers_ecr.SubjectRepoDescribe, handleNATSRequest(d.ecrMetaService.RepoDescribe), "spinifex-workers"},
natsSub{handlers_ecr.SubjectRepoList, handleNATSRequest(d.ecrMetaService.RepoList), "spinifex-workers"},
natsSub{handlers_ecr.SubjectRepoDelete, handleNATSRequest(d.ecrMetaService.RepoDelete), "spinifex-workers"},
natsSub{handlers_ecr.SubjectPolicyPut, handleNATSRequest(d.ecrMetaService.PolicyPut), "spinifex-workers"},
natsSub{handlers_ecr.SubjectPolicyGet, handleNATSRequest(d.ecrMetaService.PolicyGet), "spinifex-workers"},
natsSub{handlers_ecr.SubjectPolicyDelete, handleNATSRequest(d.ecrMetaService.PolicyDelete), "spinifex-workers"},
natsSub{handlers_ecr.SubjectLifecyclePut, handleNATSRequest(d.ecrMetaService.LifecyclePut), "spinifex-workers"},
natsSub{handlers_ecr.SubjectLifecycleGet, handleNATSRequest(d.ecrMetaService.LifecycleGet), "spinifex-workers"},
natsSub{handlers_ecr.SubjectLifecycleDelete, handleNATSRequest(d.ecrMetaService.LifecycleDelete), "spinifex-workers"},
natsSub{handlers_ecr.SubjectTagPut, handleNATSRequest(d.ecrMetaService.TagPut), "spinifex-workers"},
natsSub{handlers_ecr.SubjectTagGet, handleNATSRequest(d.ecrMetaService.TagGet), "spinifex-workers"},
natsSub{handlers_ecr.SubjectTagList, handleNATSRequest(d.ecrMetaService.TagList), "spinifex-workers"},
natsSub{handlers_ecr.SubjectTagDelete, handleNATSRequest(d.ecrMetaService.TagDelete), "spinifex-workers"},
natsSub{handlers_ecr.SubjectManifestPut, handleNATSRequest(d.ecrMetaService.ManifestPut), "spinifex-workers"},
natsSub{handlers_ecr.SubjectManifestDescribe, handleNATSRequest(d.ecrMetaService.ManifestDescribe), "spinifex-workers"},
natsSub{handlers_ecr.SubjectManifestList, handleNATSRequest(d.ecrMetaService.ManifestList), "spinifex-workers"},
natsSub{handlers_ecr.SubjectManifestDelete, handleNATSRequest(d.ecrMetaService.ManifestDelete), "spinifex-workers"},
natsSub{handlers_ecr.SubjectUploadCreate, handleNATSRequest(d.ecrMetaService.UploadCreate), "spinifex-workers"},
natsSub{handlers_ecr.SubjectUploadGet, handleNATSRequest(d.ecrMetaService.UploadGet), "spinifex-workers"},
natsSub{handlers_ecr.SubjectUploadUpdate, handleNATSRequest(d.ecrMetaService.UploadUpdate), "spinifex-workers"},
natsSub{handlers_ecr.SubjectUploadDelete, handleNATSRequest(d.ecrMetaService.UploadDelete), "spinifex-workers"},
)
}
@@ -1077,12 +1081,12 @@ func (d *Daemon) subscribeAll() error {
// the disabled stub, so reads return empty lists and mutations a clean
// UnsupportedOperation instead of a NATS timeout.
subs = append(subs,
natsSub{"ec2.AllocateAddress", d.handleEC2AllocateAddress, "spinifex-workers"},
natsSub{"ec2.ReleaseAddress", d.handleEC2ReleaseAddress, "spinifex-workers"},
natsSub{"ec2.AssociateAddress", d.handleEC2AssociateAddress, "spinifex-workers"},
natsSub{"ec2.DisassociateAddress", d.handleEC2DisassociateAddress, "spinifex-workers"},
natsSub{"ec2.DescribeAddresses", d.handleEC2DescribeAddresses, "spinifex-workers"},
natsSub{"ec2.DescribeAddressesAttribute", d.handleEC2DescribeAddressesAttribute, "spinifex-workers"},
natsSub{"ec2.AllocateAddress", handleNATSRequest(d.eipService.AllocateAddress), "spinifex-workers"},
natsSub{"ec2.ReleaseAddress", handleNATSRequest(d.eipService.ReleaseAddress), "spinifex-workers"},
natsSub{"ec2.AssociateAddress", handleNATSRequest(d.eipService.AssociateAddress), "spinifex-workers"},
natsSub{"ec2.DisassociateAddress", handleNATSRequest(d.eipService.DisassociateAddress), "spinifex-workers"},
natsSub{"ec2.DescribeAddresses", handleNATSRequest(d.eipService.DescribeAddresses), "spinifex-workers"},
natsSub{"ec2.DescribeAddressesAttribute", handleNATSRequest(d.eipService.DescribeAddressesAttribute), "spinifex-workers"},
)
for _, s := range subs {
@@ -1294,8 +1298,8 @@ func (d *Daemon) startCluster() error {
}
// Remove the obsolete spinifex-dhcp-leases bucket (idempotent).
if js, jsErr := d.natsConn.JetStream(); jsErr == nil {
if err := utils.DeleteKVBucketIfExists(js, "spinifex-dhcp-leases"); err != nil {
if js, jsErr := jetstream.New(d.natsConn); jsErr == nil {
if err := kvutil.DeleteBucketIfExists(d.ctx, js, "spinifex-dhcp-leases"); err != nil {
slog.Warn("Failed to delete obsolete spinifex-dhcp-leases KV bucket", "err", err)
}
}
@@ -1331,10 +1335,10 @@ func (d *Daemon) startCluster() error {
type snapResult struct {
svc *handlers_ec2_snapshot.SnapshotServiceImpl
kv nats.KeyValue
kv jetstream.KeyValue
}
snap, err := initServiceWithRetry("snapshot service", func() (snapResult, error) {
svc, kv, err := handlers_ec2_snapshot.NewSnapshotServiceImplWithNATS(d.config, d.natsConn)
svc, kv, err := handlers_ec2_snapshot.NewSnapshotServiceImplWithNATS(d.ctx, d.config, d.natsConn)
return snapResult{svc, kv}, err
})
if err != nil {
@@ -1346,42 +1350,42 @@ func (d *Daemon) startCluster() error {
d.tagsService = handlers_ec2_tags.NewTagsServiceImpl(d.config)
d.eigwService, err = initServiceWithRetry("EIGW service", func() (*handlers_ec2_eigw.EgressOnlyIGWServiceImpl, error) {
return handlers_ec2_eigw.NewEgressOnlyIGWServiceImplWithNATS(d.config, d.natsConn)
return handlers_ec2_eigw.NewEgressOnlyIGWServiceImplWithNATS(d.ctx, d.config, d.natsConn)
})
if err != nil {
return fmt.Errorf("failed to initialize EIGW service: %w", err)
}
d.igwService, err = initServiceWithRetry("IGW service", func() (*handlers_ec2_igw.IGWServiceImpl, error) {
return handlers_ec2_igw.NewIGWServiceImplWithNATS(d.config, d.natsConn)
return handlers_ec2_igw.NewIGWServiceImplWithNATS(d.ctx, d.config, d.natsConn)
})
if err != nil {
return fmt.Errorf("failed to initialize IGW service: %w", err)
}
d.placementGroupService, err = initServiceWithRetry("placement group service", func() (*handlers_ec2_placementgroup.PlacementGroupServiceImpl, error) {
return handlers_ec2_placementgroup.NewPlacementGroupServiceImplWithNATS(d.config, d.natsConn)
return handlers_ec2_placementgroup.NewPlacementGroupServiceImplWithNATS(d.ctx, d.config, d.natsConn)
})
if err != nil {
return fmt.Errorf("failed to initialize placement group service: %w", err)
}
d.launchTemplateService, err = initServiceWithRetry("launch template service", func() (*handlers_ec2_launchtemplate.LaunchTemplateServiceImpl, error) {
return handlers_ec2_launchtemplate.NewLaunchTemplateServiceImplWithNATS(d.config, d.natsConn)
return handlers_ec2_launchtemplate.NewLaunchTemplateServiceImplWithNATS(d.ctx, d.config, d.natsConn)
})
if err != nil {
return fmt.Errorf("failed to initialize launch template service: %w", err)
}
d.spotInstanceService, err = initServiceWithRetry("spot instance service", func() (*handlers_ec2_spotinstance.SpotInstanceServiceImpl, error) {
return handlers_ec2_spotinstance.NewSpotInstanceServiceImplWithNATS(d.config, d.natsConn)
return handlers_ec2_spotinstance.NewSpotInstanceServiceImplWithNATS(d.ctx, d.config, d.natsConn)
})
if err != nil {
return fmt.Errorf("failed to initialize spot instance service: %w", err)
}
d.vpcService, err = initServiceWithRetry("VPC service", func() (*handlers_ec2_vpc.VPCServiceImpl, error) {
return handlers_ec2_vpc.NewVPCServiceImplWithNATS(d.config, d.natsConn)
return handlers_ec2_vpc.NewVPCServiceImplWithNATS(d.ctx, d.config, d.natsConn)
})
if err != nil {
return fmt.Errorf("failed to initialize VPC service: %w", err)
@@ -1392,7 +1396,7 @@ func (d *Daemon) startCluster() error {
d.vpcService.SetDefaultPublicIPMapping(d.hasPublicIPPools())
d.routeTableService, err = initServiceWithRetry("RouteTable service", func() (*handlers_ec2_routetable.RouteTableServiceImpl, error) {
return handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(d.config, d.natsConn)
return handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(d.ctx, d.config, d.natsConn)
})
if err != nil {
return fmt.Errorf("failed to initialize RouteTable service: %w", err)
@@ -1402,7 +1406,7 @@ func (d *Daemon) startCluster() error {
d.igwService.SetGatePublisher(d.routeTableService)
d.natGatewayService, err = initServiceWithRetry("NatGateway service", func() (*handlers_ec2_natgw.NatGatewayServiceImpl, error) {
return handlers_ec2_natgw.NewNatGatewayServiceImplWithNATS(d.natsConn)
return handlers_ec2_natgw.NewNatGatewayServiceImplWithNATS(d.ctx, d.natsConn)
})
if err != nil {
return fmt.Errorf("failed to initialize NatGateway service: %w", err)
@@ -1412,7 +1416,7 @@ func (d *Daemon) startCluster() error {
// mode with a public pool alongside the transit segment). The transit pool
// never enters IPAM — its addresses are gateway-LRP plumbing, not EIPs.
if d.hasPublicIPPools() {
js, jsErr := d.natsConn.JetStream()
js, jsErr := jetstream.New(d.natsConn)
if jsErr != nil {
slog.Warn("Failed to get JetStream for external IPAM", "err", jsErr)
} else {
@@ -1438,7 +1442,7 @@ func (d *Daemon) startCluster() error {
anyDHCP = true
}
}
d.externalIPAM, err = handlers_ec2_vpc.NewExternalIPAM(js, pools)
d.externalIPAM, err = handlers_ec2_vpc.NewExternalIPAM(d.ctx, js, pools)
if err != nil {
slog.Warn("Failed to initialize external IPAM", "err", err)
} else {
@@ -1455,7 +1459,7 @@ func (d *Daemon) startCluster() error {
// Initialize EIP service if external IPAM is available
if d.externalIPAM != nil && d.vpcService != nil {
eipSvc, eipErr := handlers_ec2_eip.NewEIPServiceImpl(d.natsConn, d.externalIPAM, d.vpcService)
eipSvc, eipErr := handlers_ec2_eip.NewEIPServiceImpl(d.ctx, d.natsConn, d.externalIPAM, d.vpcService)
if eipErr != nil {
slog.Warn("Failed to initialize EIP service", "err", eipErr)
} else {
@@ -1465,11 +1469,11 @@ func (d *Daemon) startCluster() error {
// Inject external IPAM + EIP KV into VPC service so DeleteNetworkInterface
// can release auto-assigned public IPs and NAT rules.
eipJS, eipJSErr := d.natsConn.JetStream()
eipJS, eipJSErr := jetstream.New(d.natsConn)
if eipJSErr != nil {
slog.Warn("Failed to get JetStream for VPC external IPAM injection", "err", eipJSErr)
} else {
eipKV, eipKVErr := utils.GetOrCreateKVBucket(eipJS, handlers_ec2_eip.KVBucketEIPs, 10)
eipKV, eipKVErr := kvutil.GetOrCreateBucket(d.ctx, eipJS, handlers_ec2_eip.KVBucketEIPs, 10)
if eipKVErr != nil {
slog.Warn("Failed to get EIP KV bucket for VPC service", "err", eipKVErr)
} else {
@@ -1493,7 +1497,7 @@ func (d *Daemon) startCluster() error {
}
d.accountService, err = initServiceWithRetry("account settings service", func() (*handlers_ec2_account.AccountSettingsServiceImpl, error) {
return handlers_ec2_account.NewAccountSettingsServiceImplWithNATS(d.config, d.natsConn)
return handlers_ec2_account.NewAccountSettingsServiceImplWithNATS(d.ctx, d.config, d.natsConn)
})
if err != nil {
return fmt.Errorf("failed to initialize account settings service: %w", err)
@@ -1545,9 +1549,9 @@ func (d *Daemon) startCluster() error {
// reaper. The scheduler is disabled (handlers still serve) when JetStream is
// unavailable.
d.ecsService = handlers_ecs.NewService(d.natsConn, d.config.Region, d.clusterConfig.AWS.InternalSuffix).WithDeps(d.buildECSServiceDeps())
if js, jsErr := d.natsConn.JetStream(); jsErr != nil {
if js, jsErr := jetstream.New(d.natsConn); jsErr != nil {
slog.Warn("ECS scheduler disabled: JetStream unavailable", "err", jsErr)
} else if _, lbErr := handlers_ecs.InitLeaderBucket(js); lbErr != nil {
} else if _, lbErr := handlers_ecs.InitLeaderBucket(d.ctx, js); lbErr != nil {
slog.Warn("ECS scheduler disabled: leader bucket init failed", "err", lbErr)
} else {
d.ecsScheduler = handlers_ecs.NewScheduler(d.natsConn, d.ecsService, d.node)
@@ -1562,7 +1566,7 @@ func (d *Daemon) startCluster() error {
}
d.acmService, err = initServiceWithRetry("ACM service", func() (*handlers_acm.ACMServiceImpl, error) {
return handlers_acm.NewACMServiceImplWithNATS(d.config, d.natsConn)
return handlers_acm.NewACMServiceImplWithNATS(d.ctx, d.config, d.natsConn)
})
if err != nil {
return fmt.Errorf("failed to initialize ACM service: %w", err)
@@ -1571,7 +1575,7 @@ func (d *Daemon) startCluster() error {
// ECR metadata service: owns per-account JetStream KV for repos, tags,
// manifest records and upload-state CAS. Disabled (gateway returns NATS
// timeouts) when JetStream is unavailable.
if js, jsErr := d.natsConn.JetStream(); jsErr != nil {
if js, jsErr := jetstream.New(d.natsConn); jsErr != nil {
slog.Warn("ECR metadata service disabled: JetStream unavailable", "err", jsErr)
} else {
d.ecrMetaService = handlers_ecr.NewKVMetaService(js)
@@ -1613,8 +1617,13 @@ func (d *Daemon) startCluster() error {
return fmt.Errorf("restore instances: %w", err)
}
// Rebuild mgmt IP allocator so already-allocated IPs aren't reused.
if d.mgmtIPAllocator != nil {
// Bind the mgmt IP allocator to cluster KV now that JetStream is up (DDIL
// §1e-audit: startLocal built it KV-less). Rebuild then reconciles this
// node's already-running VMs into the cluster-wide record instead of
// just refreshing the local cache, so already-allocated IPs aren't
// reused by another node either.
if d.mgmtIPAllocator != nil && d.jsManager != nil {
d.mgmtIPAllocator.BindKV(d.jsManager, d.node)
d.mgmtIPAllocator.Rebuild(d.vmMgr.SnapshotMap())
slog.Info("Rebuilt mgmt IP allocator from restored instances", "allocated", d.mgmtIPAllocator.AllocatedCount())
}
@@ -1655,6 +1664,7 @@ func (d *Daemon) startCluster() error {
reapers := []vm.Reaper{
d.vmMgr.NewTerminatedTeardownReaper(),
d.vmMgr.NewOrphanQEMUReaper(),
d.vmMgr.NewStuckTerminateReaper(),
}
if eniRec := d.newENIReconciler(); eniRec != nil {
reapers = append(reapers, eniRec)
+57 -45
View File
@@ -66,60 +66,65 @@ func respondWithJSON(msg *nats.Msg, data any) {
}
}
// handleNATSRequest is a generic helper for the common unmarshal → service → marshal → respond pattern.
// It opens a consumer span joining the producer's trace, extracts the account ID
// from the NATS message header, and passes both to the service function.
func handleNATSRequest[I any, O any](msg *nats.Msg, serviceFn func(context.Context, *I, string) (*O, error)) {
ctx, span := utils.StartConsumerSpan(msg)
defer span.End()
// handleNATSRequest returns the nats.MsgHandler for the common unmarshal → service →
// marshal → respond pattern. Per message the handler opens a consumer span joining the
// producer's trace, extracts the account ID from the NATS message header, and passes
// both to the service function.
func handleNATSRequest[I any, O any](serviceFn func(context.Context, *I, string) (*O, error)) nats.MsgHandler {
return func(msg *nats.Msg) {
ctx, span := utils.StartConsumerSpan(msg)
defer span.End()
accountID := utils.AccountIDFromMsg(msg)
input := new(I)
if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil {
utils.MarkSpanError(span, errors.New(awserrors.ErrorInvalidParameterValue))
if err := msg.Respond(errResp); err != nil {
slog.ErrorContext(ctx, "Failed to respond to NATS request", "err", err)
accountID := utils.AccountIDFromMsg(msg)
input := new(I)
if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil {
utils.MarkSpanError(span, errors.New(awserrors.ErrorInvalidParameterValue))
if err := msg.Respond(errResp); err != nil {
slog.ErrorContext(ctx, "Failed to respond to NATS request", "err", err)
}
return
}
return
output, err := serviceFn(ctx, input, accountID)
if err != nil {
// The error was otherwise only recorded on the OTel span, invisible
// without a trace backend. Log at Error so handler failures show up
// in the journal too.
slog.ErrorContext(ctx, "handleNATSRequest: service call failed", "subject", msg.Subject, "err", err)
utils.MarkSpanError(span, err)
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
respondWithJSON(msg, output)
}
output, err := serviceFn(ctx, input, accountID)
if err != nil {
// The error was otherwise only recorded on the OTel span, invisible
// without a trace backend. Log at Error so handler failures show up
// in the journal too.
slog.ErrorContext(ctx, "handleNATSRequest: service call failed", "subject", msg.Subject, "err", err)
utils.MarkSpanError(span, err)
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
return
}
respondWithJSON(msg, output)
}
// handleNATSRequestWithPrincipal is handleNATSRequest for service methods that
// also need the caller's IAM principal ARN (X-Principal-ARN header) — e.g. EKS
// CreateCluster, which mints the bootstrap-creator-admin AccessEntry for the
// caller.
func handleNATSRequestWithPrincipal[I any, O any](msg *nats.Msg, serviceFn func(context.Context, *I, string, string) (*O, error)) {
ctx, span := utils.StartConsumerSpan(msg)
defer span.End()
func handleNATSRequestWithPrincipal[I any, O any](serviceFn func(context.Context, *I, string, string) (*O, error)) nats.MsgHandler {
return func(msg *nats.Msg) {
ctx, span := utils.StartConsumerSpan(msg)
defer span.End()
accountID := utils.AccountIDFromMsg(msg)
principalARN := utils.PrincipalARNFromMsg(msg)
input := new(I)
if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil {
utils.MarkSpanError(span, errors.New(awserrors.ErrorInvalidParameterValue))
if err := msg.Respond(errResp); err != nil {
slog.ErrorContext(ctx, "Failed to respond to NATS request", "err", err)
accountID := utils.AccountIDFromMsg(msg)
principalARN := utils.PrincipalARNFromMsg(msg)
input := new(I)
if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil {
utils.MarkSpanError(span, errors.New(awserrors.ErrorInvalidParameterValue))
if err := msg.Respond(errResp); err != nil {
slog.ErrorContext(ctx, "Failed to respond to NATS request", "err", err)
}
return
}
return
output, err := serviceFn(ctx, input, accountID, principalARN)
if err != nil {
utils.MarkSpanError(span, err)
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
respondWithJSON(msg, output)
}
output, err := serviceFn(ctx, input, accountID, principalARN)
if err != nil {
utils.MarkSpanError(span, err)
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
return
}
respondWithJSON(msg, output)
}
// handleEC2Events processes incoming EC2 instance events (start, stop, terminate, attach-volume).
@@ -155,6 +160,13 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) {
d.handleAttachVolume(ctx, msg, command, instance)
case command.Attributes.DetachVolume:
d.handleDetachVolume(ctx, msg, command, instance)
case command.Attributes.DrainVolume:
// A drain flushes the volume's whole dirty set to S3, so it is bounded by
// the dirty set rather than by a unit of work. nats.go delivers a
// subscription's messages serially, so running it inline would stall
// every other command for this instance — stop, terminate, hot-plug —
// for the duration. It touches no VM state, so it is safe off-thread.
go d.handleDrainVolume(ctx, msg, command, instance)
case command.Attributes.AttachENI:
d.handleAttachNetworkInterface(ctx, msg, command, instance)
case command.Attributes.DetachENI:
@@ -171,7 +183,7 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) {
endOpSpan(opSpan, err)
if err != nil {
utils.MarkSpanError(span, err)
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
if err := msg.Respond(fmt.Appendf(nil, `{"status":"running","instanceId":"%s"}`, instance.ID)); err != nil {
@@ -183,7 +195,7 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) {
endOpSpan(opSpan, err)
if err != nil {
utils.MarkSpanError(span, err)
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
if err := msg.Respond([]byte(`{}`)); err != nil {
@@ -199,7 +211,7 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) {
endOpSpan(opSpan, err)
if err != nil {
utils.MarkSpanError(span, err)
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
if err := msg.Respond([]byte(`{}`)); err != nil {
@@ -1,29 +0,0 @@
package daemon
import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2EnableEbsEncryptionByDefault(msg *nats.Msg) {
handleNATSRequest(msg, d.accountService.EnableEbsEncryptionByDefault)
}
func (d *Daemon) handleEC2DisableEbsEncryptionByDefault(msg *nats.Msg) {
handleNATSRequest(msg, d.accountService.DisableEbsEncryptionByDefault)
}
func (d *Daemon) handleEC2GetEbsEncryptionByDefault(msg *nats.Msg) {
handleNATSRequest(msg, d.accountService.GetEbsEncryptionByDefault)
}
func (d *Daemon) handleEC2GetSerialConsoleAccessStatus(msg *nats.Msg) {
handleNATSRequest(msg, d.accountService.GetSerialConsoleAccessStatus)
}
func (d *Daemon) handleEC2EnableSerialConsoleAccess(msg *nats.Msg) {
handleNATSRequest(msg, d.accountService.EnableSerialConsoleAccess)
}
func (d *Daemon) handleEC2DisableSerialConsoleAccess(msg *nats.Msg) {
handleNATSRequest(msg, d.accountService.DisableSerialConsoleAccess)
}
-31
View File
@@ -1,31 +0,0 @@
package daemon
import "github.com/nats-io/nats.go"
func (d *Daemon) handleACMImportCertificate(msg *nats.Msg) {
handleNATSRequest(msg, d.acmService.ImportCertificate)
}
func (d *Daemon) handleACMDescribeCertificate(msg *nats.Msg) {
handleNATSRequest(msg, d.acmService.DescribeCertificate)
}
func (d *Daemon) handleACMListCertificates(msg *nats.Msg) {
handleNATSRequest(msg, d.acmService.ListCertificates)
}
func (d *Daemon) handleACMDeleteCertificate(msg *nats.Msg) {
handleNATSRequest(msg, d.acmService.DeleteCertificate)
}
func (d *Daemon) handleACMListTagsForCertificate(msg *nats.Msg) {
handleNATSRequest(msg, d.acmService.ListTagsForCertificate)
}
func (d *Daemon) handleACMAddTagsToCertificate(msg *nats.Msg) {
handleNATSRequest(msg, d.acmService.AddTagsToCertificate)
}
func (d *Daemon) handleACMRemoveTagsFromCertificate(msg *nats.Msg) {
handleNATSRequest(msg, d.acmService.RemoveTagsFromCertificate)
}
@@ -58,7 +58,7 @@ func (d *Daemon) handleEC2CreateCapacityReservation(msg *nats.Msg) {
}
if err := d.resourceMgr.CreateReservation(rec); err != nil {
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
-91
View File
@@ -1,91 +0,0 @@
package daemon
import "github.com/nats-io/nats.go"
func (d *Daemon) handleECRRepoCreate(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.RepoCreate)
}
func (d *Daemon) handleECRRepoDescribe(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.RepoDescribe)
}
func (d *Daemon) handleECRRepoList(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.RepoList)
}
func (d *Daemon) handleECRRepoDelete(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.RepoDelete)
}
func (d *Daemon) handleECRPolicyPut(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.PolicyPut)
}
func (d *Daemon) handleECRPolicyGet(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.PolicyGet)
}
func (d *Daemon) handleECRPolicyDelete(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.PolicyDelete)
}
func (d *Daemon) handleECRLifecyclePut(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.LifecyclePut)
}
func (d *Daemon) handleECRLifecycleGet(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.LifecycleGet)
}
func (d *Daemon) handleECRLifecycleDelete(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.LifecycleDelete)
}
func (d *Daemon) handleECRTagPut(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.TagPut)
}
func (d *Daemon) handleECRTagGet(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.TagGet)
}
func (d *Daemon) handleECRTagList(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.TagList)
}
func (d *Daemon) handleECRTagDelete(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.TagDelete)
}
func (d *Daemon) handleECRManifestPut(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.ManifestPut)
}
func (d *Daemon) handleECRManifestDescribe(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.ManifestDescribe)
}
func (d *Daemon) handleECRManifestList(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.ManifestList)
}
func (d *Daemon) handleECRManifestDelete(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.ManifestDelete)
}
func (d *Daemon) handleECRUploadCreate(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.UploadCreate)
}
func (d *Daemon) handleECRUploadGet(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.UploadGet)
}
func (d *Daemon) handleECRUploadUpdate(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.UploadUpdate)
}
func (d *Daemon) handleECRUploadDelete(msg *nats.Msg) {
handleNATSRequest(msg, d.ecrMetaService.UploadDelete)
}
-155
View File
@@ -1,155 +0,0 @@
package daemon
import "github.com/nats-io/nats.go"
// --- Cluster ---
func (d *Daemon) handleECSCreateCluster(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.CreateCluster)
}
func (d *Daemon) handleECSDeleteCluster(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.DeleteCluster)
}
func (d *Daemon) handleECSDescribeClusters(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.DescribeClusters)
}
func (d *Daemon) handleECSListClusters(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.ListClusters)
}
// --- Task definition ---
func (d *Daemon) handleECSRegisterTaskDefinition(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.RegisterTaskDefinition)
}
func (d *Daemon) handleECSDeregisterTaskDefinition(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.DeregisterTaskDefinition)
}
func (d *Daemon) handleECSDescribeTaskDefinition(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.DescribeTaskDefinition)
}
func (d *Daemon) handleECSListTaskDefinitions(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.ListTaskDefinitions)
}
// --- Container instance ---
func (d *Daemon) handleECSRegisterContainerInstance(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.RegisterContainerInstance)
}
func (d *Daemon) handleECSDeregisterContainerInstance(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.DeregisterContainerInstance)
}
func (d *Daemon) handleECSUpdateContainerInstancesState(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.UpdateContainerInstancesState)
}
func (d *Daemon) handleECSDescribeContainerInstances(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.DescribeContainerInstances)
}
func (d *Daemon) handleECSListContainerInstances(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.ListContainerInstances)
}
// --- Task ---
func (d *Daemon) handleECSRunTask(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.RunTask)
}
func (d *Daemon) handleECSStartTask(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.StartTask)
}
func (d *Daemon) handleECSStopTask(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.StopTask)
}
func (d *Daemon) handleECSDescribeTasks(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.DescribeTasks)
}
// --- Service ---
func (d *Daemon) handleECSCreateService(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.CreateService)
}
func (d *Daemon) handleECSUpdateService(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.UpdateService)
}
func (d *Daemon) handleECSDeleteService(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.DeleteService)
}
func (d *Daemon) handleECSDescribeServices(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.DescribeServices)
}
func (d *Daemon) handleECSListServices(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.ListServices)
}
func (d *Daemon) handleECSSubmitTaskStateChange(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.SubmitTaskStateChange)
}
func (d *Daemon) handleECSListTasks(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.ListTasks)
}
func (d *Daemon) handleECSPollAssignments(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.PollAssignments)
}
func (d *Daemon) handleECSReportTaskGPU(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.ReportTaskGPU)
}
// --- Capacity ---
func (d *Daemon) handleECSProvisionCapacity(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.ProvisionCapacity)
}
// --- Tags ---
func (d *Daemon) handleECSTagResource(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.TagResource)
}
func (d *Daemon) handleECSUntagResource(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.UntagResource)
}
func (d *Daemon) handleECSListTagsForResource(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.ListTagsForResource)
}
// --- Capacity providers ---
func (d *Daemon) handleECSPutClusterCapacityProviders(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.PutClusterCapacityProviders)
}
func (d *Daemon) handleECSCreateCapacityProvider(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.CreateCapacityProvider)
}
func (d *Daemon) handleECSDescribeCapacityProviders(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.DescribeCapacityProviders)
}
func (d *Daemon) handleECSDeleteCapacityProvider(msg *nats.Msg) {
handleNATSRequest(msg, d.ecsService.DeleteCapacityProvider)
}
-27
View File
@@ -1,27 +0,0 @@
package daemon
import "github.com/nats-io/nats.go"
func (d *Daemon) handleEC2AllocateAddress(msg *nats.Msg) {
handleNATSRequest(msg, d.eipService.AllocateAddress)
}
func (d *Daemon) handleEC2ReleaseAddress(msg *nats.Msg) {
handleNATSRequest(msg, d.eipService.ReleaseAddress)
}
func (d *Daemon) handleEC2AssociateAddress(msg *nats.Msg) {
handleNATSRequest(msg, d.eipService.AssociateAddress)
}
func (d *Daemon) handleEC2DisassociateAddress(msg *nats.Msg) {
handleNATSRequest(msg, d.eipService.DisassociateAddress)
}
func (d *Daemon) handleEC2DescribeAddresses(msg *nats.Msg) {
handleNATSRequest(msg, d.eipService.DescribeAddresses)
}
func (d *Daemon) handleEC2DescribeAddressesAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.eipService.DescribeAddressesAttribute)
}
-167
View File
@@ -1,167 +0,0 @@
package daemon
import "github.com/nats-io/nats.go"
// --- Cluster ---
func (d *Daemon) handleEKSCreateCluster(msg *nats.Msg) {
handleNATSRequestWithPrincipal(msg, d.eksService.CreateCluster)
}
func (d *Daemon) handleEKSDescribeCluster(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.DescribeCluster)
}
func (d *Daemon) handleEKSListClusters(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.ListClusters)
}
func (d *Daemon) handleEKSUpdateClusterConfig(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.UpdateClusterConfig)
}
func (d *Daemon) handleEKSUpdateClusterVersion(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.UpdateClusterVersion)
}
func (d *Daemon) handleEKSDeleteCluster(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.DeleteCluster)
}
// --- Nodegroup ---
func (d *Daemon) handleEKSCreateNodegroup(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.CreateNodegroup)
}
func (d *Daemon) handleEKSDescribeNodegroup(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.DescribeNodegroup)
}
func (d *Daemon) handleEKSListNodegroups(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.ListNodegroups)
}
func (d *Daemon) handleEKSUpdateNodegroupConfig(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.UpdateNodegroupConfig)
}
func (d *Daemon) handleEKSUpdateNodegroupVersion(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.UpdateNodegroupVersion)
}
func (d *Daemon) handleEKSDeleteNodegroup(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.DeleteNodegroup)
}
// --- AccessEntry + AccessPolicy ---
func (d *Daemon) handleEKSCreateAccessEntry(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.CreateAccessEntry)
}
func (d *Daemon) handleEKSDescribeAccessEntry(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.DescribeAccessEntry)
}
func (d *Daemon) handleEKSListAccessEntries(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.ListAccessEntries)
}
func (d *Daemon) handleEKSUpdateAccessEntry(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.UpdateAccessEntry)
}
func (d *Daemon) handleEKSDeleteAccessEntry(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.DeleteAccessEntry)
}
func (d *Daemon) handleEKSAssociateAccessPolicy(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.AssociateAccessPolicy)
}
func (d *Daemon) handleEKSDisassociateAccessPolicy(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.DisassociateAccessPolicy)
}
func (d *Daemon) handleEKSListAssociatedAccessPolicies(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.ListAssociatedAccessPolicies)
}
func (d *Daemon) handleEKSListAccessPolicies(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.ListAccessPolicies)
}
// --- Addons ---
func (d *Daemon) handleEKSListAddons(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.ListAddons)
}
func (d *Daemon) handleEKSDescribeAddonVersions(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.DescribeAddonVersions)
}
func (d *Daemon) handleEKSCreateAddon(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.CreateAddon)
}
func (d *Daemon) handleEKSDeleteAddon(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.DeleteAddon)
}
func (d *Daemon) handleEKSDescribeAddon(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.DescribeAddon)
}
func (d *Daemon) handleEKSUpdateAddon(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.UpdateAddon)
}
func (d *Daemon) handleEKSListStagedAddonManifests(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.ListStagedAddonManifests)
}
func (d *Daemon) handleEKSGetRecoveryDirective(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.GetRecoveryDirective)
}
func (d *Daemon) handleEKSSetRecoveryDirective(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.SetRecoveryDirective)
}
func (d *Daemon) handleEKSRestoreSnapshot(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.RestoreSnapshot)
}
// --- OIDC identity-provider configs ---
func (d *Daemon) handleEKSAssociateIdentityProviderConfig(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.AssociateIdentityProviderConfig)
}
func (d *Daemon) handleEKSDescribeIdentityProviderConfig(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.DescribeIdentityProviderConfig)
}
func (d *Daemon) handleEKSListIdentityProviderConfigs(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.ListIdentityProviderConfigs)
}
func (d *Daemon) handleEKSDisassociateIdentityProviderConfig(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.DisassociateIdentityProviderConfig)
}
// --- Tags ---
func (d *Daemon) handleEKSTagResource(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.TagResource)
}
func (d *Daemon) handleEKSUntagResource(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.UntagResource)
}
func (d *Daemon) handleEKSListTagsForResource(msg *nats.Msg) {
handleNATSRequest(msg, d.eksService.ListTagsForResource)
}
+30 -30
View File
@@ -71,39 +71,39 @@ func TestDaemonHandleEKS_AllHandlersDispatchToService(t *testing.T) {
handler nats.MsgHandler
wantCode string
}{
{"eks.UpdateClusterConfig", d.handleEKSUpdateClusterConfig, notImpl},
{"eks.UpdateClusterVersion", d.handleEKSUpdateClusterVersion, notImpl},
{"eks.CreateNodegroup", d.handleEKSCreateNodegroup, unavailable},
{"eks.DescribeNodegroup", d.handleEKSDescribeNodegroup, invalid},
{"eks.ListNodegroups", d.handleEKSListNodegroups, invalid},
{"eks.UpdateNodegroupConfig", d.handleEKSUpdateNodegroupConfig, unavailable},
{"eks.UpdateNodegroupVersion", d.handleEKSUpdateNodegroupVersion, notImpl},
{"eks.DeleteNodegroup", d.handleEKSDeleteNodegroup, unavailable},
{"eks.CreateAccessEntry", d.handleEKSCreateAccessEntry, invalid},
{"eks.DescribeAccessEntry", d.handleEKSDescribeAccessEntry, invalid},
{"eks.ListAccessEntries", d.handleEKSListAccessEntries, invalid},
{"eks.UpdateAccessEntry", d.handleEKSUpdateAccessEntry, invalid},
{"eks.DeleteAccessEntry", d.handleEKSDeleteAccessEntry, invalid},
{"eks.AssociateAccessPolicy", d.handleEKSAssociateAccessPolicy, invalid},
{"eks.DisassociateAccessPolicy", d.handleEKSDisassociateAccessPolicy, invalid},
{"eks.ListAssociatedAccessPolicies", d.handleEKSListAssociatedAccessPolicies, invalid},
{"eks.ListAccessPolicies", d.handleEKSListAccessPolicies, ""},
{"eks.UpdateClusterConfig", handleNATSRequest(d.eksService.UpdateClusterConfig), notImpl},
{"eks.UpdateClusterVersion", handleNATSRequest(d.eksService.UpdateClusterVersion), notImpl},
{"eks.CreateNodegroup", handleNATSRequest(d.eksService.CreateNodegroup), unavailable},
{"eks.DescribeNodegroup", handleNATSRequest(d.eksService.DescribeNodegroup), invalid},
{"eks.ListNodegroups", handleNATSRequest(d.eksService.ListNodegroups), invalid},
{"eks.UpdateNodegroupConfig", handleNATSRequest(d.eksService.UpdateNodegroupConfig), unavailable},
{"eks.UpdateNodegroupVersion", handleNATSRequest(d.eksService.UpdateNodegroupVersion), notImpl},
{"eks.DeleteNodegroup", handleNATSRequest(d.eksService.DeleteNodegroup), unavailable},
{"eks.CreateAccessEntry", handleNATSRequest(d.eksService.CreateAccessEntry), invalid},
{"eks.DescribeAccessEntry", handleNATSRequest(d.eksService.DescribeAccessEntry), invalid},
{"eks.ListAccessEntries", handleNATSRequest(d.eksService.ListAccessEntries), invalid},
{"eks.UpdateAccessEntry", handleNATSRequest(d.eksService.UpdateAccessEntry), invalid},
{"eks.DeleteAccessEntry", handleNATSRequest(d.eksService.DeleteAccessEntry), invalid},
{"eks.AssociateAccessPolicy", handleNATSRequest(d.eksService.AssociateAccessPolicy), invalid},
{"eks.DisassociateAccessPolicy", handleNATSRequest(d.eksService.DisassociateAccessPolicy), invalid},
{"eks.ListAssociatedAccessPolicies", handleNATSRequest(d.eksService.ListAssociatedAccessPolicies), invalid},
{"eks.ListAccessPolicies", handleNATSRequest(d.eksService.ListAccessPolicies), ""},
// Addon handlers are implemented (Sprint 6 P1): an empty body fails
// cluster validation with InvalidParameterValue, except
// DescribeAddonVersions which returns the static catalogue (success).
{"eks.ListAddons", d.handleEKSListAddons, invalid},
{"eks.DescribeAddonVersions", d.handleEKSDescribeAddonVersions, ""},
{"eks.CreateAddon", d.handleEKSCreateAddon, invalid},
{"eks.DeleteAddon", d.handleEKSDeleteAddon, invalid},
{"eks.DescribeAddon", d.handleEKSDescribeAddon, invalid},
{"eks.UpdateAddon", d.handleEKSUpdateAddon, invalid},
{"eks.AssociateIdentityProviderConfig", d.handleEKSAssociateIdentityProviderConfig, notImpl},
{"eks.DescribeIdentityProviderConfig", d.handleEKSDescribeIdentityProviderConfig, notImpl},
{"eks.ListIdentityProviderConfigs", d.handleEKSListIdentityProviderConfigs, notImpl},
{"eks.DisassociateIdentityProviderConfig", d.handleEKSDisassociateIdentityProviderConfig, notImpl},
{"eks.TagResource", d.handleEKSTagResource, notImpl},
{"eks.UntagResource", d.handleEKSUntagResource, notImpl},
{"eks.ListTagsForResource", d.handleEKSListTagsForResource, notImpl},
{"eks.ListAddons", handleNATSRequest(d.eksService.ListAddons), invalid},
{"eks.DescribeAddonVersions", handleNATSRequest(d.eksService.DescribeAddonVersions), ""},
{"eks.CreateAddon", handleNATSRequest(d.eksService.CreateAddon), invalid},
{"eks.DeleteAddon", handleNATSRequest(d.eksService.DeleteAddon), invalid},
{"eks.DescribeAddon", handleNATSRequest(d.eksService.DescribeAddon), invalid},
{"eks.UpdateAddon", handleNATSRequest(d.eksService.UpdateAddon), invalid},
{"eks.AssociateIdentityProviderConfig", handleNATSRequest(d.eksService.AssociateIdentityProviderConfig), notImpl},
{"eks.DescribeIdentityProviderConfig", handleNATSRequest(d.eksService.DescribeIdentityProviderConfig), notImpl},
{"eks.ListIdentityProviderConfigs", handleNATSRequest(d.eksService.ListIdentityProviderConfigs), notImpl},
{"eks.DisassociateIdentityProviderConfig", handleNATSRequest(d.eksService.DisassociateIdentityProviderConfig), notImpl},
{"eks.TagResource", handleNATSRequest(d.eksService.TagResource), notImpl},
{"eks.UntagResource", handleNATSRequest(d.eksService.UntagResource), notImpl},
{"eks.ListTagsForResource", handleNATSRequest(d.eksService.ListTagsForResource), notImpl},
}
require.Len(t, cases, 30, "expected exactly one handler per non-lifecycle AWS EKS action")
-143
View File
@@ -1,143 +0,0 @@
package daemon
import "github.com/nats-io/nats.go"
func (d *Daemon) handleELBv2CreateLoadBalancer(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.CreateLoadBalancer)
}
func (d *Daemon) handleELBv2DeleteLoadBalancer(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DeleteLoadBalancer)
}
func (d *Daemon) handleELBv2DescribeLoadBalancers(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeLoadBalancers)
}
func (d *Daemon) handleELBv2CreateTargetGroup(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.CreateTargetGroup)
}
func (d *Daemon) handleELBv2ModifyTargetGroup(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.ModifyTargetGroup)
}
func (d *Daemon) handleELBv2DeleteTargetGroup(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DeleteTargetGroup)
}
func (d *Daemon) handleELBv2DescribeTargetGroups(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeTargetGroups)
}
func (d *Daemon) handleELBv2RegisterTargets(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.RegisterTargets)
}
func (d *Daemon) handleELBv2DeregisterTargets(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DeregisterTargets)
}
func (d *Daemon) handleELBv2DescribeTargetHealth(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeTargetHealth)
}
func (d *Daemon) handleELBv2CreateListener(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.CreateListener)
}
func (d *Daemon) handleELBv2DeleteListener(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DeleteListener)
}
func (d *Daemon) handleELBv2ModifyListener(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.ModifyListener)
}
func (d *Daemon) handleELBv2DescribeListeners(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeListeners)
}
func (d *Daemon) handleELBv2CreateRule(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.CreateRule)
}
func (d *Daemon) handleELBv2ModifyRule(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.ModifyRule)
}
func (d *Daemon) handleELBv2DeleteRule(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DeleteRule)
}
func (d *Daemon) handleELBv2DescribeRules(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeRules)
}
func (d *Daemon) handleELBv2SetRulePriorities(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.SetRulePriorities)
}
func (d *Daemon) handleELBv2DescribeTags(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeTags)
}
func (d *Daemon) handleELBv2AddTags(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.AddTags)
}
func (d *Daemon) handleELBv2RemoveTags(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.RemoveTags)
}
func (d *Daemon) handleELBv2LBAgentHeartbeat(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.LBAgentHeartbeat)
}
func (d *Daemon) handleELBv2GetLBConfig(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.GetLBConfig)
}
func (d *Daemon) handleELBv2ModifyTargetGroupAttributes(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.ModifyTargetGroupAttributes)
}
func (d *Daemon) handleELBv2DescribeTargetGroupAttributes(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeTargetGroupAttributes)
}
func (d *Daemon) handleELBv2ModifyLoadBalancerAttributes(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.ModifyLoadBalancerAttributes)
}
func (d *Daemon) handleELBv2DescribeLoadBalancerAttributes(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeLoadBalancerAttributes)
}
func (d *Daemon) handleELBv2SetSecurityGroups(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.SetSecurityGroups)
}
func (d *Daemon) handleELBv2SetIpAddressType(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.SetIpAddressType)
}
func (d *Daemon) handleELBv2SetSubnets(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.SetSubnets)
}
func (d *Daemon) handleELBv2AddListenerCertificates(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.AddListenerCertificates)
}
func (d *Daemon) handleELBv2RemoveListenerCertificates(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.RemoveListenerCertificates)
}
func (d *Daemon) handleELBv2DescribeListenerCertificates(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeListenerCertificates)
}
func (d *Daemon) handleELBv2DescribeSSLPolicies(msg *nats.Msg) {
handleNATSRequest(msg, d.elbv2Service.DescribeSSLPolicies)
}
+3 -12
View File
@@ -39,7 +39,7 @@ func (d *Daemon) handleAttachNetworkInterface(ctx context.Context, msg *nats.Msg
record, err := d.vpcService.GetENIRecord(accountID, eniID)
if err != nil {
respondWithError(msg, errorCodeFor(err))
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
if record.Status == "in-use" && record.InstanceId != instance.ID {
@@ -49,7 +49,7 @@ func (d *Daemon) handleAttachNetworkInterface(ctx context.Context, msg *nats.Msg
attachmentID, err := d.vpcService.AttachENI(accountID, eniID, instance.ID, deviceIndex)
if err != nil {
respondWithError(msg, errorCodeFor(err))
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
if err := d.vpcService.UpdateENI(accountID, eniID, func(r *handlers_ec2_vpc.ENIRecord) {
@@ -117,7 +117,7 @@ func (d *Daemon) handleDetachNetworkInterface(ctx context.Context, msg *nats.Msg
record, err := d.vpcService.FindENIByAttachment(accountID, attachmentID)
if err != nil {
respondWithError(msg, errorCodeFor(err))
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
if record.InstanceId != instance.ID {
@@ -191,15 +191,6 @@ func eniHotplugErrorCode(err error) string {
}
}
// errorCodeFor passes through errors whose message is already a valid AWS
// code (the convention VPCServiceImpl uses for its sentinel errors).
func errorCodeFor(err error) string {
if err == nil {
return ""
}
return awserrors.ValidErrorCode(err.Error())
}
// publishENIHotplugEvent emits the per-instance NATS event on best-effort
// basis — observers (Phase 4 ecs-agent, future vpcd subscribers) read it
// for fanout. A publish failure does not roll back the pipeline.
+1 -22
View File
@@ -15,29 +15,8 @@ import (
func (d *Daemon) handleAssociateIamInstanceProfile(ctx context.Context, msg *nats.Msg, command types.EC2InstanceCommand, instance *vm.VM) {
result, err := d.instanceService.AssociateIamInstanceProfile(ctx, instance, command)
if err != nil {
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
respondWithJSON(msg, result)
}
// handleIamProfileDisassociate services the disassociate fan-out. The owning
// daemon responds with the mutated association; non-owners respond with JSON
// null so the gateway's expectedNodes collector can exit early.
func (d *Daemon) handleIamProfileDisassociate(msg *nats.Msg) {
handleNATSRequest(msg, d.instanceService.DisassociateIamProfileAssociation)
}
// handleIamProfileReplace services the replace fan-out. Same contract as
// handleIamProfileDisassociate: every daemon responds (JSON null on
// non-owners) so the gateway collector can exit early.
func (d *Daemon) handleIamProfileReplace(msg *nats.Msg) {
handleNATSRequest(msg, d.instanceService.ReplaceIamProfileAssociation)
}
// handleIamProfileDescribe services the describe fan-out. An empty slice is a
// valid response (no matches on this daemon) and counts toward the gateway's
// expectedNodes collector; the gateway concatenates per-daemon slices.
func (d *Daemon) handleIamProfileDescribe(msg *nats.Msg) {
handleNATSRequest(msg, d.instanceService.DescribeIamProfileAssociations)
}
-37
View File
@@ -1,37 +0,0 @@
package daemon
import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2CreateInternetGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.igwService.CreateInternetGateway)
}
func (d *Daemon) handleEC2DeleteInternetGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.igwService.DeleteInternetGateway)
}
func (d *Daemon) handleEC2DescribeInternetGateways(msg *nats.Msg) {
handleNATSRequest(msg, d.igwService.DescribeInternetGateways)
}
func (d *Daemon) handleEC2AttachInternetGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.igwService.AttachInternetGateway)
}
func (d *Daemon) handleEC2DetachInternetGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.igwService.DetachInternetGateway)
}
func (d *Daemon) handleEC2CreateEgressOnlyInternetGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.eigwService.CreateEgressOnlyInternetGateway)
}
func (d *Daemon) handleEC2DeleteEgressOnlyInternetGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.eigwService.DeleteEgressOnlyInternetGateway)
}
func (d *Daemon) handleEC2DescribeEgressOnlyInternetGateways(msg *nats.Msg) {
handleNATSRequest(msg, d.eigwService.DescribeEgressOnlyInternetGateways)
}
+2 -30
View File
@@ -14,34 +14,6 @@ import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2DescribeImages(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.DescribeImages)
}
func (d *Daemon) handleEC2DeregisterImage(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.DeregisterImage)
}
func (d *Daemon) handleEC2RegisterImage(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.RegisterImage)
}
func (d *Daemon) handleEC2CopyImage(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.CopyImage)
}
func (d *Daemon) handleEC2DescribeImageAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.DescribeImageAttribute)
}
func (d *Daemon) handleEC2ModifyImageAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.ModifyImageAttribute)
}
func (d *Daemon) handleEC2ResetImageAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.ResetImageAttribute)
}
func (d *Daemon) handleSpinifexPromoteImage(msg *nats.Msg) {
promoteImage := func(_ context.Context, input *admin.PromoteImageOpts, _ string) (*admin.PromoteImageResult, error) {
store := objectstore.NewS3ObjectStoreFromConfig(
@@ -52,7 +24,7 @@ func (d *Daemon) handleSpinifexPromoteImage(msg *nats.Msg) {
)
return admin.PromoteSystemImage(store, d.config.Predastore.Bucket, *input)
}
handleNATSRequest(msg, promoteImage)
handleNATSRequest(promoteImage)(msg)
}
// handleEC2CreateImage is a stateful handler that extracts instance context
@@ -158,7 +130,7 @@ func (d *Daemon) handleEC2CreateImage(msg *nats.Msg) {
output, err := d.imageService.CreateImageFromInstance(params, accountID)
if err != nil {
slog.Error("CreateImage: service failed", "instanceId", instanceID, "err", err)
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
+5 -432
View File
@@ -6,14 +6,11 @@ import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/filterutil"
handlers_dns "github.com/mulgadc/spinifex/spinifex/handlers/dns"
handlers_ec2_instance "github.com/mulgadc/spinifex/spinifex/handlers/ec2/instance"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
@@ -72,7 +69,7 @@ func (d *Daemon) handleSetInstanceTags(ctx context.Context, msg *nats.Msg, comma
return true
})
if err != nil {
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
if !found {
@@ -120,7 +117,7 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
if reservationID != "" {
if it, ok := d.resourceMgr.instanceTypes[aws.StringValue(input.InstanceType)]; ok {
if vErr := d.resourceMgr.ValidateReservationTarget(reservationID, accountID, it); vErr != nil {
respondWithError(msg, awserrors.ValidErrorCode(vErr.Error()))
respondWithError(msg, awserrors.ValidErrorCodeFromError(vErr))
return
}
}
@@ -130,7 +127,7 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
reservation, instances, instanceType, err := d.instanceService.PrepareRunInstances(ctx, input, accountID, reservationID)
endOpSpan(prepSpan, err)
if err != nil {
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
@@ -209,285 +206,6 @@ func capacityReservationTargetID(input *ec2.RunInstancesInput) string {
return aws.StringValue(spec.CapacityReservationTarget.CapacityReservationId)
}
// describeInstancesValidFilters defines the set of filter names accepted by DescribeInstances.
var describeInstancesValidFilters = map[string]bool{
"instance-state-name": true,
"instance-id": true,
"instance-type": true,
"vpc-id": true,
"subnet-id": true,
"tag-key": true,
"tag-value": true,
}
// instanceMatchesFilters checks whether a VM + its built ec2.Instance copy satisfy all parsed filters.
func instanceMatchesFilters(inst *vm.VM, ic *ec2.Instance, filters map[string][]string) bool {
for name, values := range filters {
if strings.HasPrefix(name, "tag:") {
// tag:Key filters are handled after all field filters.
continue
}
var field string
switch name {
case "instance-state-name":
if ic.State != nil && ic.State.Name != nil {
field = *ic.State.Name
}
case "instance-id":
field = inst.ID
case "instance-type":
field = inst.InstanceType
case "vpc-id":
if ic.VpcId != nil {
field = *ic.VpcId
}
case "subnet-id":
if ic.SubnetId != nil {
field = *ic.SubnetId
}
case "tag-key":
if !matchTagKey(ic.Tags, values) {
return false
}
continue
case "tag-value":
if !matchTagValue(ic.Tags, values) {
return false
}
continue
default:
// Filter name passed ParseFilters but has no case — treat as non-match
// to avoid silently ignoring it.
return false
}
if !filterutil.MatchesAny(values, field) {
return false
}
}
// Check tag:Key filters via the instance's Tag slice.
tags := filterutil.EC2TagsToMap(ic.Tags)
return filterutil.MatchesTags(filters, tags)
}
// matchTagKey returns true if any tag key on the resource matches any of the filter values.
func matchTagKey(tags []*ec2.Tag, values []string) bool {
for _, t := range tags {
if t.Key != nil && filterutil.MatchesAny(values, *t.Key) {
return true
}
}
return false
}
// matchTagValue returns true if any tag value on the resource matches any of the filter values.
func matchTagValue(tags []*ec2.Tag, values []string) bool {
for _, t := range tags {
if t.Value != nil && filterutil.MatchesAny(values, *t.Value) {
return true
}
}
return false
}
// handleEC2DescribeInstances responds with instances running on this node visible to the caller.
func (d *Daemon) handleEC2DescribeInstances(msg *nats.Msg) {
slog.Debug("Received message", "subject", msg.Subject, "data", string(msg.Data))
// Extract account ID from NATS header for scoping
accountID := utils.AccountIDFromMsg(msg)
// Initialize describeInstancesInput before unmarshaling into it
describeInstancesInput := &ec2.DescribeInstancesInput{}
errResp := utils.UnmarshalJsonPayload(describeInstancesInput, msg.Data)
if errResp != nil {
if err := msg.Respond(errResp); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
slog.Error("Request does not match DescribeInstancesInput")
return
}
slog.Info("Processing DescribeInstances request from this node", "accountID", accountID)
// Validate and filter instances if specific instance IDs were requested
instanceIDFilter := make(map[string]bool)
if len(describeInstancesInput.InstanceIds) > 0 {
for _, id := range describeInstancesInput.InstanceIds {
if id != nil && *id != "" {
if !strings.HasPrefix(*id, "i-") {
respondWithError(msg, awserrors.ErrorInvalidInstanceIDMalformed)
return
}
instanceIDFilter[*id] = true
}
}
}
// Parse filters (returns error for unknown filter names)
parsedFilters, err := filterutil.ParseFilters(describeInstancesInput.Filters, describeInstancesValidFilters)
if err != nil {
slog.Warn("DescribeInstances: invalid filter", "err", err)
respondWithError(msg, awserrors.ErrorInvalidParameterValue)
return
}
// Group instances by reservation ID (AWS returns instances grouped by reservation)
reservationMap := make(map[string]*ec2.Reservation)
// Iterate under the manager lock — VM fields (Status, Instance, Reservation,
// PublicIP, PlacementGroupName) are mutated through manager-locked
// Inspect/UpdateState elsewhere, so reading them lock-free would race.
d.vmMgr.View(func(vms map[string]*vm.VM) {
for _, instance := range vms {
// Skip instances not owned by the caller's account.
// Instances with an empty AccountID (legacy/migration data)
// are only visible to root.
if !isInstanceVisible(accountID, instance.AccountID) {
continue
}
// Skip if filtering by instance IDs and this instance is not in the filter
if len(instanceIDFilter) > 0 && !instanceIDFilter[instance.ID] {
continue
}
// Use stored reservation metadata if available
if instance.Reservation != nil && instance.Instance != nil {
resID := ""
if instance.Reservation.ReservationId != nil {
resID = *instance.Reservation.ReservationId
}
// Create reservation entry if it doesn't exist
if _, exists := reservationMap[resID]; !exists {
reservation := &ec2.Reservation{}
reservation.SetReservationId(resID)
if instance.Reservation.OwnerId != nil {
reservation.SetOwnerId(*instance.Reservation.OwnerId)
}
reservation.Instances = []*ec2.Instance{}
reservationMap[resID] = reservation
}
// Update the instance state to current state
instanceCopy := *instance.Instance
instanceCopy.State = &ec2.InstanceState{}
// Populate PublicIpAddress from VM if stored
if instance.PublicIP != "" && instanceCopy.PublicIpAddress == nil {
instanceCopy.PublicIpAddress = aws.String(instance.PublicIP)
}
// Project the AWS-shaped DNS names from the current IPs, the same
// way PublicIpAddress/State are derived here. Mirrors the records
// the control-plane writer publishes to northstar.
publicDNS, privateDNS := handlers_dns.EC2DNSNames(
d.config.Region, d.dnsBaseDomain, d.dnsInternalDomain,
aws.StringValue(instanceCopy.PublicIpAddress), aws.StringValue(instanceCopy.PrivateIpAddress),
)
if publicDNS != "" {
instanceCopy.PublicDnsName = aws.String(publicDNS)
}
if privateDNS != "" {
instanceCopy.PrivateDnsName = aws.String(privateDNS)
}
// Map internal status to AWS state, projecting Spinifex-only states
// (e.g. error -> stopped) so SDK/UI clients see a valid label.
if info, ok := vm.EC2APIState(instance.Status); ok {
instanceCopy.State.SetCode(info.Code)
instanceCopy.State.SetName(info.Name)
} else {
slog.Warn("Instance has unmapped status, reporting as pending",
"instanceId", instance.ID, "status", string(instance.Status))
instanceCopy.State.SetCode(0)
instanceCopy.State.SetName("pending")
}
// Project IamInstanceProfile from vm.VM (single source of truth
// across Associate/Disassociate/Replace lifecycle). Id is left
// nil — the gateway resolves it via IAMService post-aggregation
// since daemons have no IAM access. Empty Arn clears any stale
// reference left on stored instance.Instance (e.g. after
// Disassociate or auto-clear on terminate).
if instance.IamInstanceProfileArn != "" {
instanceCopy.IamInstanceProfile = &ec2.IamInstanceProfile{
Arn: aws.String(instance.IamInstanceProfileArn),
}
} else {
instanceCopy.IamInstanceProfile = nil
}
// Populate Placement if instance belongs to a placement group
if instance.PlacementGroupName != "" {
instanceCopy.Placement = &ec2.Placement{
GroupName: aws.String(instance.PlacementGroupName),
AvailabilityZone: aws.String(d.config.AZ),
}
}
// Echo the consumed capacity reservation so targeted-launch
// Terraform converges — without it the instance reports no
// reservation and the plan never settles.
if instance.CapacityReservationId != "" {
instanceCopy.CapacityReservationId = aws.String(instance.CapacityReservationId)
instanceCopy.CapacityReservationSpecification = &ec2.CapacityReservationSpecificationResponse{
CapacityReservationPreference: aws.String(ec2.CapacityReservationPreferenceOpen),
CapacityReservationTarget: &ec2.CapacityReservationTargetResponse{
CapacityReservationId: aws.String(instance.CapacityReservationId),
},
}
}
// Project spot lineage stamped by the post-launch write-back.
// Both empty for on-demand, so the fields stay absent there.
if instance.InstanceLifecycle != "" {
instanceCopy.InstanceLifecycle = aws.String(instance.InstanceLifecycle)
}
if instance.SpotInstanceRequestId != "" {
instanceCopy.SpotInstanceRequestId = aws.String(instance.SpotInstanceRequestId)
}
// Apply filters against the fully-built instance copy
if len(parsedFilters) > 0 && !instanceMatchesFilters(instance, &instanceCopy, parsedFilters) {
continue
}
// Add instance to its reservation
reservationMap[resID].Instances = append(reservationMap[resID].Instances, &instanceCopy)
}
}
})
// Convert map to slice
reservations := make([]*ec2.Reservation, 0, len(reservationMap))
for _, reservation := range reservationMap {
reservations = append(reservations, reservation)
}
// Create the response
output := &ec2.DescribeInstancesOutput{
Reservations: reservations,
}
respondWithJSON(msg, output)
slog.Info("handleEC2DescribeInstances completed", "count", len(reservations))
}
// handleEC2DescribeInstanceTypes responds with instance types provisionable on this node.
func (d *Daemon) handleEC2DescribeInstanceTypes(msg *nats.Msg) {
handleNATSRequest(msg, d.instanceService.DescribeInstanceTypes)
}
// handleEC2DescribeInstanceStatus responds with status entries for VMs on this node visible to the caller.
func (d *Daemon) handleEC2DescribeInstanceStatus(msg *nats.Msg) {
handleNATSRequest(msg, d.instanceService.DescribeInstanceStatus)
}
// handleEC2StartStoppedInstance handles the generic ec2.start queue-group topic.
// It reads the stopped instance's LastNode from shared KV and forwards the
// request to ec2.start.{lastNode} to keep instances on their original node.
@@ -504,7 +222,7 @@ func (d *Daemon) handleEC2StartStoppedInstance(msg *nats.Msg) {
if err := json.Unmarshal(msg.Data, &peek); err != nil || peek.InstanceID == "" {
// Can't determine target node — fall through to local start which will
// return the appropriate error (missing parameter / unmarshal failure).
handleNATSRequest(msg, d.instanceService.StartStoppedInstance)
handleNATSRequest(d.instanceService.StartStoppedInstance)(msg)
return
}
@@ -550,150 +268,5 @@ func (d *Daemon) handleEC2StartStoppedInstance(msg *nats.Msg) {
}
}
handleNATSRequest(msg, d.instanceService.StartStoppedInstance)
}
// handleEC2StartStoppedInstanceDirect handles ec2.start.{nodeId} — the
// node-targeted topic used by handleEC2StartStoppedInstance to forward start
// requests back to the original node. Always starts locally; never forwards
// further, preventing routing loops.
func (d *Daemon) handleEC2StartStoppedInstanceDirect(msg *nats.Msg) {
handleNATSRequest(msg, d.instanceService.StartStoppedInstance)
}
func (d *Daemon) handleEC2TerminateStoppedInstance(msg *nats.Msg) {
handleNATSRequest(msg, d.instanceService.TerminateStoppedInstance)
}
// handleEC2DescribeStoppedInstances returns stopped instances from shared KV.
func (d *Daemon) handleEC2DescribeStoppedInstances(msg *nats.Msg) {
if d.stateStore == nil {
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
d.describeInstancesFromKV(msg, d.stateStore.ListStoppedInstances, 80, "stopped", "handleEC2DescribeStoppedInstances")
}
// handleEC2DescribeTerminatedInstances returns terminated instances from the terminated KV bucket.
func (d *Daemon) handleEC2DescribeTerminatedInstances(msg *nats.Msg) {
if d.stateStore == nil {
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
d.describeInstancesFromKV(msg, d.stateStore.ListTerminatedInstances, 48, "terminated", "handleEC2DescribeTerminatedInstances")
}
// describeInstancesFromKV is a shared helper for DescribeStopped/TerminatedInstances handlers.
// It lists instances from a KV bucket, filters by account/instance ID, and responds with reservations.
func (d *Daemon) describeInstancesFromKV(msg *nats.Msg, listFn func() ([]*vm.VM, error), fallbackCode int64, fallbackName, handlerName string) {
accountID := utils.AccountIDFromMsg(msg)
describeInput := &ec2.DescribeInstancesInput{}
if len(msg.Data) > 0 {
if errResp := utils.UnmarshalJsonPayload(describeInput, msg.Data); errResp != nil {
if err := msg.Respond(errResp); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
return
}
}
instanceIDFilter := make(map[string]bool)
for _, id := range describeInput.InstanceIds {
if id != nil {
instanceIDFilter[*id] = true
}
}
parsedFilters, filterErr := filterutil.ParseFilters(describeInput.Filters, describeInstancesValidFilters)
if filterErr != nil {
slog.Warn(handlerName+": invalid filter", "err", filterErr)
respondWithError(msg, awserrors.ErrorInvalidParameterValue)
return
}
instances, err := listFn()
if err != nil {
slog.Error(handlerName+": failed to list instances", "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
reservationMap := make(map[string]*ec2.Reservation)
for _, instance := range instances {
if !isInstanceVisible(accountID, instance.AccountID) {
continue
}
if len(instanceIDFilter) > 0 && !instanceIDFilter[instance.ID] {
continue
}
if instance.Reservation == nil || instance.Instance == nil {
slog.Warn(handlerName+": skipping instance with nil Reservation/Instance (data integrity issue)",
"instanceId", instance.ID)
continue
}
resID := ""
if instance.Reservation.ReservationId != nil {
resID = *instance.Reservation.ReservationId
}
if _, exists := reservationMap[resID]; !exists {
reservation := &ec2.Reservation{}
reservation.SetReservationId(resID)
if instance.Reservation.OwnerId != nil {
reservation.SetOwnerId(*instance.Reservation.OwnerId)
}
reservation.Instances = []*ec2.Instance{}
reservationMap[resID] = reservation
}
instanceCopy := *instance.Instance
instanceCopy.State = &ec2.InstanceState{}
if info, ok := vm.EC2APIState(instance.Status); ok {
instanceCopy.State.SetCode(info.Code)
instanceCopy.State.SetName(info.Name)
} else {
instanceCopy.State.SetCode(fallbackCode)
instanceCopy.State.SetName(fallbackName)
}
// Project IamInstanceProfile from vm.VM (cleared on terminate;
// preserved across stop/start). Mirrors handleEC2DescribeInstances.
if instance.IamInstanceProfileArn != "" {
instanceCopy.IamInstanceProfile = &ec2.IamInstanceProfile{
Arn: aws.String(instance.IamInstanceProfileArn),
}
} else {
instanceCopy.IamInstanceProfile = nil
}
if len(parsedFilters) > 0 && !instanceMatchesFilters(instance, &instanceCopy, parsedFilters) {
continue
}
reservationMap[resID].Instances = append(reservationMap[resID].Instances, &instanceCopy)
}
reservations := make([]*ec2.Reservation, 0, len(reservationMap))
for _, reservation := range reservationMap {
reservations = append(reservations, reservation)
}
respondWithJSON(msg, &ec2.DescribeInstancesOutput{Reservations: reservations})
slog.Info(handlerName+" completed", "count", len(reservations))
}
func (d *Daemon) handleEC2ModifyInstanceAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.instanceService.ModifyInstanceAttribute)
}
func (d *Daemon) handleEC2ModifyInstanceMetadataOptions(msg *nats.Msg) {
handleNATSRequest(msg, d.instanceService.ModifyInstanceMetadataOptions)
}
// handleEC2DescribeInstanceAttribute returns a single requested attribute for an instance.
func (d *Daemon) handleEC2DescribeInstanceAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.instanceService.DescribeInstanceAttribute)
handleNATSRequest(d.instanceService.StartStoppedInstance)(msg)
}
@@ -13,7 +13,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -54,11 +54,11 @@ func (m *memStoppedStore) DeleteStoppedInstance(id string) error {
}
// UpdateStoppedInstance mimics the real CAS semantics: a missing record
// returns nats.ErrKeyNotFound instead of resurrecting it.
// returns jetstream.ErrKeyNotFound instead of resurrecting it.
func (m *memStoppedStore) UpdateStoppedInstance(id string, mutate func(*vm.VM)) (*vm.VM, error) {
v, ok := m.instances[id]
if !ok {
return nil, nats.ErrKeyNotFound
return nil, jetstream.ErrKeyNotFound
}
mutate(v)
return v, nil
+20 -221
View File
@@ -15,6 +15,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -126,7 +127,7 @@ func (f *fakeStateStore) ClaimStoppedInstance(id string) (*vm.VM, error) {
// UpdateStoppedInstance mimics the real CAS semantics for tests: mutate runs
// under the store lock against the stored value, and a missing record
// returns nats.ErrKeyNotFound (matching JetStreamManager's createIfAbsent=false
// returns jetstream.ErrKeyNotFound (matching JetStreamManager's createIfAbsent=false
// behavior) rather than resurrecting it.
func (f *fakeStateStore) UpdateStoppedInstance(id string, mutate func(*vm.VM)) (*vm.VM, error) {
f.mu.Lock()
@@ -136,7 +137,7 @@ func (f *fakeStateStore) UpdateStoppedInstance(id string, mutate func(*vm.VM)) (
}
v, ok := f.stopped[id]
if !ok {
return nil, nats.ErrKeyNotFound
return nil, jetstream.ErrKeyNotFound
}
mutate(v)
return v, nil
@@ -391,7 +392,7 @@ func TestHandleEC2TerminateStoppedInstance_LoadError(t *testing.T) {
d := daemonWithFakeStateStore(t, store)
body, _ := json.Marshal(handlers_ec2_instance.TerminateStoppedInstanceInput{InstanceID: "i-load-fail"})
reply := requestHandler(t, d.natsConn, "ec2.terminate.test1", d.handleEC2TerminateStoppedInstance, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.terminate.test1", handleNATSRequest(d.instanceService.TerminateStoppedInstance), testAccountID, body)
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
}
@@ -399,7 +400,7 @@ func TestHandleEC2TerminateStoppedInstance_StateStoreNil(t *testing.T) {
d := createTestDaemon(t, sharedNATSURL)
body, _ := json.Marshal(handlers_ec2_instance.TerminateStoppedInstanceInput{InstanceID: "i-no-store"})
reply := requestHandler(t, d.natsConn, "ec2.terminate.test2", d.handleEC2TerminateStoppedInstance, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.terminate.test2", handleNATSRequest(d.instanceService.TerminateStoppedInstance), testAccountID, body)
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
}
@@ -413,7 +414,7 @@ func TestHandleEC2TerminateStoppedInstance_WriteTerminatedFailureAborts(t *testi
d := daemonWithFakeStateStore(t, store)
body, _ := json.Marshal(handlers_ec2_instance.TerminateStoppedInstanceInput{InstanceID: v.ID})
reply := requestHandler(t, d.natsConn, "ec2.terminate.test3", d.handleEC2TerminateStoppedInstance, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.terminate.test3", handleNATSRequest(d.instanceService.TerminateStoppedInstance), testAccountID, body)
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
store.mu.Lock()
@@ -436,7 +437,7 @@ func TestHandleEC2TerminateStoppedInstance_DeleteRetrySucceeds(t *testing.T) {
d := daemonWithFakeStateStore(t, store)
body, _ := json.Marshal(handlers_ec2_instance.TerminateStoppedInstanceInput{InstanceID: v.ID})
reply := requestHandler(t, d.natsConn, "ec2.terminate.test4", d.handleEC2TerminateStoppedInstance, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.terminate.test4", handleNATSRequest(d.instanceService.TerminateStoppedInstance), testAccountID, body)
var resp map[string]string
require.NoError(t, json.Unmarshal(reply.Data, &resp))
@@ -463,7 +464,7 @@ func TestHandleEC2TerminateStoppedInstance_DeleteAlwaysFailsKeepsTerminated(t *t
d := daemonWithFakeStateStore(t, store)
body, _ := json.Marshal(handlers_ec2_instance.TerminateStoppedInstanceInput{InstanceID: v.ID})
reply := requestHandler(t, d.natsConn, "ec2.terminate.test5", d.handleEC2TerminateStoppedInstance, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.terminate.test5", handleNATSRequest(d.instanceService.TerminateStoppedInstance), testAccountID, body)
var resp map[string]string
require.NoError(t, json.Unmarshal(reply.Data, &resp))
@@ -481,7 +482,7 @@ func TestHandleEC2TerminateStoppedInstance_CrossTenantRejected(t *testing.T) {
d := daemonWithFakeStateStore(t, store)
body, _ := json.Marshal(handlers_ec2_instance.TerminateStoppedInstanceInput{InstanceID: "i-foreign-term"})
reply := requestHandler(t, d.natsConn, "ec2.terminate.test6", d.handleEC2TerminateStoppedInstance, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.terminate.test6", handleNATSRequest(d.instanceService.TerminateStoppedInstance), testAccountID, body)
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, decodeError(t, reply.Data)["Code"])
store.mu.Lock()
@@ -506,7 +507,7 @@ func TestHandleEC2ModifyInstanceAttribute_WriteFailureReturnsServerInternal(t *t
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.large")},
}
body, _ := json.Marshal(input)
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test1", d.handleEC2ModifyInstanceAttribute, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test1", handleNATSRequest(d.instanceService.ModifyInstanceAttribute), testAccountID, body)
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
}
@@ -520,7 +521,7 @@ func TestHandleEC2ModifyInstanceAttribute_LoadFailureReturnsServerInternal(t *te
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.large")},
}
body, _ := json.Marshal(input)
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test2", d.handleEC2ModifyInstanceAttribute, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test2", handleNATSRequest(d.instanceService.ModifyInstanceAttribute), testAccountID, body)
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
}
@@ -544,7 +545,7 @@ func TestHandleEC2ModifyInstanceAttribute_NilInstanceFieldGuard(t *testing.T) {
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.large")},
}
body, _ := json.Marshal(input)
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test3", d.handleEC2ModifyInstanceAttribute, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test3", handleNATSRequest(d.instanceService.ModifyInstanceAttribute), testAccountID, body)
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
}
@@ -559,7 +560,7 @@ func TestHandleEC2ModifyInstanceAttribute_EmptyInstanceTypeRejected(t *testing.T
InstanceType: &ec2.AttributeValue{Value: aws.String("")},
}
body, _ := json.Marshal(input)
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test4", d.handleEC2ModifyInstanceAttribute, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test4", handleNATSRequest(d.instanceService.ModifyInstanceAttribute), testAccountID, body)
assert.Equal(t, awserrors.ErrorInvalidInstanceAttributeValue, decodeError(t, reply.Data)["Code"])
}
@@ -573,7 +574,7 @@ func TestHandleEC2ModifyInstanceAttribute_CrossTenantRejected(t *testing.T) {
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.large")},
}
body, _ := json.Marshal(input)
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test5", d.handleEC2ModifyInstanceAttribute, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.ModifyInstanceAttribute.test5", handleNATSRequest(d.instanceService.ModifyInstanceAttribute), testAccountID, body)
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, decodeError(t, reply.Data)["Code"])
}
@@ -591,7 +592,7 @@ func TestHandleEC2DescribeInstanceAttribute_StoppedFallback_LoadError(t *testing
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
}
body, _ := json.Marshal(input)
reply := requestHandler(t, d.natsConn, "ec2.DescribeInstanceAttribute.test1", d.handleEC2DescribeInstanceAttribute, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.DescribeInstanceAttribute.test1", handleNATSRequest(d.instanceService.DescribeInstanceAttribute), testAccountID, body)
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
}
@@ -608,7 +609,7 @@ func TestHandleEC2DescribeInstanceAttribute_StoppedFallback_HitsKV(t *testing.T)
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
}
body, _ := json.Marshal(input)
reply := requestHandler(t, d.natsConn, "ec2.DescribeInstanceAttribute.test2", d.handleEC2DescribeInstanceAttribute, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.DescribeInstanceAttribute.test2", handleNATSRequest(d.instanceService.DescribeInstanceAttribute), testAccountID, body)
var output ec2.DescribeInstanceAttributeOutput
require.NoError(t, json.Unmarshal(reply.Data, &output))
@@ -627,7 +628,7 @@ func TestHandleEC2DescribeInstanceAttribute_StateStoreNil(t *testing.T) {
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
}
body, _ := json.Marshal(input)
reply := requestHandler(t, d.natsConn, "ec2.DescribeInstanceAttribute.test3", d.handleEC2DescribeInstanceAttribute, testAccountID, body)
reply := requestHandler(t, d.natsConn, "ec2.DescribeInstanceAttribute.test3", handleNATSRequest(d.instanceService.DescribeInstanceAttribute), testAccountID, body)
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
}
@@ -638,7 +639,7 @@ func TestHandleEC2DescribeStoppedInstances_ListError(t *testing.T) {
store.listStoppedErr = errors.New("list failed")
d := daemonWithFakeStateStore(t, store)
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test1", d.handleEC2DescribeStoppedInstances, testAccountID, []byte("{}"))
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test1", handleNATSRequest(d.instanceService.DescribeStoppedInstances), testAccountID, []byte("{}"))
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
}
@@ -647,21 +648,7 @@ func TestHandleEC2DescribeTerminatedInstances_ListError(t *testing.T) {
store.listTerminatedErr = errors.New("list failed")
d := daemonWithFakeStateStore(t, store)
reply := requestHandler(t, d.natsConn, "ec2.DescribeTerminatedInstances.test1", d.handleEC2DescribeTerminatedInstances, testAccountID, []byte("{}"))
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
}
func TestHandleEC2DescribeStoppedInstances_StateStoreNil(t *testing.T) {
d := createTestDaemon(t, sharedNATSURL)
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test2", d.handleEC2DescribeStoppedInstances, testAccountID, []byte("{}"))
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
}
func TestHandleEC2DescribeTerminatedInstances_StateStoreNil(t *testing.T) {
d := createTestDaemon(t, sharedNATSURL)
reply := requestHandler(t, d.natsConn, "ec2.DescribeTerminatedInstances.test2", d.handleEC2DescribeTerminatedInstances, testAccountID, []byte("{}"))
reply := requestHandler(t, d.natsConn, "ec2.DescribeTerminatedInstances.test1", handleNATSRequest(d.instanceService.DescribeTerminatedInstances), testAccountID, []byte("{}"))
assert.Equal(t, awserrors.ErrorServerInternal, decodeError(t, reply.Data)["Code"])
}
@@ -671,7 +658,7 @@ func TestHandleEC2DescribeStoppedInstances_CrossAccountIsolation(t *testing.T) {
store.stopped["i-yours"] = stoppedVMFixture("i-yours", "999988887777")
d := daemonWithFakeStateStore(t, store)
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test3", d.handleEC2DescribeStoppedInstances, testAccountID, []byte("{}"))
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test3", handleNATSRequest(d.instanceService.DescribeStoppedInstances), testAccountID, []byte("{}"))
var output ec2.DescribeInstancesOutput
require.NoError(t, json.Unmarshal(reply.Data, &output))
@@ -686,191 +673,3 @@ func TestHandleEC2DescribeStoppedInstances_CrossAccountIsolation(t *testing.T) {
}
assert.ElementsMatch(t, []string{"i-mine"}, seen, "caller must only see their own instances")
}
func TestHandleEC2DescribeStoppedInstances_EmptyReturnsValidShape(t *testing.T) {
store := newFakeStateStore()
d := daemonWithFakeStateStore(t, store)
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test4", d.handleEC2DescribeStoppedInstances, testAccountID, []byte("{}"))
var output ec2.DescribeInstancesOutput
require.NoError(t, json.Unmarshal(reply.Data, &output))
assert.Empty(t, output.Reservations, "empty store must produce empty reservation list")
}
// Two instances sharing a ReservationId must collapse into a single
// reservation with both Instances attached.
func TestHandleEC2DescribeStoppedInstances_ReservationGrouping(t *testing.T) {
store := newFakeStateStore()
a := stoppedVMFixture("i-grp-a", testAccountID)
b := stoppedVMFixture("i-grp-b", testAccountID)
a.Reservation.ReservationId = aws.String("r-shared")
b.Reservation.ReservationId = aws.String("r-shared")
store.stopped[a.ID] = a
store.stopped[b.ID] = b
d := daemonWithFakeStateStore(t, store)
reply := requestHandler(t, d.natsConn, "ec2.DescribeStoppedInstances.test5", d.handleEC2DescribeStoppedInstances, testAccountID, []byte("{}"))
var output ec2.DescribeInstancesOutput
require.NoError(t, json.Unmarshal(reply.Data, &output))
require.Len(t, output.Reservations, 1, "shared ReservationId must collapse into one reservation")
require.NotNil(t, output.Reservations[0].ReservationId)
assert.Equal(t, "r-shared", *output.Reservations[0].ReservationId)
assert.Len(t, output.Reservations[0].Instances, 2)
}
// DescribeInstances echoes the consumed capacity reservation so a targeted
// launch reports a CapacityReservationId (without it, Terraform never converges).
func TestHandleEC2DescribeInstances_CapacityReservationEcho(t *testing.T) {
daemon := createTestDaemon(t, sharedNATSURL)
reservation := &ec2.Reservation{}
reservation.SetReservationId("r-cr-echo")
reservation.SetOwnerId(testAccountID)
instance := &ec2.Instance{}
instance.SetInstanceId("i-cr-echo")
instance.SetInstanceType("t3.micro")
daemon.vmMgr.Insert(&vm.VM{
ID: "i-cr-echo",
Status: vm.StateRunning,
AccountID: testAccountID,
CapacityReservationId: "cr-0123456789abcdef0",
Reservation: reservation,
Instance: instance,
})
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances", daemon.handleEC2DescribeInstances)
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
reqData, _ := json.Marshal(&ec2.DescribeInstancesInput{})
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", reqData, 5*time.Second)
require.NoError(t, err)
var out ec2.DescribeInstancesOutput
require.NoError(t, json.Unmarshal(reply.Data, &out))
require.Len(t, out.Reservations, 1)
require.Len(t, out.Reservations[0].Instances, 1)
got := out.Reservations[0].Instances[0]
assert.Equal(t, "cr-0123456789abcdef0", aws.StringValue(got.CapacityReservationId))
require.NotNil(t, got.CapacityReservationSpecification)
assert.Equal(t, ec2.CapacityReservationPreferenceOpen,
aws.StringValue(got.CapacityReservationSpecification.CapacityReservationPreference))
require.NotNil(t, got.CapacityReservationSpecification.CapacityReservationTarget)
assert.Equal(t, "cr-0123456789abcdef0",
aws.StringValue(got.CapacityReservationSpecification.CapacityReservationTarget.CapacityReservationId))
}
// An instance with no reservation reports no capacity-reservation fields.
func TestHandleEC2DescribeInstances_NoCapacityReservationEcho(t *testing.T) {
daemon := createTestDaemon(t, sharedNATSURL)
reservation := &ec2.Reservation{}
reservation.SetReservationId("r-plain")
reservation.SetOwnerId(testAccountID)
instance := &ec2.Instance{}
instance.SetInstanceId("i-plain")
instance.SetInstanceType("t3.micro")
daemon.vmMgr.Insert(&vm.VM{
ID: "i-plain",
Status: vm.StateRunning,
AccountID: testAccountID,
Reservation: reservation,
Instance: instance,
})
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances", daemon.handleEC2DescribeInstances)
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
reqData, _ := json.Marshal(&ec2.DescribeInstancesInput{})
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", reqData, 5*time.Second)
require.NoError(t, err)
var out ec2.DescribeInstancesOutput
require.NoError(t, json.Unmarshal(reply.Data, &out))
require.Len(t, out.Reservations, 1)
require.Len(t, out.Reservations[0].Instances, 1)
got := out.Reservations[0].Instances[0]
assert.Empty(t, aws.StringValue(got.CapacityReservationId))
assert.Nil(t, got.CapacityReservationSpecification)
}
// A spot-launched instance projects InstanceLifecycle + SpotInstanceRequestId.
func TestHandleEC2DescribeInstances_SpotLineageEcho(t *testing.T) {
daemon := createTestDaemon(t, sharedNATSURL)
reservation := &ec2.Reservation{}
reservation.SetReservationId("r-spot-echo")
reservation.SetOwnerId(testAccountID)
instance := &ec2.Instance{}
instance.SetInstanceId("i-spot-echo")
instance.SetInstanceType("t3.micro")
daemon.vmMgr.Insert(&vm.VM{
ID: "i-spot-echo",
Status: vm.StateRunning,
AccountID: testAccountID,
InstanceLifecycle: ec2.InstanceLifecycleTypeSpot,
SpotInstanceRequestId: "sir-0123456789abcdef0",
Reservation: reservation,
Instance: instance,
})
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances", daemon.handleEC2DescribeInstances)
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
reqData, _ := json.Marshal(&ec2.DescribeInstancesInput{})
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", reqData, 5*time.Second)
require.NoError(t, err)
var out ec2.DescribeInstancesOutput
require.NoError(t, json.Unmarshal(reply.Data, &out))
require.Len(t, out.Reservations, 1)
require.Len(t, out.Reservations[0].Instances, 1)
got := out.Reservations[0].Instances[0]
assert.Equal(t, ec2.InstanceLifecycleTypeSpot, aws.StringValue(got.InstanceLifecycle))
assert.Equal(t, "sir-0123456789abcdef0", aws.StringValue(got.SpotInstanceRequestId))
}
// An on-demand instance reports no spot-lineage fields.
func TestHandleEC2DescribeInstances_NoSpotLineageEcho(t *testing.T) {
daemon := createTestDaemon(t, sharedNATSURL)
reservation := &ec2.Reservation{}
reservation.SetReservationId("r-ondemand")
reservation.SetOwnerId(testAccountID)
instance := &ec2.Instance{}
instance.SetInstanceId("i-ondemand")
instance.SetInstanceType("t3.micro")
daemon.vmMgr.Insert(&vm.VM{
ID: "i-ondemand",
Status: vm.StateRunning,
AccountID: testAccountID,
Reservation: reservation,
Instance: instance,
})
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances", daemon.handleEC2DescribeInstances)
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
reqData, _ := json.Marshal(&ec2.DescribeInstancesInput{})
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", reqData, 5*time.Second)
require.NoError(t, err)
var out ec2.DescribeInstancesOutput
require.NoError(t, json.Unmarshal(reply.Data, &out))
require.Len(t, out.Reservations, 1)
require.Len(t, out.Reservations[0].Instances, 1)
got := out.Reservations[0].Instances[0]
assert.Empty(t, aws.StringValue(got.InstanceLifecycle))
assert.Empty(t, aws.StringValue(got.SpotInstanceRequestId))
}
-16
View File
@@ -6,22 +6,6 @@ import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2CreateKeyPair(msg *nats.Msg) {
handleNATSRequest(msg, d.keyService.CreateKeyPair)
}
func (d *Daemon) handleEC2DeleteKeyPair(msg *nats.Msg) {
handleNATSRequest(msg, d.keyService.DeleteKeyPair)
}
func (d *Daemon) handleEC2DescribeKeyPairs(msg *nats.Msg) {
handleNATSRequest(msg, d.keyService.DescribeKeyPairs)
}
func (d *Daemon) handleEC2ImportKeyPair(msg *nats.Msg) {
handleNATSRequest(msg, d.keyService.ImportKeyPair)
}
// handleIMDSGetPublicKey answers the IMDS material-path RPC. The responder lives
// here (not in vpcd) because the daemon holds the key service and its Predastore
// credentials; vpcd carries only a nats.Conn.
@@ -1,31 +0,0 @@
package daemon
import "github.com/nats-io/nats.go"
func (d *Daemon) handleEC2CreateLaunchTemplate(msg *nats.Msg) {
handleNATSRequest(msg, d.launchTemplateService.CreateLaunchTemplate)
}
func (d *Daemon) handleEC2CreateLaunchTemplateVersion(msg *nats.Msg) {
handleNATSRequest(msg, d.launchTemplateService.CreateLaunchTemplateVersion)
}
func (d *Daemon) handleEC2DeleteLaunchTemplate(msg *nats.Msg) {
handleNATSRequest(msg, d.launchTemplateService.DeleteLaunchTemplate)
}
func (d *Daemon) handleEC2DeleteLaunchTemplateVersions(msg *nats.Msg) {
handleNATSRequest(msg, d.launchTemplateService.DeleteLaunchTemplateVersions)
}
func (d *Daemon) handleEC2ModifyLaunchTemplate(msg *nats.Msg) {
handleNATSRequest(msg, d.launchTemplateService.ModifyLaunchTemplate)
}
func (d *Daemon) handleEC2DescribeLaunchTemplates(msg *nats.Msg) {
handleNATSRequest(msg, d.launchTemplateService.DescribeLaunchTemplates)
}
func (d *Daemon) handleEC2DescribeLaunchTemplateVersions(msg *nats.Msg) {
handleNATSRequest(msg, d.launchTemplateService.DescribeLaunchTemplateVersions)
}
-17
View File
@@ -1,17 +0,0 @@
package daemon
import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2CreateNatGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.natGatewayService.CreateNatGateway)
}
func (d *Daemon) handleEC2DeleteNatGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.natGatewayService.DeleteNatGateway)
}
func (d *Daemon) handleEC2DescribeNatGateways(msg *nats.Msg) {
handleNATSRequest(msg, d.natGatewayService.DescribeNatGateways)
}
@@ -1,39 +0,0 @@
package daemon
import "github.com/nats-io/nats.go"
func (d *Daemon) handleEC2CreatePlacementGroup(msg *nats.Msg) {
handleNATSRequest(msg, d.placementGroupService.CreatePlacementGroup)
}
func (d *Daemon) handleEC2DeletePlacementGroup(msg *nats.Msg) {
handleNATSRequest(msg, d.placementGroupService.DeletePlacementGroup)
}
func (d *Daemon) handleEC2DescribePlacementGroups(msg *nats.Msg) {
handleNATSRequest(msg, d.placementGroupService.DescribePlacementGroups)
}
func (d *Daemon) handleEC2ReserveSpreadNodes(msg *nats.Msg) {
handleNATSRequest(msg, d.placementGroupService.ReserveSpreadNodes)
}
func (d *Daemon) handleEC2FinalizeSpreadInstances(msg *nats.Msg) {
handleNATSRequest(msg, d.placementGroupService.FinalizeSpreadInstances)
}
func (d *Daemon) handleEC2ReleaseSpreadNodes(msg *nats.Msg) {
handleNATSRequest(msg, d.placementGroupService.ReleaseSpreadNodes)
}
func (d *Daemon) handleEC2RemoveInstanceFromPlacementGroup(msg *nats.Msg) {
handleNATSRequest(msg, d.placementGroupService.RemoveInstance)
}
func (d *Daemon) handleEC2ReserveClusterNode(msg *nats.Msg) {
handleNATSRequest(msg, d.placementGroupService.ReserveClusterNode)
}
func (d *Daemon) handleEC2FinalizeClusterInstances(msg *nats.Msg) {
handleNATSRequest(msg, d.placementGroupService.FinalizeClusterInstances)
}
@@ -1,41 +0,0 @@
package daemon
import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2CreateRouteTable(msg *nats.Msg) {
handleNATSRequest(msg, d.routeTableService.CreateRouteTable)
}
func (d *Daemon) handleEC2DeleteRouteTable(msg *nats.Msg) {
handleNATSRequest(msg, d.routeTableService.DeleteRouteTable)
}
func (d *Daemon) handleEC2DescribeRouteTables(msg *nats.Msg) {
handleNATSRequest(msg, d.routeTableService.DescribeRouteTables)
}
func (d *Daemon) handleEC2CreateRoute(msg *nats.Msg) {
handleNATSRequest(msg, d.routeTableService.CreateRoute)
}
func (d *Daemon) handleEC2DeleteRoute(msg *nats.Msg) {
handleNATSRequest(msg, d.routeTableService.DeleteRoute)
}
func (d *Daemon) handleEC2ReplaceRoute(msg *nats.Msg) {
handleNATSRequest(msg, d.routeTableService.ReplaceRoute)
}
func (d *Daemon) handleEC2AssociateRouteTable(msg *nats.Msg) {
handleNATSRequest(msg, d.routeTableService.AssociateRouteTable)
}
func (d *Daemon) handleEC2DisassociateRouteTable(msg *nats.Msg) {
handleNATSRequest(msg, d.routeTableService.DisassociateRouteTable)
}
func (d *Daemon) handleEC2ReplaceRouteTableAssociation(msg *nats.Msg) {
handleNATSRequest(msg, d.routeTableService.ReplaceRouteTableAssociation)
}
-35
View File
@@ -1,35 +0,0 @@
package daemon
import "github.com/nats-io/nats.go"
func (d *Daemon) handleEC2CreateSecurityGroup(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.CreateSecurityGroup)
}
func (d *Daemon) handleEC2DeleteSecurityGroup(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DeleteSecurityGroup)
}
func (d *Daemon) handleEC2DescribeSecurityGroups(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DescribeSecurityGroups)
}
func (d *Daemon) handleEC2DescribeSecurityGroupRules(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DescribeSecurityGroupRules)
}
func (d *Daemon) handleEC2AuthorizeSecurityGroupIngress(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.AuthorizeSecurityGroupIngress)
}
func (d *Daemon) handleEC2AuthorizeSecurityGroupEgress(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.AuthorizeSecurityGroupEgress)
}
func (d *Daemon) handleEC2RevokeSecurityGroupIngress(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.RevokeSecurityGroupIngress)
}
func (d *Daemon) handleEC2RevokeSecurityGroupEgress(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.RevokeSecurityGroupEgress)
}
@@ -1,21 +0,0 @@
package daemon
import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2CreateSnapshot(msg *nats.Msg) {
handleNATSRequest(msg, d.snapshotService.CreateSnapshot)
}
func (d *Daemon) handleEC2DescribeSnapshots(msg *nats.Msg) {
handleNATSRequest(msg, d.snapshotService.DescribeSnapshots)
}
func (d *Daemon) handleEC2DeleteSnapshot(msg *nats.Msg) {
handleNATSRequest(msg, d.snapshotService.DeleteSnapshot)
}
func (d *Daemon) handleEC2CopySnapshot(msg *nats.Msg) {
handleNATSRequest(msg, d.snapshotService.CopySnapshot)
}
@@ -11,18 +11,6 @@ import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2PutSpotInstanceRequests(msg *nats.Msg) {
handleNATSRequest(msg, d.spotInstanceService.PutSpotInstanceRequests)
}
func (d *Daemon) handleEC2DescribeSpotInstanceRequests(msg *nats.Msg) {
handleNATSRequest(msg, d.spotInstanceService.DescribeSpotInstanceRequests)
}
func (d *Daemon) handleEC2CancelSpotInstanceRequests(msg *nats.Msg) {
handleNATSRequest(msg, d.spotInstanceService.CancelSpotInstanceRequests)
}
// handleSetSpotLineage stamps the spot lineage (InstanceLifecycle=spot +
// SpotInstanceRequestId) onto a launched VM. Dispatched from handleEC2Events,
// which already verified ownership; the SIR id is the only datum on the wire as
@@ -39,7 +27,7 @@ func (d *Daemon) handleSetSpotLineage(ctx context.Context, msg *nats.Msg, comman
return true
})
if err != nil {
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
if !found {
-12
View File
@@ -21,18 +21,6 @@ import (
// only a partitioned-but-subscribed owner pays the full timeout.
const instanceTagCommandTimeout = 5 * time.Second
func (d *Daemon) handleEC2CreateTags(msg *nats.Msg) {
handleNATSRequest(msg, d.createTags)
}
func (d *Daemon) handleEC2DeleteTags(msg *nats.Msg) {
handleNATSRequest(msg, d.deleteTags)
}
func (d *Daemon) handleEC2DescribeTags(msg *nats.Msg) {
handleNATSRequest(msg, d.tagsService.DescribeTags)
}
// recordTagMirror is implemented by every service whose resource records
// mirror the central tag store, so tag-filtered describes observe tags
// written through create-tags/delete-tags.
+130 -110
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
@@ -39,6 +40,7 @@ import (
"github.com/mulgadc/viperblock/viperblock"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -131,7 +133,7 @@ func initAccountServiceForTest(t *testing.T, daemon *Daemon) {
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
svc, err := handlers_ec2_account.NewAccountSettingsServiceImplWithNATS(nil, nc)
svc, err := handlers_ec2_account.NewAccountSettingsServiceImplWithNATS(t.Context(), nil, nc)
require.NoError(t, err)
daemon.accountService = svc
}
@@ -157,9 +159,7 @@ func TestHandleNATSRequest_ValidRequest(t *testing.T) {
return &testOutput{Greeting: "hello " + in.Name}, nil
}
sub, err := nc.Subscribe("test.greet", func(msg *nats.Msg) {
handleNATSRequest(msg, serviceFn)
})
sub, err := nc.Subscribe("test.greet", handleNATSRequest(serviceFn))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -184,9 +184,7 @@ func TestHandleNATSRequest_MalformedJSON(t *testing.T) {
return &testOutput{Greeting: "hello"}, nil
}
sub, err := nc.Subscribe("test.malformed", func(msg *nats.Msg) {
handleNATSRequest(msg, serviceFn)
})
sub, err := nc.Subscribe("test.malformed", handleNATSRequest(serviceFn))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -210,9 +208,7 @@ func TestHandleNATSRequest_ServiceError(t *testing.T) {
return nil, fmt.Errorf("something went wrong")
}
sub, err := nc.Subscribe("test.err", func(msg *nats.Msg) {
handleNATSRequest(msg, serviceFn)
})
sub, err := nc.Subscribe("test.err", handleNATSRequest(serviceFn))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -228,6 +224,30 @@ func TestHandleNATSRequest_ServiceError(t *testing.T) {
assert.Equal(t, awserrors.ErrorServerInternal, errResp["Code"])
}
func TestHandleNATSRequest_WrappedAWSServiceError(t *testing.T) {
nc, err := nats.Connect(sharedNATSURL)
require.NoError(t, err)
defer nc.Close()
serviceFn := func(_ context.Context, _ *testInput, _ string) (*testOutput, error) {
cause := errors.New(awserrors.ErrorInsufficientAddressCapacity)
return nil, fmt.Errorf("allocate public address: %w", cause)
}
sub, err := nc.Subscribe("test.err.wrapped", handleNATSRequest(serviceFn))
require.NoError(t, err)
defer sub.Unsubscribe()
reqData, err := json.Marshal(testInput{Name: "world"})
require.NoError(t, err)
reply, err := nc.Request("test.err.wrapped", reqData, 5*time.Second)
require.NoError(t, err)
var errResp map[string]any
require.NoError(t, json.Unmarshal(reply.Data, &errResp))
assert.Equal(t, awserrors.ErrorInsufficientAddressCapacity, errResp["Code"])
}
// --- Handler wrapper tests (representative set via NATS round-trip) ---
func TestHandleEC2CreateKeyPair_RoundTrip(t *testing.T) {
@@ -235,7 +255,7 @@ func TestHandleEC2CreateKeyPair_RoundTrip(t *testing.T) {
daemon := createFullTestDaemon(t, natsURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.CreateKeyPair", "spinifex-workers", daemon.handleEC2CreateKeyPair)
sub, err := daemon.natsConn.QueueSubscribe("ec2.CreateKeyPair", "spinifex-workers", handleNATSRequest(daemon.keyService.CreateKeyPair))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1226,7 +1246,7 @@ func TestHandleEC2DescribeStoppedInstances_ReturnsStoppedInstances(t *testing.T)
err := daemon.jsManager.WriteStoppedInstance(stoppedVM.ID, stoppedVM)
require.NoError(t, err)
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeStoppedInstances", "spinifex-workers", daemon.handleEC2DescribeStoppedInstances)
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeStoppedInstances", "spinifex-workers", handleNATSRequest(daemon.instanceService.DescribeStoppedInstances))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1280,7 +1300,7 @@ func TestHandleEC2DescribeStoppedInstances_WithFilter(t *testing.T) {
require.NoError(t, err)
}
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeStoppedInstances", "spinifex-workers", daemon.handleEC2DescribeStoppedInstances)
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeStoppedInstances", "spinifex-workers", handleNATSRequest(daemon.instanceService.DescribeStoppedInstances))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1510,7 +1530,7 @@ func TestHandleEC2ModifyInstanceAttribute_WrapperRoundTrip(t *testing.T) {
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyInstanceAttribute", "spinifex-workers", daemon.handleEC2ModifyInstanceAttribute)
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyInstanceAttribute", "spinifex-workers", handleNATSRequest(daemon.instanceService.ModifyInstanceAttribute))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1552,7 +1572,7 @@ func TestHandleEC2DescribeInstanceAttribute_RunningInstance_InstanceType(t *test
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1594,7 +1614,7 @@ func TestHandleEC2DescribeInstanceAttribute_StoppedInstance_InstanceType(t *test
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1634,7 +1654,7 @@ func TestHandleEC2DescribeInstanceAttribute_UserData(t *testing.T) {
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1676,7 +1696,7 @@ func TestHandleEC2DescribeInstanceAttribute_DefaultAttribute_DisableApiTerminati
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1720,7 +1740,7 @@ func TestHandleEC2DescribeInstanceAttribute_DefaultAttribute_ShutdownBehavior(t
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1758,7 +1778,7 @@ func TestHandleEC2DescribeInstanceAttribute_GroupSet_WithSecurityGroups(t *testi
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1804,7 +1824,7 @@ func TestHandleEC2DescribeInstanceAttribute_GroupSet_NilInstance(t *testing.T) {
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1841,7 +1861,7 @@ func TestHandleEC2DescribeInstanceAttribute_InstanceNotFound(t *testing.T) {
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1864,7 +1884,7 @@ func TestHandleEC2DescribeInstanceAttribute_UnsupportedAttribute(t *testing.T) {
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1900,7 +1920,7 @@ func TestHandleEC2DescribeInstanceAttribute_InvalidJSON(t *testing.T) {
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", daemon.handleEC2DescribeInstanceAttribute)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceAttribute", handleNATSRequest(daemon.instanceService.DescribeInstanceAttribute))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -1941,68 +1961,68 @@ func TestDelegateHandlers_RoundTrip(t *testing.T) {
{
name: "DeleteKeyPair",
topic: "ec2.test.DeleteKeyPair",
handler: daemon.handleEC2DeleteKeyPair,
handler: handleNATSRequest(daemon.keyService.DeleteKeyPair),
input: &ec2.DeleteKeyPairInput{KeyName: aws.String("nonexistent-key")},
// Idempotent delete: missing key returns a success response.
},
{
name: "ImportKeyPair",
topic: "ec2.test.ImportKeyPair",
handler: daemon.handleEC2ImportKeyPair,
handler: handleNATSRequest(daemon.keyService.ImportKeyPair),
input: &ec2.ImportKeyPairInput{KeyName: aws.String("imported-key"), PublicKeyMaterial: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest test@test")},
expectedCode: awserrors.ErrorInvalidKeyFormat,
},
{
name: "CreateVolume",
topic: "ec2.test.CreateVolume",
handler: daemon.handleEC2CreateVolume,
handler: handleNATSRequest(daemon.volumeService.CreateVolume),
input: &ec2.CreateVolumeInput{AvailabilityZone: aws.String("us-east-1a"), Size: aws.Int64(10)},
expectedCode: awserrors.ErrorInvalidAvailabilityZone,
},
{
name: "DescribeVolumeStatus",
topic: "ec2.test.DescribeVolumeStatus",
handler: daemon.handleEC2DescribeVolumeStatus,
handler: handleNATSRequest(daemon.volumeService.DescribeVolumeStatus),
input: &ec2.DescribeVolumeStatusInput{},
},
{
name: "DeleteVolume",
topic: "ec2.test.DeleteVolume",
handler: daemon.handleEC2DeleteVolume,
handler: handleNATSRequest(daemon.volumeService.DeleteVolume),
input: &ec2.DeleteVolumeInput{VolumeId: aws.String("vol-nonexistent")},
expectedCode: awserrors.ErrorInvalidVolumeNotFound,
},
{
name: "CreateSnapshot",
topic: "ec2.test.CreateSnapshot",
handler: daemon.handleEC2CreateSnapshot,
handler: handleNATSRequest(daemon.snapshotService.CreateSnapshot),
input: &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-nonexistent")},
expectedCode: awserrors.ErrorInvalidVolumeNotFound,
},
{
name: "DescribeSnapshots",
topic: "ec2.test.DescribeSnapshots",
handler: daemon.handleEC2DescribeSnapshots,
handler: handleNATSRequest(daemon.snapshotService.DescribeSnapshots),
input: &ec2.DescribeSnapshotsInput{},
},
{
name: "DeleteSnapshot",
topic: "ec2.test.DeleteSnapshot",
handler: daemon.handleEC2DeleteSnapshot,
handler: handleNATSRequest(daemon.snapshotService.DeleteSnapshot),
input: &ec2.DeleteSnapshotInput{SnapshotId: aws.String("snap-nonexistent")},
expectedCode: awserrors.ErrorInvalidSnapshotNotFound,
},
{
name: "CopySnapshot",
topic: "ec2.test.CopySnapshot",
handler: daemon.handleEC2CopySnapshot,
handler: handleNATSRequest(daemon.snapshotService.CopySnapshot),
input: &ec2.CopySnapshotInput{SourceRegion: aws.String("us-east-1"), SourceSnapshotId: aws.String("snap-nonexistent")},
expectedCode: awserrors.ErrorInvalidSnapshotNotFound,
},
{
name: "DeleteTags",
topic: "ec2.test.DeleteTags",
handler: daemon.handleEC2DeleteTags,
handler: handleNATSRequest(daemon.deleteTags),
// Instance IDs route to the owning daemon; with no owner
// subscribed the mutation is rejected rather than written blind.
input: &ec2.DeleteTagsInput{Resources: []*string{aws.String("i-12345678")}},
@@ -2011,7 +2031,7 @@ func TestDelegateHandlers_RoundTrip(t *testing.T) {
{
name: "DescribeTags",
topic: "ec2.test.DescribeTags",
handler: daemon.handleEC2DescribeTags,
handler: handleNATSRequest(daemon.tagsService.DescribeTags),
input: &ec2.DescribeTagsInput{},
},
}
@@ -2276,11 +2296,11 @@ func createVPCTestDaemon(t *testing.T) *Daemon {
testutil.StubVpcdSGResponder(t, nc)
vpcSvc, err := handlers_ec2_vpc.NewVPCServiceImplWithNATS(daemon.config, nc)
vpcSvc, err := handlers_ec2_vpc.NewVPCServiceImplWithNATS(t.Context(), daemon.config, nc)
require.NoError(t, err)
daemon.vpcService = vpcSvc
igwSvc, err := handlers_ec2_igw.NewIGWServiceImplWithNATS(daemon.config, nc)
igwSvc, err := handlers_ec2_igw.NewIGWServiceImplWithNATS(t.Context(), daemon.config, nc)
require.NoError(t, err)
daemon.igwService = igwSvc
@@ -2301,26 +2321,26 @@ func TestDelegateHandlers_VPC(t *testing.T) {
{
name: "CreateVpc",
topic: "ec2.test.CreateVpc",
handler: daemon.handleEC2CreateVpc,
handler: handleNATSRequest(daemon.vpcService.CreateVpc),
input: &ec2.CreateVpcInput{CidrBlock: aws.String("10.0.0.0/16")},
},
{
name: "DeleteVpc",
topic: "ec2.test.DeleteVpc",
handler: daemon.handleEC2DeleteVpc,
handler: handleNATSRequest(daemon.vpcService.DeleteVpc),
input: &ec2.DeleteVpcInput{VpcId: aws.String("vpc-nonexistent")},
expectedCode: awserrors.ErrorInvalidVpcIDNotFound,
},
{
name: "DescribeVpcs",
topic: "ec2.test.DescribeVpcs",
handler: daemon.handleEC2DescribeVpcs,
handler: handleNATSRequest(daemon.vpcService.DescribeVpcs),
input: &ec2.DescribeVpcsInput{},
},
{
name: "CreateSubnet",
topic: "ec2.test.CreateSubnet",
handler: daemon.handleEC2CreateSubnet,
handler: handleNATSRequest(daemon.vpcService.CreateSubnet),
input: &ec2.CreateSubnetInput{
VpcId: aws.String("vpc-nonexistent"),
CidrBlock: aws.String("10.0.1.0/24"),
@@ -2330,34 +2350,34 @@ func TestDelegateHandlers_VPC(t *testing.T) {
{
name: "DeleteSubnet",
topic: "ec2.test.DeleteSubnet",
handler: daemon.handleEC2DeleteSubnet,
handler: handleNATSRequest(daemon.vpcService.DeleteSubnet),
input: &ec2.DeleteSubnetInput{SubnetId: aws.String("subnet-nonexistent")},
expectedCode: awserrors.ErrorInvalidSubnetIDNotFound,
},
{
name: "DescribeSubnets",
topic: "ec2.test.DescribeSubnets",
handler: daemon.handleEC2DescribeSubnets,
handler: handleNATSRequest(daemon.vpcService.DescribeSubnets),
input: &ec2.DescribeSubnetsInput{},
},
{
name: "CreateNetworkInterface",
topic: "ec2.test.CreateNetworkInterface",
handler: daemon.handleEC2CreateNetworkInterface,
handler: handleNATSRequest(daemon.vpcService.CreateNetworkInterface),
input: &ec2.CreateNetworkInterfaceInput{SubnetId: aws.String("subnet-nonexistent")},
expectedCode: awserrors.ErrorInvalidSubnetIDNotFound,
},
{
name: "DeleteNetworkInterface",
topic: "ec2.test.DeleteNetworkInterface",
handler: daemon.handleEC2DeleteNetworkInterface,
handler: handleNATSRequest(daemon.vpcService.DeleteNetworkInterface),
input: &ec2.DeleteNetworkInterfaceInput{NetworkInterfaceId: aws.String("eni-nonexistent")},
expectedCode: awserrors.ErrorInvalidNetworkInterfaceIDNotFound,
},
{
name: "DescribeNetworkInterfaces",
topic: "ec2.test.DescribeNetworkInterfaces",
handler: daemon.handleEC2DescribeNetworkInterfaces,
handler: handleNATSRequest(daemon.vpcService.DescribeNetworkInterfaces),
input: &ec2.DescribeNetworkInterfacesInput{},
},
}
@@ -2394,26 +2414,26 @@ func TestDelegateHandlers_IGW(t *testing.T) {
{
name: "CreateInternetGateway",
topic: "ec2.test.CreateInternetGateway",
handler: daemon.handleEC2CreateInternetGateway,
handler: handleNATSRequest(daemon.igwService.CreateInternetGateway),
input: &ec2.CreateInternetGatewayInput{},
},
{
name: "DeleteInternetGateway",
topic: "ec2.test.DeleteInternetGateway",
handler: daemon.handleEC2DeleteInternetGateway,
handler: handleNATSRequest(daemon.igwService.DeleteInternetGateway),
input: &ec2.DeleteInternetGatewayInput{InternetGatewayId: aws.String("igw-nonexistent")},
expectedCode: awserrors.ErrorInvalidInternetGatewayIDNotFound,
},
{
name: "DescribeInternetGateways",
topic: "ec2.test.DescribeInternetGateways",
handler: daemon.handleEC2DescribeInternetGateways,
handler: handleNATSRequest(daemon.igwService.DescribeInternetGateways),
input: &ec2.DescribeInternetGatewaysInput{},
},
{
name: "AttachInternetGateway",
topic: "ec2.test.AttachInternetGateway",
handler: daemon.handleEC2AttachInternetGateway,
handler: handleNATSRequest(daemon.igwService.AttachInternetGateway),
input: &ec2.AttachInternetGatewayInput{
InternetGatewayId: aws.String("igw-nonexistent"),
VpcId: aws.String("vpc-nonexistent"),
@@ -2423,7 +2443,7 @@ func TestDelegateHandlers_IGW(t *testing.T) {
{
name: "DetachInternetGateway",
topic: "ec2.test.DetachInternetGateway",
handler: daemon.handleEC2DetachInternetGateway,
handler: handleNATSRequest(daemon.igwService.DetachInternetGateway),
input: &ec2.DetachInternetGatewayInput{
InternetGatewayId: aws.String("igw-nonexistent"),
VpcId: aws.String("vpc-nonexistent"),
@@ -2453,7 +2473,7 @@ func TestDelegateHandlers_IGW(t *testing.T) {
func TestHandleEC2CreateVpc_SuccessPath(t *testing.T) {
daemon := createVPCTestDaemon(t)
sub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", daemon.handleEC2CreateVpc)
sub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", handleNATSRequest(daemon.vpcService.CreateVpc))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -2474,11 +2494,11 @@ func TestHandleEC2CreateVpc_SuccessPath(t *testing.T) {
func TestHandleEC2CreateAndDescribeVpc_RoundTrip(t *testing.T) {
daemon := createVPCTestDaemon(t)
createSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", daemon.handleEC2CreateVpc)
createSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", handleNATSRequest(daemon.vpcService.CreateVpc))
require.NoError(t, err)
defer createSub.Unsubscribe()
describeSub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeVpcs", "spinifex-workers", daemon.handleEC2DescribeVpcs)
describeSub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeVpcs", "spinifex-workers", handleNATSRequest(daemon.vpcService.DescribeVpcs))
require.NoError(t, err)
defer describeSub.Unsubscribe()
@@ -2514,7 +2534,7 @@ func TestHandleEC2CreateAndDescribeVpc_RoundTrip(t *testing.T) {
func TestHandleEC2CreateInternetGateway_SuccessPath(t *testing.T) {
daemon := createVPCTestDaemon(t)
sub, err := daemon.natsConn.QueueSubscribe("ec2.CreateInternetGateway", "spinifex-workers", daemon.handleEC2CreateInternetGateway)
sub, err := daemon.natsConn.QueueSubscribe("ec2.CreateInternetGateway", "spinifex-workers", handleNATSRequest(daemon.igwService.CreateInternetGateway))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -2534,11 +2554,11 @@ func TestHandleEC2CreateInternetGateway_SuccessPath(t *testing.T) {
func TestHandleEC2CreateSubnet_SuccessPath(t *testing.T) {
daemon := createVPCTestDaemon(t)
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", daemon.handleEC2CreateVpc)
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", handleNATSRequest(daemon.vpcService.CreateVpc))
require.NoError(t, err)
defer createVpcSub.Unsubscribe()
createSubnetSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateSubnet", "spinifex-workers", daemon.handleEC2CreateSubnet)
createSubnetSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateSubnet", "spinifex-workers", handleNATSRequest(daemon.vpcService.CreateSubnet))
require.NoError(t, err)
defer createSubnetSub.Unsubscribe()
@@ -2592,7 +2612,7 @@ func TestDelegateHandlers_EIGW(t *testing.T) {
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
eigwSvc, err := handlers_ec2_eigw.NewEgressOnlyIGWServiceImplWithNATS(daemon.config, nc)
eigwSvc, err := handlers_ec2_eigw.NewEgressOnlyIGWServiceImplWithNATS(t.Context(), daemon.config, nc)
require.NoError(t, err)
daemon.eigwService = eigwSvc
@@ -2607,21 +2627,21 @@ func TestDelegateHandlers_EIGW(t *testing.T) {
{
name: "CreateEgressOnlyInternetGateway",
topic: "ec2.test.CreateEgressOnlyIGW",
handler: daemon.handleEC2CreateEgressOnlyInternetGateway,
handler: handleNATSRequest(daemon.eigwService.CreateEgressOnlyInternetGateway),
input: &ec2.CreateEgressOnlyInternetGatewayInput{VpcId: aws.String("vpc-123")},
expectedCode: awserrors.ErrorInvalidVpcIDNotFound,
},
{
name: "DeleteEgressOnlyInternetGateway",
topic: "ec2.test.DeleteEgressOnlyIGW",
handler: daemon.handleEC2DeleteEgressOnlyInternetGateway,
handler: handleNATSRequest(daemon.eigwService.DeleteEgressOnlyInternetGateway),
input: &ec2.DeleteEgressOnlyInternetGatewayInput{EgressOnlyInternetGatewayId: aws.String("eigw-nonexistent")},
expectedCode: awserrors.ErrorInvalidEgressOnlyInternetGatewayIdNotFound,
},
{
name: "DescribeEgressOnlyInternetGateways",
topic: "ec2.test.DescribeEgressOnlyIGWs",
handler: daemon.handleEC2DescribeEgressOnlyInternetGateways,
handler: handleNATSRequest(daemon.eigwService.DescribeEgressOnlyInternetGateways),
input: &ec2.DescribeEgressOnlyInternetGatewaysInput{},
},
}
@@ -2704,7 +2724,7 @@ func TestHandleEC2ModifyVolume_Success(t *testing.T) {
func TestHandleEC2DescribeInstanceTypes_CapacityFilter(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceTypes", "spinifex-workers", daemon.handleEC2DescribeInstanceTypes)
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceTypes", "spinifex-workers", handleNATSRequest(daemon.instanceService.DescribeInstanceTypes))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -2731,7 +2751,7 @@ func TestHandleEC2DescribeInstanceTypes_CapacityFilter(t *testing.T) {
func TestHandleEC2DescribeInstanceTypes_NoFilter(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceTypes.nofilter", "spinifex-workers", daemon.handleEC2DescribeInstanceTypes)
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceTypes.nofilter", "spinifex-workers", handleNATSRequest(daemon.instanceService.DescribeInstanceTypes))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -3321,7 +3341,7 @@ func TestHandleEC2RunInstances_MalformedInput(t *testing.T) {
func TestHandleEC2DescribeInstances_MalformedInstanceID(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances.malformed", daemon.handleEC2DescribeInstances)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances.malformed", handleNATSRequest(daemon.instanceService.DescribeInstances))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -3353,7 +3373,7 @@ func TestHandleEC2DescribeInstanceStatus_RoundTrip(t *testing.T) {
daemon.vmMgr.Insert(runningVM)
t.Cleanup(func() { daemon.vmMgr.Delete(runningVM.ID) })
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceStatus.rt", daemon.handleEC2DescribeInstanceStatus)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceStatus.rt", handleNATSRequest(daemon.instanceService.DescribeInstanceStatus))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -3379,7 +3399,7 @@ func TestHandleEC2DescribeInstanceStatus_RoundTrip(t *testing.T) {
func TestHandleEC2DescribeInstanceStatus_MalformedInstanceID(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceStatus.malformed", daemon.handleEC2DescribeInstanceStatus)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceStatus.malformed", handleNATSRequest(daemon.instanceService.DescribeInstanceStatus))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -3414,7 +3434,7 @@ func TestHandleEC2DescribeTerminatedInstances_ReturnsTerminatedInstances(t *test
err := daemon.jsManager.WriteTerminatedInstance(terminatedVM.ID, terminatedVM)
require.NoError(t, err)
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeTerminatedInstances", "spinifex-workers", daemon.handleEC2DescribeTerminatedInstances)
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeTerminatedInstances", "spinifex-workers", handleNATSRequest(daemon.instanceService.DescribeTerminatedInstances))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -3463,7 +3483,7 @@ func TestHandleEC2DescribeTerminatedInstances_WithFilter(t *testing.T) {
require.NoError(t, daemon.jsManager.WriteTerminatedInstance(v.ID, v))
}
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeTerminatedInstances", "spinifex-workers", daemon.handleEC2DescribeTerminatedInstances)
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeTerminatedInstances", "spinifex-workers", handleNATSRequest(daemon.instanceService.DescribeTerminatedInstances))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -3499,7 +3519,7 @@ func TestHandleEC2TerminateStoppedInstance_WritesToTerminatedKV(t *testing.T) {
}
require.NoError(t, daemon.jsManager.WriteStoppedInstance(stoppedVM.ID, stoppedVM))
sub, err := daemon.natsConn.QueueSubscribe("ec2.terminate", "spinifex-workers", daemon.handleEC2TerminateStoppedInstance)
sub, err := daemon.natsConn.QueueSubscribe("ec2.terminate", "spinifex-workers", handleNATSRequest(daemon.instanceService.TerminateStoppedInstance))
require.NoError(t, err)
defer sub.Unsubscribe()
@@ -3544,14 +3564,14 @@ func TestDelegateHandlers_EIP(t *testing.T) {
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
js, err := nc.JetStream()
js, err := jetstream.New(nc)
require.NoError(t, err)
ipam, err := handlers_ec2_vpc.NewExternalIPAM(js, []external.ExternalPoolConfig{
ipam, err := handlers_ec2_vpc.NewExternalIPAM(t.Context(), js, []external.ExternalPoolConfig{
{Name: "test-pool", RangeStart: "192.168.100.2", RangeEnd: "192.168.100.254", Gateway: "192.168.100.1", PrefixLen: 24},
})
require.NoError(t, err)
eipSvc, err := handlers_ec2_eip.NewEIPServiceImpl(nc, ipam, daemon.vpcService)
eipSvc, err := handlers_ec2_eip.NewEIPServiceImpl(t.Context(), nc, ipam, daemon.vpcService)
require.NoError(t, err)
daemon.eipService = eipSvc
@@ -3566,34 +3586,34 @@ func TestDelegateHandlers_EIP(t *testing.T) {
{
name: "AllocateAddress",
topic: "ec2.test.AllocateAddress",
handler: daemon.handleEC2AllocateAddress,
handler: handleNATSRequest(daemon.eipService.AllocateAddress),
input: &ec2.AllocateAddressInput{},
},
{
name: "ReleaseAddress",
topic: "ec2.test.ReleaseAddress",
handler: daemon.handleEC2ReleaseAddress,
handler: handleNATSRequest(daemon.eipService.ReleaseAddress),
input: &ec2.ReleaseAddressInput{AllocationId: aws.String("eipalloc-nonexistent")},
expectedCode: awserrors.ErrorInvalidAllocationIDNotFound,
},
{
name: "AssociateAddress",
topic: "ec2.test.AssociateAddress",
handler: daemon.handleEC2AssociateAddress,
handler: handleNATSRequest(daemon.eipService.AssociateAddress),
input: &ec2.AssociateAddressInput{AllocationId: aws.String("eipalloc-nonexistent")},
expectedCode: awserrors.ErrorInvalidAllocationIDNotFound,
},
{
name: "DisassociateAddress",
topic: "ec2.test.DisassociateAddress",
handler: daemon.handleEC2DisassociateAddress,
handler: handleNATSRequest(daemon.eipService.DisassociateAddress),
input: &ec2.DisassociateAddressInput{AssociationId: aws.String("eipassoc-nonexistent")},
expectedCode: awserrors.ErrorInvalidAssociationIDNotFound,
},
{
name: "DescribeAddresses",
topic: "ec2.test.DescribeAddresses",
handler: daemon.handleEC2DescribeAddresses,
handler: handleNATSRequest(daemon.eipService.DescribeAddresses),
input: &ec2.DescribeAddressesInput{},
},
}
@@ -3622,7 +3642,7 @@ func TestDelegateHandlers_SecurityGroup(t *testing.T) {
daemon := createVPCTestDaemon(t)
// Create a VPC first so SG operations have a valid target
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", daemon.handleEC2CreateVpc)
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", handleNATSRequest(daemon.vpcService.CreateVpc))
require.NoError(t, err)
defer createVpcSub.Unsubscribe()
@@ -3646,7 +3666,7 @@ func TestDelegateHandlers_SecurityGroup(t *testing.T) {
{
name: "CreateSecurityGroup",
topic: "ec2.test.CreateSecurityGroup",
handler: daemon.handleEC2CreateSecurityGroup,
handler: handleNATSRequest(daemon.vpcService.CreateSecurityGroup),
input: &ec2.CreateSecurityGroupInput{
GroupName: aws.String("test-sg"),
Description: aws.String("test security group"),
@@ -3656,41 +3676,41 @@ func TestDelegateHandlers_SecurityGroup(t *testing.T) {
{
name: "DescribeSecurityGroups",
topic: "ec2.test.DescribeSecurityGroups",
handler: daemon.handleEC2DescribeSecurityGroups,
handler: handleNATSRequest(daemon.vpcService.DescribeSecurityGroups),
input: &ec2.DescribeSecurityGroupsInput{},
},
{
name: "AuthorizeSecurityGroupIngress",
topic: "ec2.test.AuthorizeSecurityGroupIngress",
handler: daemon.handleEC2AuthorizeSecurityGroupIngress,
handler: handleNATSRequest(daemon.vpcService.AuthorizeSecurityGroupIngress),
input: &ec2.AuthorizeSecurityGroupIngressInput{GroupId: aws.String("sg-nonexistent")},
expectedCode: awserrors.ErrorInvalidGroupNotFound,
},
{
name: "AuthorizeSecurityGroupEgress",
topic: "ec2.test.AuthorizeSecurityGroupEgress",
handler: daemon.handleEC2AuthorizeSecurityGroupEgress,
handler: handleNATSRequest(daemon.vpcService.AuthorizeSecurityGroupEgress),
input: &ec2.AuthorizeSecurityGroupEgressInput{GroupId: aws.String("sg-nonexistent")},
expectedCode: awserrors.ErrorInvalidGroupNotFound,
},
{
name: "RevokeSecurityGroupIngress",
topic: "ec2.test.RevokeSecurityGroupIngress",
handler: daemon.handleEC2RevokeSecurityGroupIngress,
handler: handleNATSRequest(daemon.vpcService.RevokeSecurityGroupIngress),
input: &ec2.RevokeSecurityGroupIngressInput{GroupId: aws.String("sg-nonexistent")},
expectedCode: awserrors.ErrorInvalidGroupNotFound,
},
{
name: "RevokeSecurityGroupEgress",
topic: "ec2.test.RevokeSecurityGroupEgress",
handler: daemon.handleEC2RevokeSecurityGroupEgress,
handler: handleNATSRequest(daemon.vpcService.RevokeSecurityGroupEgress),
input: &ec2.RevokeSecurityGroupEgressInput{GroupId: aws.String("sg-nonexistent")},
expectedCode: awserrors.ErrorInvalidGroupNotFound,
},
{
name: "DeleteSecurityGroup",
topic: "ec2.test.DeleteSecurityGroup",
handler: daemon.handleEC2DeleteSecurityGroup,
handler: handleNATSRequest(daemon.vpcService.DeleteSecurityGroup),
input: &ec2.DeleteSecurityGroupInput{GroupId: aws.String("sg-nonexistent")},
expectedCode: awserrors.ErrorInvalidGroupNotFound,
},
@@ -3737,7 +3757,7 @@ func TestDelegateHandlers_RouteTable(t *testing.T) {
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
rtbSvc, err := handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(daemon.config, nc)
rtbSvc, err := handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(t.Context(), daemon.config, nc)
require.NoError(t, err)
daemon.routeTableService = rtbSvc
@@ -3752,27 +3772,27 @@ func TestDelegateHandlers_RouteTable(t *testing.T) {
{
name: "CreateRouteTable",
topic: "ec2.test.CreateRouteTable",
handler: daemon.handleEC2CreateRouteTable,
handler: handleNATSRequest(daemon.routeTableService.CreateRouteTable),
input: &ec2.CreateRouteTableInput{VpcId: aws.String("vpc-nonexistent")},
expectedCode: awserrors.ErrorInvalidVpcIDNotFound,
},
{
name: "DeleteRouteTable",
topic: "ec2.test.DeleteRouteTable",
handler: daemon.handleEC2DeleteRouteTable,
handler: handleNATSRequest(daemon.routeTableService.DeleteRouteTable),
input: &ec2.DeleteRouteTableInput{RouteTableId: aws.String("rtb-nonexistent")},
expectedCode: awserrors.ErrorInvalidRouteTableIDNotFound,
},
{
name: "DescribeRouteTables",
topic: "ec2.test.DescribeRouteTables",
handler: daemon.handleEC2DescribeRouteTables,
handler: handleNATSRequest(daemon.routeTableService.DescribeRouteTables),
input: &ec2.DescribeRouteTablesInput{},
},
{
name: "CreateRoute",
topic: "ec2.test.CreateRoute",
handler: daemon.handleEC2CreateRoute,
handler: handleNATSRequest(daemon.routeTableService.CreateRoute),
input: &ec2.CreateRouteInput{
RouteTableId: aws.String("rtb-nonexistent"),
DestinationCidrBlock: aws.String("0.0.0.0/0"),
@@ -3783,7 +3803,7 @@ func TestDelegateHandlers_RouteTable(t *testing.T) {
{
name: "DeleteRoute",
topic: "ec2.test.DeleteRoute",
handler: daemon.handleEC2DeleteRoute,
handler: handleNATSRequest(daemon.routeTableService.DeleteRoute),
input: &ec2.DeleteRouteInput{
RouteTableId: aws.String("rtb-nonexistent"),
DestinationCidrBlock: aws.String("0.0.0.0/0"),
@@ -3793,7 +3813,7 @@ func TestDelegateHandlers_RouteTable(t *testing.T) {
{
name: "ReplaceRoute",
topic: "ec2.test.ReplaceRoute",
handler: daemon.handleEC2ReplaceRoute,
handler: handleNATSRequest(daemon.routeTableService.ReplaceRoute),
input: &ec2.ReplaceRouteInput{
RouteTableId: aws.String("rtb-nonexistent"),
DestinationCidrBlock: aws.String("0.0.0.0/0"),
@@ -3804,7 +3824,7 @@ func TestDelegateHandlers_RouteTable(t *testing.T) {
{
name: "AssociateRouteTable",
topic: "ec2.test.AssociateRouteTable",
handler: daemon.handleEC2AssociateRouteTable,
handler: handleNATSRequest(daemon.routeTableService.AssociateRouteTable),
input: &ec2.AssociateRouteTableInput{
RouteTableId: aws.String("rtb-nonexistent"),
SubnetId: aws.String("subnet-nonexistent"),
@@ -3814,14 +3834,14 @@ func TestDelegateHandlers_RouteTable(t *testing.T) {
{
name: "DisassociateRouteTable",
topic: "ec2.test.DisassociateRouteTable",
handler: daemon.handleEC2DisassociateRouteTable,
handler: handleNATSRequest(daemon.routeTableService.DisassociateRouteTable),
input: &ec2.DisassociateRouteTableInput{AssociationId: aws.String("rtbassoc-nonexistent")},
expectedCode: awserrors.ErrorInvalidAssociationIDNotFound,
},
{
name: "ReplaceRouteTableAssociation",
topic: "ec2.test.ReplaceRouteTableAssociation",
handler: daemon.handleEC2ReplaceRouteTableAssociation,
handler: handleNATSRequest(daemon.routeTableService.ReplaceRouteTableAssociation),
input: &ec2.ReplaceRouteTableAssociationInput{
AssociationId: aws.String("rtbassoc-nonexistent"),
RouteTableId: aws.String("rtb-nonexistent"),
@@ -3870,7 +3890,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
pgSvc, err := handlers_ec2_placementgroup.NewPlacementGroupServiceImplWithNATS(daemon.config, nc)
pgSvc, err := handlers_ec2_placementgroup.NewPlacementGroupServiceImplWithNATS(t.Context(), daemon.config, nc)
require.NoError(t, err)
daemon.placementGroupService = pgSvc
@@ -3885,7 +3905,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
{
name: "CreatePlacementGroup",
topic: "ec2.test.CreatePlacementGroup",
handler: daemon.handleEC2CreatePlacementGroup,
handler: handleNATSRequest(daemon.placementGroupService.CreatePlacementGroup),
input: &ec2.CreatePlacementGroupInput{
GroupName: aws.String("test-pg"),
Strategy: aws.String("spread"),
@@ -3894,20 +3914,20 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
{
name: "DescribePlacementGroups",
topic: "ec2.test.DescribePlacementGroups",
handler: daemon.handleEC2DescribePlacementGroups,
handler: handleNATSRequest(daemon.placementGroupService.DescribePlacementGroups),
input: &ec2.DescribePlacementGroupsInput{},
},
{
name: "DeletePlacementGroup",
topic: "ec2.test.DeletePlacementGroup",
handler: daemon.handleEC2DeletePlacementGroup,
handler: handleNATSRequest(daemon.placementGroupService.DeletePlacementGroup),
input: &ec2.DeletePlacementGroupInput{GroupName: aws.String("pg-nonexistent")},
expectedCode: awserrors.ErrorInvalidPlacementGroupUnknown,
},
{
name: "ReserveSpreadNodes",
topic: "ec2.test.ReserveSpreadNodes",
handler: daemon.handleEC2ReserveSpreadNodes,
handler: handleNATSRequest(daemon.placementGroupService.ReserveSpreadNodes),
input: &handlers_ec2_placementgroup.ReserveSpreadNodesInput{
GroupName: "pg-nonexistent",
EligibleNodes: []string{"node-1"},
@@ -3919,7 +3939,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
{
name: "FinalizeSpreadInstances",
topic: "ec2.test.FinalizeSpreadInstances",
handler: daemon.handleEC2FinalizeSpreadInstances,
handler: handleNATSRequest(daemon.placementGroupService.FinalizeSpreadInstances),
input: &handlers_ec2_placementgroup.FinalizeSpreadInstancesInput{
GroupName: "pg-nonexistent",
NodeInstances: map[string][]string{"node-1": {"i-123"}},
@@ -3929,7 +3949,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
{
name: "ReleaseSpreadNodes",
topic: "ec2.test.ReleaseSpreadNodes",
handler: daemon.handleEC2ReleaseSpreadNodes,
handler: handleNATSRequest(daemon.placementGroupService.ReleaseSpreadNodes),
input: &handlers_ec2_placementgroup.ReleaseSpreadNodesInput{
GroupName: "pg-nonexistent",
Nodes: []string{"node-1"},
@@ -3939,7 +3959,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
{
name: "RemoveInstanceFromPlacementGroup",
topic: "ec2.test.RemoveInstanceFromPlacementGroup",
handler: daemon.handleEC2RemoveInstanceFromPlacementGroup,
handler: handleNATSRequest(daemon.placementGroupService.RemoveInstance),
input: &handlers_ec2_placementgroup.RemoveInstanceInput{
GroupName: "pg-nonexistent",
NodeName: "node-1",
@@ -3952,7 +3972,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
{
name: "ReserveClusterNode",
topic: "ec2.test.ReserveClusterNode",
handler: daemon.handleEC2ReserveClusterNode,
handler: handleNATSRequest(daemon.placementGroupService.ReserveClusterNode),
input: &handlers_ec2_placementgroup.ReserveClusterNodeInput{
GroupName: "pg-nonexistent",
EligibleNodes: []string{"node-1"},
@@ -3962,7 +3982,7 @@ func TestDelegateHandlers_PlacementGroup(t *testing.T) {
{
name: "FinalizeClusterInstances",
topic: "ec2.test.FinalizeClusterInstances",
handler: daemon.handleEC2FinalizeClusterInstances,
handler: handleNATSRequest(daemon.placementGroupService.FinalizeClusterInstances),
input: &handlers_ec2_placementgroup.FinalizeClusterInstancesInput{
GroupName: "pg-nonexistent",
NodeInstances: map[string][]string{"node-1": {"i-123"}},
@@ -3995,7 +4015,7 @@ func TestDelegateHandlers_VPCAttributes(t *testing.T) {
daemon := createVPCTestDaemon(t)
// Create a VPC first
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", daemon.handleEC2CreateVpc)
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", handleNATSRequest(daemon.vpcService.CreateVpc))
require.NoError(t, err)
defer createVpcSub.Unsubscribe()
@@ -4019,7 +4039,7 @@ func TestDelegateHandlers_VPCAttributes(t *testing.T) {
{
name: "ModifySubnetAttribute",
topic: "ec2.test.ModifySubnetAttribute",
handler: daemon.handleEC2ModifySubnetAttribute,
handler: handleNATSRequest(daemon.vpcService.ModifySubnetAttribute),
input: &ec2.ModifySubnetAttributeInput{SubnetId: aws.String("subnet-nonexistent")},
expectedCode: awserrors.ErrorInvalidSubnetIDNotFound,
},
@@ -4029,14 +4049,14 @@ func TestDelegateHandlers_VPCAttributes(t *testing.T) {
// the validation contract.
name: "ModifyVpcAttribute",
topic: "ec2.test.ModifyVpcAttribute",
handler: daemon.handleEC2ModifyVpcAttribute,
handler: handleNATSRequest(daemon.vpcService.ModifyVpcAttribute),
input: &ec2.ModifyVpcAttributeInput{VpcId: aws.String(vpcID)},
expectedCode: awserrors.ErrorInvalidParameterValue,
},
{
name: "DescribeVpcAttribute",
topic: "ec2.test.DescribeVpcAttribute",
handler: daemon.handleEC2DescribeVpcAttribute,
handler: handleNATSRequest(daemon.vpcService.DescribeVpcAttribute),
input: &ec2.DescribeVpcAttributeInput{
VpcId: aws.String(vpcID),
Attribute: aws.String("enableDnsSupport"),
@@ -4047,7 +4067,7 @@ func TestDelegateHandlers_VPCAttributes(t *testing.T) {
// "exactly one attribute" guard before the ENI lookup runs.
name: "ModifyNetworkInterfaceAttribute",
topic: "ec2.test.ModifyNetworkInterfaceAttribute",
handler: daemon.handleEC2ModifyNetworkInterfaceAttribute,
handler: handleNATSRequest(daemon.vpcService.ModifyNetworkInterfaceAttribute),
input: &ec2.ModifyNetworkInterfaceAttributeInput{NetworkInterfaceId: aws.String("eni-nonexistent")},
expectedCode: awserrors.ErrorInvalidParameterValue,
},
+47 -21
View File
@@ -9,6 +9,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
@@ -139,6 +140,51 @@ func (d *Daemon) handleDetachVolume(ctx context.Context, msg *nats.Msg, command
d.respondWithVolumeAttachment(msg, command.DetachVolumeData.VolumeID, command.ID, deviceName, "detaching")
}
// handleDrainVolume flushes the volume's in-flight writes to S3 by dialing the
// drain socket the local NBD plugin serves. ec2.CreateSnapshot is answered by
// any node in the cluster, but only the node hosting the instance subscribes
// ec2.cmd.{instanceID}, so routing the drain here lands it where the writes are.
//
// Runs on its own goroutine (see handleEC2Events) so a long flush cannot hold
// the instance's command subscription.
func (d *Daemon) handleDrainVolume(ctx context.Context, msg *nats.Msg, command types.EC2InstanceCommand, instance *vm.VM) {
ctx, span := startOpSpan(ctx, "ec2.DrainVolume", command.ID)
var err error
defer func() { endOpSpan(span, err) }()
if command.DrainVolumeData == nil || command.DrainVolumeData.VolumeID == "" {
slog.ErrorContext(ctx, "DrainVolume: missing drain volume data", "instanceId", command.ID)
respondWithError(msg, awserrors.ErrorInvalidParameterValue)
return
}
volumeID := command.DrainVolumeData.VolumeID
// Only stable post-teardown states prove the volume was sealed. Transitional
// states still have a live or shutting-down writer, so they must drain or
// fail rather than acknowledge an older checkpoint as current.
status := d.vmMgr.Status(instance)
if status == vm.StateStopped || status == vm.StateTerminated {
slog.InfoContext(ctx, "DrainVolume: instance teardown is complete, nothing to drain",
"volumeId", volumeID, "instanceId", command.ID, "status", status)
respondWithJSON(msg, types.DrainVolumeResponse{VolumeID: volumeID, Status: types.DrainVolumeStatusNotRunning})
return
}
slog.InfoContext(ctx, "Draining volume before snapshot", "volumeId", volumeID, "instanceId", command.ID)
// A missing or unresponsive socket under a running instance means the writes
// cannot be made current, so the caller must fail its snapshot rather than
// read a stale checkpoint.
if err = handlers_ec2_snapshot.DrainVolumeSocket(d.config.DataDir, volumeID); err != nil {
slog.ErrorContext(ctx, "DrainVolume: drain failed", "volumeId", volumeID, "instanceId", command.ID, "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
respondWithJSON(msg, types.DrainVolumeResponse{VolumeID: volumeID, Status: types.DrainVolumeStatusDrained})
}
// attachDetachErrorCode maps a vm.Manager error returned by AttachVolume
// or DetachVolume to the AWS API error code that the SDK expects.
func attachDetachErrorCode(err error) string {
@@ -160,22 +206,6 @@ func attachDetachErrorCode(err error) string {
}
}
func (d *Daemon) handleEC2CreateVolume(msg *nats.Msg) {
handleNATSRequest(msg, d.volumeService.CreateVolume)
}
func (d *Daemon) handleEC2DescribeVolumes(msg *nats.Msg) {
handleNATSRequest(msg, d.volumeService.DescribeVolumes)
}
func (d *Daemon) handleEC2DescribeVolumeStatus(msg *nats.Msg) {
handleNATSRequest(msg, d.volumeService.DescribeVolumeStatus)
}
func (d *Daemon) handleEC2DescribeVolumesModifications(msg *nats.Msg) {
handleNATSRequest(msg, d.volumeService.DescribeVolumesModifications)
}
// handleEC2ModifyVolume processes incoming EC2 ModifyVolume requests.
func (d *Daemon) handleEC2ModifyVolume(msg *nats.Msg) {
ctx, span := utils.StartConsumerSpan(msg)
@@ -205,7 +235,7 @@ func (d *Daemon) handleEC2ModifyVolume(msg *nats.Msg) {
if err != nil {
slog.ErrorContext(ctx, "handleEC2ModifyVolume service.ModifyVolume failed", "err", err)
utils.MarkSpanError(span, err)
respondWithError(msg, awserrors.ValidErrorCode(err.Error()))
respondWithError(msg, awserrors.ValidErrorCodeFromError(err))
return
}
@@ -227,7 +257,3 @@ func (d *Daemon) handleEC2ModifyVolume(msg *nats.Msg) {
slog.InfoContext(ctx, "handleEC2ModifyVolume completed", "volumeId", modifyVolumeInput.VolumeId)
}
func (d *Daemon) handleEC2DeleteVolume(msg *nats.Msg) {
handleNATSRequest(msg, d.volumeService.DeleteVolume)
}
@@ -15,11 +15,15 @@ import (
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/qmp"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/mulgadc/viperblock/viperblock"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)
// TestAttachDetachErrorCode locks down the manager-error → AWS-API-code
@@ -314,3 +318,254 @@ func TestAttachVolume_IdempotentSameInstance_DeviceMismatch(t *testing.T) {
require.NoError(t, err)
assert.Contains(t, string(resp.Data), awserrors.ErrorVolumeInUse)
}
// drainCommandFor builds the drain command a snapshot addresses to the node
// hosting instanceID.
func drainCommandFor(t *testing.T, instanceID, volumeID string) []byte {
t.Helper()
command := types.EC2InstanceCommand{
ID: instanceID,
Attributes: types.EC2CommandAttributes{DrainVolume: true},
DrainVolumeData: &types.DrainVolumeData{VolumeID: volumeID},
}
data, err := json.Marshal(command)
require.NoError(t, err)
return data
}
// drainTestDaemon returns a daemon owning a running instance, with a data dir
// short enough to hold the drain socket path.
func drainTestDaemon(t *testing.T, instanceID string) *Daemon {
t.Helper()
return drainTestDaemonInState(t, instanceID, vm.StateRunning)
}
// drainTestDaemonInState is drainTestDaemon for an instance the node still
// holds in some other state.
func drainTestDaemonInState(t *testing.T, instanceID string, status vm.InstanceState) *Daemon {
t.Helper()
daemon := createTestDaemon(t, sharedNATSURL)
daemon.config.DataDir = testutil.SocketTempDir(t)
daemon.vmMgr.Insert(&vm.VM{
ID: instanceID,
Status: status,
AccountID: testAccountID,
InstanceType: getTestInstanceType(t),
Instance: &ec2.Instance{},
})
return daemon
}
// drainRequest issues a drain on the instance's command subject, returning the
// raw reply.
func drainRequest(t *testing.T, daemon *Daemon, instanceID, volumeID string) *nats.Msg {
t.Helper()
reply, err := sendDrainCommand(daemon, instanceID, drainCommandFor(t, instanceID, volumeID))
require.NoError(t, err)
return reply
}
// sendDrainCommand is drainRequest without the assertions, so a test can issue
// one from a goroutine and assert on the result back on the test goroutine.
func sendDrainCommand(daemon *Daemon, instanceID string, command []byte) (*nats.Msg, error) {
msg := nats.NewMsg("ec2.cmd." + instanceID)
msg.Data = command
msg.Header.Set(utils.AccountIDHeader, testAccountID)
return daemon.natsConn.RequestMsg(msg, 5*time.Second)
}
// subscribeEC2Commands wires the daemon's real dispatcher onto the per-instance
// command subject, so tests exercise the same serial delivery production has.
func subscribeEC2Commands(t *testing.T, daemon *Daemon, instanceID string) {
t.Helper()
sub, err := daemon.natsConn.Subscribe("ec2.cmd."+instanceID, daemon.handleEC2Events)
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
// A drain routed to the node hosting the instance reaches the local socket and
// acks, which is what lets a snapshot answered elsewhere read a current
// checkpoint.
func TestDrainVolume_HostNodeAcksLocalSocket(t *testing.T) {
const instanceID, volumeID = "i-drain-ok", "vol-drain-ok"
daemon := drainTestDaemon(t, instanceID)
testutil.StartDrainSocket(t, daemon.config.DataDir, volumeID, "OK\n")
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
testAccountID, drainCommandFor(t, instanceID, volumeID))
var ack types.DrainVolumeResponse
require.NoError(t, json.Unmarshal(resp.Data, &ack))
assert.Equal(t, volumeID, ack.VolumeID)
assert.Equal(t, types.DrainVolumeStatusDrained, ack.Status)
}
// The owning node having no socket for the volume means the writes cannot be
// flushed. That must reach the caller as an error, never as a silent success:
// the caller would otherwise snapshot a stale checkpoint.
func TestDrainVolume_HostNodeWithoutSocketFails(t *testing.T) {
const instanceID, volumeID = "i-drain-nosock", "vol-drain-nosock"
daemon := drainTestDaemon(t, instanceID)
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
testAccountID, drainCommandFor(t, instanceID, volumeID))
assert.Equal(t, awserrors.ErrorServerInternal, replyErrCode(t, resp.Data))
}
// A plugin that refuses the drain (ERR rather than OK) is a failure, not an ack.
func TestDrainVolume_HostNodeRelaysSocketFailure(t *testing.T) {
const instanceID, volumeID = "i-drain-err", "vol-drain-err"
daemon := drainTestDaemon(t, instanceID)
testutil.StartDrainSocket(t, daemon.config.DataDir, volumeID, "ERR\n")
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
testAccountID, drainCommandFor(t, instanceID, volumeID))
assert.Equal(t, awserrors.ErrorServerInternal, replyErrCode(t, resp.Data))
}
// A drain command with no volume is a caller error, not a server fault.
func TestDrainVolume_MissingVolumeDataIsInvalidParameter(t *testing.T) {
const instanceID = "i-drain-novol"
daemon := drainTestDaemon(t, instanceID)
command := types.EC2InstanceCommand{
ID: instanceID,
Attributes: types.EC2CommandAttributes{DrainVolume: true},
}
data, err := json.Marshal(command)
require.NoError(t, err)
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
testAccountID, data)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, replyErrCode(t, resp.Data))
}
// Only the instance's owner may drain its volume: the command carries the same
// ownership gate as every other per-instance command.
func TestDrainVolume_ForeignAccountRejected(t *testing.T) {
const instanceID, volumeID = "i-drain-foreign", "vol-drain-foreign"
daemon := drainTestDaemon(t, instanceID)
testutil.StartDrainSocket(t, daemon.config.DataDir, volumeID, "OK\n")
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
"999988887777", drainCommandFor(t, instanceID, volumeID))
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, replyErrCode(t, resp.Data))
}
// Only stable post-teardown states prove the volume is sealed. These states
// acknowledge not-running without touching the absent plugin socket.
func TestDrainVolume_CompletedTeardownAcksNotRunning(t *testing.T) {
for _, status := range []vm.InstanceState{vm.StateStopped, vm.StateTerminated} {
t.Run(string(status), func(t *testing.T) {
instanceID := "i-drain-" + string(status)
volumeID := "vol-drain-" + string(status)
daemon := drainTestDaemonInState(t, instanceID, status)
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
testAccountID, drainCommandFor(t, instanceID, volumeID))
var ack types.DrainVolumeResponse
require.NoError(t, json.Unmarshal(resp.Data, &ack))
assert.Equal(t, volumeID, ack.VolumeID)
assert.Equal(t, types.DrainVolumeStatusNotRunning, ack.Status)
})
}
}
// Transitional and uncertain states are not proof of a sealed checkpoint. An
// absent socket in any of them must fail rather than silently snapshot stale
// data while launch or teardown may still own the volume.
func TestDrainVolume_UnsealedStatesRequireDrain(t *testing.T) {
statuses := []vm.InstanceState{
vm.StateRunning,
vm.StateStopping,
vm.StateShuttingDown,
vm.StatePending,
vm.StateProvisioning,
vm.StateError,
}
for _, status := range statuses {
t.Run(string(status), func(t *testing.T) {
instanceID := "i-drain-" + string(status)
volumeID := "vol-drain-" + string(status)
daemon := drainTestDaemonInState(t, instanceID, status)
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
testAccountID, drainCommandFor(t, instanceID, volumeID))
assert.Equal(t, awserrors.ErrorServerInternal, replyErrCode(t, resp.Data))
})
}
}
// nats.go delivers a subscription's messages serially, so a drain run inline
// would hold ec2.cmd.{instanceID} for the length of the flush — stalling stop,
// terminate and hot-plug for that instance. A drain still flushing must not
// keep the next command waiting.
func TestDrainVolume_SlowDrainDoesNotBlockTheCommandSubject(t *testing.T) {
const instanceID = "i-drain-parallel"
daemon := drainTestDaemon(t, instanceID)
accepted, release := testutil.StartBlockingDrainSocket(t, daemon.config.DataDir, "vol-drain-slow", "OK\n")
testutil.StartDrainSocket(t, daemon.config.DataDir, "vol-drain-fast", "OK\n")
subscribeEC2Commands(t, daemon, instanceID)
type reply struct {
msg *nats.Msg
err error
}
slowCommand := drainCommandFor(t, instanceID, "vol-drain-slow")
slow := make(chan reply, 1)
go func() {
msg, err := sendDrainCommand(daemon, instanceID, slowCommand)
slow <- reply{msg, err}
}()
// Only assert once the slow drain is genuinely mid-flush.
select {
case <-accepted:
case <-time.After(5 * time.Second):
t.Fatal("the slow drain never reached its socket")
}
fast := drainRequest(t, daemon, instanceID, "vol-drain-fast")
var ack types.DrainVolumeResponse
require.NoError(t, json.Unmarshal(fast.Data, &ack))
assert.Equal(t, types.DrainVolumeStatusDrained, ack.Status)
release()
select {
case got := <-slow:
require.NoError(t, got.err)
require.NoError(t, json.Unmarshal(got.msg.Data, &ack))
assert.Equal(t, types.DrainVolumeStatusDrained, ack.Status)
case <-time.After(5 * time.Second):
t.Fatal("the released drain never replied")
}
}
// Running the drain off the delivery goroutine must not leak it: the goroutine
// has to be gone once the ack is on the wire, or a snapshot loop against a
// wedged plugin accumulates them.
func TestDrainVolume_DispatchedGoroutineDoesNotLeak(t *testing.T) {
const instanceID, volumeID = "i-drain-leak", "vol-drain-leak"
daemon := drainTestDaemon(t, instanceID)
testutil.StartDrainSocket(t, daemon.config.DataDir, volumeID, "OK\n")
subscribeEC2Commands(t, daemon, instanceID)
// Take the baseline after one full round trip: the first request is what
// makes nats.go stand up its long-lived response-inbox goroutine, which
// would otherwise read as the leak.
var ack types.DrainVolumeResponse
require.NoError(t, json.Unmarshal(drainRequest(t, daemon, instanceID, volumeID).Data, &ack))
require.Equal(t, types.DrainVolumeStatusDrained, ack.Status)
ignoreExisting := goleak.IgnoreCurrent()
require.NoError(t, json.Unmarshal(drainRequest(t, daemon, instanceID, volumeID).Data, &ack))
require.Equal(t, types.DrainVolumeStatusDrained, ack.Status)
goleak.VerifyNone(t, ignoreExisting)
}
-52
View File
@@ -12,58 +12,6 @@ import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2CreateVpc(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.CreateVpc)
}
func (d *Daemon) handleEC2DeleteVpc(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DeleteVpc)
}
func (d *Daemon) handleEC2DescribeVpcs(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DescribeVpcs)
}
func (d *Daemon) handleEC2CreateSubnet(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.CreateSubnet)
}
func (d *Daemon) handleEC2DeleteSubnet(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DeleteSubnet)
}
func (d *Daemon) handleEC2DescribeSubnets(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DescribeSubnets)
}
func (d *Daemon) handleEC2ModifySubnetAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.ModifySubnetAttribute)
}
func (d *Daemon) handleEC2ModifyVpcAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.ModifyVpcAttribute)
}
func (d *Daemon) handleEC2DescribeVpcAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DescribeVpcAttribute)
}
func (d *Daemon) handleEC2CreateNetworkInterface(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.CreateNetworkInterface)
}
func (d *Daemon) handleEC2DeleteNetworkInterface(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DeleteNetworkInterface)
}
func (d *Daemon) handleEC2ModifyNetworkInterfaceAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.ModifyNetworkInterfaceAttribute)
}
func (d *Daemon) handleEC2DescribeNetworkInterfaces(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DescribeNetworkInterfaces)
}
// handleAccountCreated creates a default VPC for a newly created account.
func (d *Daemon) handleAccountCreated(msg *nats.Msg) {
ctx, span := utils.StartConsumerSpan(msg)
+247 -32
View File
@@ -1,27 +1,107 @@
package daemon
import (
"errors"
"fmt"
"log/slog"
"net"
"sync"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/nats-io/nats.go/jetstream"
)
// MgmtIPAllocator manages static IP allocation for management NICs on system instances.
// IPs are derived from the host's management bridge subnet (.10–.249 range).
// Thread-safe via its own mutex — LaunchSystemInstance does not hold a global lock.
// mgmtIPAMKeyPrefix namespaces mgmt-IP allocation records within the shared
// spinifex-cluster-state KV bucket (see JetStreamManager.UpdateMgmtIPAM). One
// record per management subnet, so clusters running distinct br-mgmt CIDRs
// never contend on the same key.
const mgmtIPAMKeyPrefix = "mgmt-ipam."
// mgmtCASOuterRetries bounds the outer retry loop Allocate/Release/Rebuild
// wrap around UpdateMgmtIPAM. casUpdate already retries a revision conflict
// internally (maxCASRetries), but that window only absorbs one overlapping
// writer per call; a burst of many nodes or VMs allocating on the same
// subnet at once (e.g. several system instances launched together at
// cluster bootstrap) can exhaust it. Retrying the whole read-mutate-write
// cycle from a fresh Get here converges on success under that contention
// instead of surfacing a transient conflict as a hard allocation failure.
const mgmtCASOuterRetries = 20
// updateMgmtIPAMWithRetry wraps jsManager.UpdateMgmtIPAM with the outer
// retry described above. Only a revision-conflict exhaustion is retried —
// any other error (KV not initialized, context errors, etc.) is returned
// immediately.
func updateMgmtIPAMWithRetry(jsManager *JetStreamManager, subnet string, mutate func(*MgmtIPRecord), createIfAbsent bool) (*MgmtIPRecord, error) {
var lastErr error
for range mgmtCASOuterRetries {
record, err := jsManager.UpdateMgmtIPAM(subnet, mutate, createIfAbsent)
if err == nil {
return record, nil
}
if !errors.Is(err, jetstream.ErrKeyExists) {
return nil, err
}
lastErr = err
}
return nil, fmt.Errorf("mgmt IPAM update for subnet %s exhausted %d outer retries under contention: %w",
subnet, mgmtCASOuterRetries, lastErr)
}
// MgmtIPEntry records one management IP allocation: the address, the
// instance it belongs to, and the node that made the allocation. Node is
// carried for audit and for a future reclaim sweep against dead nodes'
// heartbeats — it plays no part in allocation itself, which must consider
// every entry regardless of which node owns it.
type MgmtIPEntry struct {
IP string `json:"ip"`
InstanceID string `json:"instance_id"`
Node string `json:"node"`
}
// MgmtIPRecord is the KV-CAS record for one management subnet. br-mgmt is
// trunked onto a shared physical NIC on the tofu/ansible/baremetal cluster
// paths, so the /24 it lives on is one flat L2 segment across every host —
// every node allocating on that subnet reads and writes this same record.
type MgmtIPRecord struct {
Subnet string `json:"subnet"` // e.g. "10.28.8.0/24"
Allocated []MgmtIPEntry `json:"allocated"`
}
// MgmtIPAllocator manages static IP allocation for management NICs on
// system instances. IPs are derived from the host's management bridge
// subnet (.10-.249 range).
//
// Allocation is backed by a cluster-wide NATS KV compare-and-swap record
// once BindKV has been called, so nodes sharing the same br-mgmt L2 segment
// never hand out the same address (mulga-f3j2x): every node independently
// scanning from .10 was the bug, since br-mgmt is one flat subnet across the
// whole cluster, not a per-host range.
//
// allocated is a write-through local cache mirroring this node's view of the
// KV record, so AllocatedCount and lookups for an already-known instance ID
// keep working even when KV is unreachable. Allocate refuses to mint a NEW
// address in that case — this node's view of the cluster-wide record may be
// stale — but a cached instance ID and Release both still work locally.
type MgmtIPAllocator struct {
mu sync.Mutex
allocated map[string]string // instanceID → IP
allocated map[string]string // instanceID -> IP (write-through cache)
baseIP net.IP // network base (e.g. 10.15.8.0)
subnet string // "10.15.8.0/24", the CAS record's key suffix
rangeMin byte // first host byte (10)
rangeMax byte // last host byte (249)
// jsManager / node are bound by BindKV once the daemon reaches
// startCluster. Both stay nil/empty through startLocal — the DDIL
// boundary there (daemon.go's assertNoClusterServicesInitialised) means
// startLocal must never touch JetStream KV, so the allocator constructed
// there is local-cache-only until BindKV is called.
jsManager *JetStreamManager
node string
}
// NewMgmtIPAllocator creates an allocator for the /24 subnet of bridgeIP.
// Allocates from .10 to .249 (240 addresses).
// Allocates from .10 to .249 (240 addresses). The allocator is KV-less until
// BindKV is called; see the DDIL boundary note above.
func NewMgmtIPAllocator(bridgeIP string) (*MgmtIPAllocator, error) {
ip := net.ParseIP(bridgeIP)
if ip == nil {
@@ -40,70 +120,205 @@ func NewMgmtIPAllocator(bridgeIP string) (*MgmtIPAllocator, error) {
return &MgmtIPAllocator{
allocated: make(map[string]string),
baseIP: base,
subnet: fmt.Sprintf("%d.%d.%d.0/24", base[0], base[1], base[2]),
rangeMin: 10,
rangeMax: 249,
}, nil
}
// Allocate assigns a management IP to the given instance.
// Returns the allocated IP string (e.g. "10.15.8.10").
func (a *MgmtIPAllocator) Allocate(instanceID string) (string, error) {
// BindKV attaches the cluster-wide KV backing store. Called once from
// daemon.startCluster after JetStream is up — never from startLocal (DDIL
// §1e-audit). node identifies this node's entries in the shared record for
// audit; it plays no part in allocation, which considers every entry in the
// record regardless of which node owns it.
func (a *MgmtIPAllocator) BindKV(jsManager *JetStreamManager, node string) {
a.mu.Lock()
defer a.mu.Unlock()
a.jsManager = jsManager
a.node = node
}
// Check if already allocated
// Allocate assigns a management IP to the given instance.
// Returns the allocated IP string (e.g. "10.15.8.10").
//
// A known instance ID is served from the local cache without touching KV, so
// a node that already owns an address can always answer for it. A brand new
// allocation requires a healthy cluster KV — this node's cache cannot prove
// an address is free cluster-wide on its own — and is refused otherwise.
func (a *MgmtIPAllocator) Allocate(instanceID string) (string, error) {
a.mu.Lock()
if ip, ok := a.allocated[instanceID]; ok {
a.mu.Unlock()
return ip, nil
}
jsManager, node, subnet := a.jsManager, a.node, a.subnet
a.mu.Unlock()
// Build set of used IPs for fast lookup
used := make(map[string]struct{}, len(a.allocated))
for _, ip := range a.allocated {
used[ip] = struct{}{}
if jsManager == nil || !jsManager.KVHealthy() {
return "", fmt.Errorf("mgmt IP allocation unavailable: cluster state KV unreachable for subnet %s", subnet)
}
// Find first free IP in range
for i := a.rangeMin; i <= a.rangeMax; i++ {
candidate := fmt.Sprintf("%d.%d.%d.%d", a.baseIP[0], a.baseIP[1], a.baseIP[2], i)
// mutate has no error return (casUpdate's contract), so exhaustion and
// idempotent hits are threaded back out through these closed-over
// locals. On either path mutate leaves the record unchanged, so the
// CAS write that follows is a harmless no-op against the same revision.
var allocatedIP string
var mutateErr error
_, err := updateMgmtIPAMWithRetry(jsManager, subnet, func(r *MgmtIPRecord) {
if r.Subnet == "" {
r.Subnet = subnet
}
// Idempotent: an instance that already holds an address (allocated
// by this node earlier, or by another node before a restart lost
// the local cache) keeps it rather than taking a second one.
for _, e := range r.Allocated {
if e.InstanceID == instanceID {
allocatedIP = e.IP
return
}
}
ip, findErr := nextFreeIP(a.baseIP, a.rangeMin, a.rangeMax, r.Allocated)
if findErr != nil {
mutateErr = findErr
return
}
r.Allocated = append(r.Allocated, MgmtIPEntry{IP: ip, InstanceID: instanceID, Node: node})
allocatedIP = ip
}, true)
if err != nil {
return "", fmt.Errorf("mgmt IP CAS update for subnet %s: %w", subnet, err)
}
if mutateErr != nil {
return "", mutateErr
}
a.mu.Lock()
a.allocated[instanceID] = allocatedIP
a.mu.Unlock()
slog.Debug("Management IP allocated", "instance", instanceID, "ip", allocatedIP, "node", node)
return allocatedIP, nil
}
// nextFreeIP scans rangeMin-rangeMax on the /24 rooted at baseIP, skipping
// every IP already present in allocated regardless of which entry's Node it
// belongs to — that is the whole fix for mulga-f3j2x: the range is shared
// across the cluster, not partitioned per host.
func nextFreeIP(baseIP net.IP, rangeMin, rangeMax byte, allocated []MgmtIPEntry) (string, error) {
used := make(map[string]struct{}, len(allocated))
for _, e := range allocated {
used[e.IP] = struct{}{}
}
for i := rangeMin; i <= rangeMax; i++ {
candidate := fmt.Sprintf("%d.%d.%d.%d", baseIP[0], baseIP[1], baseIP[2], i)
if _, taken := used[candidate]; !taken {
a.allocated[instanceID] = candidate
slog.Debug("Management IP allocated", "instance", instanceID, "ip", candidate)
return candidate, nil
}
}
return "", fmt.Errorf("management IP range exhausted (%d.%d.%d.%d–%d.%d.%d.%d)",
a.baseIP[0], a.baseIP[1], a.baseIP[2], a.rangeMin,
a.baseIP[0], a.baseIP[1], a.baseIP[2], a.rangeMax)
return "", fmt.Errorf("management IP range exhausted across the cluster (%d.%d.%d.%d-%d.%d.%d.%d, %d addresses)",
baseIP[0], baseIP[1], baseIP[2], rangeMin,
baseIP[0], baseIP[1], baseIP[2], rangeMax,
int(rangeMax-rangeMin)+1)
}
// Release frees the management IP for the given instance.
// Release frees the management IP for the given instance, cluster-wide.
// Best-effort against KV: the local cache is always updated so this node
// never reoffers the address, but a KV write failure is logged rather than
// returned — callers (LaunchSystemInstance rollback, CleanupMgmtNetwork)
// cannot act on a Release error, matching the pre-existing signature.
func (a *MgmtIPAllocator) Release(instanceID string) {
a.mu.Lock()
defer a.mu.Unlock()
ip, hadLocal := a.allocated[instanceID]
delete(a.allocated, instanceID)
jsManager, subnet := a.jsManager, a.subnet
a.mu.Unlock()
if ip, ok := a.allocated[instanceID]; ok {
delete(a.allocated, instanceID)
slog.Debug("Management IP released", "instance", instanceID, "ip", ip)
if hadLocal {
slog.Debug("Management IP released locally", "instance", instanceID, "ip", ip)
}
if jsManager == nil {
return
}
// createIfAbsent=false: releasing an instance this node never allocated
// (or that no node has a record for) must not conjure an empty record
// into existence — jetstream.ErrKeyNotFound just means there was nothing to
// release.
_, err := updateMgmtIPAMWithRetry(jsManager, subnet, func(r *MgmtIPRecord) {
for i, e := range r.Allocated {
if e.InstanceID == instanceID {
r.Allocated = append(r.Allocated[:i], r.Allocated[i+1:]...)
return
}
}
}, false)
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
slog.Warn("Failed to release mgmt IP in cluster KV; address remains reserved until reconciled",
"instance", instanceID, "err", err)
}
}
// Rebuild reconstructs the allocated set from existing VMs.
// Called on daemon startup after loading VMs from KV store.
// Rebuild reconciles the allocator against vms (this node's own VM
// snapshot). It always refreshes the local write-through cache first, then
// — once KV is bound (see BindKV) and healthy — CAS-inserts an entry for
// every local VM carrying a MgmtIP, idempotent by instance ID. This never
// removes another node's entries: reclaiming addresses of a dead node is a
// separate concern (tracked apart from mulga-f3j2x).
//
// Called on daemon startup (startLocal, cache-only since KV isn't bound yet)
// and again after cluster bootstrap (startCluster, once BindKV has run) so
// already-allocated IPs are never handed out a second time.
func (a *MgmtIPAllocator) Rebuild(vms map[string]*vm.VM) {
a.mu.Lock()
defer a.mu.Unlock()
for id, v := range vms {
if v.MgmtIP != "" {
a.allocated[id] = v.MgmtIP
}
}
count := len(a.allocated)
jsManager, node, subnet := a.jsManager, a.node, a.subnet
a.mu.Unlock()
slog.Info("Management IP allocator rebuilt", "count", len(a.allocated))
slog.Info("Management IP allocator rebuilt", "count", count)
if jsManager == nil {
// startLocal: no cluster KV yet, cache-only rebuild.
return
}
if !jsManager.KVHealthy() {
slog.Warn("Skipping mgmt IP KV reconcile: cluster state KV unreachable", "subnet", subnet)
return
}
for id, v := range vms {
if v.MgmtIP == "" {
continue
}
mgmtIP := v.MgmtIP
_, err := updateMgmtIPAMWithRetry(jsManager, subnet, func(r *MgmtIPRecord) {
if r.Subnet == "" {
r.Subnet = subnet
}
for _, e := range r.Allocated {
if e.InstanceID == id {
return // already reconciled
}
}
r.Allocated = append(r.Allocated, MgmtIPEntry{IP: mgmtIP, InstanceID: id, Node: node})
}, true)
if err != nil {
slog.Warn("Failed to reconcile mgmt IP into cluster KV", "instance", id, "ip", mgmtIP, "err", err)
}
}
}
// AllocatedCount returns the number of currently allocated IPs.
// AllocatedCount returns the number of currently allocated IPs known to this
// node's local cache.
func (a *MgmtIPAllocator) AllocatedCount() int {
a.mu.Lock()
defer a.mu.Unlock()
+406 -15
View File
@@ -1,13 +1,48 @@
package daemon
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/nats-io/nats.go"
)
// newTestMgmtJSM returns a JetStreamManager backed by the shared per-package
// JetStream test server, with the cluster-state bucket initialised — the
// same bucket UpdateMgmtIPAM writes into (namespaced under "mgmt-ipam.").
func newTestMgmtJSM(t *testing.T) *JetStreamManager {
t.Helper()
nc, err := nats.Connect(sharedJSNATSURL)
if err != nil {
t.Fatalf("connect NATS: %v", err)
}
t.Cleanup(nc.Close)
jsm, err := NewJetStreamManager(nc, 1)
if err != nil {
t.Fatalf("new JetStreamManager: %v", err)
}
if err := jsm.InitClusterStateBucket(); err != nil {
t.Fatalf("init cluster state bucket: %v", err)
}
return jsm
}
// cleanupMgmtIPAM deletes the mgmt-ipam record for a's subnet at test end.
// The cluster-state bucket is shared by the whole package's test binary, so
// without this a record left behind by one test (or one -count repetition)
// would leak into the next test/run that reuses the same bridge subnet.
func cleanupMgmtIPAM(t *testing.T, jsm *JetStreamManager, a *MgmtIPAllocator) {
t.Helper()
t.Cleanup(func() {
_ = jsm.clusterKV.Delete(context.Background(), mgmtIPAMKeyPrefix+a.subnet)
})
}
func TestNewMgmtIPAllocator(t *testing.T) {
tests := []struct {
name string
@@ -40,6 +75,10 @@ func TestNewMgmtIPAllocator(t *testing.T) {
}
}
// TestMgmtIPAllocator_Allocate exercises the same scan-from-.10 contract as
// before the KV rewrite, but now through a bound cluster KV — Allocate
// refuses new addresses without one (see fail-closed tests below), so every
// allocator under test here binds one first.
func TestMgmtIPAllocator_Allocate(t *testing.T) {
tests := []struct {
name string
@@ -52,10 +91,13 @@ func TestMgmtIPAllocator_Allocate(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jsm := newTestMgmtJSM(t)
a, err := NewMgmtIPAllocator(tt.bridgeIP)
if err != nil {
t.Fatal(err)
}
a.BindKV(jsm, "node-a")
cleanupMgmtIPAM(t, jsm, a)
ip, err := a.Allocate("i-first")
if err != nil {
@@ -90,10 +132,13 @@ func TestMgmtIPAllocator_Allocate(t *testing.T) {
}
func TestMgmtIPAllocator_Release(t *testing.T) {
a, err := NewMgmtIPAllocator("10.15.8.1")
jsm := newTestMgmtJSM(t)
a, err := NewMgmtIPAllocator("10.16.8.1")
if err != nil {
t.Fatal(err)
}
a.BindKV(jsm, "node-a")
cleanupMgmtIPAM(t, jsm, a)
a.Allocate("i-one")
a.Allocate("i-two")
@@ -108,11 +153,14 @@ func TestMgmtIPAllocator_Release(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if ip != "10.15.8.10" {
t.Errorf("reused IP = %q, want 10.15.8.10", ip)
if ip != "10.16.8.10" {
t.Errorf("reused IP = %q, want 10.16.8.10", ip)
}
}
// TestMgmtIPAllocator_ReleaseNonexistent asserts Release never panics or
// blocks on an instance ID with no allocation — including with no KV bound
// at all, the shape startLocal constructs.
func TestMgmtIPAllocator_ReleaseNonexistent(t *testing.T) {
a, err := NewMgmtIPAllocator("10.15.8.1")
if err != nil {
@@ -123,12 +171,15 @@ func TestMgmtIPAllocator_ReleaseNonexistent(t *testing.T) {
}
func TestMgmtIPAllocator_Exhaustion(t *testing.T) {
a, err := NewMgmtIPAllocator("10.15.8.1")
jsm := newTestMgmtJSM(t)
a, err := NewMgmtIPAllocator("10.17.8.1")
if err != nil {
t.Fatal(err)
}
a.BindKV(jsm, "node-a")
cleanupMgmtIPAM(t, jsm, a)
// Fill all 240 slots (.10–.249)
// Fill all 240 slots (.10-.249)
for i := range 240 {
_, err := a.Allocate(fmt.Sprintf("i-%d", i))
if err != nil {
@@ -136,11 +187,14 @@ func TestMgmtIPAllocator_Exhaustion(t *testing.T) {
}
}
// Next should fail
// Next should fail, naming the cluster rather than a host-local limit.
_, err = a.Allocate("i-overflow")
if err == nil {
t.Fatal("expected exhaustion error")
}
if !strings.Contains(err.Error(), "exhausted across the cluster") {
t.Errorf("exhaustion error = %q, want it to name the cluster", err.Error())
}
// Release one, then it should work
a.Release("i-0")
@@ -148,22 +202,28 @@ func TestMgmtIPAllocator_Exhaustion(t *testing.T) {
if err != nil {
t.Fatalf("after release: %v", err)
}
if ip != "10.15.8.10" {
t.Errorf("reused IP = %q, want 10.15.8.10", ip)
if ip != "10.17.8.10" {
t.Errorf("reused IP = %q, want 10.17.8.10", ip)
}
}
// TestMgmtIPAllocator_Rebuild binds KV before Rebuild so it exercises the
// reconcile path (CAS-inserting local VMs into the shared record), not just
// the local-cache refresh that runs unbound in startLocal.
func TestMgmtIPAllocator_Rebuild(t *testing.T) {
a, err := NewMgmtIPAllocator("10.15.8.1")
jsm := newTestMgmtJSM(t)
a, err := NewMgmtIPAllocator("10.18.8.1")
if err != nil {
t.Fatal(err)
}
a.BindKV(jsm, "node-a")
cleanupMgmtIPAM(t, jsm, a)
vms := map[string]*vm.VM{
"i-a": {MgmtIP: "10.15.8.10"},
"i-b": {MgmtIP: "10.15.8.15"},
"i-a": {MgmtIP: "10.18.8.10"},
"i-b": {MgmtIP: "10.18.8.15"},
"i-c": {MgmtIP: ""}, // no mgmt NIC
"i-d": {MgmtIP: "10.15.8.20"},
"i-d": {MgmtIP: "10.18.8.20"},
}
a.Rebuild(vms)
@@ -177,16 +237,46 @@ func TestMgmtIPAllocator_Rebuild(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if ip != "10.15.8.11" {
t.Errorf("next IP after rebuild = %q, want 10.15.8.11", ip)
if ip != "10.18.8.11" {
t.Errorf("next IP after rebuild = %q, want 10.18.8.11", ip)
}
}
// TestMgmtIPAllocator_Rebuild_UnboundIsCacheOnly is the startLocal shape:
// Rebuild with no KV bound must only refresh the local cache and must never
// panic or block waiting on a cluster that doesn't exist yet.
func TestMgmtIPAllocator_Rebuild_UnboundIsCacheOnly(t *testing.T) {
a, err := NewMgmtIPAllocator("10.19.8.1")
if err != nil {
t.Fatal(err)
}
vms := map[string]*vm.VM{
"i-a": {MgmtIP: "10.19.8.10"},
}
a.Rebuild(vms)
if a.AllocatedCount() != 1 {
t.Errorf("count after unbound rebuild = %d, want 1", a.AllocatedCount())
}
// A cached ID still resolves without KV.
ip, err := a.Allocate("i-a")
if err != nil {
t.Fatalf("cached instance should resolve without KV: %v", err)
}
if ip != "10.19.8.10" {
t.Errorf("cached IP = %q, want 10.19.8.10", ip)
}
}
func TestMgmtIPAllocator_Concurrent(t *testing.T) {
a, err := NewMgmtIPAllocator("10.15.8.1")
jsm := newTestMgmtJSM(t)
a, err := NewMgmtIPAllocator("10.20.8.1")
if err != nil {
t.Fatal(err)
}
a.BindKV(jsm, "node-a")
cleanupMgmtIPAM(t, jsm, a)
var wg sync.WaitGroup
results := make(map[string]string)
@@ -226,3 +316,304 @@ func TestMgmtIPAllocator_Concurrent(t *testing.T) {
seen[ip] = true
}
}
// --- Cluster-safety coverage (mulga-f3j2x) ---
// TestMgmtIPAllocator_SharedKV_NoDuplicates is the core regression test:
// two allocators standing in for two nodes, sharing one KV, must never both
// hand out the same address even though each scans from .10 independently.
func TestMgmtIPAllocator_SharedKV_NoDuplicates(t *testing.T) {
jsm := newTestMgmtJSM(t)
node1, err := NewMgmtIPAllocator("10.21.8.1")
if err != nil {
t.Fatal(err)
}
node1.BindKV(jsm, "node-1")
cleanupMgmtIPAM(t, jsm, node1)
node2, err := NewMgmtIPAllocator("10.21.8.2")
if err != nil {
t.Fatal(err)
}
node2.BindKV(jsm, "node-2")
seen := make(map[string]string) // ip -> owning instance
allocate := func(a *MgmtIPAllocator, id string) {
ip, err := a.Allocate(id)
if err != nil {
t.Fatalf("allocate %s: %v", id, err)
}
if owner, dup := seen[ip]; dup {
t.Fatalf("duplicate address %s: held by %s and %s", ip, owner, id)
}
seen[ip] = id
}
// Alternate allocation across the two "nodes" — each locally believes it
// is scanning a fresh range from .10, but the shared KV record must
// still serialise them onto distinct addresses.
for i := range 10 {
allocate(node1, fmt.Sprintf("node1-i-%d", i))
allocate(node2, fmt.Sprintf("node2-i-%d", i))
}
if len(seen) != 20 {
t.Errorf("got %d unique addresses, want 20", len(seen))
}
}
// TestMgmtIPAllocator_ConcurrentAcrossNodes fires concurrent allocations
// from three simulated nodes against the same KV record, forcing genuine
// CAS conflicts (not just the happy path) as their Get/mutate/Update
// windows overlap. Every address handed out across all three must be
// unique — this is the exact shape of CI run 29912697556, where independent
// per-node allocation raced onto the same br-mgmt address.
func TestMgmtIPAllocator_ConcurrentAcrossNodes(t *testing.T) {
jsm := newTestMgmtJSM(t)
const nodeCount = 3
const perNode = 20
nodes := make([]*MgmtIPAllocator, nodeCount)
for n := range nodeCount {
a, err := NewMgmtIPAllocator("10.22.8.1")
if err != nil {
t.Fatal(err)
}
a.BindKV(jsm, fmt.Sprintf("node-%d", n))
if n == 0 {
cleanupMgmtIPAM(t, jsm, a)
}
nodes[n] = a
}
type result struct {
id string
ip string
err error
}
results := make(chan result, nodeCount*perNode)
var wg sync.WaitGroup
for n := range nodeCount {
for i := range perNode {
wg.Add(1)
go func(n, i int) {
defer wg.Done()
id := fmt.Sprintf("node%d-i-%d", n, i)
ip, err := nodes[n].Allocate(id)
results <- result{id: id, ip: ip, err: err}
}(n, i)
}
}
wg.Wait()
close(results)
seen := make(map[string]string)
for r := range results {
if r.err != nil {
t.Fatalf("allocate %s: %v", r.id, r.err)
}
if owner, dup := seen[r.ip]; dup {
t.Fatalf("duplicate address %s: held by %s and %s", r.ip, owner, r.id)
}
seen[r.ip] = r.id
}
if len(seen) != nodeCount*perNode {
t.Errorf("got %d unique addresses, want %d", len(seen), nodeCount*perNode)
}
}
// TestMgmtIPAllocator_Allocate_IdempotentAcrossNodes proves idempotency is a
// property of the shared KV record, not of one allocator's local cache: a
// second allocator (standing in for the instance's owning node restarting
// inside a partition and losing its cache) re-requesting a known instance ID
// gets back the same address rather than a new one.
func TestMgmtIPAllocator_Allocate_IdempotentAcrossNodes(t *testing.T) {
jsm := newTestMgmtJSM(t)
node1, err := NewMgmtIPAllocator("10.23.8.1")
if err != nil {
t.Fatal(err)
}
node1.BindKV(jsm, "node-1")
cleanupMgmtIPAM(t, jsm, node1)
ip, err := node1.Allocate("i-reattach")
if err != nil {
t.Fatal(err)
}
// A second allocator, simulating a fresh process on the same node after
// a restart with an empty local cache.
node1Restarted, err := NewMgmtIPAllocator("10.23.8.1")
if err != nil {
t.Fatal(err)
}
node1Restarted.BindKV(jsm, "node-1")
ip2, err := node1Restarted.Allocate("i-reattach")
if err != nil {
t.Fatal(err)
}
if ip2 != ip {
t.Errorf("re-allocate from fresh allocator = %q, want %q (idempotent by instance ID)", ip2, ip)
}
}
// TestMgmtIPAllocator_Release_FreesAddressClusterWide proves Release's
// effect is visible cluster-wide: a second allocator, not the one that
// released the address, can immediately take it.
func TestMgmtIPAllocator_Release_FreesAddressClusterWide(t *testing.T) {
jsm := newTestMgmtJSM(t)
node1, err := NewMgmtIPAllocator("10.24.8.1")
if err != nil {
t.Fatal(err)
}
node1.BindKV(jsm, "node-1")
cleanupMgmtIPAM(t, jsm, node1)
node2, err := NewMgmtIPAllocator("10.24.8.2")
if err != nil {
t.Fatal(err)
}
node2.BindKV(jsm, "node-2")
ip, err := node1.Allocate("i-node1-only")
if err != nil {
t.Fatal(err)
}
node1.Release("i-node1-only")
// node2 must now be able to take the address node1 just released.
ip2, err := node2.Allocate("i-node2-new")
if err != nil {
t.Fatal(err)
}
if ip2 != ip {
t.Errorf("node2 got %q, want the released address %q", ip2, ip)
}
}
// TestMgmtIPAllocator_Exhaustion_ClusterWide proves the exhaustion check is
// against the cluster-wide record, not this allocator's own local cache:
// two allocators together hold all 240 addresses (120 each, so neither
// locally believes the range is full) and a third allocation from either
// must still fail.
func TestMgmtIPAllocator_Exhaustion_ClusterWide(t *testing.T) {
jsm := newTestMgmtJSM(t)
node1, err := NewMgmtIPAllocator("10.25.8.1")
if err != nil {
t.Fatal(err)
}
node1.BindKV(jsm, "node-1")
cleanupMgmtIPAM(t, jsm, node1)
node2, err := NewMgmtIPAllocator("10.25.8.2")
if err != nil {
t.Fatal(err)
}
node2.BindKV(jsm, "node-2")
for i := range 120 {
if _, err := node1.Allocate(fmt.Sprintf("node1-i-%d", i)); err != nil {
t.Fatalf("node1 allocation %d failed: %v", i, err)
}
}
for i := range 120 {
if _, err := node2.Allocate(fmt.Sprintf("node2-i-%d", i)); err != nil {
t.Fatalf("node2 allocation %d failed: %v", i, err)
}
}
// Each allocator's local cache only knows about its own 120 — but the
// cluster-wide record holds all 240, so the next allocation from either
// must fail.
if node1.AllocatedCount() != 120 {
t.Fatalf("node1 local cache = %d, want 120 (proves the check isn't local)", node1.AllocatedCount())
}
if _, err := node1.Allocate("i-overflow-from-node1"); err == nil {
t.Fatal("expected exhaustion error from node1")
}
if _, err := node2.Allocate("i-overflow-from-node2"); err == nil {
t.Fatal("expected exhaustion error from node2")
}
}
// TestMgmtIPAllocator_Allocate_RefusedWhenKVUnhealthy is the fail-closed
// contract: once KV is unreachable, a brand new allocation is refused, but
// an instance ID already resolved (and cached) before the outage keeps
// resolving from the local cache without touching KV at all.
func TestMgmtIPAllocator_Allocate_RefusedWhenKVUnhealthy(t *testing.T) {
nc, err := nats.Connect(sharedJSNATSURL)
if err != nil {
t.Fatal(err)
}
jsm, err := NewJetStreamManager(nc, 1)
if err != nil {
t.Fatal(err)
}
if err := jsm.InitClusterStateBucket(); err != nil {
t.Fatal(err)
}
a, err := NewMgmtIPAllocator("10.26.8.1")
if err != nil {
t.Fatal(err)
}
a.BindKV(jsm, "node-1")
ip, err := a.Allocate("i-known")
if err != nil {
t.Fatal(err)
}
// Sever the connection backing this JetStreamManager: KVHealthy's
// AccountInfo round-trip will now fail, simulating a partition.
nc.Close()
if jsm.KVHealthy() {
t.Fatal("expected KVHealthy to report false after closing the connection")
}
// A known instance ID still resolves — served entirely from the local
// cache, no KV round-trip required.
cachedIP, err := a.Allocate("i-known")
if err != nil {
t.Fatalf("cached instance should resolve without KV: %v", err)
}
if cachedIP != ip {
t.Errorf("cached IP = %q, want %q", cachedIP, ip)
}
// A brand new allocation must be refused rather than risk a duplicate
// against a cluster-wide record this node can no longer trust.
if _, err := a.Allocate("i-new-during-partition"); err == nil {
t.Fatal("expected allocation to be refused while KV is unhealthy")
}
}
// TestMgmtIPAllocator_Allocate_RefusedWhenKVNeverBound is the startLocal
// shape: an allocator with no KV bound at all (as constructed before
// startCluster runs) must refuse new allocations exactly like an unhealthy
// bound KV, while still serving whatever Rebuild already cached.
func TestMgmtIPAllocator_Allocate_RefusedWhenKVNeverBound(t *testing.T) {
a, err := NewMgmtIPAllocator("10.27.8.1")
if err != nil {
t.Fatal(err)
}
a.Rebuild(map[string]*vm.VM{"i-known": {MgmtIP: "10.27.8.10"}})
ip, err := a.Allocate("i-known")
if err != nil {
t.Fatalf("cached instance should resolve without KV: %v", err)
}
if ip != "10.27.8.10" {
t.Errorf("cached IP = %q, want 10.27.8.10", ip)
}
if _, err := a.Allocate("i-brand-new"); err == nil {
t.Fatal("expected allocation to be refused with no KV bound")
}
}
+24
View File
@@ -418,6 +418,11 @@ func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput)
// right NIC for the Ec2 datasource on its own. The instance is owned by
// input.AccountID (the account its pre-created ENI lives in) and tagged
// input.ManagedBy so customer listings hide it.
//
// Sharing the Prepare/Launch pair with RunInstances means a GPU instance type
// gets the same admission, GPU claim and teardown-time release as a customer
// VM: PrepareRunInstances gates on a free GPU slot, LaunchRunInstances claims
// the device, and vm.Manager's cleanup chain releases it.
func (d *Daemon) launchAMISystemInstance(input *sysinstance.SystemInstanceInput) (*sysinstance.SystemInstanceOutput, error) {
if d.instanceService == nil {
return nil, errors.New("sysinstance: instance service not initialized")
@@ -499,6 +504,10 @@ func (d *Daemon) launchAMISystemInstance(input *sysinstance.SystemInstanceInput)
d.instanceService.LaunchRunInstances(context.Background(), instances, runInput, instanceType)
if err := d.verifySystemInstanceLaunched(inst); err != nil {
return nil, err
}
privateIP := input.ENIIP
if privateIP == "" && inst.Instance != nil {
privateIP = aws.StringValue(inst.Instance.PrivateIpAddress)
@@ -517,6 +526,21 @@ func (d *Daemon) launchAMISystemInstance(input *sysinstance.SystemInstanceInput)
}, nil
}
// verifySystemInstanceLaunched checks that inst actually reached running state
// after LaunchRunInstances. That call is a best-effort batch launcher: volume
// preparation, GPU claim and vmMgr.Run failures each mark the instance failed
// and return nothing, so the outcome is only visible in the VM's state. Without
// this check a system-instance caller (EKS control plane, ELB, Bedrock serving
// VM) receives an instance ID for a VM already being torn down and waits on an
// endpoint that will never answer. Cleanup is left to the MarkFailed chain
// LaunchRunInstances already ran.
func (d *Daemon) verifySystemInstanceLaunched(inst *vm.VM) error {
if status := d.vmMgr.Status(inst); status != vm.StateRunning {
return fmt.Errorf("sysinstance: instance %s did not reach running state (status %q)", inst.ID, status)
}
return nil
}
// attachSystemMgmtNIC allocates a management-bridge IP + MAC for a system VM and
// creates the host-side tap on br-mgmt. The mgmt NIC is mandatory for system
// instances that boot from an AMI: their VPC ENI has no route off the tenant
+55 -9
View File
@@ -1,6 +1,7 @@
package daemon
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -20,6 +21,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -84,6 +86,50 @@ func TestWaitForSystemInstance_Timeout(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// verifySystemInstanceLaunched
// ---------------------------------------------------------------------------
func TestVerifySystemInstanceLaunched_Running(t *testing.T) {
inst := &vm.VM{ID: "i-up", Status: vm.StateRunning}
d := newDaemonWithVMs(inst)
require.NoError(t, d.verifySystemInstanceLaunched(inst))
}
// LaunchRunInstances swallows launch errors and leaves the instance in a
// cleanup state, so every non-running state must surface as a launch failure —
// a GPU claim failure marks the VM shutting-down via MarkFailed.
func TestVerifySystemInstanceLaunched_NonRunningStatesFail(t *testing.T) {
for _, status := range []vm.InstanceState{
vm.StateShuttingDown,
vm.StateError,
vm.StateTerminated,
vm.StatePending,
vm.StateProvisioning,
} {
t.Run(string(status), func(t *testing.T) {
inst := &vm.VM{ID: "i-down", Status: status}
d := newDaemonWithVMs(inst)
err := d.verifySystemInstanceLaunched(inst)
require.Error(t, err)
assert.Contains(t, err.Error(), "i-down")
assert.Contains(t, err.Error(), string(status))
})
}
}
// MarkFailed transitions synchronously before running its cleanup goroutine, so
// the check sees the failure without racing the teardown.
func TestVerifySystemInstanceLaunched_AfterMarkFailedIsAnError(t *testing.T) {
inst := &vm.VM{ID: "i-gpu-claim-failed", Status: vm.StateProvisioning}
d := newDaemonWithVMs(inst)
d.vmMgr.MarkFailed(context.Background(), inst, "gpu_claim_failed")
require.Error(t, d.verifySystemInstanceLaunched(inst))
}
// ---------------------------------------------------------------------------
// buildNetcfgBlob
// ---------------------------------------------------------------------------
@@ -592,9 +638,9 @@ func TestRefreshSystemInstanceState_RewritesBlobs(t *testing.T) {
d.elbv2Service.GatewayURL = "https://10.0.0.1:9999"
d.elbv2Service.CACert = "-----BEGIN CERTIFICATE-----\nfake-ca\n-----END CERTIFICATE-----\n"
seedStore, err := handlers_elbv2.NewStore(nc)
seedStore, err := handlers_elbv2.NewStore(t.Context(), nc)
require.NoError(t, err)
require.NoError(t, seedStore.PutLoadBalancer(&handlers_elbv2.LoadBalancerRecord{
require.NoError(t, seedStore.PutLoadBalancer(t.Context(), &handlers_elbv2.LoadBalancerRecord{
LoadBalancerID: "lb-recover",
Name: "recover-alb",
Scheme: handlers_elbv2.SchemeInternal,
@@ -638,9 +684,9 @@ func TestRefreshSystemInstanceState_RewritesBlobs(t *testing.T) {
func TestRefreshSystemInstanceState_WriteErrorPropagates(t *testing.T) {
d, nc, tmpDir := newRefreshTestDaemon(t)
seedStore, err := handlers_elbv2.NewStore(nc)
seedStore, err := handlers_elbv2.NewStore(t.Context(), nc)
require.NoError(t, err)
require.NoError(t, seedStore.PutLoadBalancer(&handlers_elbv2.LoadBalancerRecord{
require.NoError(t, seedStore.PutLoadBalancer(t.Context(), &handlers_elbv2.LoadBalancerRecord{
LoadBalancerID: "lb-werr",
Name: "werr-alb",
Scheme: handlers_elbv2.SchemeInternal,
@@ -702,9 +748,9 @@ func TestLaunchSystemInstance_NATFailureRollsBackPublicIP(t *testing.T) {
require.NoError(t, err)
t.Cleanup(func() { jsNC.Close() })
js, err := jsNC.JetStream()
js, err := jetstream.New(jsNC)
require.NoError(t, err)
ipam, err := handlers_ec2_vpc.NewExternalIPAM(js, []external.ExternalPoolConfig{
ipam, err := handlers_ec2_vpc.NewExternalIPAM(t.Context(), js, []external.ExternalPoolConfig{
{Name: "wan-test", RangeStart: "203.0.113.10", RangeEnd: "203.0.113.20", Gateway: "203.0.113.1", PrefixLen: 24},
})
require.NoError(t, err)
@@ -824,15 +870,15 @@ func TestReleaseSystemInstanceEIP_ReleasesEipServiceAllocation(t *testing.T) {
require.NoError(t, err)
t.Cleanup(func() { jsNC.Close() })
js, err := jsNC.JetStream()
js, err := jetstream.New(jsNC)
require.NoError(t, err)
ipam, err := handlers_ec2_vpc.NewExternalIPAM(js, []external.ExternalPoolConfig{
ipam, err := handlers_ec2_vpc.NewExternalIPAM(t.Context(), js, []external.ExternalPoolConfig{
{Name: "wan-test", RangeStart: "203.0.113.10", RangeEnd: "203.0.113.20", Gateway: "203.0.113.1", PrefixLen: 24},
})
require.NoError(t, err)
d.externalIPAM = ipam
eipSvc, err := handlers_ec2_eip.NewEIPServiceImpl(jsNC, ipam, d.vpcService)
eipSvc, err := handlers_ec2_eip.NewEIPServiceImpl(t.Context(), jsNC, ipam, d.vpcService)
require.NoError(t, err)
d.eipService = eipSvc
+169 -226
View File
@@ -2,6 +2,7 @@ package daemon
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
@@ -51,7 +52,9 @@ import (
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/mulgadc/viperblock/viperblock"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -384,7 +387,7 @@ func TestHandleEC2DescribeInstanceTypes(t *testing.T) {
daemon := createTestDaemon(t, natsURL)
// Subscribe to DescribeInstanceTypes (no queue group for fan-out)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceTypes", daemon.handleEC2DescribeInstanceTypes)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstanceTypes", handleNATSRequest(daemon.instanceService.DescribeInstanceTypes))
require.NoError(t, err, "Failed to subscribe to ec2.DescribeInstanceTypes")
defer sub.Unsubscribe()
@@ -1018,197 +1021,6 @@ func TestAllocate_NoOvercommitUnderContention(t *testing.T) {
assert.InDelta(t, memGB*float64(capacityFor), finalMem, 0.001, "allocated memory must not exceed schedulable pool")
}
// TestDescribeInstances_ReservationGrouping tests that instances are grouped by reservation ID.
func TestDescribeInstances_ReservationGrouping(t *testing.T) {
natsURL := sharedNATSURL
daemon := createTestDaemon(t, natsURL)
// Create instances with shared reservation (simulating --count 3)
reservation1 := &ec2.Reservation{}
reservation1.SetReservationId("r-shared-001")
reservation1.SetOwnerId("123456789012")
// Add 3 instances with same reservation ID
for i := 1; i <= 3; i++ {
instanceID := fmt.Sprintf("i-group1-%03d", i)
ec2Instance := &ec2.Instance{}
ec2Instance.SetInstanceId(instanceID)
ec2Instance.SetInstanceType("t3.micro")
daemon.vmMgr.Insert(&vm.VM{
ID: instanceID,
Status: vm.StateRunning,
AccountID: testAccountID,
Reservation: reservation1,
Instance: ec2Instance,
})
}
// Create another reservation with 2 instances
reservation2 := &ec2.Reservation{}
reservation2.SetReservationId("r-shared-002")
reservation2.SetOwnerId("123456789012")
for i := 1; i <= 2; i++ {
instanceID := fmt.Sprintf("i-group2-%03d", i)
ec2Instance := &ec2.Instance{}
ec2Instance.SetInstanceId(instanceID)
ec2Instance.SetInstanceType("t3.small")
daemon.vmMgr.Insert(&vm.VM{
ID: instanceID,
Status: vm.StateRunning,
AccountID: testAccountID,
Reservation: reservation2,
Instance: ec2Instance,
})
}
// Create a single-instance reservation
reservation3 := &ec2.Reservation{}
reservation3.SetReservationId("r-single-003")
reservation3.SetOwnerId("123456789012")
ec2Instance := &ec2.Instance{}
ec2Instance.SetInstanceId("i-single-001")
ec2Instance.SetInstanceType("t3.large")
daemon.vmMgr.Insert(&vm.VM{
ID: "i-single-001",
Status: vm.StateStopped,
AccountID: testAccountID,
Reservation: reservation3,
Instance: ec2Instance,
})
// Subscribe to handle DescribeInstances
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances", daemon.handleEC2DescribeInstances)
require.NoError(t, err)
defer sub.Unsubscribe()
t.Run("GroupsInstancesByReservationID", func(t *testing.T) {
input := &ec2.DescribeInstancesInput{}
inputJSON, _ := json.Marshal(input)
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
require.NoError(t, err)
var output ec2.DescribeInstancesOutput
err = json.Unmarshal(resp.Data, &output)
require.NoError(t, err)
// Should have exactly 3 reservations
assert.Len(t, output.Reservations, 3, "Should have 3 reservations")
// Build a map of reservation ID -> instance count
resMap := make(map[string]int)
for _, res := range output.Reservations {
resID := *res.ReservationId
resMap[resID] = len(res.Instances)
t.Logf("Reservation %s has %d instances", resID, len(res.Instances))
}
assert.Equal(t, 3, resMap["r-shared-001"], "r-shared-001 should have 3 instances")
assert.Equal(t, 2, resMap["r-shared-002"], "r-shared-002 should have 2 instances")
assert.Equal(t, 1, resMap["r-single-003"], "r-single-003 should have 1 instance")
})
t.Run("FilterByInstanceID_PreservesReservation", func(t *testing.T) {
// Request only one instance from a multi-instance reservation
input := &ec2.DescribeInstancesInput{
InstanceIds: []*string{aws.String("i-group1-001")},
}
inputJSON, _ := json.Marshal(input)
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
require.NoError(t, err)
var output ec2.DescribeInstancesOutput
err = json.Unmarshal(resp.Data, &output)
require.NoError(t, err)
// Should have 1 reservation with 1 instance
require.Len(t, output.Reservations, 1)
assert.Equal(t, "r-shared-001", *output.Reservations[0].ReservationId)
assert.Len(t, output.Reservations[0].Instances, 1)
assert.Equal(t, "i-group1-001", *output.Reservations[0].Instances[0].InstanceId)
})
t.Run("FilterMultipleInstances_SameReservation", func(t *testing.T) {
// Request 2 instances from the same reservation
input := &ec2.DescribeInstancesInput{
InstanceIds: []*string{
aws.String("i-group1-001"),
aws.String("i-group1-003"),
},
}
inputJSON, _ := json.Marshal(input)
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
require.NoError(t, err)
var output ec2.DescribeInstancesOutput
err = json.Unmarshal(resp.Data, &output)
require.NoError(t, err)
// Should have 1 reservation with 2 instances
require.Len(t, output.Reservations, 1)
assert.Equal(t, "r-shared-001", *output.Reservations[0].ReservationId)
assert.Len(t, output.Reservations[0].Instances, 2)
})
t.Run("FilterMultipleInstances_DifferentReservations", func(t *testing.T) {
// Request instances from different reservations
input := &ec2.DescribeInstancesInput{
InstanceIds: []*string{
aws.String("i-group1-001"),
aws.String("i-group2-001"),
aws.String("i-single-001"),
},
}
inputJSON, _ := json.Marshal(input)
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
require.NoError(t, err)
var output ec2.DescribeInstancesOutput
err = json.Unmarshal(resp.Data, &output)
require.NoError(t, err)
// Should have 3 reservations, each with 1 instance
assert.Len(t, output.Reservations, 3)
for _, res := range output.Reservations {
assert.Len(t, res.Instances, 1, "Each reservation should have 1 instance when filtered")
}
})
t.Run("InstanceStates_AreCorrect", func(t *testing.T) {
input := &ec2.DescribeInstancesInput{}
inputJSON, _ := json.Marshal(input)
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
require.NoError(t, err)
var output ec2.DescribeInstancesOutput
err = json.Unmarshal(resp.Data, &output)
require.NoError(t, err)
// Find the stopped instance and verify its state
for _, res := range output.Reservations {
for _, inst := range res.Instances {
if *inst.InstanceId == "i-single-001" {
assert.Equal(t, int64(80), *inst.State.Code, "Stopped instance should have code 80")
assert.Equal(t, "stopped", *inst.State.Name)
} else {
assert.Equal(t, int64(16), *inst.State.Code, "Running instance should have code 16")
assert.Equal(t, "running", *inst.State.Name)
}
}
}
})
}
// TestRunInstances_CountValidation tests MinCount/MaxCount validation scenarios.
func TestRunInstances_CountValidation(t *testing.T) {
natsURL := sharedNATSURL
@@ -1738,6 +1550,171 @@ func TestInstanceCleanerAdapter_DeleteVolumes_DeleteOnTermination_False(t *testi
assert.False(t, ebsDeletedVolumes["vol-keep"], "Root volume with DeleteOnTermination=false should NOT be deleted")
}
// TestInstanceCleanerAdapter_DeleteVolumes_BootVolumeDeletedAfterAttachmentClear
// locks the mulga-1dzx9 fix on the running-instance terminate path: a Boot,
// DeleteOnTermination root volume left attached (terminateCleanup's
// shutdownAndUnmount runs Unmount, which deliberately never clears a Boot
// volume's AttachedInstance) previously hit DeleteVolume's in-use guard
// directly. DeleteVolumes must now call DeleteVolumeOnTerminate, which clears
// the stale attachment first, so the delete actually succeeds.
func TestInstanceCleanerAdapter_DeleteVolumes_BootVolumeDeletedAfterAttachmentClear(t *testing.T) {
daemon, store := createFullTestDaemonWithStore(t, sharedNATSURL)
// DeleteVolume's snapshot-reference guard requires a non-nil snapshotKV;
// wire a JetStream-backed one (no snapshot refs recorded) so the delete
// reaches the S3 cleanup step instead of failing closed on a nil KV.
jsConn, err := nats.Connect(sharedJSNATSURL)
require.NoError(t, err)
t.Cleanup(func() { jsConn.Close() })
js, err := jetstream.New(jsConn)
require.NoError(t, err)
snapKV, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{
Bucket: "snap-kv-" + strings.ReplaceAll(t.Name(), "/", "-"),
})
require.NoError(t, err)
daemon.volumeService = handlers_ec2_volume.NewVolumeServiceImplWithStore(daemon.config, store, daemon.natsConn, snapKV)
volumeID := "vol-root-attached"
seedVolumeConfig(t, store, volumeID, viperblock.VolumeMetadata{
VolumeID: volumeID,
TenantID: testAccountID,
SizeGiB: 10,
State: "available",
AttachedInstance: "i-test-boot-delete", // stale: never cleared by Stop/shutdownAndUnmount's Boot carve-out
})
instance := &vm.VM{
ID: "i-test-boot-delete",
AccountID: testAccountID,
EBSRequests: types.EBSRequests{
Requests: []types.EBSRequest{
{Name: volumeID, Boot: true, DeleteOnTermination: true},
},
},
}
cleaner := newInstanceCleanerAdapter(daemon)
err = cleaner.DeleteVolumes(instance)
require.NoError(t, err, "the still-attached boot volume must be deleted, not rejected as VolumeInUse")
_, err = daemon.volumeService.GetVolumeConfig(volumeID)
require.Error(t, err, "the volume must actually be deleted")
assert.Contains(t, err.Error(), awserrors.ErrorInvalidVolumeNotFound)
}
// TestInstanceCleanerAdapter_DeleteVolumes_NonDoTBootVolumeDetachedNotDeleted
// locks the running-path half of the terminate-implies-detach fix for a
// DeleteOnTermination=false root volume: shutdownAndUnmount's Unmount
// (daemon/vm_adapters.go) deliberately never clears a Boot volume's
// attachment, so without this it would strand attached to the now-terminated
// instance forever. Terminate must still detach it (AWS semantics: it
// survives as available, it just isn't deleted).
func TestInstanceCleanerAdapter_DeleteVolumes_NonDoTBootVolumeDetachedNotDeleted(t *testing.T) {
daemon, store := createFullTestDaemonWithStore(t, sharedNATSURL)
volumeID := "vol-root-nondot-attached"
seedVolumeConfig(t, store, volumeID, viperblock.VolumeMetadata{
VolumeID: volumeID,
TenantID: testAccountID,
SizeGiB: 10,
State: "available",
AttachedInstance: "i-test-boot-detach", // stale: never cleared by Unmount's Boot carve-out
})
instance := &vm.VM{
ID: "i-test-boot-detach",
AccountID: testAccountID,
EBSRequests: types.EBSRequests{
Requests: []types.EBSRequest{
{Name: volumeID, Boot: true, DeleteOnTermination: false},
},
},
}
cleaner := newInstanceCleanerAdapter(daemon)
err := cleaner.DeleteVolumes(instance)
require.NoError(t, err)
cfg, err := daemon.volumeService.GetVolumeConfig(volumeID)
require.NoError(t, err, "a DeleteOnTermination=false volume must survive terminate")
assert.Equal(t, "available", cfg.VolumeMetadata.State)
assert.Empty(t, cfg.VolumeMetadata.AttachedInstance, "terminate must still detach it")
}
// TestTerminatedTeardownReaper_SelfHealsFailedVolumeTeardown proves the
// go-forward self-heal: a stopped-terminate that stamped
// Teardown[volumes]=failed on a transient error (deleteInstanceVolumes,
// handlers/ec2/instance/service_impl.go) is retried by
// TerminatedTeardownReaper.Sweep (vm/teardown_reaper.go) through the real
// instanceCleanerAdapter, which stage 1 rerouted through
// DeleteVolumeOnTerminate. The retry clears the stale attachment, the delete
// now succeeds, and the mark flips to done — without abandoning the record.
func TestTerminatedTeardownReaper_SelfHealsFailedVolumeTeardown(t *testing.T) {
daemon, store := createFullTestDaemonWithStore(t, sharedJSNATSURL)
// A real terminated-instance KV, so Sweep can list/update/(not yet)purge
// the record exactly as production does.
jsManager, err := NewJetStreamManager(daemon.natsConn, 1)
require.NoError(t, err)
require.NoError(t, jsManager.InitTerminatedInstanceBucket())
daemon.stateStore = newStateStoreAdapter(jsManager)
// DeleteVolume's snapshot-reference guard requires a non-nil snapshotKV.
js, err := jetstream.New(daemon.natsConn)
require.NoError(t, err)
snapKV, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{
Bucket: "snap-kv-" + strings.ReplaceAll(t.Name(), "/", "-"),
})
require.NoError(t, err)
daemon.volumeService = handlers_ec2_volume.NewVolumeServiceImplWithStore(daemon.config, store, daemon.natsConn, snapKV)
volumeID := "vol-root-self-heal"
instanceID := "i-self-heal"
seedVolumeConfig(t, store, volumeID, viperblock.VolumeMetadata{
VolumeID: volumeID,
TenantID: testAccountID,
SizeGiB: 10,
State: "available",
AttachedInstance: instanceID, // stale, left by the earlier failed terminate
})
instance := &vm.VM{
ID: instanceID,
AccountID: testAccountID,
Status: vm.StateTerminated,
LastNode: daemon.node,
TerminatedAt: time.Now(), // inside the visibility window: Sweep must not purge yet
Teardown: map[string]string{
vm.TeardownVolumes: string(vm.TeardownFailed),
},
EBSRequests: types.EBSRequests{
Requests: []types.EBSRequest{
{Name: volumeID, Boot: true, DeleteOnTermination: true},
},
},
}
require.NoError(t, daemon.stateStore.WriteTerminatedInstance(instance.ID, instance))
reaper := vm.NewManagerWithDeps(vm.Deps{
NodeID: daemon.node,
StateStore: daemon.stateStore,
InstanceCleaner: newInstanceCleanerAdapter(daemon),
}).NewTerminatedTeardownReaper()
_, err = reaper.Sweep(context.Background())
require.NoError(t, err)
_, err = daemon.volumeService.GetVolumeConfig(volumeID)
require.Error(t, err, "the retried delete must actually remove the volume")
assert.Contains(t, err.Error(), awserrors.ErrorInvalidVolumeNotFound)
remaining, err := daemon.stateStore.ListTerminatedInstances()
require.NoError(t, err)
require.Len(t, remaining, 1, "still within the visibility window: not purged yet")
assert.Equal(t, string(vm.TeardownDone), remaining[0].Teardown[vm.TeardownVolumes],
"a retry that now succeeds must flip the mark from failed to done")
}
// TestHandleEC2Events_AttachVolume tests the attach-volume handler in handleEC2Events.
func TestHandleEC2Events_AttachVolume(t *testing.T) {
natsURL := sharedNATSURL
@@ -3519,40 +3496,6 @@ func TestVolumeMounterAdapter_Mount_RollbackFailurePropagates(t *testing.T) {
"underlying unmount error must be wrapped in")
}
// TestDescribeInstances_InvalidInstanceIDMalformed verifies that DescribeInstances
// returns InvalidInstanceID.Malformed when given instance IDs without the i- prefix.
func TestDescribeInstances_InvalidInstanceIDMalformed(t *testing.T) {
natsURL := sharedNATSURL
daemon := createTestDaemon(t, natsURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances", daemon.handleEC2DescribeInstances)
require.NoError(t, err)
defer sub.Unsubscribe()
t.Run("MalformedInstanceID", func(t *testing.T) {
input := &ec2.DescribeInstancesInput{
InstanceIds: []*string{aws.String("bad-instance-id")},
}
inputJSON, _ := json.Marshal(input)
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
require.NoError(t, err)
assert.Contains(t, string(resp.Data), "InvalidInstanceID.Malformed")
})
t.Run("ValidInstanceIDPassesValidation", func(t *testing.T) {
input := &ec2.DescribeInstancesInput{
InstanceIds: []*string{aws.String("i-nonexistent")},
}
inputJSON, _ := json.Marshal(input)
resp, err := natsRequest(daemon.natsConn, "ec2.DescribeInstances", inputJSON, 5*time.Second)
require.NoError(t, err)
// Should not contain a malformed error — returns empty results instead
assert.NotContains(t, string(resp.Data), "InvalidInstanceID.Malformed")
})
}
// TestStopTerminate_IncorrectInstanceState verifies that stopping an already-stopped
// instance or terminating an already-terminated instance returns IncorrectInstanceState
// instead of ServerInternal.
+5
View File
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/mulgadc/spinifex/spinifex/config"
handlers_ec2_eip "github.com/mulgadc/spinifex/spinifex/handlers/ec2/eip"
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/nats-io/nats.go"
@@ -261,6 +262,10 @@ func newSubscribeTestDaemon(t *testing.T, nc *nats.Conn, gatewayURL, accessKey s
natsSubscriptions: make(map[string]*nats.Subscription),
elbv2Service: svc,
systemAccessKey: accessKey,
// subscribeAll now binds d.eipService.Method eagerly; production always
// sets the disabled stub when EIP is unconfigured, so mirror that here to
// avoid a nil-interface method-value panic while building the table.
eipService: handlers_ec2_eip.NewDisabledEIPService(),
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ func (d *Daemon) buildECSServiceDeps() handlers_ecs.Deps {
if d.clusterConfig != nil {
clusterSize = len(d.clusterConfig.Nodes)
}
if iamSvc, iamErr := handlers_iam.NewIAMServiceImpl(d.natsConn, masterKey, clusterSize); iamErr != nil {
if iamSvc, iamErr := handlers_iam.NewIAMServiceImpl(d.ctx, d.natsConn, masterKey, clusterSize); iamErr != nil {
slog.Warn("ECS: IAM service init failed; capacity provisioning disabled", "err", iamErr)
} else {
deps.IAM = iamSvc
+1 -1
View File
@@ -37,7 +37,7 @@ func (d *Daemon) systemRoleEnsurer() handlers_iam.SystemInstanceRoleEnsurer {
if d.clusterConfig != nil {
clusterSize = len(d.clusterConfig.Nodes)
}
iamSvc, iamErr := handlers_iam.NewIAMServiceImpl(d.natsConn, masterKey, clusterSize)
iamSvc, iamErr := handlers_iam.NewIAMServiceImpl(d.ctx, d.natsConn, masterKey, clusterSize)
if iamErr != nil {
slog.Warn("System role ensurer: IAM service init failed (retried on next launch); system VMs fall back to baked static creds",
"err", iamErr)
+1 -1
View File
@@ -124,7 +124,7 @@ func TestHeartbeatKVContract(t *testing.T) {
// Verify the KV key prefix and the JSON tag names directly. Reading back
// via ReadHeartbeat alone would also pass against a typo'd tag.
entry, err := jsm.clusterKV.Get("heartbeat." + h.Node)
entry, err := jsm.clusterKV.Get(t.Context(), "heartbeat."+h.Node)
require.NoError(t, err)
var raw map[string]any
-218
View File
@@ -1,218 +0,0 @@
package daemon
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/filterutil"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInstanceMatchesFilters_NoFilters(t *testing.T) {
inst := &vm.VM{ID: "i-123", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-123"),
InstanceType: aws.String("t3.micro"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
assert.True(t, instanceMatchesFilters(inst, ic, nil))
}
func TestInstanceMatchesFilters_SingleFilter_Match(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("t3.micro"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{
"instance-state-name": {"running"},
}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_SingleFilter_NoMatch(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("t3.micro"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{
"instance-state-name": {"stopped"},
}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_MultipleValues_ORLogic(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("t3.micro"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{
"instance-state-name": {"running", "stopped"},
}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_MultipleFilters_ANDLogic(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("t3.micro"),
VpcId: aws.String("vpc-111"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
// Both must match
filters := map[string][]string{
"instance-state-name": {"running"},
"vpc-id": {"vpc-111"},
}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
// One doesn't match
filters["vpc-id"] = []string{"vpc-999"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_InstanceId(t *testing.T) {
inst := &vm.VM{ID: "i-abc"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{"instance-id": {"i-abc"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
filters["instance-id"] = []string{"i-other"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_InstanceType(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "m5.large"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("m5.large"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{"instance-type": {"m5.large"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_SubnetId(t *testing.T) {
inst := &vm.VM{ID: "i-abc"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
SubnetId: aws.String("subnet-123"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{"subnet-id": {"subnet-123"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
filters["subnet-id"] = []string{"subnet-999"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_Wildcard(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("t3.micro"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{"instance-type": {"t3.*"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
filters["instance-type"] = []string{"m5.*"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_TagFilter(t *testing.T) {
inst := &vm.VM{ID: "i-abc"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
State: &ec2.InstanceState{Name: aws.String("running")},
Tags: []*ec2.Tag{
{Key: aws.String("Environment"), Value: aws.String("prod")},
{Key: aws.String("Team"), Value: aws.String("backend")},
},
}
filters := map[string][]string{"tag:Environment": {"prod"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
filters["tag:Environment"] = []string{"staging"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_TagKey(t *testing.T) {
inst := &vm.VM{ID: "i-abc"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
State: &ec2.InstanceState{Name: aws.String("running")},
Tags: []*ec2.Tag{
{Key: aws.String("Environment"), Value: aws.String("prod")},
},
}
filters := map[string][]string{"tag-key": {"Environment"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
filters["tag-key"] = []string{"MissingKey"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_TagValue(t *testing.T) {
inst := &vm.VM{ID: "i-abc"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
State: &ec2.InstanceState{Name: aws.String("running")},
Tags: []*ec2.Tag{
{Key: aws.String("Environment"), Value: aws.String("prod")},
},
}
filters := map[string][]string{"tag-value": {"prod"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
filters["tag-value"] = []string{"staging"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_EmptyResults(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("t3.micro"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
// Filter that matches nothing
filters := map[string][]string{"instance-type": {"c5.4xlarge"}}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestParseFilters_UnknownFilterName(t *testing.T) {
filters := []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("val")}},
}
_, err := filterutil.ParseFilters(filters, describeInstancesValidFilters)
require.Error(t, err)
assert.Contains(t, err.Error(), "InvalidParameterValue")
}
func TestEC2TagsToMap(t *testing.T) {
tags := []*ec2.Tag{
{Key: aws.String("a"), Value: aws.String("1")},
{Key: aws.String("b"), Value: aws.String("2")},
}
m := filterutil.EC2TagsToMap(tags)
assert.Equal(t, "1", m["a"])
assert.Equal(t, "2", m["b"])
}
func TestEC2TagsToMap_Nil(t *testing.T) {
assert.Nil(t, filterutil.EC2TagsToMap(nil))
}
+172 -152
View File
@@ -1,6 +1,7 @@
package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -13,6 +14,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
const (
@@ -46,10 +48,10 @@ type KVSyncObserver interface {
// JetStreamManager manages JetStream KV store operations for instance state.
type JetStreamManager struct {
js nats.JetStreamContext
kv nats.KeyValue // spinifex-instance-state
clusterKV nats.KeyValue // spinifex-cluster-state
terminatedKV nats.KeyValue // spinifex-terminated-instances
js jetstream.JetStream
kv jetstream.KeyValue // spinifex-instance-state
clusterKV jetstream.KeyValue // spinifex-cluster-state
terminatedKV jetstream.KeyValue // spinifex-terminated-instances
replicas int
kvMu sync.Mutex // protects kv during recovery
obs KVSyncObserver
@@ -68,14 +70,14 @@ func (m *JetStreamManager) KVHealthy() bool {
if m.js == nil {
return false
}
_, err := m.js.AccountInfo()
_, err := m.js.AccountInfo(context.Background())
return err == nil
}
// NewJetStreamManager creates a new JetStreamManager from a NATS connection.
// replicas specifies the number of replicas for the KV bucket (typically matches cluster node count).
func NewJetStreamManager(nc *nats.Conn, replicas int) (*JetStreamManager, error) {
js, err := nc.JetStream()
js, err := jetstream.New(nc)
if err != nil {
return nil, err
}
@@ -88,13 +90,14 @@ func NewJetStreamManager(nc *nats.Conn, replicas int) (*JetStreamManager, error)
// InitKVBucket initializes the KV bucket, creating it if it doesn't exist.
func (m *JetStreamManager) InitKVBucket() error {
ctx := context.Background()
// Try to get the existing bucket first
kv, err := m.js.KeyValue(InstanceStateBucket)
kv, err := m.js.KeyValue(ctx, InstanceStateBucket)
if err != nil {
if errors.Is(err, nats.ErrBucketNotFound) {
if errors.Is(err, jetstream.ErrBucketNotFound) {
// Bucket doesn't exist, create it
slog.Debug("Creating JetStream KV bucket", "bucket", InstanceStateBucket, "replicas", m.replicas)
kv, err = m.js.CreateKeyValue(&nats.KeyValueConfig{
kv, err = m.js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: InstanceStateBucket,
Description: "Spinifex instance state storage",
History: 1, // Only keep latest value
@@ -111,7 +114,7 @@ func (m *JetStreamManager) InitKVBucket() error {
}
m.kv = kv
if err := migrate.DefaultRegistry.RunKV(InstanceStateBucket, kv, InstanceStateBucketVersion); err != nil {
if err := migrate.DefaultRegistry.RunKV(ctx, InstanceStateBucket, kv, InstanceStateBucketVersion); err != nil {
return fmt.Errorf("migrate %s: %w", InstanceStateBucket, err)
}
return nil
@@ -119,11 +122,12 @@ func (m *JetStreamManager) InitKVBucket() error {
// InitClusterStateBucket initializes the cluster-state KV bucket, creating it if it doesn't exist.
func (m *JetStreamManager) InitClusterStateBucket() error {
kv, err := m.js.KeyValue(ClusterStateBucket)
ctx := context.Background()
kv, err := m.js.KeyValue(ctx, ClusterStateBucket)
if err != nil {
if errors.Is(err, nats.ErrBucketNotFound) {
if errors.Is(err, jetstream.ErrBucketNotFound) {
slog.Debug("Creating JetStream KV bucket", "bucket", ClusterStateBucket, "replicas", m.replicas)
kv, err = m.js.CreateKeyValue(&nats.KeyValueConfig{
kv, err = m.js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: ClusterStateBucket,
Description: "Spinifex cluster state (heartbeats, shutdown markers, service maps)",
History: 1,
@@ -141,7 +145,7 @@ func (m *JetStreamManager) InitClusterStateBucket() error {
}
m.clusterKV = kv
if err := migrate.DefaultRegistry.RunKV(ClusterStateBucket, kv, ClusterStateBucketVersion); err != nil {
if err := migrate.DefaultRegistry.RunKV(ctx, ClusterStateBucket, kv, ClusterStateBucketVersion); err != nil {
return fmt.Errorf("migrate %s: %w", ClusterStateBucket, err)
}
return nil
@@ -150,11 +154,12 @@ func (m *JetStreamManager) InitClusterStateBucket() error {
// InitTerminatedInstanceBucket initializes the terminated-instances KV bucket with a 1-hour TTL.
// JetStream automatically purges keys after 1 hour, matching AWS behavior for terminated instances.
func (m *JetStreamManager) InitTerminatedInstanceBucket() error {
kv, err := m.js.KeyValue(TerminatedInstanceBucket)
ctx := context.Background()
kv, err := m.js.KeyValue(ctx, TerminatedInstanceBucket)
if err != nil {
if errors.Is(err, nats.ErrBucketNotFound) {
if errors.Is(err, jetstream.ErrBucketNotFound) {
slog.Debug("Creating JetStream KV bucket", "bucket", TerminatedInstanceBucket, "replicas", m.replicas)
kv, err = m.js.CreateKeyValue(&nats.KeyValueConfig{
kv, err = m.js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: TerminatedInstanceBucket,
Description: "Terminated instances (auto-expire after 1 hour)",
History: 1,
@@ -172,7 +177,7 @@ func (m *JetStreamManager) InitTerminatedInstanceBucket() error {
}
m.terminatedKV = kv
if err := migrate.DefaultRegistry.RunKV(TerminatedInstanceBucket, kv, TerminatedInstanceBucketVersion); err != nil {
if err := migrate.DefaultRegistry.RunKV(ctx, TerminatedInstanceBucket, kv, TerminatedInstanceBucketVersion); err != nil {
return fmt.Errorf("migrate %s: %w", TerminatedInstanceBucket, err)
}
return nil
@@ -189,8 +194,8 @@ func isStreamUnavailable(err error) bool {
if err == nil {
return false
}
if errors.Is(err, nats.ErrStreamNotFound) ||
errors.Is(err, nats.ErrNoStreamResponse) ||
if errors.Is(err, jetstream.ErrStreamNotFound) ||
errors.Is(err, jetstream.ErrNoStreamResponse) ||
errors.Is(err, nats.ErrNoResponders) {
return true
}
@@ -201,19 +206,19 @@ func isStreamUnavailable(err error) bool {
// underlying JetStream stream was lost during cluster formation.
// Returns the recovered KV handle directly so callers avoid a racy re-read.
// When a bucket is recreated, the schema version is re-stamped.
func (m *JetStreamManager) recoverBucket(cfg *nats.KeyValueConfig, field *nats.KeyValue, version int) (nats.KeyValue, error) {
func (m *JetStreamManager) recoverBucket(ctx context.Context, cfg jetstream.KeyValueConfig, field *jetstream.KeyValue, version int) (jetstream.KeyValue, error) {
m.kvMu.Lock()
defer m.kvMu.Unlock()
// Try to reconnect to existing bucket first (another goroutine may have recovered it)
kv, err := m.js.KeyValue(cfg.Bucket)
kv, err := m.js.KeyValue(ctx, cfg.Bucket)
if err == nil {
*field = kv
slog.Info("Reconnected to KV bucket", "bucket", cfg.Bucket)
return kv, nil
}
if !errors.Is(err, nats.ErrBucketNotFound) && !isStreamUnavailable(err) {
if !errors.Is(err, jetstream.ErrBucketNotFound) && !isStreamUnavailable(err) {
return nil, err
}
@@ -221,13 +226,13 @@ func (m *JetStreamManager) recoverBucket(cfg *nats.KeyValueConfig, field *nats.K
slog.Warn("KV bucket stream lost, recreating", "bucket", cfg.Bucket, "replicas", m.replicas)
cfg.History = 1
cfg.Replicas = m.replicas
kv, err = m.js.CreateKeyValue(cfg)
kv, err = m.js.CreateKeyValue(ctx, cfg)
if err != nil {
slog.Error("Failed to recreate KV bucket", "bucket", cfg.Bucket, "err", err)
return nil, err
}
if err := migrate.DefaultRegistry.RunKV(cfg.Bucket, kv, version); err != nil {
if err := migrate.DefaultRegistry.RunKV(ctx, cfg.Bucket, kv, version); err != nil {
slog.Error("Failed to run migrations on recreated bucket", "bucket", cfg.Bucket, "err", err)
return nil, fmt.Errorf("migrate recreated bucket %s: %w", cfg.Bucket, err)
}
@@ -237,15 +242,15 @@ func (m *JetStreamManager) recoverBucket(cfg *nats.KeyValueConfig, field *nats.K
return kv, nil
}
func (m *JetStreamManager) recoverKVBucket() (nats.KeyValue, error) {
return m.recoverBucket(&nats.KeyValueConfig{
func (m *JetStreamManager) recoverKVBucket(ctx context.Context) (jetstream.KeyValue, error) {
return m.recoverBucket(ctx, jetstream.KeyValueConfig{
Bucket: InstanceStateBucket,
Description: "Spinifex instance state storage",
}, &m.kv, InstanceStateBucketVersion)
}
func (m *JetStreamManager) recoverTerminatedKVBucket() (nats.KeyValue, error) {
return m.recoverBucket(&nats.KeyValueConfig{
func (m *JetStreamManager) recoverTerminatedKVBucket(ctx context.Context) (jetstream.KeyValue, error) {
return m.recoverBucket(ctx, jetstream.KeyValueConfig{
Bucket: TerminatedInstanceBucket,
Description: "Terminated instances (auto-expire after 1 hour)",
TTL: 1 * time.Hour,
@@ -260,10 +265,10 @@ const maxCASRetries = 5
// isRevisionConflict reports whether err is the nats.go "wrong last
// sequence" error that KeyValue.Update/Create return when the key's
// revision no longer matches what the caller expected, i.e. a concurrent
// writer won the race. nats.go surfaces this as nats.ErrKeyExists for both
// Update (stale revision) and Create (key already present).
// writer won the race. The jetstream package surfaces this as
// jetstream.ErrKeyExists for both Update (stale revision) and Create (key already present).
func isRevisionConflict(err error) bool {
return errors.Is(err, nats.ErrKeyExists)
return errors.Is(err, jetstream.ErrKeyExists)
}
// casUpdate performs an optimistic-concurrency read-modify-write against kv:
@@ -272,20 +277,20 @@ func isRevisionConflict(err error) bool {
// Update/Create using the observed revision. A concurrent writer that changes
// the key between Get and Update/Create is detected via a revision conflict
// and retried, bounded by maxCASRetries.
func casUpdate[T any](kv nats.KeyValue, key string, mutate func(*T), createIfAbsent bool) (*T, error) {
func casUpdate[T any](ctx context.Context, kv jetstream.KeyValue, key string, mutate func(*T), createIfAbsent bool) (*T, error) {
var lastErr error
for range maxCASRetries {
var value T
var revision uint64
entry, err := kv.Get(key)
entry, err := kv.Get(ctx, key)
switch {
case err == nil:
if uerr := json.Unmarshal(entry.Value(), &value); uerr != nil {
return nil, uerr
}
revision = entry.Revision()
case errors.Is(err, nats.ErrKeyNotFound) && createIfAbsent:
case errors.Is(err, jetstream.ErrKeyNotFound) && createIfAbsent:
revision = 0
default:
return nil, err
@@ -299,9 +304,9 @@ func casUpdate[T any](kv nats.KeyValue, key string, mutate func(*T), createIfAbs
}
if revision == 0 {
_, err = kv.Create(key, data)
_, err = kv.Create(ctx, key, data)
} else {
_, err = kv.Update(key, data, revision)
_, err = kv.Update(ctx, key, data, revision)
}
if err == nil {
return &value, nil
@@ -322,25 +327,25 @@ func casUpdate[T any](kv nats.KeyValue, key string, mutate func(*T), createIfAbs
// counterpart to casUpdate, for values (like vm.VM, which embeds a
// sync.Mutex) that cannot be safely decoded into and copied out of a shared
// struct value.
func casPut(kv nats.KeyValue, key string, data []byte, createIfAbsent bool) error {
func casPut(ctx context.Context, kv jetstream.KeyValue, key string, data []byte, createIfAbsent bool) error {
var lastErr error
for range maxCASRetries {
var revision uint64
entry, err := kv.Get(key)
entry, err := kv.Get(ctx, key)
switch {
case err == nil:
revision = entry.Revision()
case errors.Is(err, nats.ErrKeyNotFound) && createIfAbsent:
case errors.Is(err, jetstream.ErrKeyNotFound) && createIfAbsent:
revision = 0
default:
return err
}
if revision == 0 {
_, err = kv.Create(key, data)
_, err = kv.Create(ctx, key, data)
} else {
_, err = kv.Update(key, data, revision)
_, err = kv.Update(ctx, key, data, revision)
}
if err == nil {
return nil
@@ -363,12 +368,12 @@ func casPut(kv nats.KeyValue, key string, data []byte, createIfAbsent bool) erro
// detected via a revision conflict and retried, bounded by maxCASRetries,
// so a losing racer only fails outright when the key is truly gone
// (notFound) or retries are exhausted.
func casClaim[T any](kv nats.KeyValue, key string) (value *T, notFound bool, err error) {
func casClaim[T any](ctx context.Context, kv jetstream.KeyValue, key string) (value *T, notFound bool, err error) {
var lastErr error
for range maxCASRetries {
entry, gerr := kv.Get(key)
entry, gerr := kv.Get(ctx, key)
if gerr != nil {
if errors.Is(gerr, nats.ErrKeyNotFound) {
if errors.Is(gerr, jetstream.ErrKeyNotFound) {
return nil, true, nil
}
return nil, false, gerr
@@ -379,7 +384,7 @@ func casClaim[T any](kv nats.KeyValue, key string) (value *T, notFound bool, err
return nil, false, uerr
}
if derr := kv.Delete(key, nats.LastRevision(entry.Revision())); derr != nil {
if derr := kv.Delete(ctx, key, jetstream.LastRevision(entry.Revision())); derr != nil {
if isRevisionConflict(derr) {
lastErr = derr
continue
@@ -422,7 +427,7 @@ func (m *JetStreamManager) WriteHeartbeat(h *Heartbeat) error {
if err != nil {
return err
}
_, err = m.clusterKV.Put("heartbeat."+h.Node, data)
_, err = m.clusterKV.Put(context.Background(), "heartbeat."+h.Node, data)
return err
}
@@ -431,7 +436,7 @@ func (m *JetStreamManager) ReadHeartbeat(nodeID string) (*Heartbeat, error) {
if m.clusterKV == nil {
return nil, errors.New("cluster state KV not initialized")
}
entry, err := m.clusterKV.Get("heartbeat." + nodeID)
entry, err := m.clusterKV.Get(context.Background(), "heartbeat."+nodeID)
if err != nil {
return nil, err
}
@@ -461,7 +466,7 @@ func (m *JetStreamManager) WriteClusterShutdown(state *ClusterShutdownState) err
if m.clusterKV == nil {
return errors.New("cluster state KV not initialized")
}
_, err := casUpdate(m.clusterKV, "cluster.shutdown", func(s *ClusterShutdownState) {
_, err := casUpdate(context.Background(), m.clusterKV, "cluster.shutdown", func(s *ClusterShutdownState) {
*s = *state
}, true)
return err
@@ -470,12 +475,28 @@ func (m *JetStreamManager) WriteClusterShutdown(state *ClusterShutdownState) err
// UpdateClusterShutdown atomically applies mutate to the current cluster
// shutdown state (e.g. merging a node's ack into NodesAcked) and writes it
// back with optimistic concurrency, retrying on a concurrent writer's
// revision conflict. Returns nats.ErrKeyNotFound if no shutdown is in progress.
// revision conflict. Returns jetstream.ErrKeyNotFound if no shutdown is in progress.
func (m *JetStreamManager) UpdateClusterShutdown(mutate func(*ClusterShutdownState)) (*ClusterShutdownState, error) {
if m.clusterKV == nil {
return nil, errors.New("cluster state KV not initialized")
}
return casUpdate(m.clusterKV, "cluster.shutdown", mutate, false)
return casUpdate(context.Background(), m.clusterKV, "cluster.shutdown", mutate, false)
}
// UpdateMgmtIPAM atomically applies mutate to the mgmt-ipam record for
// subnet and writes it back with optimistic concurrency, retrying on a
// concurrent writer's revision conflict. createIfAbsent lets the first
// allocation for a subnet create its record; releases pass false so a
// release of an instance nobody holds an address for does not conjure an
// empty record into existence. Every node sharing the management bridge's
// L2 segment reads and writes the same record, keyed by subnet, which is
// what makes mgmt IP allocation safe across the whole cluster rather than
// just this process (mulga-f3j2x).
func (m *JetStreamManager) UpdateMgmtIPAM(subnet string, mutate func(*MgmtIPRecord), createIfAbsent bool) (*MgmtIPRecord, error) {
if m.clusterKV == nil {
return nil, errors.New("cluster state KV not initialized")
}
return casUpdate(context.Background(), m.clusterKV, mgmtIPAMKeyPrefix+subnet, mutate, createIfAbsent)
}
// ReadClusterShutdown reads the cluster shutdown state from KV.
@@ -483,7 +504,7 @@ func (m *JetStreamManager) ReadClusterShutdown() (*ClusterShutdownState, error)
if m.clusterKV == nil {
return nil, errors.New("cluster state KV not initialized")
}
entry, err := m.clusterKV.Get("cluster.shutdown")
entry, err := m.clusterKV.Get(context.Background(), "cluster.shutdown")
if err != nil {
return nil, err
}
@@ -499,8 +520,8 @@ func (m *JetStreamManager) DeleteClusterShutdown() error {
if m.clusterKV == nil {
return errors.New("cluster state KV not initialized")
}
err := m.clusterKV.Delete("cluster.shutdown")
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
err := m.clusterKV.Delete(context.Background(), "cluster.shutdown")
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
return err
}
return nil
@@ -518,7 +539,7 @@ func (m *JetStreamManager) WriteShutdownMarker(nodeID string) error {
if err != nil {
return fmt.Errorf("marshal shutdown marker: %w", err)
}
_, err = m.clusterKV.Put("shutdown."+nodeID, data)
_, err = m.clusterKV.Put(context.Background(), "shutdown."+nodeID, data)
return err
}
@@ -527,8 +548,8 @@ func (m *JetStreamManager) ReadShutdownMarker(nodeID string) (bool, error) {
if m.clusterKV == nil {
return false, errors.New("cluster state KV not initialized")
}
_, err := m.clusterKV.Get("shutdown." + nodeID)
if errors.Is(err, nats.ErrKeyNotFound) {
_, err := m.clusterKV.Get(context.Background(), "shutdown."+nodeID)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return false, nil
}
if err != nil {
@@ -542,8 +563,8 @@ func (m *JetStreamManager) DeleteShutdownMarker(nodeID string) error {
if m.clusterKV == nil {
return errors.New("cluster state KV not initialized")
}
err := m.clusterKV.Delete("shutdown." + nodeID)
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
err := m.clusterKV.Delete(context.Background(), "shutdown."+nodeID)
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
return err
}
return nil
@@ -564,7 +585,7 @@ func (m *JetStreamManager) WriteServiceManifest(nodeID string, services []string
if err != nil {
return fmt.Errorf("marshal service manifest: %w", err)
}
_, err = m.clusterKV.Put("node."+nodeID+".services", data)
_, err = m.clusterKV.Put(context.Background(), "node."+nodeID+".services", data)
return err
}
@@ -581,15 +602,15 @@ func (m *JetStreamManager) WriteState(nodeID string, vms map[string]*vm.VM) erro
}
key := InstanceStatePrefix + nodeID
_, err = m.kv.Put(key, jsonData)
_, err = m.kv.Put(context.Background(), key, jsonData)
if err != nil {
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "WriteState", "key", key, "err", err)
kv, recoverErr := m.recoverKVBucket()
kv, recoverErr := m.recoverKVBucket(context.Background())
if recoverErr != nil {
return err
}
if _, retryErr := kv.Put(key, jsonData); retryErr != nil {
if _, retryErr := kv.Put(context.Background(), key, jsonData); retryErr != nil {
return retryErr
}
slog.Debug("Wrote state to JetStream KV (after recovery)", "key", key, "instances", len(vms))
@@ -607,43 +628,32 @@ func (m *JetStreamManager) WriteState(nodeID string, vms map[string]*vm.VM) erro
// blocks the caller past `timeout` and never returns an error. Used when the
// local state file is the source of truth and KV is a best-effort cache; hot
// paths marshal under a short-lived lock and commit lock-free.
//
// Note: kv.Put has no context API. On timeout, the in-flight Put goroutine
// continues and completes (or fails) on its own. This leaks at most one
// goroutine per write; under sustained partition the leak is bounded by
// WriteState call cadence (per-state-transition, not a tight loop).
func (m *JetStreamManager) WriteStateBytesBestEffort(nodeID string, jsonData []byte, timeout time.Duration) {
if m.kv == nil {
slog.Debug("KV bucket not initialized, skipping cluster sync", "node", nodeID)
return
}
key := InstanceStatePrefix + nodeID
done := make(chan error, 1)
go func() {
_, putErr := m.kv.Put(key, jsonData)
done <- putErr
}()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
select {
case putErr := <-done:
if putErr != nil {
if m.obs != nil {
m.obs.RecordKVSyncFailure(InstanceStateBucket, putErr)
}
slog.Warn("KV sync failed (best-effort)", "key", key, "err", putErr)
key := InstanceStatePrefix + nodeID
_, err := m.kv.Put(ctx, key, jsonData)
if err != nil {
if m.obs != nil {
m.obs.RecordKVSyncFailure(InstanceStateBucket, err)
}
if errors.Is(err, context.DeadlineExceeded) {
slog.Warn("KV sync timed out (best-effort)", "key", key, "timeout", timeout)
return
}
if m.obs != nil {
m.obs.RecordKVSyncSuccess(InstanceStateBucket)
}
slog.Debug("Wrote state to KV (best-effort)", "key", key, "bytes", len(jsonData))
case <-time.After(timeout):
if m.obs != nil {
m.obs.RecordKVSyncFailure(InstanceStateBucket, fmt.Errorf("kv sync timeout after %s", timeout))
}
slog.Warn("KV sync timed out (best-effort)", "key", key, "timeout", timeout)
slog.Warn("KV sync failed (best-effort)", "key", key, "err", err)
return
}
if m.obs != nil {
m.obs.RecordKVSyncSuccess(InstanceStateBucket)
}
slog.Debug("Wrote state to KV (best-effort)", "key", key, "bytes", len(jsonData))
}
// marshalInstanceState produces the JSON wire form of vms.
@@ -664,22 +674,22 @@ func (m *JetStreamManager) LoadState(nodeID string) (map[string]*vm.VM, error) {
}
key := InstanceStatePrefix + nodeID
entry, err := m.kv.Get(key)
entry, err := m.kv.Get(context.Background(), key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
if errors.Is(err, jetstream.ErrKeyNotFound) {
slog.Debug("No existing state in JetStream KV, returning empty state", "key", key)
return make(map[string]*vm.VM), nil
}
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "LoadState", "key", key, "err", err)
kv, recoverErr := m.recoverKVBucket()
kv, recoverErr := m.recoverKVBucket(context.Background())
if recoverErr != nil {
return nil, err
}
// Retry the read — if we reconnected, data may still exist
entry, err = kv.Get(key)
entry, err = kv.Get(context.Background(), key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
if errors.Is(err, jetstream.ErrKeyNotFound) {
slog.Warn("No state found after KV recovery", "key", key)
return make(map[string]*vm.VM), nil
}
@@ -712,16 +722,16 @@ func (m *JetStreamManager) DeleteState(nodeID string) error {
}
key := InstanceStatePrefix + nodeID
err := m.kv.Delete(key)
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
err := m.kv.Delete(context.Background(), key)
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "DeleteState", "key", key, "err", err)
kv, recoverErr := m.recoverKVBucket()
kv, recoverErr := m.recoverKVBucket(context.Background())
if recoverErr != nil {
return err
}
// Retry — if we reconnected the key may still exist
if retryErr := kv.Delete(key); retryErr != nil && !errors.Is(retryErr, nats.ErrKeyNotFound) {
if retryErr := kv.Delete(context.Background(), key); retryErr != nil && !errors.Is(retryErr, jetstream.ErrKeyNotFound) {
return retryErr
}
return nil
@@ -744,14 +754,21 @@ func (m *JetStreamManager) UpdateReplicas(newReplicas int) error {
m.replicas = newReplicas
ctx := context.Background()
// Iterate all streams and update any KV-backed stream (prefixed "KV_")
updated := 0
for name := range m.js.StreamNames() {
lister := m.js.StreamNames(ctx)
for name := range lister.Name() {
if !strings.HasPrefix(name, "KV_") {
continue
}
info, err := m.js.StreamInfo(name)
stream, err := m.js.Stream(ctx, name)
if err != nil {
slog.Warn("Failed to open stream", "stream", name, "error", err)
continue
}
info, err := stream.Info(ctx)
if err != nil {
slog.Warn("Failed to get stream info", "stream", name, "error", err)
continue
@@ -763,7 +780,7 @@ func (m *JetStreamManager) UpdateReplicas(newReplicas int) error {
oldReplicas := info.Config.Replicas
info.Config.Replicas = newReplicas
if _, err := m.js.UpdateStream(&info.Config); err != nil {
if _, err := m.js.UpdateStream(ctx, info.Config); err != nil {
slog.Warn("Failed to update KV bucket replicas", "stream", name, "error", err)
continue
}
@@ -772,6 +789,9 @@ func (m *JetStreamManager) UpdateReplicas(newReplicas int) error {
slog.Info("Updated KV bucket replicas", "bucket", bucket, "oldReplicas", oldReplicas, "newReplicas", newReplicas)
updated++
}
if err := lister.Err(); err != nil {
return fmt.Errorf("list JetStream streams: %w", err)
}
if updated > 0 {
slog.Info("KV replication update complete", "bucketsUpdated", updated, "replicas", newReplicas)
@@ -797,15 +817,15 @@ func (m *JetStreamManager) WriteStoppedInstance(instanceID string, instance *vm.
}
key := StoppedInstancePrefix + instanceID
err = casPut(m.kv, key, jsonData, true)
err = casPut(context.Background(), m.kv, key, jsonData, true)
if err != nil {
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "WriteStoppedInstance", "key", key, "err", err)
kv, recoverErr := m.recoverKVBucket()
kv, recoverErr := m.recoverKVBucket(context.Background())
if recoverErr != nil {
return err
}
if retryErr := casPut(kv, key, jsonData, true); retryErr != nil {
if retryErr := casPut(context.Background(), kv, key, jsonData, true); retryErr != nil {
return retryErr
}
slog.Debug("Wrote stopped instance to JetStream KV (after recovery)", "key", key, "instanceId", instanceID)
@@ -826,21 +846,21 @@ func (m *JetStreamManager) LoadStoppedInstance(instanceID string) (*vm.VM, error
}
key := StoppedInstancePrefix + instanceID
entry, err := m.kv.Get(key)
entry, err := m.kv.Get(context.Background(), key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, nil
}
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "LoadStoppedInstance", "key", key, "err", err)
kv, recoverErr := m.recoverKVBucket()
kv, recoverErr := m.recoverKVBucket(context.Background())
if recoverErr != nil {
return nil, err
}
// Retry — if we reconnected, data may still exist
entry, err = kv.Get(key)
entry, err = kv.Get(context.Background(), key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, nil
}
return nil, err
@@ -867,16 +887,16 @@ func (m *JetStreamManager) DeleteStoppedInstance(instanceID string) error {
}
key := StoppedInstancePrefix + instanceID
err := m.kv.Delete(key)
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
err := m.kv.Delete(context.Background(), key)
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "DeleteStoppedInstance", "key", key, "err", err)
kv, recoverErr := m.recoverKVBucket()
kv, recoverErr := m.recoverKVBucket(context.Background())
if recoverErr != nil {
return err
}
// Retry — if we reconnected the key may still exist
if retryErr := kv.Delete(key); retryErr != nil && !errors.Is(retryErr, nats.ErrKeyNotFound) {
if retryErr := kv.Delete(context.Background(), key); retryErr != nil && !errors.Is(retryErr, jetstream.ErrKeyNotFound) {
return retryErr
}
return nil
@@ -903,15 +923,15 @@ func (m *JetStreamManager) ClaimStoppedInstance(instanceID string) (*vm.VM, erro
}
key := StoppedInstancePrefix + instanceID
instance, notFound, err := casClaim[vm.VM](m.kv, key)
instance, notFound, err := casClaim[vm.VM](context.Background(), m.kv, key)
if err != nil {
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "ClaimStoppedInstance", "key", key, "err", err)
kv, recoverErr := m.recoverKVBucket()
kv, recoverErr := m.recoverKVBucket(context.Background())
if recoverErr != nil {
return nil, err
}
instance, notFound, err = casClaim[vm.VM](kv, key)
instance, notFound, err = casClaim[vm.VM](context.Background(), kv, key)
if err != nil {
return nil, err
}
@@ -931,25 +951,25 @@ func (m *JetStreamManager) ClaimStoppedInstance(instanceID string) (*vm.VM, erro
// stopped record for instanceID and writes it back using optimistic
// concurrency (CAS), retrying on a concurrent writer's revision conflict.
// createIfAbsent is false: if a winning ClaimStoppedInstance deletes the
// record mid-flight, the CAS write fails with nats.ErrKeyNotFound instead of
// record mid-flight, the CAS write fails with jetstream.ErrKeyNotFound instead of
// resurrecting it. Used by tag/attribute mutations that read-modify-write a
// stopped instance so they cannot race a claim into recreating a stale
// record. Returns nats.ErrKeyNotFound if no record exists.
// record. Returns jetstream.ErrKeyNotFound if no record exists.
func (m *JetStreamManager) UpdateStoppedInstance(instanceID string, mutate func(*vm.VM)) (*vm.VM, error) {
if m.kv == nil {
return nil, errors.New("KV bucket not initialized")
}
key := StoppedInstancePrefix + instanceID
updated, err := casUpdate(m.kv, key, mutate, false)
updated, err := casUpdate(context.Background(), m.kv, key, mutate, false)
if err != nil {
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "UpdateStoppedInstance", "key", key, "err", err)
kv, recoverErr := m.recoverKVBucket()
kv, recoverErr := m.recoverKVBucket(context.Background())
if recoverErr != nil {
return nil, err
}
return casUpdate(kv, key, mutate, false)
return casUpdate(context.Background(), kv, key, mutate, false)
}
return nil, err
}
@@ -962,21 +982,21 @@ func (m *JetStreamManager) ListStoppedInstances() ([]*vm.VM, error) {
return nil, errors.New("KV bucket not initialized")
}
keys, err := m.kv.Keys()
keys, err := m.kv.Keys(context.Background())
if err != nil {
if errors.Is(err, nats.ErrNoKeysFound) {
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "ListStoppedInstances", "err", err)
kv, recoverErr := m.recoverKVBucket()
kv, recoverErr := m.recoverKVBucket(context.Background())
if recoverErr != nil {
return nil, err
}
// Retry — if we reconnected, data may still exist
keys, err = kv.Keys()
keys, err = kv.Keys(context.Background())
if err != nil {
if errors.Is(err, nats.ErrNoKeysFound) {
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return nil, err
@@ -996,9 +1016,9 @@ func (m *JetStreamManager) ListStoppedInstances() ([]*vm.VM, error) {
continue
}
entry, err := m.kv.Get(key)
entry, err := m.kv.Get(context.Background(), key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
if errors.Is(err, jetstream.ErrKeyNotFound) {
continue
}
return nil, err
@@ -1035,15 +1055,15 @@ func (m *JetStreamManager) WriteTerminatedInstance(instanceID string, instance *
}
key := TerminatedInstancePrefix + instanceID
err = casPut(m.terminatedKV, key, jsonData, true)
err = casPut(context.Background(), m.terminatedKV, key, jsonData, true)
if err != nil {
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "WriteTerminatedInstance", "key", key, "err", err)
kv, recoverErr := m.recoverTerminatedKVBucket()
kv, recoverErr := m.recoverTerminatedKVBucket(context.Background())
if recoverErr != nil {
return err
}
if retryErr := casPut(kv, key, jsonData, true); retryErr != nil {
if retryErr := casPut(context.Background(), kv, key, jsonData, true); retryErr != nil {
return retryErr
}
slog.Debug("Wrote terminated instance to JetStream KV (after recovery)", "key", key, "instanceId", instanceID)
@@ -1061,22 +1081,22 @@ func (m *JetStreamManager) WriteTerminatedInstance(instanceID string, instance *
// optimistic concurrency (CAS), retrying on a concurrent writer's revision
// conflict. Used by the teardown reaper to merge per-dependent Teardown
// progress without clobbering marks written by a concurrent update to the
// same record. Returns nats.ErrKeyNotFound if no record exists yet.
// same record. Returns jetstream.ErrKeyNotFound if no record exists yet.
func (m *JetStreamManager) UpdateTerminatedInstance(instanceID string, mutate func(*vm.VM)) (*vm.VM, error) {
if m.terminatedKV == nil {
return nil, errors.New("terminated instance KV bucket not initialized")
}
key := TerminatedInstancePrefix + instanceID
updated, err := casUpdate(m.terminatedKV, key, mutate, false)
updated, err := casUpdate(context.Background(), m.terminatedKV, key, mutate, false)
if err != nil {
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "UpdateTerminatedInstance", "key", key, "err", err)
kv, recoverErr := m.recoverTerminatedKVBucket()
kv, recoverErr := m.recoverTerminatedKVBucket(context.Background())
if recoverErr != nil {
return nil, err
}
return casUpdate(kv, key, mutate, false)
return casUpdate(context.Background(), kv, key, mutate, false)
}
return nil, err
}
@@ -1089,20 +1109,20 @@ func (m *JetStreamManager) ListTerminatedInstances() ([]*vm.VM, error) {
return nil, errors.New("terminated instance KV bucket not initialized")
}
keys, err := m.terminatedKV.Keys()
keys, err := m.terminatedKV.Keys(context.Background())
if err != nil {
if errors.Is(err, nats.ErrNoKeysFound) {
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "ListTerminatedInstances", "err", err)
kv, recoverErr := m.recoverTerminatedKVBucket()
kv, recoverErr := m.recoverTerminatedKVBucket(context.Background())
if recoverErr != nil {
return nil, err
}
keys, err = kv.Keys()
keys, err = kv.Keys(context.Background())
if err != nil {
if errors.Is(err, nats.ErrNoKeysFound) {
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return nil, err
@@ -1121,9 +1141,9 @@ func (m *JetStreamManager) ListTerminatedInstances() ([]*vm.VM, error) {
continue
}
entry, err := m.terminatedKV.Get(key)
entry, err := m.terminatedKV.Get(context.Background(), key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
if errors.Is(err, jetstream.ErrKeyNotFound) {
continue
}
return nil, err
@@ -1148,15 +1168,15 @@ func (m *JetStreamManager) DeleteTerminatedInstance(instanceID string) error {
}
key := TerminatedInstancePrefix + instanceID
err := m.terminatedKV.Delete(key)
if err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
err := m.terminatedKV.Delete(context.Background(), key)
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "DeleteTerminatedInstance", "key", key, "err", err)
kv, recoverErr := m.recoverTerminatedKVBucket()
kv, recoverErr := m.recoverTerminatedKVBucket(context.Background())
if recoverErr != nil {
return err
}
if retryErr := kv.Delete(key); retryErr != nil && !errors.Is(retryErr, nats.ErrKeyNotFound) {
if retryErr := kv.Delete(context.Background(), key); retryErr != nil && !errors.Is(retryErr, jetstream.ErrKeyNotFound) {
return retryErr
}
return nil
@@ -1176,20 +1196,20 @@ func (m *JetStreamManager) LoadTerminatedInstance(instanceID string) (*vm.VM, er
}
key := TerminatedInstancePrefix + instanceID
entry, err := m.terminatedKV.Get(key)
entry, err := m.terminatedKV.Get(context.Background(), key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, nil
}
if isStreamUnavailable(err) {
slog.Warn("KV stream unavailable, attempting recovery", "operation", "LoadTerminatedInstance", "key", key, "err", err)
kv, recoverErr := m.recoverTerminatedKVBucket()
kv, recoverErr := m.recoverTerminatedKVBucket(context.Background())
if recoverErr != nil {
return nil, err
}
entry, err = kv.Get(key)
entry, err = kv.Get(context.Background(), key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, nil
}
return nil, err
+110 -20
View File
@@ -7,9 +7,10 @@ import (
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/mulgadc/spinifex/spinifex/kvutil"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -343,8 +344,10 @@ func TestJetStreamManager_UpdateReplicas(t *testing.T) {
require.NoError(t, err)
// Verify initial replica count
js, _ := nc.JetStream()
streamInfo, err := js.StreamInfo("KV_" + InstanceStateBucket)
js, _ := jetstream.New(nc)
stream, err := js.Stream(t.Context(), "KV_"+InstanceStateBucket)
require.NoError(t, err)
streamInfo, err := stream.Info(t.Context())
require.NoError(t, err)
assert.Equal(t, 1, streamInfo.Config.Replicas, "Should start with 1 replica")
@@ -598,7 +601,7 @@ func TestJetStreamManager_UpdateStoppedInstance_ConflictRetry(t *testing.T) {
}
// TestJetStreamManager_UpdateStoppedInstance_NotFound verifies
// UpdateStoppedInstance surfaces nats.ErrKeyNotFound rather than silently
// UpdateStoppedInstance surfaces jetstream.ErrKeyNotFound rather than silently
// creating a record when nothing exists to update.
func TestJetStreamManager_UpdateStoppedInstance_NotFound(t *testing.T) {
nc, err := nats.Connect(sharedJSNATSURL)
@@ -610,7 +613,7 @@ func TestJetStreamManager_UpdateStoppedInstance_NotFound(t *testing.T) {
require.NoError(t, jsm.InitKVBucket())
_, err = jsm.UpdateStoppedInstance("i-does-not-exist", func(v *vm.VM) {})
assert.ErrorIs(t, err, nats.ErrKeyNotFound)
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound)
}
// TestJetStreamManager_UpdateStoppedInstance_NoResurrectAfterClaim is the
@@ -646,7 +649,7 @@ func TestJetStreamManager_UpdateStoppedInstance_NoResurrectAfterClaim(t *testing
_, err = jsm.UpdateStoppedInstance(testVM.ID, func(v *vm.VM) {
v.InstanceType = "should-not-land"
})
assert.ErrorIs(t, err, nats.ErrKeyNotFound, "a claim that deleted the record must not be resurrected by a losing racer's update")
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound, "a claim that deleted the record must not be resurrected by a losing racer's update")
stillGone, err := jsm.LoadStoppedInstance(testVM.ID)
require.NoError(t, err)
@@ -776,7 +779,7 @@ func TestJetStreamManager_WriteServiceManifest(t *testing.T) {
require.NoError(t, err)
// Read the KV entry directly and verify JSON contents
entry, err := jsm.clusterKV.Get("node.test-node-svc.services")
entry, err := jsm.clusterKV.Get(t.Context(), "node.test-node-svc.services")
require.NoError(t, err)
var manifest map[string]any
@@ -811,7 +814,7 @@ func TestJetStreamManager_WriteServiceManifest_EmptyServices(t *testing.T) {
err = jsm.WriteServiceManifest("empty-svc-node", []string{}, "10.0.0.2:4222", "10.0.0.2:8443")
require.NoError(t, err)
entry, err := jsm.clusterKV.Get("node.empty-svc-node.services")
entry, err := jsm.clusterKV.Get(t.Context(), "node.empty-svc-node.services")
require.NoError(t, err)
var manifest map[string]any
@@ -847,9 +850,9 @@ func TestJetStreamManager_WriteServiceManifest_ClusterKVNotInitialized(t *testin
// deleteInstanceStateBucket deletes the underlying JetStream stream for the instance-state KV bucket.
func deleteInstanceStateBucket(t *testing.T, nc *nats.Conn) {
t.Helper()
js, err := nc.JetStream()
js, err := jetstream.New(nc)
require.NoError(t, err)
err = js.DeleteStream("KV_" + InstanceStateBucket)
err = js.DeleteStream(t.Context(), "KV_"+InstanceStateBucket)
require.NoError(t, err)
}
@@ -1006,7 +1009,7 @@ func swapToNonJSContext(t *testing.T, jsm *JetStreamManager) {
nc, err := nats.Connect(sharedNATSURL)
require.NoError(t, err)
t.Cleanup(func() { nc.Close() })
js, err := nc.JetStream()
js, err := jetstream.New(nc)
require.NoError(t, err)
jsm.js = js
}
@@ -1137,12 +1140,12 @@ func TestJetStreamManager_ListStoppedInstances_RecoveryFailure(t *testing.T) {
func TestIsStreamUnavailable(t *testing.T) {
assert.False(t, isStreamUnavailable(nil))
assert.True(t, isStreamUnavailable(nats.ErrStreamNotFound))
assert.True(t, isStreamUnavailable(nats.ErrNoStreamResponse))
assert.True(t, isStreamUnavailable(jetstream.ErrStreamNotFound))
assert.True(t, isStreamUnavailable(jetstream.ErrNoStreamResponse))
assert.True(t, isStreamUnavailable(nats.ErrNoResponders))
assert.True(t, isStreamUnavailable(errors.New("nats: stream not found")))
assert.False(t, isStreamUnavailable(errors.New("some other error")))
assert.False(t, isStreamUnavailable(nats.ErrKeyNotFound))
assert.False(t, isStreamUnavailable(jetstream.ErrKeyNotFound))
}
// --- Terminated instance KV tests ---
@@ -1376,7 +1379,7 @@ func TestJetStreamManager_UpdateTerminatedInstance_NotFound(t *testing.T) {
require.NoError(t, jsm.InitTerminatedInstanceBucket())
_, err = jsm.UpdateTerminatedInstance("i-does-not-exist", func(v *vm.VM) {})
assert.ErrorIs(t, err, nats.ErrKeyNotFound)
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound)
}
// TestJetStreamManager_WriteStoppedInstance_OverwritesConcurrentValue verifies
@@ -1416,9 +1419,9 @@ func TestJetStreamManager_WriteStoppedInstance_OverwritesConcurrentValue(t *test
// deleteTerminatedInstanceBucket deletes the underlying JetStream stream for the terminated-instances KV bucket.
func deleteTerminatedInstanceBucket(t *testing.T, nc *nats.Conn) {
t.Helper()
js, err := nc.JetStream()
js, err := jetstream.New(nc)
require.NoError(t, err)
err = js.DeleteStream("KV_" + TerminatedInstanceBucket)
err = js.DeleteStream(t.Context(), "KV_"+TerminatedInstanceBucket)
require.NoError(t, err)
}
@@ -1504,15 +1507,15 @@ func TestJetStreamManager_InitBuckets_WritesVersion(t *testing.T) {
require.NoError(t, jsm.InitClusterStateBucket())
require.NoError(t, jsm.InitTerminatedInstanceBucket())
v, err := utils.ReadVersion(jsm.kv)
v, err := kvutil.ReadVersion(t.Context(), jsm.kv)
require.NoError(t, err)
assert.Equal(t, InstanceStateBucketVersion, v)
v, err = utils.ReadVersion(jsm.clusterKV)
v, err = kvutil.ReadVersion(t.Context(), jsm.clusterKV)
require.NoError(t, err)
assert.Equal(t, ClusterStateBucketVersion, v)
v, err = utils.ReadVersion(jsm.terminatedKV)
v, err = kvutil.ReadVersion(t.Context(), jsm.terminatedKV)
require.NoError(t, err)
assert.Equal(t, TerminatedInstanceBucketVersion, v)
}
@@ -1634,3 +1637,90 @@ func TestJetStreamManager_BestEffort_NilObserver_NoPanic(t *testing.T) {
// No observer set — must not panic on success or failure paths.
jsm.WriteStateBytesBestEffort("obs-nil", []byte(`{"vms":{}}`), 5*time.Second)
}
// --- UpdateMgmtIPAM CAS tests ---
// TestJetStreamManager_UpdateMgmtIPAM_ConflictRetry locks the CAS contract
// for mgmt-ipam records: a concurrent writer landing between Get and Update
// must be detected via a revision conflict and retried, and both the
// injected concurrent write and the retried write must land — mirroring
// UpdateStoppedInstance/UpdateTerminatedInstance's conflict-retry tests,
// applied to the allocator's backing store (mulga-f3j2x).
func TestJetStreamManager_UpdateMgmtIPAM_ConflictRetry(t *testing.T) {
nc, err := nats.Connect(sharedJSNATSURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
require.NoError(t, jsm.InitClusterStateBucket())
const subnet = "10.99.8.0/24"
defer func() { _ = jsm.clusterKV.Delete(t.Context(), mgmtIPAMKeyPrefix+subnet) }()
_, err = jsm.UpdateMgmtIPAM(subnet, func(r *MgmtIPRecord) {
r.Subnet = subnet
r.Allocated = append(r.Allocated, MgmtIPEntry{IP: "10.99.8.10", InstanceID: "i-seed", Node: "node-seed"})
}, true)
require.NoError(t, err)
var attempts int
var injectOnce sync.Once
updated, err := jsm.UpdateMgmtIPAM(subnet, func(r *MgmtIPRecord) {
attempts++
r.Allocated = append(r.Allocated, MgmtIPEntry{IP: "10.99.8.11", InstanceID: "i-retried", Node: "node-a"})
// Simulate a concurrent writer landing between our Get and Update on
// the first attempt only, forcing exactly one revision conflict.
injectOnce.Do(func() {
_, cerr := jsm.UpdateMgmtIPAM(subnet, func(cr *MgmtIPRecord) {
cr.Allocated = append(cr.Allocated, MgmtIPEntry{IP: "10.99.8.12", InstanceID: "i-concurrent", Node: "node-b"})
}, true)
require.NoError(t, cerr)
})
}, true)
require.NoError(t, err, "the retried Update must eventually succeed against the fresh revision")
require.NotNil(t, updated)
assert.Equal(t, 2, attempts, "the injected concurrent write must force exactly one retry")
ids := make(map[string]bool, len(updated.Allocated))
for _, e := range updated.Allocated {
ids[e.InstanceID] = true
}
assert.True(t, ids["i-seed"], "the original entry must survive")
assert.True(t, ids["i-concurrent"], "the concurrent writer's entry must survive, not be clobbered")
assert.True(t, ids["i-retried"], "the retried update must also land")
}
// TestJetStreamManager_UpdateMgmtIPAM_NotFound verifies createIfAbsent=false
// surfaces jetstream.ErrKeyNotFound instead of silently creating a record —
// MgmtIPAllocator.Release relies on this to no-op cleanly when there is
// nothing to release.
func TestJetStreamManager_UpdateMgmtIPAM_NotFound(t *testing.T) {
nc, err := nats.Connect(sharedJSNATSURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
require.NoError(t, jsm.InitClusterStateBucket())
_, err = jsm.UpdateMgmtIPAM("10.98.8.0/24-nonexistent", func(r *MgmtIPRecord) {}, false)
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound)
}
// TestJetStreamManager_UpdateMgmtIPAM_ClusterKVNotInitialized tests error
// handling when the cluster-state KV bucket has not been initialized.
func TestJetStreamManager_UpdateMgmtIPAM_ClusterKVNotInitialized(t *testing.T) {
nc, err := nats.Connect(sharedJSNATSURL)
require.NoError(t, err)
defer nc.Close()
jsm, err := NewJetStreamManager(nc, 1)
require.NoError(t, err)
// Don't call InitClusterStateBucket
_, err = jsm.UpdateMgmtIPAM("10.97.8.0/24", func(r *MgmtIPRecord) {}, true)
assert.Error(t, err)
assert.Contains(t, err.Error(), "cluster state KV not initialized")
}
-9
View File
@@ -32,15 +32,6 @@ func checkInstanceOwnership(msg *nats.Msg, instanceID, ownerAccountID string) bo
return true
}
// isInstanceVisible reports whether the caller may see this instance.
// Empty ownerAccountID is root-only.
func isInstanceVisible(callerAccountID, ownerAccountID string) bool {
if ownerAccountID == "" {
return callerAccountID == utils.GlobalAccountID
}
return callerAccountID == ownerAccountID
}
// volumeVisibleTo reports whether callerAccountID may access a volume.
// Empty tenantID is root-only to prevent untenanted volumes from leaking.
func volumeVisibleTo(tenantID, callerAccountID string) bool {
-56
View File
@@ -6,62 +6,6 @@ import (
"github.com/mulgadc/spinifex/spinifex/utils"
)
func TestIsInstanceVisible(t *testing.T) {
tests := []struct {
name string
callerAccountID string
ownerAccountID string
expected bool
}{
{
name: "same account",
callerAccountID: "123456789012",
ownerAccountID: "123456789012",
expected: true,
},
{
name: "different account",
callerAccountID: "123456789012",
ownerAccountID: "999999999999",
expected: false,
},
{
name: "untenanted instance visible to root",
callerAccountID: utils.GlobalAccountID,
ownerAccountID: "",
expected: true,
},
{
name: "untenanted instance hidden from non-root",
callerAccountID: "123456789012",
ownerAccountID: "",
expected: false,
},
{
name: "root accessing owned instance",
callerAccountID: utils.GlobalAccountID,
ownerAccountID: utils.GlobalAccountID,
expected: true,
},
{
name: "root accessing other account instance",
callerAccountID: utils.GlobalAccountID,
ownerAccountID: "123456789012",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isInstanceVisible(tt.callerAccountID, tt.ownerAccountID)
if got != tt.expected {
t.Errorf("isInstanceVisible(%q, %q) = %v, want %v",
tt.callerAccountID, tt.ownerAccountID, got, tt.expected)
}
})
}
}
func TestVolumeVisibleTo(t *testing.T) {
tests := []struct {
name string
+38 -9
View File
@@ -9,7 +9,6 @@ import (
"log/slog"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_ec2_placementgroup "github.com/mulgadc/spinifex/spinifex/handlers/ec2/placementgroup"
"github.com/mulgadc/spinifex/spinifex/network/topology"
@@ -91,9 +90,12 @@ type volumeMounterAdapter struct {
var _ vm.VolumeMounter = (*volumeMounterAdapter)(nil)
// unmountSealTimeout bounds the ebs.unmount NATS request. The handler now drives
// a synchronous block-map seal to predastore (S3 I/O), so it matches
// viperblockd's 120s KillProcess wait rather than a bare RPC budget.
const unmountSealTimeout = 120 * time.Second
// a synchronous block-map seal to predastore (S3 I/O), which runs AFTER
// viperblockd's SIGKILL once its 120s KillProcess grace elapses — so this
// must strictly exceed that grace plus a seal allowance, never merely match
// it: an unmount that consumes the whole grace could otherwise never be
// observed as successful.
const unmountSealTimeout = 180 * time.Second
func newVolumeMounterAdapter(nc *nats.Conn, node string, volState vm.VolumeStateUpdater) *volumeMounterAdapter {
return &volumeMounterAdapter{nc: nc, node: node, volState: volState}
@@ -619,10 +621,34 @@ func (a *instanceCleanerAdapter) DeleteVolumes(instance *vm.VM) error {
continue
}
// User-visible volumes: only delete when DeleteOnTermination is set.
// User-visible volumes: DeleteOnTermination=false survives terminate,
// but terminate still implies detach (AWS semantics). Only the Boot
// volume can still be attached here — shutdownAndUnmount's Unmount
// clears every non-Boot volume's attachment already, so a non-Boot
// DoT=false volume is genuinely a no-op skip. A DoT=false Boot volume
// is never touched by Unmount, so without this it would strand
// attached to the now-terminated instance forever.
if !ebsRequest.DeleteOnTermination {
slog.Info("Volume has DeleteOnTermination=false, skipping deletion",
if !ebsRequest.Boot {
slog.Info("Volume has DeleteOnTermination=false, skipping deletion",
"name", ebsRequest.Name, "id", instance.ID)
continue
}
slog.Info("Boot volume has DeleteOnTermination=false, detaching without deleting",
"name", ebsRequest.Name, "id", instance.ID)
if a.d.volumeService == nil {
slog.Warn("Volume service not configured, cannot detach volume",
"name", ebsRequest.Name, "id", instance.ID)
continue
}
if err := a.d.volumeService.DetachVolumeOnTerminate(context.Background(), ebsRequest.Name, instance.AccountID); err != nil && !awserrors.IsNotFound(err) {
slog.Error("Failed to detach volume on termination",
"name", ebsRequest.Name, "id", instance.ID, "err", err)
firstErr = cmp.Or(firstErr, err)
} else {
slog.Info("Detached volume on termination",
"name", ebsRequest.Name, "id", instance.ID)
}
continue
}
@@ -633,9 +659,12 @@ func (a *instanceCleanerAdapter) DeleteVolumes(instance *vm.VM) error {
"name", ebsRequest.Name, "id", instance.ID)
continue
}
if _, err := a.d.volumeService.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{
VolumeId: &ebsRequest.Name,
}, instance.AccountID); err != nil && !awserrors.IsNotFound(err) {
// Terminate implies detach: DeleteVolumeOnTerminate clears the stale
// AttachedInstance before deleting. shutdownAndUnmount's Unmount
// (above, via terminateCleanup) deliberately never clears a Boot
// volume's attachment, so without this the root volume still hits
// DeleteVolume's in-use guard here.
if err := a.d.volumeService.DeleteVolumeOnTerminate(context.Background(), ebsRequest.Name, instance.AccountID); err != nil && !awserrors.IsNotFound(err) {
slog.Error("Failed to delete volume on termination",
"name", ebsRequest.Name, "id", instance.ID, "err", err)
firstErr = cmp.Or(firstErr, err)
+17 -22
View File
@@ -7,7 +7,8 @@ import (
"sync"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/nats-io/nats.go"
"github.com/mulgadc/spinifex/spinifex/kvutil"
"github.com/nats-io/nats.go/jetstream"
)
// bedrockCredentialsBucket is the cluster-replicated KV bucket holding
@@ -26,13 +27,13 @@ func credentialKey(accountID, vendor string) string {
// bedrock-credentials JetStream KV bucket, falling back to a platform-wide
// default when an account has none. Keys are encrypted at rest.
type CredentialStore struct {
js nats.JetStreamContext
js jetstream.JetStream
masterKey []byte
replicas int
platformDefaults map[string]string
mu sync.Mutex
kv nats.KeyValue
kv jetstream.KeyValue
}
var _ CredentialResolver = (*CredentialStore)(nil)
@@ -40,7 +41,7 @@ var _ CredentialResolver = (*CredentialStore)(nil)
// NewCredentialStore constructs a CredentialStore. platformDefaults maps
// vendor to a platform-wide API key used when an account has no key of its
// own; pass an empty map to disable platform defaults entirely.
func NewCredentialStore(js nats.JetStreamContext, masterKey []byte, replicas int, platformDefaults map[string]string) *CredentialStore {
func NewCredentialStore(js jetstream.JetStream, masterKey []byte, replicas int, platformDefaults map[string]string) *CredentialStore {
return &CredentialStore{
js: js,
masterKey: masterKey,
@@ -49,23 +50,17 @@ func NewCredentialStore(js nats.JetStreamContext, masterKey []byte, replicas int
}
}
// bucket lazily opens (or creates) the bedrock-credentials KV bucket,
// caching the handle, mirroring gateway_ecrauth.openSigningBucket.
func (s *CredentialStore) bucket() (nats.KeyValue, error) {
// bucket lazily opens (or creates) the cluster-replicated bedrock-credentials
// KV bucket, caching the handle for subsequent calls.
func (s *CredentialStore) bucket(ctx context.Context) (jetstream.KeyValue, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.kv != nil {
return s.kv, nil
}
kv, err := s.js.CreateKeyValue(&nats.KeyValueConfig{
Bucket: bedrockCredentialsBucket,
History: bedrockCredentialsHistory,
Replicas: s.replicas,
})
kv, err := kvutil.GetOrCreateBucketWithReplicas(ctx, s.js, bedrockCredentialsBucket, bedrockCredentialsHistory, s.replicas)
if err != nil {
if kv, err = s.js.KeyValue(bedrockCredentialsBucket); err != nil {
return nil, fmt.Errorf("open %s bucket: %w", bedrockCredentialsBucket, err)
}
return nil, err
}
s.kv = kv
return kv, nil
@@ -75,13 +70,13 @@ func (s *CredentialStore) bucket() (nats.KeyValue, error) {
// stored, else the platform default, else ("", false, nil). A nil js is
// tolerated so a store built with only platformDefaults (e.g. in tests) can
// still serve the default-only path without touching JetStream.
func (s *CredentialStore) Resolve(_ context.Context, accountID, vendor string) (string, bool, error) {
func (s *CredentialStore) Resolve(ctx context.Context, accountID, vendor string) (string, bool, error) {
if s.js != nil {
kv, err := s.bucket()
kv, err := s.bucket(ctx)
if err != nil {
return "", false, err
}
entry, err := kv.Get(credentialKey(accountID, vendor))
entry, err := kv.Get(ctx, credentialKey(accountID, vendor))
switch {
case err == nil:
plaintext, err := handlers_iam.DecryptSecret(string(entry.Value()), s.masterKey)
@@ -89,7 +84,7 @@ func (s *CredentialStore) Resolve(_ context.Context, accountID, vendor string) (
return "", false, fmt.Errorf("decrypt credential for %s/%s: %w", accountID, vendor, err)
}
return plaintext, true, nil
case !errors.Is(err, nats.ErrKeyNotFound):
case !errors.Is(err, jetstream.ErrKeyNotFound):
return "", false, fmt.Errorf("kv get credential for %s/%s: %w", accountID, vendor, err)
}
}
@@ -100,8 +95,8 @@ func (s *CredentialStore) Resolve(_ context.Context, accountID, vendor string) (
}
// PutCredential encrypts and stores key as accountID's vendor credential.
func (s *CredentialStore) PutCredential(_ context.Context, accountID, vendor, key string) error {
kv, err := s.bucket()
func (s *CredentialStore) PutCredential(ctx context.Context, accountID, vendor, key string) error {
kv, err := s.bucket(ctx)
if err != nil {
return err
}
@@ -109,7 +104,7 @@ func (s *CredentialStore) PutCredential(_ context.Context, accountID, vendor, ke
if err != nil {
return fmt.Errorf("encrypt credential for %s/%s: %w", accountID, vendor, err)
}
if _, err := kv.Put(credentialKey(accountID, vendor), []byte(ciphertext)); err != nil {
if _, err := kv.Put(ctx, credentialKey(accountID, vendor), []byte(ciphertext)); err != nil {
return fmt.Errorf("kv put credential for %s/%s: %w", accountID, vendor, err)
}
return nil
+8 -8
View File
@@ -17,7 +17,7 @@ func fixedMasterKey() []byte {
}
// TestCredentialStore_Resolve_NilJS covers the platform-default and
// vendor-miss paths of a store built with a nil JetStream context (as a
// vendor-miss paths of a store built with a nil JetStream handle (as a
// live NATS server is out of scope here). Resolve must consult
// platformDefaults without dereferencing js.
func TestCredentialStore_Resolve_NilJS(t *testing.T) {
@@ -62,8 +62,8 @@ func TestNoopCredentialResolver_ResolvesNothing(t *testing.T) {
// bucket (lazy create), credentialKey, PutCredential (encrypt+Put), and the
// KV-hit branch of Resolve (Get+decrypt).
func TestCredentialStore_PutAndResolve_KV(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
store := NewCredentialStore(js, fixedMasterKey(), 1, nil)
_, nc, _ := testutil.StartTestJetStream(t)
store := NewCredentialStore(testutil.NewJetStream(t, nc), fixedMasterKey(), 1, nil)
ctx := context.Background()
require.NoError(t, store.PutCredential(ctx, "000000000001", "anthropic", "sk-test"))
@@ -75,10 +75,10 @@ func TestCredentialStore_PutAndResolve_KV(t *testing.T) {
}
// TestCredentialStore_Resolve_KVMissFallsBackToPlatformDefault covers a KV
// miss (nats.ErrKeyNotFound) that falls through to the platform default.
// miss (jetstream.ErrKeyNotFound) that falls through to the platform default.
func TestCredentialStore_Resolve_KVMissFallsBackToPlatformDefault(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
store := NewCredentialStore(js, fixedMasterKey(), 1, map[string]string{"anthropic": "sk-platform-default"})
_, nc, _ := testutil.StartTestJetStream(t)
store := NewCredentialStore(testutil.NewJetStream(t, nc), fixedMasterKey(), 1, map[string]string{"anthropic": "sk-platform-default"})
key, ok, err := store.Resolve(context.Background(), "999999999999", "anthropic")
require.NoError(t, err)
@@ -89,8 +89,8 @@ func TestCredentialStore_Resolve_KVMissFallsBackToPlatformDefault(t *testing.T)
// TestCredentialStore_Resolve_KVMissNoPlatformDefault covers a KV miss on an
// unknown account with no platform defaults configured: ("", false, nil).
func TestCredentialStore_Resolve_KVMissNoPlatformDefault(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
store := NewCredentialStore(js, fixedMasterKey(), 1, nil)
_, nc, _ := testutil.StartTestJetStream(t)
store := NewCredentialStore(testutil.NewJetStream(t, nc), fixedMasterKey(), 1, nil)
key, ok, err := store.Resolve(context.Background(), "999999999999", "anthropic")
require.NoError(t, err)
+4 -4
View File
@@ -110,7 +110,7 @@ var ec2Actions = map[string]EC2Handler{
return gw.checkPolicyResource(r, "iam", "PassRole", roleARN)
}
launchQuotaCheck := func() error {
return gw.Quota.EnforceLaunch(accountID, aws.StringValue(input.InstanceType), int(aws.Int64Value(input.MaxCount)))
return gw.Quota.EnforceLaunch(ctx, accountID, aws.StringValue(input.InstanceType), int(aws.Int64Value(input.MaxCount)))
}
reservation, err := gateway_ec2_instance.RunInstances(ctx, input, gw.NATSConn, gw.IAMService, accountID, passRoleCheck, launchQuotaCheck, gw.ExpectedNodes)
if err != nil {
@@ -118,7 +118,7 @@ var ec2Actions = map[string]EC2Handler{
}
// Charge the actual launched vCPUs; a counter write failure is drift for
// reconcile to correct, so it must not fail the already-successful launch.
if err := gw.Quota.ChargeLaunch(accountID, &reservation); err != nil {
if err := gw.Quota.ChargeLaunch(ctx, accountID, &reservation); err != nil {
slog.WarnContext(ctx, "RunInstances: vcpu quota charge failed, reconcile will correct", "account", accountID, "err", err)
}
return reservation, nil
@@ -166,7 +166,7 @@ var ec2Actions = map[string]EC2Handler{
var delta int
if input.InstanceType != nil {
resolve := handlers_quota.NATSInstanceTypeResolver(gw.NATSConn, func() int { return gw.ExpectedNodes })
d, err := gw.Quota.EnforceRetype(resolve, accountID, aws.StringValue(input.InstanceId), aws.StringValue(input.InstanceType.Value))
d, err := gw.Quota.EnforceRetype(ctx, resolve, accountID, aws.StringValue(input.InstanceId), aws.StringValue(input.InstanceType.Value))
if err != nil {
return nil, err
}
@@ -178,7 +178,7 @@ var ec2Actions = map[string]EC2Handler{
}
// Charge the retype's vCPU growth; a counter write failure is drift for
// reconcile to correct, so it must not fail the applied retype.
if err := gw.Quota.AddVCPU(accountID, delta); err != nil {
if err := gw.Quota.AddVCPU(ctx, accountID, delta); err != nil {
slog.WarnContext(ctx, "ModifyInstanceAttribute: vcpu quota charge failed, reconcile will correct", "account", accountID, "err", err)
}
return out, nil
@@ -101,7 +101,7 @@ func RunInstances(ctx context.Context, input *ec2.RunInstancesInput, natsConn *n
}
// Hash before any mutation so the same token+params always matches.
paramHash := clientTokenParamHash(input)
return runInstancesWithClientToken(store, accountID, token, paramHash, func() (ec2.Reservation, error) {
return runInstancesWithClientToken(ctx, store, accountID, token, paramHash, func() (ec2.Reservation, error) {
return runInstancesInner(ctx, input, natsConn, iamSvc, accountID, passRoleCheck, launchQuotaCheck, expectedNodes)
})
}
@@ -168,7 +168,7 @@ func runInstancesInner(ctx context.Context, input *ec2.RunInstancesInput, natsCo
reservationPtr, err := distributeInstances(ctx, input, natsConn, accountID, expectedNodes)
if err != nil {
// Distinguish "unknown type" from "no capacity" via DescribeInstanceTypes.
if err.Error() == awserrors.ErrorInsufficientInstanceCapacity {
if code, ok := awserrors.ResolveErrorCode(err); ok && code == awserrors.ErrorInsufficientInstanceCapacity {
if !isKnownInstanceType(ctx, natsConn, *input.InstanceType) {
return reservation, errors.New(awserrors.ErrorInvalidInstanceType)
}
+32 -21
View File
@@ -14,6 +14,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
const (
@@ -54,7 +55,7 @@ type tokenRecord struct {
// ClientTokenStore implements RunInstances ClientToken idempotency over a TTL KV
// bucket. The first caller owns the launch; duplicates replay (done) or poll (in-flight).
type ClientTokenStore struct {
kv nats.KeyValue
kv jetstream.KeyValue
}
var (
@@ -66,20 +67,24 @@ var (
// getClientTokenStore lazily initialises the process-wide client-token store via sync.Once.
func getClientTokenStore(ctx context.Context, nc *nats.Conn) (*ClientTokenStore, error) {
ctOnce.Do(func() {
js, err := nc.JetStream()
js, err := jetstream.New(nc)
if err != nil {
errCTStoreInit = fmt.Errorf("clienttoken jetstream: %w", err)
return
}
ctStore, errCTStoreInit = newClientTokenStore(js)
// The bind happens once per process, so it must not inherit the first
// caller's cancellation: a client that disconnects mid-open would poison
// the store for every later launch. Deadline-free, so the open falls back
// to the JetStream API's own timeout.
ctStore, errCTStoreInit = newClientTokenStore(context.WithoutCancel(ctx), js)
})
return ctStore, errCTStoreInit
}
func newClientTokenStore(js nats.JetStreamContext) (*ClientTokenStore, error) {
kv, err := js.KeyValue(kvBucketClientTokens)
if errors.Is(err, nats.ErrBucketNotFound) {
kv, err = js.CreateKeyValue(&nats.KeyValueConfig{
func newClientTokenStore(ctx context.Context, js jetstream.JetStream) (*ClientTokenStore, error) {
kv, err := js.KeyValue(ctx, kvBucketClientTokens)
if errors.Is(err, jetstream.ErrBucketNotFound) {
kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: kvBucketClientTokens,
History: 1,
TTL: clientTokenTTL,
@@ -112,7 +117,7 @@ func clientTokenParamHash(input *ec2.RunInstancesInput) string {
// Claim attempts to own the launch for this token.
// Returns (nil, true, nil) when the caller owns the launch (must Finalize or Abort),
// (reservation, false, nil) to replay a completed launch, or an error on mismatch/timeout.
func (c *ClientTokenStore) Claim(accountID, token, paramHash string) (*ec2.Reservation, bool, error) {
func (c *ClientTokenStore) Claim(ctx context.Context, accountID, token, paramHash string) (*ec2.Reservation, bool, error) {
key := clientTokenKey(accountID, token)
inflight, err := json.Marshal(tokenRecord{
Status: tokenStatusInFlight,
@@ -122,21 +127,21 @@ func (c *ClientTokenStore) Claim(accountID, token, paramHash string) (*ec2.Reser
if err != nil {
return nil, false, fmt.Errorf("clienttoken marshal: %w", err)
}
if _, cerr := c.kv.Create(key, inflight); cerr == nil {
if _, cerr := c.kv.Create(ctx, key, inflight); cerr == nil {
return nil, true, nil
} else if !errors.Is(cerr, nats.ErrKeyExists) {
} else if !errors.Is(cerr, jetstream.ErrKeyExists) {
return nil, false, fmt.Errorf("clienttoken create %s: %w", key, cerr)
}
deadline := time.Now().Add(clientTokenWaitTimeout)
for {
entry, gerr := c.kv.Get(key)
entry, gerr := c.kv.Get(ctx, key)
if gerr != nil {
if errors.Is(gerr, nats.ErrKeyNotFound) {
if errors.Is(gerr, jetstream.ErrKeyNotFound) {
// Owner aborted or record aged out: race for ownership.
if _, rcerr := c.kv.Create(key, inflight); rcerr == nil {
if _, rcerr := c.kv.Create(ctx, key, inflight); rcerr == nil {
return nil, true, nil
} else if !errors.Is(rcerr, nats.ErrKeyExists) {
} else if !errors.Is(rcerr, jetstream.ErrKeyExists) {
return nil, false, fmt.Errorf("clienttoken recreate %s: %w", key, rcerr)
}
continue
@@ -161,7 +166,7 @@ func (c *ClientTokenStore) Claim(accountID, token, paramHash string) (*ec2.Reser
}
// Finalize marks the token done with the reservation so duplicates can replay it.
func (c *ClientTokenStore) Finalize(accountID, token, paramHash string, res *ec2.Reservation) error {
func (c *ClientTokenStore) Finalize(ctx context.Context, accountID, token, paramHash string, res *ec2.Reservation) error {
key := clientTokenKey(accountID, token)
data, err := json.Marshal(tokenRecord{
Status: tokenStatusDone,
@@ -172,7 +177,7 @@ func (c *ClientTokenStore) Finalize(accountID, token, paramHash string, res *ec2
if err != nil {
return fmt.Errorf("clienttoken finalize marshal: %w", err)
}
if _, err := c.kv.Put(key, data); err != nil {
if _, err := c.kv.Put(ctx, key, data); err != nil {
return fmt.Errorf("clienttoken finalize put %s: %w", key, err)
}
return nil
@@ -182,12 +187,13 @@ func (c *ClientTokenStore) Finalize(accountID, token, paramHash string, res *ec2
// claims the token, replays a completed reservation, or (as owner) launches,
// finalizes, and aborts on failure. Extracted for unit-testability.
func runInstancesWithClientToken(
ctx context.Context,
store *ClientTokenStore,
accountID, token, paramHash string,
launch func() (ec2.Reservation, error),
) (ec2.Reservation, error) {
var zero ec2.Reservation
replay, owned, cerr := store.Claim(accountID, token, paramHash)
replay, owned, cerr := store.Claim(ctx, accountID, token, paramHash)
if cerr != nil {
if errors.Is(cerr, errIdempotentParamMismatch) {
return zero, errors.New(awserrors.ErrorIdempotentParameterMismatch)
@@ -203,11 +209,16 @@ func runInstancesWithClientToken(
}
res, rerr := launch()
// Recording the launch outcome outlives ctx: a caller that went away mid-launch
// is exactly when the record must be settled, and leaving it in-flight parks
// every retry of that token behind the poll deadline until the record ages out.
outcomeCtx := context.WithoutCancel(ctx)
if rerr != nil {
store.Abort(accountID, token)
store.Abort(outcomeCtx, accountID, token)
return zero, rerr
}
if ferr := store.Finalize(accountID, token, paramHash, &res); ferr != nil {
if ferr := store.Finalize(outcomeCtx, accountID, token, paramHash, &res); ferr != nil {
// Launch succeeded; finalize failure only weakens future dedup — don't fail.
slog.Warn("RunInstances: failed to finalize client-token record", "token", token, "err", ferr)
}
@@ -215,9 +226,9 @@ func runInstancesWithClientToken(
}
// Abort drops the in-flight token so a retry can re-launch.
func (c *ClientTokenStore) Abort(accountID, token string) {
func (c *ClientTokenStore) Abort(ctx context.Context, accountID, token string) {
key := clientTokenKey(accountID, token)
if err := c.kv.Delete(key); err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
if err := c.kv.Delete(ctx, key); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
slog.Warn("clienttoken: failed to abort token record", "key", key, "err", err)
}
}
@@ -1,7 +1,6 @@
package gateway_ec2_instance
import (
"context"
"errors"
"sync"
"sync/atomic"
@@ -20,8 +19,8 @@ const ctTestAccount = "111122223333"
func newTestClientTokenStore(t *testing.T) *ClientTokenStore {
t.Helper()
_, _, js := testutil.StartTestJetStream(t)
store, err := newClientTokenStore(js)
_, nc, _ := testutil.StartTestJetStream(t)
store, err := newClientTokenStore(t.Context(), testutil.NewJetStream(t, nc))
require.NoError(t, err)
return store
}
@@ -32,14 +31,14 @@ func TestClientToken_ReplaysCompletedReservation(t *testing.T) {
store := newTestClientTokenStore(t)
const tok, hash = "tok-1", "hash-a"
_, owned, err := store.Claim(ctTestAccount, tok, hash)
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
require.NoError(t, err)
require.True(t, owned, "first caller owns the launch")
res := &ec2.Reservation{ReservationId: aws.String("r-123")}
require.NoError(t, store.Finalize(ctTestAccount, tok, hash, res))
require.NoError(t, store.Finalize(t.Context(), ctTestAccount, tok, hash, res))
replay, owned2, err := store.Claim(ctTestAccount, tok, hash)
replay, owned2, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
require.NoError(t, err)
assert.False(t, owned2, "duplicate caller does not own the launch")
require.NotNil(t, replay)
@@ -51,12 +50,12 @@ func TestClientToken_ParamMismatchRejected(t *testing.T) {
store := newTestClientTokenStore(t)
const tok = "tok-2"
_, owned, err := store.Claim(ctTestAccount, tok, "hash-a")
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, "hash-a")
require.NoError(t, err)
require.True(t, owned)
require.NoError(t, store.Finalize(ctTestAccount, tok, "hash-a", &ec2.Reservation{ReservationId: aws.String("r-1")}))
require.NoError(t, store.Finalize(t.Context(), ctTestAccount, tok, "hash-a", &ec2.Reservation{ReservationId: aws.String("r-1")}))
_, _, err = store.Claim(ctTestAccount, tok, "hash-DIFFERENT")
_, _, err = store.Claim(t.Context(), ctTestAccount, tok, "hash-DIFFERENT")
assert.ErrorIs(t, err, errIdempotentParamMismatch)
}
@@ -66,13 +65,13 @@ func TestClientToken_AbortAllowsRelaunch(t *testing.T) {
store := newTestClientTokenStore(t)
const tok, hash = "tok-3", "hash-a"
_, owned, err := store.Claim(ctTestAccount, tok, hash)
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
require.NoError(t, err)
require.True(t, owned)
store.Abort(ctTestAccount, tok)
store.Abort(t.Context(), ctTestAccount, tok)
_, owned2, err := store.Claim(ctTestAccount, tok, hash)
_, owned2, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
require.NoError(t, err)
assert.True(t, owned2, "after abort the token is free to re-own")
}
@@ -93,7 +92,7 @@ func TestClientToken_ConcurrentSingleOwner(t *testing.T) {
wg.Add(1)
go func(i int) {
defer wg.Done()
replay, owned, err := store.Claim(ctTestAccount, tok, hash)
replay, owned, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
if err != nil {
errs[i] = err
return
@@ -101,7 +100,7 @@ func TestClientToken_ConcurrentSingleOwner(t *testing.T) {
if owned {
atomic.AddInt32(&owners, 1)
// Simulate the launch then publish the reservation.
errs[i] = store.Finalize(ctTestAccount, tok, hash, res)
errs[i] = store.Finalize(t.Context(), ctTestAccount, tok, hash, res)
replays[i] = res
return
}
@@ -147,13 +146,13 @@ func TestClientTokenParamHash_IgnoresTokenReflectsParams(t *testing.T) {
func TestRunInstancesWithClientToken_ReplaySkipsLaunch(t *testing.T) {
store := newTestClientTokenStore(t)
const tok, hash = "rt-1", "h"
_, owned, err := store.Claim(ctTestAccount, tok, hash)
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
require.NoError(t, err)
require.True(t, owned)
require.NoError(t, store.Finalize(ctTestAccount, tok, hash, &ec2.Reservation{ReservationId: aws.String("r-x")}))
require.NoError(t, store.Finalize(t.Context(), ctTestAccount, tok, hash, &ec2.Reservation{ReservationId: aws.String("r-x")}))
launched := false
res, err := runInstancesWithClientToken(store, ctTestAccount, tok, hash, func() (ec2.Reservation, error) {
res, err := runInstancesWithClientToken(t.Context(), store, ctTestAccount, tok, hash, func() (ec2.Reservation, error) {
launched = true
return ec2.Reservation{}, nil
})
@@ -172,11 +171,11 @@ func TestRunInstancesWithClientToken_OwnerLaunchesOnceThenReplay(t *testing.T) {
return ec2.Reservation{ReservationId: aws.String("r-own")}, nil
}
res, err := runInstancesWithClientToken(store, ctTestAccount, tok, hash, launch)
res, err := runInstancesWithClientToken(t.Context(), store, ctTestAccount, tok, hash, launch)
require.NoError(t, err)
assert.Equal(t, "r-own", aws.StringValue(res.ReservationId))
res2, err := runInstancesWithClientToken(store, ctTestAccount, tok, hash, launch)
res2, err := runInstancesWithClientToken(t.Context(), store, ctTestAccount, tok, hash, launch)
require.NoError(t, err)
assert.Equal(t, "r-own", aws.StringValue(res2.ReservationId))
assert.Equal(t, 1, launches, "duplicate must replay, not relaunch")
@@ -187,13 +186,13 @@ func TestRunInstancesWithClientToken_LaunchFailureAborts(t *testing.T) {
store := newTestClientTokenStore(t)
const tok, hash = "rt-3", "h"
_, err := runInstancesWithClientToken(store, ctTestAccount, tok, hash, func() (ec2.Reservation, error) {
_, err := runInstancesWithClientToken(t.Context(), store, ctTestAccount, tok, hash, func() (ec2.Reservation, error) {
return ec2.Reservation{}, errors.New("no capacity")
})
require.Error(t, err)
relaunched := false
res, err := runInstancesWithClientToken(store, ctTestAccount, tok, hash, func() (ec2.Reservation, error) {
res, err := runInstancesWithClientToken(t.Context(), store, ctTestAccount, tok, hash, func() (ec2.Reservation, error) {
relaunched = true
return ec2.Reservation{ReservationId: aws.String("r-retry")}, nil
})
@@ -207,13 +206,13 @@ func TestRunInstancesWithClientToken_LaunchFailureAborts(t *testing.T) {
func TestRunInstancesWithClientToken_ParamMismatchMapsAWSError(t *testing.T) {
store := newTestClientTokenStore(t)
const tok = "rt-4"
_, owned, err := store.Claim(ctTestAccount, tok, "hA")
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, "hA")
require.NoError(t, err)
require.True(t, owned)
require.NoError(t, store.Finalize(ctTestAccount, tok, "hA", &ec2.Reservation{ReservationId: aws.String("r")}))
require.NoError(t, store.Finalize(t.Context(), ctTestAccount, tok, "hA", &ec2.Reservation{ReservationId: aws.String("r")}))
launched := false
_, err = runInstancesWithClientToken(store, ctTestAccount, tok, "hB", func() (ec2.Reservation, error) {
_, err = runInstancesWithClientToken(t.Context(), store, ctTestAccount, tok, "hB", func() (ec2.Reservation, error) {
launched = true
return ec2.Reservation{}, nil
})
@@ -225,10 +224,10 @@ func TestRunInstancesWithClientToken_ParamMismatchMapsAWSError(t *testing.T) {
// instance on subsequent calls.
func TestGetClientTokenStore_BindsOnce(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
s1, err := getClientTokenStore(context.Background(), nc)
s1, err := getClientTokenStore(t.Context(), nc)
require.NoError(t, err)
require.NotNil(t, s1)
s2, err := getClientTokenStore(context.Background(), nc)
s2, err := getClientTokenStore(t.Context(), nc)
require.NoError(t, err)
assert.Same(t, s1, s2, "store binds once")
}
@@ -239,7 +238,7 @@ func TestClientToken_InFlightWaitTimesOut(t *testing.T) {
store := newTestClientTokenStore(t)
const tok, hash = "wt-1", "h"
_, owned, err := store.Claim(ctTestAccount, tok, hash)
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
require.NoError(t, err)
require.True(t, owned, "owner holds the in-flight record and never finalizes")
@@ -247,6 +246,6 @@ func TestClientToken_InFlightWaitTimesOut(t *testing.T) {
clientTokenWaitTimeout, clientTokenPollStep = 30*time.Millisecond, 10*time.Millisecond
defer func() { clientTokenWaitTimeout, clientTokenPollStep = origTimeout, origStep }()
_, _, err = store.Claim(ctTestAccount, tok, hash)
_, _, err = store.Claim(t.Context(), ctTestAccount, tok, hash)
assert.ErrorIs(t, err, errClientTokenWaitTimeout)
}
+4 -4
View File
@@ -437,11 +437,11 @@ func extractClientError(results []nodeLaunchResult) error {
if r.Err == nil {
continue
}
inner := errors.Unwrap(r.Err)
if inner == nil {
code, ok := awserrors.ResolveErrorCode(r.Err)
if !ok {
continue
}
switch inner.Error() {
switch code {
case awserrors.ErrorInsufficientInstanceCapacity,
awserrors.ErrorInvalidAMIIDNotFound,
awserrors.ErrorInvalidAMIIDMalformed,
@@ -451,7 +451,7 @@ func extractClientError(results []nodeLaunchResult) error {
awserrors.ErrorInvalidGroupNotFound,
awserrors.ErrorInvalidParameterValue,
awserrors.ErrorSecurityGroupsPerInterfaceLimitExceeded:
return inner
return errors.New(code)
}
}
return nil
@@ -371,6 +371,27 @@ func TestExtractClientError_AMINotFound(t *testing.T) {
assert.Equal(t, awserrors.ErrorInvalidAMIIDNotFound, err.Error())
}
func TestExtractClientError_NestedWrappedCode(t *testing.T) {
inner := errors.New(awserrors.ErrorInvalidParameterValue)
wrapped := fmt.Errorf("launch on node-1: %w", fmt.Errorf("prepare instance: %w", inner))
results := []nodeLaunchResult{
{NodeID: "node-1", Err: wrapped},
}
err := extractClientError(results)
require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
}
func TestExtractClientError_JoinedCode(t *testing.T) {
joined := errors.Join(assert.AnError, errors.New(awserrors.ErrorInvalidGroupNotFound))
results := []nodeLaunchResult{
{NodeID: "node-1", Err: fmt.Errorf("launch on node-1: %w", joined)},
}
err := extractClientError(results)
require.EqualError(t, err, awserrors.ErrorInvalidGroupNotFound)
}
func TestExtractClientError_KeyPairNotFound(t *testing.T) {
inner := errors.New(awserrors.ErrorInvalidKeyPairNotFound)
wrapped := fmt.Errorf("launch on node-1: %w", inner)
@@ -76,7 +76,7 @@ func RequestSpotInstances(ctx context.Context, input *ec2.RequestSpotInstancesIn
// Gate on the per-account vCPU cap exactly like the on-demand path; spot
// launches are real VMs and must not slip past the quota.
launchQuotaCheck := func() error {
return quota.EnforceLaunch(accountID, aws.StringValue(runInput.InstanceType), int(count))
return quota.EnforceLaunch(ctx, accountID, aws.StringValue(runInput.InstanceType), int(count))
}
// RunInstances normalises runInput in place (e.g. instance profile to ARN),
@@ -88,7 +88,7 @@ func RequestSpotInstances(ctx context.Context, input *ec2.RequestSpotInstancesIn
// Charge the actual launched vCPUs; a counter write failure is drift for
// reconcile to correct, so it must not fail the already-successful launch.
if err := quota.ChargeLaunch(accountID, &reservation); err != nil {
if err := quota.ChargeLaunch(ctx, accountID, &reservation); err != nil {
slog.WarnContext(ctx, "RequestSpotInstances: vcpu quota charge failed, reconcile will correct", "account", accountID, "err", err)
}
+2 -1
View File
@@ -157,7 +157,8 @@ func TestListImages_TaggedAndUntagged(t *testing.T) {
// canonicalize on meta.Digest to emit a real digest and keep tag association.
// The memory store returns real digests and would mask this.
func TestListImages_KVDigestForm(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
reg := NewRegistry(objectstore.NewMemoryObjectStore(), ecr.NewKVMetaStore(js), testAccount)
repo := "team/app"
require.NoError(t, reg.Meta.PutRepo(context.Background(), testAccount, ecr.RepoMeta{Name: repo}))
@@ -20,7 +20,8 @@ import (
// subjects against an embedded KV-backed service, returning a gateway client.
func newRepoLifecycleGateway(t *testing.T) (*GatewayConfig, *nats.Conn) {
t.Helper()
_, nc, js := testutil.StartTestJetStream(t)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
svc := handlers_ecr.NewKVMetaService(js)
serveECRMeta(t, nc, handlers_ecr.SubjectRepoCreate, svc.RepoCreate)
serveECRMeta(t, nc, handlers_ecr.SubjectRepoDescribe, svc.RepoDescribe)
@@ -42,7 +42,8 @@ func serveECRMeta[I any, O any](t *testing.T, nc *nats.Conn, subject string, fn
func newDescribeReposGateway(t *testing.T, repos ...string) *GatewayConfig {
t.Helper()
_, nc, js := testutil.StartTestJetStream(t)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
svc := handlers_ecr.NewKVMetaService(js)
serveECRMeta(t, nc, handlers_ecr.SubjectRepoCreate, svc.RepoCreate)
serveECRMeta(t, nc, handlers_ecr.SubjectRepoDescribe, svc.RepoDescribe)
@@ -20,7 +20,8 @@ const validLifecyclePolicy = `{"rules":[{"rulePriority":1,"selection":{"tagStatu
// lifecycle metadata subjects served, returning the gateway NATS connection.
func newLifecycleTestConn(t *testing.T) *nats.Conn {
t.Helper()
_, nc, js := testutil.StartTestJetStream(t)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
svc := handlers_ecr.NewKVMetaService(js)
serveMeta(t, nc, handlers_ecr.SubjectRepoCreate, svc.RepoCreate)
serveMeta(t, nc, handlers_ecr.SubjectRepoDescribe, svc.RepoDescribe)
@@ -45,7 +45,8 @@ func serveMeta[I any, O any](t *testing.T, nc *nats.Conn, subject string, fn fun
// metadata subjects served, returning the gateway NATS connection.
func newPolicyTestConn(t *testing.T) *nats.Conn {
t.Helper()
_, nc, js := testutil.StartTestJetStream(t)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
svc := handlers_ecr.NewKVMetaService(js)
serveMeta(t, nc, handlers_ecr.SubjectRepoCreate, svc.RepoCreate)
serveMeta(t, nc, handlers_ecr.SubjectRepoDescribe, svc.RepoDescribe)
+36 -25
View File
@@ -37,42 +37,46 @@ func samplePrincipal() Principal {
}
func TestLoadOrCreateSigningKey_CreatesThenReloads(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
key1, verify1, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
key1, verify1, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
require.NotEmpty(t, key1.Kid)
require.Contains(t, verify1, key1.Kid)
// Second call must reload the same persisted key, not mint a new one.
key2, verify2, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
key2, verify2, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
assert.Equal(t, key1.Kid, key2.Kid, "persisted signing key must be reused")
assert.Len(t, verify2, 1)
}
func TestLoadOrCreateSigningKey_StoresEncrypted(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
key, _, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
kv, err := js.KeyValue(SigningBucket)
kv, err := js.KeyValue(t.Context(), SigningBucket)
require.NoError(t, err)
entry, err := kv.Get(signingKeyName(key.Kid))
entry, err := kv.Get(t.Context(), signingKeyName(key.Kid))
require.NoError(t, err)
assert.NotContains(t, string(entry.Value()), "PRIVATE KEY",
"signing key must be encrypted at rest")
}
func TestLoadOrCreateSigningKey_EmptyMasterKeyRejected(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
_, _, err := LoadOrCreateSigningKey(js, nil, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
_, _, err := LoadOrCreateSigningKey(t.Context(), js, nil, 1)
require.Error(t, err)
}
func TestIssuerVerifier_RoundTrip(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
key, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
iss := NewIssuer(key, testAudience)
@@ -88,8 +92,9 @@ func TestIssuerVerifier_RoundTrip(t *testing.T) {
}
func TestIssuer_MintRequiresAccountID(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
key, _, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
p := samplePrincipal()
@@ -103,8 +108,9 @@ func TestIssuer_MintRequiresAccountID(t *testing.T) {
// accessKeyID, an unsupported principalType) must fail to mint at all, since
// a token that can't name a lookup key can never be safely rehydrated.
func TestMint_RequiresCompleteIdentityPointer(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
key, _, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
iss := NewIssuer(key, testAudience)
@@ -136,8 +142,9 @@ func TestSupportedPrincipalType(t *testing.T) {
}
func TestVerifier_RejectsWrongAudience(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
key, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
tok, _, err := NewIssuer(key, testAudience).Mint(samplePrincipal())
@@ -148,8 +155,9 @@ func TestVerifier_RejectsWrongAudience(t *testing.T) {
}
func TestVerifier_RejectsUnknownKid(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
key, _, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
key, _, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
tok, _, err := NewIssuer(key, testAudience).Mint(samplePrincipal())
@@ -161,8 +169,9 @@ func TestVerifier_RejectsUnknownKid(t *testing.T) {
}
func TestVerifier_RejectsExpiredToken(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
key, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
// Hand-mint an already-expired token with the same signing key.
@@ -188,8 +197,9 @@ func TestVerifier_RejectsExpiredToken(t *testing.T) {
// Mint's own checks, proving Verify itself refuses a claim set missing any
// part of the identity pointer even if it were signed by some other path.
func TestVerifier_RejectsIncompleteIdentityPointer(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
key, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
base := func() Claims {
@@ -234,8 +244,9 @@ func TestVerifier_RejectsIncompleteIdentityPointer(t *testing.T) {
}
func TestVerifier_RejectsNonES256(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
_, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
_, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
// HS256 token must be refused regardless of signature.
+18 -12
View File
@@ -6,7 +6,7 @@ import (
"sort"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// Rotation defaults (ecr-v1 Q3): rotate the active signing key every 30 days,
@@ -17,6 +17,10 @@ const (
DefaultRetainFor = 24 * time.Hour
DefaultRetainCount = 2
DefaultRotationCheckInterval = time.Hour
// rotationCycleTimeout bounds the KV work of one rotation cycle, so a wedged
// JetStream stalls a single tick instead of the whole loop until shutdown.
rotationCycleTimeout = 30 * time.Second
)
// rotationParams holds the rotation/retention policy.
@@ -61,7 +65,7 @@ func planRotation(metas []keyMeta, now time.Time, p rotationParams) (mint bool,
// safe to run on every awsgw instance — mint and delete on the shared KV bucket
// converge, and a delete of an already-gone key is a no-op.
type Rotator struct {
kv nats.KeyValue
kv jetstream.KeyValue
masterKey []byte
issuer *Issuer
verifier *Verifier
@@ -73,8 +77,8 @@ type Rotator struct {
// NewRotator opens the signing-key bucket and builds a rotator that keeps issuer
// and verifier current. replicas matches the cluster size for first-run bucket
// creation.
func NewRotator(js nats.JetStreamContext, masterKey []byte, replicas int, issuer *Issuer, verifier *Verifier) (*Rotator, error) {
kv, err := openSigningBucket(js, masterKey, replicas)
func NewRotator(ctx context.Context, js jetstream.JetStream, masterKey []byte, replicas int, issuer *Issuer, verifier *Verifier) (*Rotator, error) {
kv, err := openSigningBucket(ctx, js, masterKey, replicas)
if err != nil {
return nil, err
}
@@ -105,15 +109,17 @@ func (r *Rotator) Run(ctx context.Context) {
slog.Info("ECR signing-key rotator stopped")
return
case <-ticker.C:
r.rotateOnce(r.now())
cycleCtx, cancel := context.WithTimeout(ctx, rotationCycleTimeout)
r.rotateOnce(cycleCtx, r.now())
cancel()
}
}
}
// rotateOnce runs one rotation/prune cycle and refreshes the issuer/verifier.
// Errors are logged and skipped so a transient KV fault never crashes the loop.
func (r *Rotator) rotateOnce(now time.Time) {
_, _, metas, err := reloadKeys(r.kv, r.masterKey)
func (r *Rotator) rotateOnce(ctx context.Context, now time.Time) {
_, _, metas, err := reloadKeys(ctx, r.kv, r.masterKey)
if err != nil {
slog.Warn("ECR signing-key rotation: load keys failed", "err", err)
return
@@ -121,7 +127,7 @@ func (r *Rotator) rotateOnce(now time.Time) {
mint, prune := planRotation(metas, now, r.params)
if mint {
newKey, err := generateSigningKey(r.kv, r.masterKey)
newKey, err := generateSigningKey(ctx, r.kv, r.masterKey)
if err != nil {
slog.Warn("ECR signing-key rotation: mint failed", "err", err)
return
@@ -129,19 +135,19 @@ func (r *Rotator) rotateOnce(now time.Time) {
slog.Info("ECR signing-key rotated", "kid", newKey.Kid)
}
for _, kid := range prune {
if err := deleteSigningKey(r.kv, kid); err != nil {
if err := deleteSigningKey(ctx, r.kv, kid); err != nil {
slog.Warn("ECR signing-key rotation: prune failed", "kid", kid, "err", err)
continue
}
slog.Info("ECR signing-key pruned", "kid", kid)
}
r.refresh()
r.refresh(ctx)
}
// refresh reloads the canonical key set and installs it on the issuer/verifier.
func (r *Rotator) refresh() {
active, verify, _, err := reloadKeys(r.kv, r.masterKey)
func (r *Rotator) refresh(ctx context.Context) {
active, verify, _, err := reloadKeys(ctx, r.kv, r.masterKey)
if err != nil {
slog.Warn("ECR signing-key rotation: refresh failed", "err", err)
return
+23 -19
View File
@@ -77,13 +77,14 @@ func TestPlanRotation(t *testing.T) {
// always due: a new active key is minted, installed on the issuer, and accepted
// by the verifier, while the prior key stays verifiable.
func TestRotateOnce_MintsAndSwaps(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
key, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
issuer := NewIssuer(key, testAudience)
verifier := NewVerifier(verify, testAudience)
kv, err := openSigningBucket(js, testMasterKey, 1)
kv, err := openSigningBucket(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
// Token minted under the original key before rotation.
@@ -95,7 +96,7 @@ func TestRotateOnce_MintsAndSwaps(t *testing.T) {
params: rotationParams{rotateAfter: 0, retainFor: time.Hour, retainCount: 2},
now: func() time.Time { return time.Now().UTC() },
}
r.rotateOnce(time.Now().UTC())
r.rotateOnce(t.Context(), time.Now().UTC())
// A new active key took over.
assert.NotEqual(t, key.Kid, issuer.ActiveKid(), "issuer should sign with the rotated key")
@@ -108,7 +109,7 @@ func TestRotateOnce_MintsAndSwaps(t *testing.T) {
_, err = verifier.Verify(newTok)
require.NoError(t, err, "token from the rotated key must verify")
_, _, metas, err := reloadKeys(kv, testMasterKey)
_, _, metas, err := reloadKeys(t.Context(), kv, testMasterKey)
require.NoError(t, err)
assert.Len(t, metas, 2, "previous key retained alongside the new active key")
}
@@ -117,12 +118,13 @@ func TestRotateOnce_MintsAndSwaps(t *testing.T) {
// retainFor=0 (and no mint) so the older key is pruned and dropped from the
// verifier.
func TestRotateOnce_PrunesExpiredKey(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
first, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
first, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
kv, err := openSigningBucket(js, testMasterKey, 1)
kv, err := openSigningBucket(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
second, err := generateSigningKey(kv, testMasterKey)
second, err := generateSigningKey(t.Context(), kv, testMasterKey)
require.NoError(t, err)
require.NotEqual(t, first.Kid, second.Kid)
@@ -134,9 +136,9 @@ func TestRotateOnce_PrunesExpiredKey(t *testing.T) {
params: rotationParams{rotateAfter: 1000 * time.Hour, retainFor: 0, retainCount: 2},
now: func() time.Time { return time.Now().UTC() },
}
r.rotateOnce(time.Now().UTC())
r.rotateOnce(t.Context(), time.Now().UTC())
active, _, metas, err := reloadKeys(kv, testMasterKey)
active, _, metas, err := reloadKeys(t.Context(), kv, testMasterKey)
require.NoError(t, err)
require.Len(t, metas, 1, "the older key should be pruned")
assert.Equal(t, second.Kid, active.Kid, "newest key remains active")
@@ -147,12 +149,13 @@ func TestRotateOnce_PrunesExpiredKey(t *testing.T) {
// with rotateAfter=0 so it mints on the first tick, then cancels: the active key
// changes and Run returns.
func TestRotator_RunRotatesThenStops(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
key, verify, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
key, verify, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
issuer := NewIssuer(key, testAudience)
verifier := NewVerifier(verify, testAudience)
kv, err := openSigningBucket(js, testMasterKey, 1)
kv, err := openSigningBucket(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
r := &Rotator{
@@ -180,14 +183,15 @@ func TestRotator_RunRotatesThenStops(t *testing.T) {
// TestIssuerVerifier_ConcurrentSwap exercises the hot-swap under -race: Mint and
// Verify run while SetActiveKey/SetKeys rotate the key set.
func TestIssuerVerifier_ConcurrentSwap(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
k1, v1, err := LoadOrCreateSigningKey(js, testMasterKey, 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
k1, v1, err := LoadOrCreateSigningKey(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
kv, err := openSigningBucket(js, testMasterKey, 1)
kv, err := openSigningBucket(t.Context(), js, testMasterKey, 1)
require.NoError(t, err)
k2, err := generateSigningKey(kv, testMasterKey)
k2, err := generateSigningKey(t.Context(), kv, testMasterKey)
require.NoError(t, err)
_, v2, _, err := reloadKeys(kv, testMasterKey)
_, v2, _, err := reloadKeys(t.Context(), kv, testMasterKey)
require.NoError(t, err)
issuer := NewIssuer(k1, testAudience)
+17 -25
View File
@@ -6,6 +6,7 @@
package gateway_ecrauth
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
@@ -19,7 +20,8 @@ import (
"time"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/nats-io/nats.go"
"github.com/mulgadc/spinifex/spinifex/kvutil"
"github.com/nats-io/nats.go/jetstream"
)
const (
@@ -73,17 +75,17 @@ func kidFor(pub *ecdsa.PublicKey) (string, error) {
// returns the active signing key. On first run it generates and persists one.
// The active key is the newest by KV creation time (ties broken by kid), so all
// nodes converge on the same signer and a freshly rotated key takes over.
func LoadOrCreateSigningKey(js nats.JetStreamContext, masterKey []byte, replicas int) (*SigningKey, map[string]*ecdsa.PublicKey, error) {
kv, err := openSigningBucket(js, masterKey, replicas)
func LoadOrCreateSigningKey(ctx context.Context, js jetstream.JetStream, masterKey []byte, replicas int) (*SigningKey, map[string]*ecdsa.PublicKey, error) {
kv, err := openSigningBucket(ctx, js, masterKey, replicas)
if err != nil {
return nil, nil, err
}
active, verify, _, err := reloadKeys(kv, masterKey)
active, verify, _, err := reloadKeys(ctx, kv, masterKey)
if err != nil {
return nil, nil, err
}
if active == nil {
active, err = generateSigningKey(kv, masterKey)
active, err = generateSigningKey(ctx, kv, masterKey)
if err != nil {
return nil, nil, err
}
@@ -94,32 +96,22 @@ func LoadOrCreateSigningKey(js nats.JetStreamContext, masterKey []byte, replicas
// openSigningBucket validates inputs and returns the awsgw-keys KV handle,
// creating the cluster-replicated bucket on first use.
func openSigningBucket(js nats.JetStreamContext, masterKey []byte, replicas int) (nats.KeyValue, error) {
func openSigningBucket(ctx context.Context, js jetstream.JetStream, masterKey []byte, replicas int) (jetstream.KeyValue, error) {
if js == nil {
return nil, errors.New("ecrauth: nil JetStream context")
}
if len(masterKey) == 0 {
return nil, errors.New("ecrauth: empty master key")
}
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{
Bucket: SigningBucket,
History: signingKeyHistory,
Replicas: replicas,
})
if err != nil {
if kv, err = js.KeyValue(SigningBucket); err != nil {
return nil, fmt.Errorf("open %s bucket: %w", SigningBucket, err)
}
}
return kv, nil
return kvutil.GetOrCreateBucketWithReplicas(ctx, js, SigningBucket, signingKeyHistory, replicas)
}
// reloadKeys reads every stored signing key, returning the verify set, each
// key's metadata, and the active (newest) decrypted key. active is nil for an
// empty bucket. Used by both startup and the rotation scheduler.
func reloadKeys(kv nats.KeyValue, masterKey []byte) (active *SigningKey, verify map[string]*ecdsa.PublicKey, metas []keyMeta, err error) {
names, err := kv.Keys()
if err != nil && !errors.Is(err, nats.ErrNoKeysFound) {
func reloadKeys(ctx context.Context, kv jetstream.KeyValue, masterKey []byte) (active *SigningKey, verify map[string]*ecdsa.PublicKey, metas []keyMeta, err error) {
names, err := kvutil.Keys(ctx, kv)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil, nil, fmt.Errorf("list signing keys: %w", err)
}
@@ -129,7 +121,7 @@ func reloadKeys(kv nats.KeyValue, masterKey []byte) (active *SigningKey, verify
if !strings.HasPrefix(name, signingKeyPrefix) {
continue
}
entry, err := kv.Get(name)
entry, err := kv.Get(ctx, name)
if err != nil {
return nil, nil, nil, fmt.Errorf("kv get %s: %w", name, err)
}
@@ -149,8 +141,8 @@ func reloadKeys(kv nats.KeyValue, masterKey []byte) (active *SigningKey, verify
// deleteSigningKey removes a rotated-out key from the bucket once its retention
// window has elapsed.
func deleteSigningKey(kv nats.KeyValue, kid string) error {
if err := kv.Delete(signingKeyName(kid)); err != nil {
func deleteSigningKey(ctx context.Context, kv jetstream.KeyValue, kid string) error {
if err := kv.Delete(ctx, signingKeyName(kid)); err != nil {
return fmt.Errorf("kv delete %s: %w", signingKeyName(kid), err)
}
return nil
@@ -172,7 +164,7 @@ func decodeSigningKey(kid string, ciphertext []byte, masterKey []byte) (*Signing
// generateSigningKey creates a fresh ES256 key, persists the encrypted PEM under
// jwt-signing/{kid}, and returns it.
func generateSigningKey(kv nats.KeyValue, masterKey []byte) (*SigningKey, error) {
func generateSigningKey(ctx context.Context, kv jetstream.KeyValue, masterKey []byte) (*SigningKey, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate ES256 key: %w", err)
@@ -190,7 +182,7 @@ func generateSigningKey(kv nats.KeyValue, masterKey []byte) (*SigningKey, error)
if err != nil {
return nil, fmt.Errorf("encrypt signing key: %w", err)
}
if _, err := kv.Put(signingKeyName(kid), []byte(ciphertext)); err != nil {
if _, err := kv.Put(ctx, signingKeyName(kid), []byte(ciphertext)); err != nil {
return nil, fmt.Errorf("kv put %s: %w", signingKeyName(kid), err)
}
return &SigningKey{Kid: kid, priv: priv}, nil
+3 -2
View File
@@ -32,8 +32,9 @@ func ecrTestMasterKey(t *testing.T) []byte {
// newECRAuth builds a wired issuer+verifier over a freshly generated signing key.
func newECRAuth(t *testing.T) (*gateway_ecrauth.Issuer, *gateway_ecrauth.Verifier) {
t.Helper()
_, _, js := testutil.StartTestJetStream(t)
key, verify, err := gateway_ecrauth.LoadOrCreateSigningKey(js, ecrTestMasterKey(t), 1)
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
key, verify, err := gateway_ecrauth.LoadOrCreateSigningKey(t.Context(), js, ecrTestMasterKey(t), 1)
require.NoError(t, err)
return gateway_ecrauth.NewIssuer(key, ecrTestAudience), gateway_ecrauth.NewVerifier(verify, ecrTestAudience)
}
+6 -3
View File
@@ -10,6 +10,7 @@ import (
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -56,9 +57,11 @@ func TestWebhookTokenReview_InfraFaultIsServerInternal(t *testing.T) {
}
func TestWebhookTokenReview_ResolvesIdentity(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
kv := testutil.SeedKV(t, js, handlers_eks.AccountBucketName("111122223333"), nil)
require.NoError(t, handlers_eks.PutAccessEntryRecord(kv, &handlers_eks.AccessEntryRecord{
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
kv, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: handlers_eks.AccountBucketName("111122223333")})
require.NoError(t, err)
require.NoError(t, handlers_eks.PutAccessEntryRecord(t.Context(), kv, &handlers_eks.AccessEntryRecord{
ClusterName: "alpha",
PrincipalARN: testARN,
KubernetesUsername: testARN,
+20 -28
View File
@@ -550,15 +550,13 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
slog.Debug("ErrorHandler", "service", svc, "error", err.Error())
var requestId = uuid.NewString()
var errorMsg = awserrors.ErrorMessage{}
if _, exists := awserrors.ErrorLookup[err.Error()]; !exists {
code, exists := awserrors.ResolveErrorCode(err)
if !exists {
slog.Warn("Unknown error code", "error", err.Error())
err = errors.New(awserrors.ErrorInternalError)
code = awserrors.ErrorInternalError
}
errorMsg = awserrors.ErrorLookup[err.Error()]
errorMsg := awserrors.ErrorLookup[code]
if errorMsg.HTTPCode == 0 {
errorMsg.HTTPCode = 500
}
@@ -566,8 +564,8 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
// EKS, ECR, ACM, ECS, tagging, and bedrock/bedrock-runtime use AWS JSON 1.1;
// query/XML services fall through.
if svc == "eks" || svc == "ecr" || svc == "acm" || svc == "ecs" || svc == "tagging" || svc == "bedrock" || svc == "bedrock-runtime" {
body := GenerateEKSErrorResponse(err.Error(), errorMsg.Message, requestId)
slog.Debug("Generated JSON error response", "service", svc, "error", err.Error(), "json", string(body), "requestId", requestId)
body := GenerateEKSErrorResponse(code, errorMsg.Message, requestId)
slog.Debug("Generated JSON error response", "service", svc, "error", err, "code", code, "json", string(body), "requestId", requestId)
w.Header().Set("Content-Type", eksJSONContentType)
w.WriteHeader(errorMsg.HTTPCode)
if _, err := w.Write(body); err != nil {
@@ -578,12 +576,12 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
var xmlError []byte
if svc == "iam" || svc == "sts" || svc == "elasticloadbalancing" {
xmlError = GenerateIAMErrorResponse(err.Error(), errorMsg.Message, requestId)
xmlError = GenerateIAMErrorResponse(code, errorMsg.Message, requestId)
} else {
xmlError = GenerateEC2ErrorResponse(err.Error(), errorMsg.Message, requestId)
xmlError = GenerateEC2ErrorResponse(code, errorMsg.Message, requestId)
}
slog.Debug("Generated error response", "error", err.Error(), "xml", string(xmlError), "requestId", requestId)
slog.Debug("Generated error response", "error", err, "code", code, "xml", string(xmlError), "requestId", requestId)
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(errorMsg.HTTPCode)
@@ -608,23 +606,17 @@ func readQueryArgs(r *http.Request) (map[string]string, error) {
// ParseAWSQueryArgs parses an AWS query-protocol body. Returns an error on
// invalid percent-encoding so callers can surface MalformedQueryString.
func ParseAWSQueryArgs(query string) (map[string]string, error) {
params := make(map[string]string)
pairs := strings.SplitSeq(query, "&")
for pair := range pairs {
kv := strings.SplitN(pair, "=", 2)
key, err := url.QueryUnescape(kv[0])
if err != nil {
return nil, fmt.Errorf("invalid URL encoding in parameter name: %w", err)
}
if len(kv) == 2 {
value, err := url.QueryUnescape(kv[1])
if err != nil {
return nil, fmt.Errorf("invalid URL encoding in value for %q: %w", key, err)
}
params[key] = value
} else {
params[key] = ""
}
values, err := url.ParseQuery(query)
if err != nil {
return nil, fmt.Errorf("invalid AWS query string: %w", err)
}
params := make(map[string]string, len(values))
for key, vs := range values {
// The query protocol indexes repeated parameters (Filter.1.Value.1), so a
// bare duplicate key only arrives from a non-conforming client. Take the
// last occurrence.
params[key] = vs[len(vs)-1]
}
return params, nil
}

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