Compare commits

..

102 Commits

Author SHA1 Message Date
joshsiv-mulga 220141b145 release: update go.mod for v1.13.0 2026-07-21 21:46:17 +10:00
tomnewton-mulga 4670d09d58 chore: RTX-6000 Supermicro demo doc draft 2026-07-21 17:09:49 +10:00
Josh Siviour 48749576c3 test: rebalance E2E into in-process integration tier + shared-guest live scenarios (#556)
* test: add in-process integration test tier

Roughly half of the behavioural tests under tests/e2e/ assert control-plane
API shapes, authz and error codes that need no live environment, yet they can
only run against a tofu-provisioned environment on a scarce self-hosted
runner. This adds the tier they can move down to.

The real gateway router runs in-process: SetupRoutes() is a pure function of
GatewayConfig, so it serves from an httptest server over embedded NATS
JetStream with real IAM/STS services and genuine SigV4 verification. Only the
daemon side of the wire is faked -- the ec2.*/ebs.* NATS subjects a live node
would answer are stubbed per test.

Nothing is provisioned and no fixed ports are bound, so the tier runs
concurrently on a shared build host and is safe as a CI front gate ahead of
the live suites. It is build-tagged and kept out of preflight, which stays
unit-only.

* test(integration): add IAM/STS client accessors and port the zero-stub authz tests

Adds IAMClient/STSClient plus ClientsWithCreds/ClientsWithSessionCreds so a
test can act as a principal other than the seeded root user, and stubs the
node-discovery subject in StartGateway so call paths that trigger discovery
resolve immediately instead of riding out the fan-out timeout.

Ports the four enforcement tests that guard on ec2:DescribeRegions, a
gateway-local action, so they need no daemon-side stub at all.

* test(integration): port remaining IAM/STS authz tests from e2e

Ports TestIAMUserCRUD, TestIAMAccessKeyLifecycle, TestIAMUserAuthentication,
TestIAMPolicyCRUD, TestIAMPolicyAttachmentEnforcement, TestIAMPolicyLifecycle,
TestIAMGroupsLifecycle, TestIAMRolesAndProfiles and
TestSTSAssumeRoleAndGetCallerIdentity from tests/e2e/iam/ into the in-process
integration tier. Each test now self-bootstraps its own users/policies/roles
instead of relying on shared suite-level state, since every integration test
gets its own gateway and embedded NATS. TestIAMRolesAndProfiles stubs
ec2.IamProfileAssociation.describe because DeleteInstanceProfile always
counts live associations over NATS regardless of which guard actually fires.
TestIAMCleanup is not ported: it only swept a shared long-lived E2E
environment, and every ordering/dependency assertion it made is already
covered by TestIAMPolicyLifecycle, TestIAMGroupsLifecycle and
TestIAMRolesAndProfiles's own delete-conflict guards.

* test(e2e): remove IAM/STS authz tests migrated to the integration tier

All 13 signed-request authz tests plus TestIAMCleanup now have faithful,
mutation-proven equivalents in tests/integration/ — the gateway dispatches
IAM/STS actions directly with no daemon hop, so the in-process tier is a
faithful and far cheaper substitute. The iam/ e2e suite now runs only
TestIAMInstanceProfileAssociation and TestIMDS.

Drop the now fully-dead iam_helpers_test.go alongside the six deleted test
files, and remove harness.IAMGroupARN/IAMDeleteGroupBestEffort, which had no
remaining caller anywhere in the repo once iam_groups_test.go was gone.

* feat(integration): daemon-lite harness for key/tags/routetable/vpc

Adds DaemonLite, an in-process stand-in for a live spinifex daemon that
subscribes the real key, tags, route table, and VPC/subnet/SG/IGW service
implementations directly to their NATS subjects, backed by memory and
embedded-JetStream stores. Ports TestKeyPairs, TestTagManagement,
TestRouteTableValidation, and TestReplaceRouteConvergence from
tests/e2e/single so the integration tier drives genuine daemon-side
business logic instead of StubSubject canned replies.

vpcd (OVN) is out of scope, so its "vpc.create-sg"/"vpc.delete-sg"
acknowledgements are stubbed with a fixed success reply — the SG record
itself is still written by the real service impl before that event fires,
so the stub only unblocks the synchronous round-trip rather than
replacing tested behavior.

* ci(e2e): gate live E2E jobs behind the in-process integration tier

Runs the in-process integration suite first, on the ci-single runner host
rather than inside a provisioned VM, so a broken control plane is caught
before either live job pays the Spinifex install. Both e2e_single and
e2e_multi now require it, and e2e_status treats a skipped integration job as
a failure so the gating status cannot go green on a gate that never ran.

Drops the shadow-mode suite selector: targeted per-diff suite selection is
not the direction being pursued, and leaving a non-functional shadow job in
the gating workflow only invited it to be mistaken for a live gate.

* test(e2e): remove single/ tests superseded by daemon-lite ports

TestKeyPairs, TestTagManagement, TestRouteTableValidation, and
TestReplaceRouteConvergence now have proven-equivalent ports in
tests/integration/ (mutation testing confirmed parity). Delete the
e2e originals and their runX helpers, along with keypair_test.go's
and tags_test.go's file-local helpers left with no caller.
generateImportPubKey is shared by other single/ tests so it moves to
helpers_test.go instead of being deleted. Slims the manifest-lint
baseline for the removed create-call entries.

* ci: run the lb e2e suite on multinode only

Both e2e jobs filter SUITES by exclusion, and neither excluded lb, so the
suite ran twice per e2e run: 354s on single and 654s on multi. Balancing
across instances on one node is not a deployment mode we support, and
ELBv2 proper requires two AZs, so the single-node run was redundant.

Drops ~354s from the single-node job, which is the gate critical path.

* test(integration): port 3 of 7 ECR tests to the in-process tier

Wires the gateway's ECR control plane (repository/policy/lifecycle
metadata over an in-process daemon-lite NATS subscriber, blob and
manifest bytes via a memory object store, JWT issuer/verifier keyed off
the existing IAM master key) into the integration harness, and ports
TestECRControlPlane, TestECRGetLoginPassword, and
TestECRIAMAuthorization_AssumedRolePullOnly from tests/e2e/ecr — each
proven equivalent by mutation (evaluatePrincipalPolicy forced to allow,
and the repo-describe path forced to not-found) before its E2E original
was removed.

The other 4 authorization tests (PullOnly, ReadOnly,
DetachPolicyDeniesImmediately, DeactivatedKeyReturns401) stay on the E2E
tier: AttachUserPolicy lacks the AWS-managed-ARN existence-check bypass
that AttachRolePolicy/AttachGroupPolicy already have, so attaching
AmazonEC2ContainerRegistryPullOnly/ReadOnly straight to a user fails
NoSuchEntity in both tiers alike. Left untouched pending a fix to that
shared IAM code.

* fix(iam): accept AWS-managed policy ARNs on AttachUserPolicy

AttachUserPolicy called getPolicyByARN unconditionally, so attaching an
AWS-managed ARN to a user returned NoSuchEntity. AttachRolePolicy and
AttachGroupPolicy already skip that lookup for AWS-managed ARNs and store
them opaquely; the user path was missed when that guard was introduced.

ListAttachedUserPolicies had the mirror of the same gap: it resolved every
ARN through getPolicyByARN and silently dropped the ones it could not find,
so a managed ARN would disappear from the listing rather than round-trip.

Evaluation already handled these correctly via resolveAttachedPolicy, so
only the write and list paths needed the guard.

* test(integration): port remaining 4 ECR authorization tests

Ports TestECRIAMAuthorization_PullOnly, _ReadOnly,
_DetachPolicyDeniesImmediately, and _DeactivatedKeyReturns401 from
tests/e2e/ecr now that AttachUserPolicy accepts AWS-managed policy ARNs.
Each was proven equivalent by mutation before its E2E original was
removed: PullOnly/ReadOnly/DetachPolicyDeniesImmediately fail when
evaluatePrincipalPolicy is forced to always allow, and
DeactivatedKeyReturns401 fails when the access-key-active check in
resolveECRLongLivedPrincipal is bypassed. All mutants were reverted and
a clean run confirmed before committing.

tests/e2e/ecr/iam_authorization_test.go is now fully removed — every
test it held has an in-process equivalent, and its helpers had no other
callers in the package.

* ci: select e2e suites by allow-list instead of deny-list

Both jobs filtered SUITES by exclusion, so any suite named in neither
deny-list ran in both jobs. That is how lb came to run twice per e2e run,
and it makes adding a suite silently fan it out everywhere.

Each job now names the suites it is eligible to run. Selection is still
driven by SUITES, so this is behaviour-preserving for the current default:
single/iam/cert on the single-node job, multinode/lb/cert on the multi-node
job, exactly as before. eks, ecs and storagegrowth are listed as eligible on
the single-node job but are not yet in the default SUITES.

* test(e2e): delete seven tests with no coverage

TestFinalClusterStats and TestInstanceLaunch assert nothing beyond
what other callers of their shared helpers already imply. The
multinode baseline, volume, guest-SSH, and preflight tests duplicate
single-node coverage or exercise per-guest behaviour that isn't
cluster-specific. TestMultinodeInstanceDistribution can't fail: three
independent RunInstances calls with no placement group make a strict
spread assertion flaky and a loose one meaningless.

Preflight checks (/dev/kvm writability, peer SSH reachability) move
into requireMultiNodeFixture so the package fixture singleton still
fails fast with a clear message instead of disappearing along with
the dedicated test. The remaining callers of needInstanceTrio
(CrossNodeOps, CrossNodeGateway, NodeFailure, VolumeDurability) still
trigger the shared trio launch on their own, so removing the
distribution and volume-lifecycle tests doesn't leave it unbuilt.

Also slims the manifest-lint baseline entry for the deleted
multinode volume test.

* fix(e2e): require full 3-node spread in TestMultinodeSpread

SpreadPlacement passed at unique>=3, warned via t.Logf at unique==2,
and only failed below that. A 33% spread failure (two instances
colocated) passed with a log line. The 243.6s NAT-egress scenario that
follows only proves what it claims to if the private trio actually
landed on three distinct chassis, so that premise needs to be a hard
assertion, not a warning.

* refactor(e2e): drop the unused spread-assertion harness helpers

AssertSpreadAcrossNodes and CountInstancesPerNode lost their only caller when
the instance-distribution test was removed. Beyond being dead, the assertion
helper documented minNodes=1 as an acceptable logging-only mode inherited from
the bash driver, which is the pattern that let a placement test pass while
every instance landed on one node.

InstanceHostingNode and its ps helpers stay; five multinode tests use them.

* fix(ec2): stop leaked goroutines on prepareEFIVolume error paths

newViperblock starts the chunk uploader and WAL syncer unconditionally,
but prepareEFIVolume only released them on the happy path via Close().
Six error returns between acquisition and Close left both goroutines
running. Add a defer immediately after acquisition, matching the
sibling prepareRootVolume; Close() remains the durability boundary.

* test(predastore): add real predastore fixture for integration tests

Extract predastore_integration_test.go's inline daemon startup into a
reusable testutil/predastore package so tests/integration can point
DaemonLite-style wiring at a real S3-compatible backend instead of an
unreachable host. Kept as its own leaf package rather than folding into
the shared testutil package: it transitively imports predastore's
quicclient, whose package-level connection pool starts a permanent
goroutine at init that would trip goleak checks in any of the ~80
other packages that import testutil for unrelated NATS helpers.

* test(integration): migrate CreateVolume to assert real predastore persistence

Add StartVolumeDaemonLite, an opt-in DaemonLite variant wired to the
real predastore fixture, and migrate TestCreateVolume_PersistsToRealPredastore
off memory-backed DaemonLite so CreateVolume/DescribeVolumes exercise
viperblock's real S3 backend end to end instead of stopping at input
validation.

* test(integration): add real storage-state oracle for CreateVolume

DescribeVolumes only proves the handler's own read path agrees with its
own write path, both via the same deserialization; it cannot catch a bug
in a field it never re-derives (e.g. a unit-conversion error in the bytes
handed to viperblock, since VolumeMetadata.SizeGiB is copied from the
request directly). vbscan reads a volume's persisted config.json and
chunk objects straight over S3, independent of any spinifex handler code,
so TestCreateVolume_PersistsToRealPredastore can assert on what viperblock
actually wrote rather than only on what the handler claims.

vbscan is a local reimplementation of scripts/vbrefscan's read-only scan
rather than an import: spinifex cannot depend on mulga, the module that
contains it. It only reimplements the config/chunk-listing half of
vbrefscan — the checkpoint-based reference-integrity half needs
viperblock.ParseBlockCheckpointBytes, which does not exist in the
viperblock version spinifex currently depends on (v1.12.1) and is also
moot for this volume: CreateVolume never calls SaveLiveCheckpoint, so a
freshly created volume has no checkpoint to scan regardless of version.

* refactor(test): move the predastore daemon fixture out of the coverage scope

test-cover runs go test ./spinifex/..., so anything under spinifex/ is
compiled and measured. The daemon fixture cannot be exercised by unit
tests, so it reported a coverage gap no unit test could close. The e2e
harness already avoids this by living under tests/; the fixture now
does the same. vbscan stays put, being ordinary library code with its
own unit tests.

* test(integration): migrate RunInstances multi-count expansion off live guests

RunInstances with MinCount/MaxCount=2 expands into a single per-node
targeted launch (distributeInstances in gateway/ec2/instance/placement.go)
before the daemon-facing NATS hop, so the real gateway router plus a
stubbed daemon response is sufficient to prove it — no throwaway VM is
needed to exercise the expansion logic itself.

The live single-node E2E test's WaitForInstanceState/TerminateInstances
sequence was teardown hygiene for the real guests it booted, not an
assertion of the expansion behavior, so it is dropped rather than
emulated. The migrated test additionally asserts the per-node launch
request itself carries MinCount=MaxCount=2, proving the count actually
propagates rather than the launch succeeding by coincidence or via the
zero-daemon degenerate path in placement.go.

* docs: drop a doc reference to the deleted live test

* test(integration): migrate launch-template RunInstances expansion off live guests

RunInstances --launch-template resolves ImageId/InstanceType from the
stored template entirely gateway-side (expandLaunchTemplate, before
validation/placement/the daemon-facing NATS hop), so proving it forwards
the resolved fields never needed a real guest boot. Ports the assertion
to tests/integration via a new opt-in StartLaunchTemplateDaemonLite (real
LaunchTemplateServiceImpl over embedded JetStream) plus a direct
subscription on the per-node launch subject to capture what the gateway
actually dispatched, rather than only the client-visible response.

The live test keeps its CRUD/versioning lifecycle (create/modify/delete
version, tag-filtered describe) — none of it touches a guest — and drops
only the RunInstances-from-template + wait-for-running + terminate tail,
whose only job was waiting out a real boot to read back fields already
proven resolved.

* test(integration): migrate KV-only account-scoping checks off live tenants

Ports the KeyPair/VPC-Subnet/IGW-EIGW/AccountSettings cross-account
isolation assertions from tests/e2e/single/account_test.go to the
in-process integration tier. Each of these resources resolves ownership
from a plain account-scoped KV key (utils.AccountKey(accountID, id), or
an account-keyed settings record) with no vm.Manager or real guest
involved, so DaemonLite now also wires the real EgressOnlyIGWServiceImpl
and AccountSettingsServiceImpl alongside the existing VPC/IGW/Key
services. Two independently minted tenant accounts (via
IAMService.CreateAccount/CreateUser/CreateAccessKey/CreatePolicy/
AttachUserPolicy, in-process — the same calls runAccountCreate makes
without the CLI/stdout round-trip) replace the live tier's carousel so
isolation is checked between real, separately owned accounts.

Left live: instance scoping (Step2) and volume scoping (Step3) —
checkInstanceOwnership, the actual cross-account gate for stop/start/
reboot/terminate/attach-volume, runs daemon-side only after a real
vm.Manager lookup, so it cannot be exercised without a live guest.
Snapshot scoping (Step5) needs a real backing volume for CreateSnapshot
(viperblock, out of DaemonLite's scope), so it stays live entirely rather
than split off only its two KV-only assertions. Global-resources (Step9)
carries no account branching in the handlers at all, so asserting its
equality across two accounts wouldn't exercise anything a fake node
responder didn't already trivially satisfy — not migrated.

* test(integration): migrate launch-template CRUD lifecycle off live guests

TestLaunchTemplates booted zero guests: its RunInstances coverage had
already moved to TestRunInstances_LaunchTemplateExpansion, leaving only
template CRUD/versioning assertions occupying a full live-cluster slot.
Port the remaining create/describe/version/modify/delete lifecycle,
including the SourceVersion merge and a nested timestamp round-trip, into
TestLaunchTemplates_CRUDLifecycle against the in-process integration
gateway.

* refactor(tests): move spot instance lifecycle coverage to integration tier

RequestSpotInstances is a mock over on-demand RunInstances (no bidding,
interruption, or reclamation), so booting real VMs in the e2e tier proved
nothing spot-specific. Migrate the SIR CRUD lifecycle (request, describe,
cancel, close-on-terminate, insufficient-capacity error) to a NATS-level
integration test that exercises the real gateway orchestration and daemon
service impl without a live guest. Real-VM-boot assertions are already
covered by TestRunInstances_MultiCountExpansion and the e2e baseline tests.

* fix(tests): share one embedded NATS server across the integration package

ClientTokenStore in spinifex/gateway/ec2/instance is a process-wide
sync.Once singleton bound to whichever *nats.Conn first initialises it.
Every integration test booted its own embedded NATS server, so the first
RunInstances-calling test captured the singleton, and any later test
inherited a KV handle bound to that server's now-closed connection.

TestMain now boots one embedded NATS+JetStream server for the whole
package; every StartGateway call connects into its own dynamically
provisioned NATS account (accountAuthenticator in shared_nats.go), giving
each test a private JetStream namespace despite the shared server, and
none of the per-test connections are closed until every test has finished
- whichever one the singleton latches onto stays live for the run.

Also wire the suite into the required PR gate: it was previously only
invoked from e2e.yml's push-triggered (main/dev), self-hosted "Integration
(front gate)" job, so a regression here never blocked a PR. The whole
package runs in single-digit seconds, so it now runs inline in
`make preflight` and as its own step in spinifex.yml's build_and_test job.

* test(integration): migrate serial-console access checks off live guests

Enable/Disable/GetSerialConsoleAccessEnabled is pure gateway+account-settings
logic with no guest involved, so it moves to the in-process integration tier
against the real AccountSettingsServiceImpl wired into DaemonLite.

* test(integration): migrate DescribeImages round-trip off live guests

runImage's AMI describe round-trip needs no real cluster: it registers an
AMI against a hand-seeded snapshot via the real ImageServiceImpl, backed by
a new StartImageDaemonLite helper wiring RegisterImage/DescribeImages onto an
in-memory object store.

* test(integration): migrate predastore object lifecycle off live guests

runPredastoreObjectLifecycle only needs a real predastore daemon, not a
tofu-provisioned cluster: it now runs against tests/fixtures/predastore's
in-process fixture, covering the same create/put/get/delete/list contract.

* test(integration): migrate ListFoundationModels catalog check off live cluster

ListFoundationModels is pure gateway-side static catalog logic with no
NATS/daemon hop, so it moves to the integration tier via a new BedrockClient
harness accessor; this tier configures no credential resolver, so the
Anthropic entry's absence is asserted explicitly rather than left optional.

* test(integration): cover RunInstances client-token idempotency

Proves replaying the same ClientToken through the real gateway returns the original reservation without a second daemon dispatch, and a different token launches independently.

* test(integration): cover RunInstances launch-template expansion

Proves version selection (omitted Version resolves to the template default, explicit Version selects that version) and field precedence (explicit RunInstances parameters override the template, unset fields inherit it) through the real gateway.

* test(integration): cover RunInstances malformed-input rejection

Proves a malformed ImageId and a malformed targeted-launch CapacityReservationId are rejected before any NATS dispatch, with no daemon stub registered so a regression that let either through would fail fast instead of hanging.

* test(integration): cover RunInstances iam:PassRole enforcement

Proves a caller granted ec2:RunInstances but not iam:PassRole on an instance profile's role is denied, and the identical session succeeds once PassRole is granted for that exact role ARN.

* test(integration): cover RunInstances placement-group strategy routing

Adds a placement-group daemon-lite wiring a real PlacementGroupServiceImpl over NATS, then proves spread strategy assigns exactly one instance per node while cluster strategy pins the whole launch to the single highest-capacity node.

* test(integration): cover RunInstances capacity distribution across nodes

Proves the default (non-placement-group) path packs a multi-instance launch across nodes proportionally to spare capacity rather than exhausting one node first.

* test(e2e): merge SameSGComms and SGToSGDatapath around one client/target pair

Both booted their own instance pair to check SG-to-SG datapath enforcement
and same-default-SG east-west connectivity. Merging them around one shared
client/target pair cuts four boots to two, and converting the inline
harness.Step sequence into named t.Run stages (PortGroupMembership,
AllowedTraffic, DeniedTraffic, RevokeAndVerifyBlocked,
ReauthorizeAndVerifyRestored, SameSGComms) gives each its own pass/fail
attribution instead of one opaque test result.

SameSGComms now runs last and joins the default SG onto each ENI via
ModifyNetworkInterfaceAttribute only after every SG-to-SG allow/deny
assertion has already recorded its own result, since the default SG's
self-reference rule admits all traffic between co-members and would
otherwise defeat the denied-traffic check.

* test(e2e): merge default-SG reachability, guest DNS, and egress enforcement

DefaultSGReachabilityBaseline, GuestDNSResolution, and SecurityGroupEgress
each booted their own instance to check one facet of "a guest is reachable
only when policy says so": ingress deny/authorize, resolver + WAN egress,
and egress ACL enforcement. Merging them around one shared guest cuts
three boots to one, with named t.Run stages (Deny, Authorize, Resolver,
WANEgress, RevokeEgress/RestoreEgress) giving each its own attribution.

The egress revoke/restore round-trip now mutates this scenario's own
dedicated SG instead of the shared default SG the rest of the suite
depends on, isolating its egress-rule mutation from every other test.

* fix(ci): single-quote e2e test_run regex across the ssh boundary

* fix(e2e): anchor SG egress-enforcement probe to a WAN IP, not the gateway

* test(e2e): merge single-node guest churn tests into one durability scenario

Combines TestSSHProbe, TestENIHotplug, TestENIHotplugReconcile,
TestVolumeDurability, TestModifyInstanceAttribute, and TestRebootInstance
into TestGuestChurnDurability, a single shared-guest scenario that
re-verifies a data-integrity sentinel sha256 after every perturbation
stage (ENI hotplug, daemon restart, ENI detach, instance-type resize,
stop/start, reboot). This proves the sentinel survives the full churn
sequence rather than just one isolated stop/start.

* fix(vpc): clear subnet drop gate when installing egress reroute

A subnet with an attached IGW but no 0.0.0.0/0 route carries a
priority-1100 drop gate on the VPC router. When a route later lands
(NATGW reroute at 900, IGW reroute at 1000) the reroute is installed but
the drop gate was never removed, so the higher-priority gate wins and
black-holes the subnet's egress in lr_in_policy before it reaches routing
or SNAT. This left NAT-gateway private-subnet egress with 100% packet
loss: the SNAT stage saw zero packets and no conntrack was committed.

Remove the subnet's drop gate for the same prefix once a reroute is
installed. Idempotent and prefix-scoped, so a specific-prefix reroute is
a no-op and leaves the default gate intact.

* test(e2e): merge VPC subnet, NAT gateway, and EIP single-node tests

Merges the former TestVPCSubnetE2E, TestNewVPCEgressBaseline,
TestNATGateway, and TestInstanceEIP into one staged scenario,
TestVPCEgressPaths, around one scenario-owned VPC and its two guests,
cutting five guest boots to three.

The public guest serves PublicSubnetEgress, then the NAT Gateway
bastion hop, then (last, since association rewrites its public IP)
the EIP flip. The private guest serves the NAT Gateway path, launched
directly into the same VPC's already-existing private subnet instead
of a dedicated one. RouteBeforeSubnet keeps its own second VPC and
instance since it exercises the implicit main-route-table code path,
a different one than the explicit route table this scenario's own VPC
uses for its public subnet — collapsing the two would drop coverage of
whichever path got merged away.

The NAT Gateway up/down flip now repeats for an env-tunable round
count (SPINIFEX_VPCEGRESS_NAT_ROUNDS, default 1), matching every other
merged single-node scenario.

* test: TEMPORARY mutation-check — revert a795c2b2 drop-gate clear

Reverts the RemoveSubnetEgressDrop call in ensureSubnetEgressAtPriority
to confirm TestVPCEgressPaths/Round1/NATGatewayUp actually detects the
regression it guards against. This commit will be reverted immediately
after the live run confirms a failure.

* test: TEMPORARY mutation-check v2 — neutralize RemoveSubnetEgressDrop

Restores the real a795c2b2 fix in ensureSubnetEgressAtPriority (the
first mutation attempt only reverted this call site and was masked by
the independent gate-recompute path in routetable service_impl.go's
CreateRoute, which also clears the drop gate on every 0.0.0.0/0 route
add and predates a795c2b2). Neutralizing RemoveSubnetEgressDrop itself
disables gate-clearing regardless of caller, reproducing the literal
regression. Will be reverted immediately after live verification.

* Revert "test: TEMPORARY mutation-check v2 — neutralize RemoveSubnetEgressDrop"

Mutation-check confirmed: neutralizing RemoveSubnetEgressDrop reproduced
a795c2b2's regression exactly (stale priority-1100 drop gate outranking
the priority-1000 IGW reroute, confirmed via OVN flow dump; PublicSubnetEgress
failed with an SSH handshake timeout to the public instance). Restoring
the real fix; igw.go is now byte-identical to a795c2b2.

* fix(e2e): stop pruning shared multinode SSH key pem mid-run

The multinode package fixture cached Fixture.Artifacts once, computed
from harness.ArtifactDir(t, env) against whichever top-level Test*
happened to win the pkgFixOnce race. That helper both names the
directory after that one test and registers a t.Cleanup that removes
it once that test passes. needKeyPair wrote the shared EC2 key pair's
pem into that same directory, so as soon as the winning test finished,
its own artifact-dir cleanup deleted the pem every later test in the
package still needed to SSH into fresh guests, even though the
underlying EC2 key pair itself was untouched (its DeleteKeyPair
cleanup already correctly drains at process Close() in TestMain).

Fix: give Fixture an ArtifactDir(t) method — the same per-call pattern
already used by the gpu/single/storagegrowth/iam fixtures — so each
diagnostic dump site recomputes its own dir against the CURRENT test.
The shared key pair pem now lives under Fixture.TmpDir, which is
genuinely package-scoped and only removed once in TestMain after
m.Run() completes.

* refactor(e2e): parallelize LB e2e scenarios on multinode clusters

Each of the 8 TestLoadBalancer scenarios already stands up its own
dedicated LB + target group (ModifyListener/ListenerRules mutate their
LB's listener in place, so it can never be shared). The bottleneck was
running them serially: each teardown made createActiveLB back off
waiting for the previous LB's sys.micro slot to be reclaimed before the
next one could stand up.

On multinode clusters (peer != "", i.e. the 3-node cluster this suite
targets) there's spare capacity to stand up all 8 dedicated LBs at once,
so scenarios now run as t.Parallel() subtests instead of serial
create/teardown/backoff. Since each scenario owns an independent LB,
one never reaching active only fails that scenario — it no longer
cascades into five sequential 5-minute timeouts, so the serial fail-fast
gate is skipped in this mode; concurrency itself bounds the worst case
to a single timeout window.

Single-node dev boxes (no peer) keep the original serial path,
including the fail-fast gate, since they lack the spare capacity for
concurrent LB standup. No subtest body, assertion, or call signature
changed — only the dispatch/gating logic in TestLoadBalancer.

* fix(ci): gate e2e jobs on the suites they actually run

* fix(e2e): serialize internet-facing LB subtests among themselves

Live multinode verification showed InternetFacing_ALB and
InternetFacing_NLB running concurrently each clobber the other's
public-LB DNS record mid-registration, so neither ever resolves within
the 90s wait. The old serial suite never had two internet-facing LBs
alive at once, so this never surfaced.

Drop t.Parallel() from the three internet-facing subtests so their
register+resolve windows never overlap each other, while leaving the
internal subtests parallel as before. They may still run alongside the
internal group, which resolves internal (not public) DNS and isn't
affected.

* fix(e2e): give each ALB LB scenario its own security group

Concurrent internal subtests all created ALBs with no explicit
SecurityGroups, so each ALB's ENI fell back to the one shared VPC
default SG. One scenario authorizing/reconfiguring its own listener
racing another mutating the same SG made Internal_ALB_ModifyListener
intermittently fail to serve on the modified port under parallel load.

Each ALB scenario now creates a dedicated SG (createScenarioSG),
authorized only for the ports that scenario's listener(s) use, and
passes it to CreateLoadBalancer. The shared app-instance/probe-client
SG is untouched, since SG ingress is enforced at the destination ENI
regardless of the source ENI's SG. NLBs are unaffected: they already
get an auto-managed per-LB SG when none is supplied.

* test(e2e): annotate intentional create-path calls for manifest-lint

The vpcegress and per-scenario LB security-group tests create raw AWS
resources on purpose — the egress datapath, the route-before-subnet
ordering, and per-scenario SG isolation are the subjects under test, so
they cannot route through a shared Ensure* fixture. Mark each with
// e2e:allow-create and slim the baseline of entries for the removed
single-node network tests.

* test(e2e): restore live spot terminate-close and cancel-running coverage

The integration-tier spot test calls CloseForInstance directly and infers
cancel-keeps-running from the SIR's InstanceId, because DaemonLite has no
vm.Manager to drive the real TerminateInstances -> daemon teardown cleaner
-> RemoveFromSpotRequest -> CloseForInstance chain or to observe a real
VM's running state. Add a thin live e2e test that drives both terminal
transitions through a real backing VM: cancel one spot-backed instance and
observe it stays running, terminate the other and observe its SIR reach
closed through the genuine async delegation.

* fix(network): refresh orphan-EIP prune liveness to spare mid-pass launches

pruneOrphanEIPs listed OVN NAT rows live but matched them against the intent
snapshot captured at the start of the reconcile pass. Because the apply phase
can block for tens of seconds on guest-port datapath waits, a guest launched
during the pass had a live dnat_and_snat row absent from the stale snapshot, so
the prune swept it and blackholed the guest's auto-assigned public IP until the
next pass re-added it — an intermittent multinode SSH-dark flake.

Add an optional FreshIntent re-read callback (wired in vpcd to LoadIntentFromKV)
and union its ports into the live set before pruning, so a mid-pass launch
counts as live. Skip the prune entirely when the re-read fails rather than risk
a false sweep against a snapshot known to be stale. Self-healing and idempotent:
a genuine orphan absent from both snapshot and fresh read is still pruned next
drift. Single-node behaviour is unchanged (callback nil in tests).
2026-07-21 16:46:18 +10:00
dependabot[bot] 8c8dc173bd chore: bump the npm-dependencies group (#554)
Bumps the npm-dependencies group in /spinifex/services/spinifexui/frontend with 21 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-acm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-acm) | `3.1084.0` | `3.1089.0` |
| [@aws-sdk/client-ec2](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ec2) | `3.1084.0` | `3.1089.0` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1084.0` | `3.1089.0` |
| [@aws-sdk/client-ecs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecs) | `3.1084.0` | `3.1089.0` |
| [@aws-sdk/client-eks](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-eks) | `3.1084.0` | `3.1089.0` |
| [@aws-sdk/client-elastic-load-balancing-v2](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-elastic-load-balancing-v2) | `3.1084.0` | `3.1089.0` |
| [@aws-sdk/client-iam](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-iam) | `3.1084.0` | `3.1089.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1084.0` | `3.1089.0` |
| [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) | `3.1084.0` | `3.1089.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.1084.0` | `3.1089.0` |
| [@smithy/protocol-http](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/protocol-http) | `5.5.7` | `5.5.10` |
| [@smithy/signature-v4](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/signature-v4) | `5.6.3` | `5.6.6` |
| [@tanstack/react-router](https://github.com/TanStack/router/tree/HEAD/packages/react-router) | `1.170.17` | `1.170.18` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.2` | `4.3.3` |
| [@tanstack/router-plugin](https://github.com/TanStack/router/tree/HEAD/packages/router-plugin) | `1.168.19` | `1.168.20` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) | `0.58.0` | `0.59.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) | `1.73.0` | `1.74.0` |
| [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) | `0.24.0` | `0.25.0` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.2` | `4.3.3` |
| [ultracite](https://github.com/haydenbleasel/ultracite) | `7.9.3` | `7.9.4` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.1.4` | `8.1.5` |


Updates `@aws-sdk/client-acm` from 3.1084.0 to 3.1089.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-acm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1089.0/clients/client-acm)

Updates `@aws-sdk/client-ec2` from 3.1084.0 to 3.1089.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ec2/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1089.0/clients/client-ec2)

Updates `@aws-sdk/client-ecr` from 3.1084.0 to 3.1089.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1089.0/clients/client-ecr)

Updates `@aws-sdk/client-ecs` from 3.1084.0 to 3.1089.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecs/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1089.0/clients/client-ecs)

Updates `@aws-sdk/client-eks` from 3.1084.0 to 3.1089.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-eks/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1089.0/clients/client-eks)

Updates `@aws-sdk/client-elastic-load-balancing-v2` from 3.1084.0 to 3.1089.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-elastic-load-balancing-v2/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1089.0/clients/client-elastic-load-balancing-v2)

Updates `@aws-sdk/client-iam` from 3.1084.0 to 3.1089.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-iam/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1089.0/clients/client-iam)

Updates `@aws-sdk/client-s3` from 3.1084.0 to 3.1089.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1089.0/clients/client-s3)

Updates `@aws-sdk/client-sts` from 3.1084.0 to 3.1089.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1089.0/clients/client-sts)

Updates `@aws-sdk/lib-storage` from 3.1084.0 to 3.1089.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1089.0/lib/lib-storage)

Updates `@smithy/protocol-http` from 5.5.7 to 5.5.10
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/protocol-http/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/protocol-http@5.5.10/packages/protocol-http)

Updates `@smithy/signature-v4` from 5.6.3 to 5.6.6
- [Release notes](https://github.com/smithy-lang/smithy-typescript/releases)
- [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/signature-v4/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/signature-v4@5.6.6/packages/signature-v4)

Updates `@tanstack/react-router` from 1.170.17 to 1.170.18
- [Release notes](https://github.com/TanStack/router/releases)
- [Changelog](https://github.com/TanStack/router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/TanStack/router/commits/@tanstack/react-router@1.170.18/packages/react-router)

Updates `@tailwindcss/vite` from 4.3.2 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/@tailwindcss-vite)

Updates `@tanstack/router-plugin` from 1.168.19 to 1.168.20
- [Release notes](https://github.com/TanStack/router/releases)
- [Changelog](https://github.com/TanStack/router/blob/main/packages/router-plugin/CHANGELOG.md)
- [Commits](https://github.com/TanStack/router/commits/@tanstack/router-plugin@1.168.20/packages/router-plugin)

Updates `oxfmt` from 0.58.0 to 0.59.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxfmt/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxfmt_v0.59.0/npm/oxfmt)

Updates `oxlint` from 1.73.0 to 1.74.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.74.0/npm/oxlint)

Updates `oxlint-tsgolint` from 0.24.0 to 0.25.0
- [Release notes](https://github.com/oxc-project/tsgolint/releases)
- [Commits](https://github.com/oxc-project/tsgolint/compare/v0.24.0...v0.25.0)

Updates `tailwindcss` from 4.3.2 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/tailwindcss)

Updates `ultracite` from 7.9.3 to 7.9.4
- [Release notes](https://github.com/haydenbleasel/ultracite/releases)
- [Commits](https://github.com/haydenbleasel/ultracite/compare/ultracite@7.9.3...ultracite@7.9.4)

Updates `vite` from 8.1.4 to 8.1.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.5/packages/vite)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-acm"
  dependency-version: 3.1089.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-ec2"
  dependency-version: 3.1089.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1089.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-ecs"
  dependency-version: 3.1089.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-eks"
  dependency-version: 3.1089.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-elastic-load-balancing-v2"
  dependency-version: 3.1089.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-iam"
  dependency-version: 3.1089.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1089.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-sts"
  dependency-version: 3.1089.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1089.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@smithy/protocol-http"
  dependency-version: 5.5.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@smithy/signature-v4"
  dependency-version: 5.6.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-router"
  dependency-version: 1.170.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/router-plugin"
  dependency-version: 1.168.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: oxfmt
  dependency-version: 0.59.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: oxlint
  dependency-version: 1.74.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: oxlint-tsgolint
  dependency-version: 0.25.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: ultracite
  dependency-version: 7.9.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: vite
  dependency-version: 8.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 15:50:15 +10:00
dependabot[bot] f02d9a6c02 chore: bump the go-dependencies group with 2 updates (#553)
Bumps the go-dependencies group with 2 updates: [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) and [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2).


Updates `github.com/aws/aws-sdk-go-v2/config` from 1.32.29 to 1.32.30
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.29...config/v1.32.30)

Updates `github.com/aws/aws-sdk-go-v2/credentials` from 1.19.28 to 1.19.29
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/credentials/v1.19.28...credentials/v1.19.29)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.32.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/credentials
  dependency-version: 1.19.29
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 15:49:57 +10:00
dependabot[bot] 5dc945a6df chore: bump the github-actions group with 2 updates (#552)
Bumps the github-actions group with 2 updates: [actions/setup-go](https://github.com/actions/setup-go) and [actions/setup-node](https://github.com/actions/setup-node).


Updates `actions/setup-go` from 6 to 7
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

Updates `actions/setup-node` from 6 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 15:49:37 +10:00
Josh Siviour a01e58d90c feat: deliver backend ENOSPC to the guest and enable chunk GC in production (#549)
* feat: plumb GCEnabled config through to every VB construction site

viperblock chunk GC is gated behind VB.GCEnabled, but spinifex never
set it, so every deployed node ran with GC off by construction
regardless of which viperblock binary shipped. Add a
[viperblock] gc_enabled config key (default false, mirrors shardwal)
and thread it through to the three places spinifex builds a VB: the
nbdkit plugin it spawns per mounted volume (via a new gc_enabled
plugin param), viperblockd's detached-volume opens, and the volume
service's CreateVolume.

* chore: repin viperblock to GC-enable lineage

The gc-enable plumbing references viperblock.VB.GCEnabled, which is
absent from the previously pinned v1.12.1. Repin to the feat/nbd-gc-enable
commit so a standalone (GOWORK=off) build — the path the ansible deploy
uses — resolves the field, and spx plus the nbdkit plugin build from the
same GC-bearing viperblock lineage.

* fix(storagegrowth): stop launching a nano for image-pull repro

ensureImagePullInstance shared harness.DiscoverNanoInstanceType with the
synthetic npass suite. A 512MB guest OOM-thrashes containerd before any
disk-driven ENOSPC and conflates the storage leak with guest-memory
pressure. Add a package-local imagePullInstanceType helper: an exact
override via SPINIFEX_STORAGEGROWTH_INSTANCE_TYPE, otherwise the smallest
advertised type at or above SPINIFEX_STORAGEGROWTH_MIN_MEM_MIB (default
16384 MiB). npass keeps using the nano selector unchanged.

* feat: report guest block write errors so backend ENOSPC reaches the guest

Set werror=report and rerror=report on the guest boot drive so a viperblock
ENOSPC (or any other backend write failure) is surfaced to the guest as a
real I/O error instead of QEMU silently pausing the VM. Without this the
guest kernel never learns its disk is out of space, so it can't fail
writes cleanly or let an operator react.

* chore: repin predastore and viperblock to the storage-capacity-safety lineage

Drop the local-path replace for predastore (a test shortcut) and pin both
predastore and viperblock to their feat/storage-capacity-safety commits, so
a standalone (GOWORK=off) build resolves the free-space watermark, ENOSPC
classification, and backpressure plumbing that spinifex's werror/rerror
change depends on.

* chore: bump viperblock to 054a44a3f0f5

Realigns spx's linked viperblock with the submodule working tree, which the
build role's source-drift gate compares against.

* chore: repin predastore and viperblock to their dev lineage

Both were pinned to pseudo-versions off feat/storage-capacity-safety. That work
has now landed on dev in each repo, and both PRs were squash-merged, so the
branch commits those pins named are unreachable from any branch.

Repin to the dev commits carrying the same code: predastore 5699329 and
viperblock 35dd9f2.
2026-07-21 15:39:14 +10:00
Josh Siviour 08fc5e4c58 fix(northstar): initialise OTel SDK and source the telemetry environment (#555)
Northstar carried working northstar.dns.* instruments but shipped no
telemetry, so its dashboard was empty and the only northstar data in
Elasticsearch was journald scraped under the systemd unit name.

Two gaps, both outside the instrumentation. northstarStartCmd never called
initTelemetry, unlike every other service command, so the instruments
recorded into a no-op provider. The unit also did not source
/etc/spinifex/telemetry.env, which every other application unit reads for
OTEL_EXPORTER_OTLP_ENDPOINT, MULGA_ENV and MULGA_SOURCE — so even an
initialised SDK would have had no endpoint and no env label.

Add an invariant covering the second gap: any unit launching an spx service
must source the telemetry drop-in. A service missing it starts healthy and
silently reports nothing, which only surfaces when someone notices an empty
dashboard. Verified the test fails against the unpatched unit.
2026-07-20 13:22:27 +10:00
Josh Siviour ffc34b735b ci: emit per-run ES summary doc from e2e workflow (#551)
Add e2e_es_summary, a self-hosted job that aggregates suite-status.txt
from both e2e legs and POSTs one doc per run to the Elastic sink, keyed
by labels.ci_run_id, so Kibana can answer "did run X pass, and where
are its artifacts" without hand-aggregating traces-apm*/logs-apm.error*.

Runs on ci-single (LAN route to the sink) since e2e_status stays on
GitHub-hosted ubuntu-26.04 for the PR status-report check. State is
computed with the same single_ok/multi_ok logic as e2e_status so the
two can never disagree.
2026-07-20 08:22:34 +10:00
Josh Siviour 055fa26f37 feat(bedrock): Ochre Phase 2 — ConverseStream + InvokeModelWithResponseStream (#526)
* feat(bedrock): Ochre streaming — ConverseStream + InvokeModelWithResponseStream

Adds the two streaming bedrock-runtime operations behind a shared
eventstream.Encoder frame writer: ConverseStream reframes vLLM/Anthropic SSE
into the normalized Bedrock event taxonomy (messageStart -> contentBlock*
-> messageStop -> metadata), InvokeModelWithResponseStream wraps each
provider-native chunk in a PayloadPart frame. Fixes otelsetup's
statusRecorder to implement Flusher/Unwrap so flushes actually reach the
socket through the middleware chain, which streaming depends on.

* fix(bedrock): keep vLLM ConverseStream taxonomy ordered on edge cases

Route contentBlockStop/messageStop through an idempotent ensureStarted so a
usage-only chunk arriving before any finish_reason no longer emits metadata
ahead of messageStop, and a zero-content stream no longer emits stop events
without a preceding messageStart. Regression tests for both.

* fix(bedrock): route /model/ data-plane calls to bedrock-runtime

AWS SDKs sign bedrock-runtime with the SigV4 signing name "bedrock", the
same as the control plane; the two services are separated only by endpoint
hostname. The gateway serves one endpoint and routed purely on the signing
name, so every /model/{id}/converse*|invoke* call landed in Bedrock_Request
(control plane) and returned InvalidAction — no standard SDK/CLI could reach
any bedrock-runtime endpoint.

Disambiguate by path in GetService: a "bedrock"-scoped request to a /model/
path (data-plane-exclusive) resolves to bedrock-runtime. Control-plane paths
(/foundation-models, /custom-models, /model-invocation-job) are unaffected.

* test(bedrock): satisfy testifylint go-require/empty in streaming tests

Use assert (guarded where a value is dereferenced afterward) instead of
require inside httptest handler closures, and assert.Empty for empty-string
checks, per dev's stricter testifylint config.
2026-07-19 21:11:03 +10:00
Josh Siviour 8205f88b04 ci: build sibling repos from a co-named branch, falling back to dev (#550)
The Build/Lint/Race jobs checked out viperblock and predastore at a hardcoded
ref: dev and overlaid them with a go workspace, so the go.mod pins were ignored
and a spinifex change depending on an unmerged sibling change could never
compile in CI. Resolve each sibling to a branch of the same name when it exists
on that repo, else dev, mirroring the e2e path in
scripts/tofu-cluster/build-install-artifacts.sh so unit CI and e2e CI agree.
2026-07-19 14:30:13 +10:00
Josh Siviour 908bfbfc27 fix(ecr): enforce IAM authorization on OCI registry operations (#548)
Treat the ECR registry JWT as a signed identity pointer rather than a
permission grant. Every /v2/* request now rehydrates the token's access
key against current IAM/STS state, maps the OCI route to an ecr: action
and repository ARN, and runs the identity-policy evaluator before
dispatch. PullOnly/ReadOnly principals can no longer push, overwrite
tags, or delete manifests, and key/session/user/role/account/policy
revocation takes effect on the next request instead of at token expiry.

Cross-repository blob mounts now require source-read permission and
proof the digest is referenced by a manifest in the named source repo,
closing an account-wide blob-fishing gap.
2026-07-19 12:44:11 +10:00
Josh Siviour 95631bf57b fix(elbv2): roll back managed SG on sync LB launch failure and surface the cause (#547)
A CreateLoadBalancerSync whose data-plane launch failed returned without
rolling back the NLB managed security group. The orphaned SG sat in the EKS
managed control-plane VPC and blocked DeleteVpc with DependencyViolation
indefinitely, wedging the cluster in DELETING. The failure was also flattened
to ServerInternal, discarding the only useful diagnostic.

The sync path now unwinds fully on launch failure (ENIs, managed SG, name
claim, record) and maps the launch error onto its real cause: capacity
shortfalls pass through as their own code, anything else stays ServerInternal.
The async path deliberately keeps its failed record — the caller holds the ARN
and can reclaim it via DeleteLoadBalancer.
2026-07-19 12:40:37 +10:00
joshsiv-mulga 9ccf147fc8 Merge remote-tracking branch 'origin/feat/storagegrowth-rootvol' into harness-merge-dev
# Conflicts:
#	tests/e2e/Makefile
2026-07-17 18:27:05 +10:00
Julian Sommer 501f787c36 refactor(ui): extract AttachedPoliciesPanel and SystemImageRequired (#546) 2026-07-17 15:24:02 +10:00
Julian Sommer 5b03cf72c4 fix: prevent live volume tag clobbering (#545) 2026-07-17 14:48:13 +10:00
joshsiv-mulga 72353e476c test(e2e): read the live checkpoint on-node and target the root volume
The image-pull storage-growth workload HEADed <volID>/checkpoints/blocks.live.bin
with the operator box's gateway identity, which has no S3 read grant on the
bucket and was refused 403 on every sample, so checkpoint_bytes read 0 across a
whole run. It now HEADs the object on the node over PeerSSH using predastore's
own [[auth]] credentials from predastore.toml (root-owned, so read via
passwordless sudo; the CA is readable through the spinifex group). The
credentials are read into shell locals, used inline for one aws s3api
head-object, then unset — never echoed, never leaving the node. A probe failure
that is not a legitimate 404 voids the sample (checkpoint_bytes -1 plus a probe
error) rather than reporting a false zero, so a dead instrument can never look
like no growth. The dead operator-side S3 client path is removed.
2026-07-17 12:35:43 +10:00
Julian Sommer 3b44d3969e remove git hook 2026-07-17 12:11:25 +10:00
Ben Duncan 28a876d17d feat(dns): resolvable ALB/NLB + EKS DNS, forwarder hardening, cross-tenant reconcile (#532) 2026-07-17 12:02:59 +10:00
Julian Sommer 0c6a3c5b3a Merge branch 'main' into dev 2026-07-17 10:14:42 +10:00
Ben Duncan 8d796d74ba Rewrite README key sections and banner in the site voice
Align the top of the README with mulgadc.com: new headline and intro,
rewritten "What is Spinifex?", a new "Three ways to deploy" section
(Neocloud / on-premise / edge) and a new "AWS compatibility" service
matrix (EC2, EBS, S3, VPC, IAM, ALB/NLB, EKS, ECR, ECS available; RDS
Q3 2026) linking the GitHub releases. Refresh the architecture prose and
Key Features, add Deploy and AWS-services nav links, and swap banner.svg
for the ember-on-ink mini-stack design matching platform.svg.
2026-07-16 12:39:44 -05:00
Ben Duncan bc9dc36c6c Redesign architecture.svg in the Spinifex site theme
Rebuild the message-driven architecture diagram in the ember-on-ink
theme to match platform.svg: ember gradient NATS bus, ember-tinted
gateway and daemon cards, neutral clients and backend cards. Content
unchanged (clients → gateway → NATS → ec2/ebs/s3/vpc/iam daemons →
local backends). De-slop the copy and refresh the img alt text.
2026-07-16 12:09:22 -05:00
Ben Duncan 7a6b8908c0 Redesign platform.svg in the Spinifex site theme
Replace the platform diagram with a static SVG matching the mulgadc.com
front-page stack diagram: ember accent, ink panel, the portable-stack
box (Neocloud / on-prem / edge) wrapping the Spinifex AWS-compatible
service layer, over standard Linux on CPU/GPU hardware. Self-contained
and GitHub-safe (presentation attributes only). Refresh the img alt text.
2026-07-16 11:48:58 -05:00
Josh Siviour 3b379fea38 fix(ec2,eks,ecs,images): root volume sizing + revive the rotted Ubuntu catalog entries (#542)
* fix(admin): round the AMI root volume up so the image fits

`spx admin images import` sized the root volume with integer division, which
floors: a 4.94 GiB image registered a 4 GiB volume. The image is then copied
into a volume too small to hold it, is truncated, and the guest boots with no
root partition — it stalls on the root device until systemd gives up, and
nothing anywhere reports an undersized volume.

floorVolumeSizeToAMI already exists to prevent exactly this ("Under-sizing
hangs the guest in dracut"), but it raises the caller's requested size to the
AMI's recorded VolumeSizeGiB — the same floored value — so it inherited the
mistake instead of catching it.

This stayed hidden because every current system image is an exact multiple of
1 GiB, where flooring and rounding up agree. It surfaced on a 5306470400-byte
image, which would not boot.

Round up instead, via a helper so the boundaries are testable: an exact
multiple must not gain a spare GiB, and one byte over must.

* fix(eks): default a nodegroup's root volume to 20 GiB, as AWS does

CreateNodegroup stored diskSize verbatim, so omitting it persisted 0 and
the launch skipped its block device mapping entirely, dropping the worker
onto whatever volume the AMI itself declares.

That was harmless while the node AMIs baked generous virtual disks — the
GPU image declares 16 GiB for ~2 GiB of content, so the fall-through
happened to leave plenty of room. It stops being harmless as soon as an
image is sized to its contents: the worker then boots onto a volume with
barely a spare GiB and cannot pull the container images it exists to run.
Sizing the node is the nodegroup's decision either way, not a property
the image should be able to dictate by accident.

20 GiB matches the AWS EKS managed-nodegroup default. It is resolved at
create rather than at launch so DescribeNodegroup reports the size the
node actually gets.

TestCreateNodegroup_NoDiskSizeOmitsBlockDeviceMapping asserted the old
fall-through and is replaced by its inverse; the explicit-size case is
already covered by TestCreateNodegroup_DiskSizePropagatesToBlockDeviceMapping.

* fix(ecs): size a container instance's root volume to 30 GiB by default

ProvisionCapacity built its RunInstances input without a block device
mapping, so a container instance inherited whatever volume its AMI
declares. EC2 has no default of its own to fall back on — the AMI is the
only thing that decides — and a node AMI is sized to hold the image, which
leaves the agent no room for the container images it exists to pull.

30 GiB mirrors what the AWS ECS-optimized AMI declares. ProvisionCapacity
is spinifex's own API rather than an AWS one, so DiskSize is added to its
input for callers that want to size a cluster explicitly, defaulting when
omitted the same way InstanceType and Count already do.

* fix(images): pull the Ubuntu catalog images from releases, not a pinned daily

Both Ubuntu catalog entries pointed at a dated build under the codename
dailies tree, which Canonical prunes as builds age out. That path now 404s,
so "spx admin images import --name ubuntu-26.04-x86_64" fails outright for
x86_64 and arm64 alike — the entries had rotted with nothing to catch it.

Release builds are retained rather than pruned, so the pin stays resolvable
while keeping a specific, reproducible build. The filenames under releases/
carry the version instead of the codename, which matters because the sums
entry is matched on the URL's basename.

CI is unaffected and could not have caught this: it fetches resolute/current/,
a symlink that never rots, so nothing exercised the pinned path.

Debian pins a dated directory too but is fine — those builds are retained,
and its entries still resolve.
2026-07-16 13:42:22 +10:00
Julian Sommer 73eeb87751 chore: enable testifylint 2026-07-16 09:36:22 +10:00
joshsiv-mulga 4cbd2a5c88 test(e2e): add container image-pull storage-growth workload
Pulls a container image inside a guest onto a viperblock volume and samples
guest used bytes, host backing-store bytes, nbdkit RSS and the live
checkpoint size as it goes, so backing-store cost can be fitted against the
bytes the guest actually wrote.

Complements the N-pass overwrite workload, which rewrites one extent and so
pins the block map size — that shape cannot show whether cost grows in
proportion to bytes written or faster than them.

Redirects both dockerd's data-root and containerd's root onto the target
volume. Redirecting data-root alone leaves containerd extracting layers into
/var/lib/containerd on the AMI's small root disk, which fails with ENOSPC
while the target volume sits empty.
2026-07-16 08:10:24 +10:00
Julian Sommer b5198d55e2 chore: enable depguard to block log 2026-07-15 16:41:25 +10:00
Julian Sommer bf21e92caf refactor(oidc): use fixed-width stdlib ECDSA point encoding 2026-07-15 16:30:42 +10:00
Julian Sommer ac5bd613b4 chore: enable godot 2026-07-15 16:08:30 +10:00
Julian Sommer 03d981779b chore: enable errorlint 2026-07-15 15:45:25 +10:00
joshsiv-mulga d46d257278 fix(e2e): don't require CA cert when SPINIFEX_AWS_INSECURE=1
The storagegrowth gate called ResolveCACert unconditionally before
building the AWS client, even though newAWSClient itself skips CA
resolution entirely when SPINIFEX_AWS_INSECURE=1. That blocked the
documented runner-resident remote path (env12 and similar clusters
with no local CA cert on disk) and the skip message falsely claimed
no node was running when the real cause was a missing CA. Skip the
CA check under SPINIFEX_AWS_INSECURE=1 and state the actual cause
plus the escape hatch in the skip message.
2026-07-15 15:17:13 +10:00
Julian Sommer f94a52eb80 chore: enable lint rules 2026-07-15 15:14:25 +10:00
joshsiv-mulga 6e043f6453 test(e2e): add storage-growth N-pass overwrite suite
Rewrites one logical extent N times through full attach/format-write/detach
cycles, so the guest-visible footprint stays constant while total bytes written
scales with N. That shape isolates the leak where viperblock mints chunk objects
under unique monotonic keys and never deletes the superseded ones: a sequential
write-once supersedes nothing and so cannot exercise it.

The suite drives the workload only and deliberately does not measure the
backend. Measuring on-disk bytes from here would mean either a host du (which
cannot separate live from dead bytes, and so cannot distinguish this leak from
the unrelated one where an overwritten key's old extent is orphaned without a
tombstone) or a dependency on tooling outside this repository, which would stop
the suite building standalone. It instead reports what it did to a caller-named
result file and asserts only what a guest can observe: a sha256 round-trip
through a fresh mount per pass, and a footprint that stays constant across them.

The volume is retained on request, because deleting it removes the evidence:
DeleteVolume enumerates the bucket by prefix and drops every key under it,
including the leaked chunks.
2026-07-15 15:03:21 +10:00
Julian Sommer 30d4745b6a chore: move tailwindcss to dev deps 2026-07-15 12:13:46 +10:00
Julian Sommer 3b7b781bdf release: update go.mod for v1.12.1 2026-07-15 09:29:22 +10:00
Josh Siviour 92b775951b fix(network): poll second-VPC transit SNAT in natuplink + log AddSNAT install (#541)
Phase 8 of TestNATUplink checked the second VPC router's lr-nat-list with a
single non-polling assert; the igw-default-snat row commits a beat after the
gateway LRP appears, so the read raced the OVN commit and intermittently saw
an empty list (flaky CI red). Wrap it in EventuallyErr, matching the sibling
LRP and host-route polls in the same phase.

AddSNAT logged only on the idempotent-skip path, so the first install was
silent and RCA could not tell a late SNAT from a missing one. Emit an install
log keyed by router + vpc_cidr + external_ip.
2026-07-15 09:26:15 +10:00
Josh Siviour 0a8e6bca15 fix(admin): always pin mgmt-bridge IP in server cert SAN (#540)
Server-cert IP SANs were built solely from live interface enumeration
(DiscoverLocalIPs) plus bindIP, so the canonical mgmt-bridge IP entered
the SAN set only when br-mgmt happened to be up at cert-generation time.
On a host where br-mgmt is down when the cert is minted, 10.15.8.1 was
silently dropped and the EKS control-plane publish to https://10.15.8.1
failed cert verify, leaving the cluster stuck CREATING.

Pin config.DefaultMgmtBridgeIP into the extra SANs for both the init and
join cert paths so publish succeeds regardless of interface state at
generation time. Add a regression test asserting the SAN is present when
br-mgmt is not a live interface.
2026-07-15 09:00:35 +10:00
Josh Siviour d3ac8c0656 test(eks): in-cluster IRSA pod e2e (AssumeRoleWithWebIdentity) (#535)
Add an IRSAPod subtest that applies a real pod wired for IRSA — a projected
ServiceAccount token (aud sts.amazonaws.com) plus the AWS_ROLE_ARN /
AWS_WEB_IDENTITY_TOKEN_FILE / AWS_ENDPOINT_URL_STS / AWS_CA_BUNDLE env the
addon-sync agent injects — and asserts the pod's in-cluster
aws sts get-caller-identity resolves to the assumed IRSA role. This closes the
gap left by IRSAWebIdentity, which only exchanges the token host-side.

Extract registerOIDCRole (shared with IRSAWebIdentity); the pod runs on a
1-node worker nodegroup (the control-plane node is CriticalAddonsOnly-tainted)
and reaches awsgw STS over the worker subnet NAT.
2026-07-15 08:31:09 +10:00
joshsiv-mulga 1281381096 fix(ci): publish-system-image.sh honors SYSTEM_IMAGE_BUILD_DIR
The build script derives BUILD_DIR from SYSTEM_IMAGE_BUILD_DIR (the AMI
publish workflow points it off tmpfs so the 16G Ubuntu GPU raw fits), but
the publish step hardcoded /tmp and could not find the freshly built raw.
GPU legs failed 'Raw image not found'; Alpine legs silently reused stale
pre-existing /tmp raws on the persistent runner. Mirror the build script's
derivation so publish looks where the build actually wrote.
2026-07-15 07:54:21 +10:00
Josh Siviour b41a193cbc fix(eks): make provider-id.sh the single k3s node-label authority for nodegroup + GPU labels (#539) 2026-07-15 00:39:13 +10:00
Julian Sommer 53f636692d fix(network): scrub stale dnat_and_snat by logical IP on EIP bind (#538) 2026-07-14 16:17:59 +10:00
Julian Sommer b40d538c61 refactor(admin): remove dead --no-external flag from spx admin init (#537) 2026-07-14 15:50:13 +10:00
joshsiv-mulga b147e2b869 ci: make Setup Go Workspace idempotent on ci-single runner
The publish-eks-ami matrix fans out four legs on the single persistent
ci-single runner, all reusing one workspace root. go work init is
create-only and checkout never scrubs the root go.work, so legs after the
first fail with 'go.work already exists'. Remove any stale go.work before
init so each leg re-inits cleanly.
2026-07-14 15:35:55 +10:00
Julian Sommer 744cb1929d fix: admin init --force preserves keys, credentials and CA (#536) 2026-07-14 14:46:12 +10:00
joshsiv-mulga 2b44de27bc ci: publish eks-node-gpu + ecs-agent-gpu AMIs
Expand the publish workflow to build the two Ubuntu NVIDIA GPU variants
alongside the Alpine eks-node/ecs-agent AMIs. The image build+publish
scripts are already manifest-driven and Ubuntu-aware; only the workflow
lacked the GPU legs.

Refactor the single-job body into a matrix over the four image manifests,
resolved by a new 'variant' input (all|cpu|gpu) so the slow 16G GPU legs
can be skipped when only the CPU AMIs need republishing. GPU images do not
fit a RAM-backed /tmp, so set SYSTEM_IMAGE_BUILD_DIR onto the runner's
on-disk _temp; legs run serially (max-parallel 1) on the single ci runner.
2026-07-14 14:05:58 +10:00
Julian Sommer 4d195eb0d9 release: update go.mod for v1.12.0 2026-07-14 13:43:02 +10:00
Bryn Mailer 6cdb6000ce feat: presigned urls (#533) 2026-07-14 13:37:14 +10:00
Josh Siviour 4d8a34b545 fix(eks): apply eks.amazonaws.com/nodegroup label via k3s node-label drop-in (#534)
The nodegroup label was only written to K3S_NODE_LABEL in agent.env, but
k3s has no such env var, so non-GPU workers never received the label.
waitWorkersReady buckets Ready nodes by that label (added in #511), so
the affected nodegroups never counted toward their ACTIVE transition and
stayed CREATING until timeout.

Ride the label on a real k3s node-label config drop-in
(15-nodegroup-label.yaml), delivered via cloud-init user-data. Drop the
inert K3S_NODE_LABEL from agent.env and the initd export.
2026-07-14 12:43:42 +10:00
Julian Sommer 1b0a14909f chore: enable prefer-named-capture-group rule 2026-07-14 10:36:33 +10:00
Julian Sommer 8552937c86 chore: enable react/no-object-type-as-default-prop rule 2026-07-14 10:27:29 +10:00
Julian Sommer 9a8b5bd841 chore: enable react compiler oxlint rules 2026-07-14 10:23:32 +10:00
Julian Sommer 3c97627102 chore: fix oxlint config 2026-07-14 09:56:07 +10:00
dependabot[bot] a8ce451ac3 chore: bump the npm-dependencies group (#516) 2026-07-14 09:42:52 +10:00
Julian Sommer de875ccdbe feat(ec2): Launch Templates UI + launch-from-template (#501) 2026-07-14 09:42:13 +10:00
dependabot[bot] f29c6585c2 chore: bump the go-dependencies group with 8 updates (#515) 2026-07-14 09:01:41 +10:00
Josh Siviour 369025f86e fix(e2e): fail fast on reaped instance launch + emit APM error on MarkFailed (#531)
WaitForInstanceState now aborts immediately with the recorded StateReason
message when it observes a reaped state (shutting-down/terminated) while
waiting for a live target, instead of burning the full 5-minute timeout on
an instance that can never reach it.

MarkFailed takes a context and records the failure on the active span via
span.RecordError so a reaped launch surfaces in the APM error stream,
correlated by trace id. Only the LaunchRunInstances path carries a live
span; other callers pass context.Background() and the record is a no-op.

Surfaced by RCA of E2E run 29258398516 (TestAccountScoping/Step2).
2026-07-14 08:29:55 +10:00
Josh Siviour 97a03e4d1e fix(network): seed static OVN MAC binding for gateway nexthop (#530)
TestNATGateway flaked with 100% egress loss: the tenant VPC gateway router's
OVN MAC_Binding had no entry for its upstream default-route nexthop. OVN
resolves that nexthop via lazy dynamic ARP on first egress; if that ARP is
lost during gateway bring-up, SNAT'd packets die in lr_in_arp_resolve and no
reply returns. MAC_Binding is per (datapath, logical_port, ip), so a sibling
VPC router resolving the same nexthop does not help.

Deterministically seed a static MAC binding for the nexthop at IGW attach. The
nexthop MAC is the host's own default-gateway MAC (the host shares the physical
uplink), read from ip neigh and written via ovn-nbctl static-mac-binding-add
(northd programs it into SB MAC_Binding, GC-safe). Host layer L0 owns the
shell-out per ADR-0006 S2; igw calls it via an injected hook. Best-effort and
idempotent: a missing MAC leaves dynamic ARP as the fallback, and it re-runs on
every AttachIGW so a binding lost to a later flap self-heals.
2026-07-14 08:24:35 +10:00
Josh Siviour 6253c3d0ae fix(nats): create lazy KV buckets at cluster-size replicas, not R1 (#529)
utils.GetOrCreateKVBucket hardcoded Replicas=1, so the ~38 handlers that
create their KV buckets lazily (VPC/subnet/ENI/EIP/ELBv2/ECS/ECR/ACM/
natgw/snapshot/placementgroup/IPAM/account-settings/dhcp/static-pool …)
created them at R1 on a multi-node cluster. upgradeJetStreamReplicas runs
once at startup, so any bucket created lazily after boot stayed R1
permanently — and an R1 stream's single Raft replica is transiently
leaderless during formation/join/rebalance, surfacing as
ErrNoResponders/ErrTimeout on KV ops.

Add a package-level default replica count that the daemon sets once at
boot (startCluster, after initJetStream) to len(clusterConfig.Nodes),
before any handler creates a bucket. GetOrCreateKVBucket now creates at
that default, so lazily-created buckets are born quorate. The one-shot
startup UpdateReplicas remains as a safety net for pre-existing R1
buckets from older deploys.

Zero call-site churn: the default flows through the existing
GetOrCreateKVBucketWithReplicas path already used by eks/ecs/iam.
2026-07-14 00:32:38 +10:00
Josh Siviour 75ca981718 fix(eks): deflake reconciler boot-scan resume (#528)
TestSpawnRegisteredReconcilers_ResumesNonTerminal flaked two ways. First,
the shared fixture meta stamps a weeks-old CreatedAt, so a resumed CREATING
reconciler instantly trips the CREATE timeout, marks the cluster FAILED and
deregisters before the registry.Has assertion runs — the test was racing a
self-removal. Stamp a recent CreatedAt so the reconciler stays CREATING.

Second, SpawnRegisteredReconcilers ran a one-shot JetStream enumeration
(KeyValueStoreNames + kv.Keys), which is eventually consistent: under load a
just-committed cluster can be missing and never resumed, silently on a real
daemon. Re-scan until the observed CREATING/ACTIVE count stops growing, capped
and short-backed-off; Spawn is idempotent so re-runs only pick up stragglers.
2026-07-14 00:06:35 +10:00
Josh Siviour 4b877c6a93 fix(e2e): resolve WANHost from spinifex.toml advertise on single-node (#527)
discoverSingleNodeIPs seeds 127.0.0.1 then appends net.InterfaceAddrs()
in undefined order, so WANHost=nodeIPs[0] was loopback and the guest
gateway URL helpers fell through to the first non-loopback interface
addr — an arbitrary co-tenant bridge on multi-bridge shared hosts
(wattle: br-lan-env1..23, br-wan), leaving guest VMs unable to reach the
advertised gateway to register.

Resolve WANHost from spinifex.toml nodes.<node>.advertise (falling back
to that node's host), mirroring awsgwBindIP's toml scan; fall back to the
first non-loopback discovered addr only when config is unreadable. This
removes the need for the SPINIFEX_WAN_IP override on shared hosts.
2026-07-13 23:49:39 +10:00
Josh Siviour 4f31b336c5 fix(vm): retry QMP dial on connection-reset during greeting (#525)
The crash-supervisor auto-restart path (qmpHeartbeat -> HandleCrash ->
MaybeRestart -> startQEMU -> newQMPClientWithHandshake -> dialQMPWithRetry)
already unlinks the stale QMP socket and retries a refused/absent connect. But
a freshly relaunched QEMU can accept the connect then reset the connection
mid-greeting while it initialises, surfacing 'connection reset by peer'
immediately after 'QEMU started successfully' as a ServerInternal.

Add ECONNRESET and EPIPE to isTransientDialError so the dial is retried until
QEMU services the monitor, closing the auto-restart QMP-connect race.
2026-07-13 23:07:47 +10:00
Josh Siviour 119081fb95 feat(bedrock): Ochre InvokeModel — per-provider-native body adapters (#522)
Add non-streaming InvokeModel to bedrock-runtime. Unlike Converse's unified
schema, InvokeModel passes model-provider-native bodies through, so route by
model family: Anthropic Claude bodies forward to the Messages API (drop the
Bedrock-only anthropic_version, inject model, return verbatim); Meta Llama
bodies translate to/from the self-host vLLM OpenAI completions endpoint.

Raw-body dispatch bypasses the JSON response path (WriteRawResponse). Reuses
the Phase 1 credential + endpoint resolvers and upstream-status mapping.
Unit-tested (routing, body rewrite, translation, error classes) plus gated
InvokeModel E2E.
2026-07-13 22:34:08 +10:00
Josh Siviour 28d14e9d32 feat(obs): emit OTLP spans from qmp-collector and spinifex-ui (#524)
Both services already run otelsetup.Init and ship logs/metrics but never
opened a span, so they were invisible in the Kibana APM service map.
qmp-collector now opens a root span per per-instance poll tick (marks
error on QMP collect/publish failure); spinifex-ui now wraps its HTTP
handler chain with otelsetup.HTTPMiddleware and its reverse-proxy
transport with otelhttp, so UI requests get a server span and proxied
calls to awsgw/predastore carry traceparent instead of stopping the
trace at the UI hop.

nats and eks are intentionally left uninstrumented in this pass: nats is
the vendored message-broker process with no message-level hook to attach
spans to (the traffic it carries is already traced end-to-end at its
producer and consumer endpoints); eks request/response calls already
produce spans today under service.name=spinifex-daemon since the
handlers run in that process, and its async reconciler/state-machine
goroutines need a separate span design across a much larger surface.
2026-07-13 22:31:25 +10:00
Josh Siviour 67981a1b17 fix(daemon): silence benign NATS unsubscribe noise on shutdown (#523)
Skip ERROR log when a subscription is already invalid
(nats.ErrBadSubscription) during graceful shutdown; log at debug
instead. Cosmetic cleanup race, no functional impact.
2026-07-13 22:24:43 +10:00
joshsiv-mulga daf637709c fix(bedrock): canonicalise HTTP header keys for canonicalheader lint
golangci-lint canonicalheader flagged 6 lowercase header literals in the
Ochre/Bedrock gateway (anthropic.go, anthropic_test.go, vllm.go). Canonical
form is wire-equivalent; canonicalise to X-Api-Key, Anthropic-Version and
Content-Type to restore CI lint.
2026-07-13 22:08:14 +10:00
Josh Siviour cee73671ff feat(bedrock): Ochre runtime MVP — Converse over self-host + Anthropic-direct (#520)
* feat(bedrock): Ochre runtime MVP — Converse over self-host + Anthropic-direct

Add the AWS-Bedrock-compatible gateway services (bedrock, bedrock-runtime)
following the EKS REST-JSON pattern: ListFoundationModels / GetFoundationModel
over a static connectivity-tiered catalog, and non-streaming Converse routed
per modelId to one of two provider backends — self-hosted vLLM (OpenAI wire)
and Anthropic-direct (Anthropic Messages wire).

Per-account provider API keys are a master-key-encrypted JetStream KV secret
with an optional platform default; the advertised catalog gates external
models on a resolvable credential. Self-host endpoints are pinned via config
(OCHRE_VLLM_ENDPOINTS) behind an EndpointResolver seam.

Adds gateway/bedrock/ (catalog, provider router, Anthropic + vLLM adapters,
credential store), awserrors REST-JSON codes, and the bedrock E2E suite
(list/get light; Converse gated on live backends). Daemon/NATS control plane
and dynamic endpoint lifecycle are deferred to Phase 3.

* test(bedrock): cover dispatch, router, credentials KV + endpoint config

Add unit coverage for the Ochre Phase 1 diff: Bedrock_Request /
BedrockRuntime_Request end-to-end dispatch, provider router, JSON
response/error writers, upstream-status mapping, the credential-store KV
round-trip, the Anthropic bound-provider path, and parseBedrockEndpoints.
Lifts PR diff coverage above the 60% gate.
2026-07-13 21:40:34 +10:00
Josh Siviour 1e1d2f3c6d ci: GPU e2e workflow on ci-gpu self-hosted runner (#518)
* ci: GPU e2e workflow on ci-gpu self-hosted runner

Runs tests/e2e/gpu (EC2/ECS/EKS GPU passthrough) against the persistent,
hand-bootstrapped wattle shared host, driven over SSH by a banksia runner
labelled ci-gpu (matches the ci-single/ci-multi convention) — no runner
installed on the shared box.

- runs-on [self-hosted, ci-gpu]; concurrency ci-gpu, cancel-in-progress
  false (single A1000 = single-flight, queue not stomp)
- workflow_dispatch + GPU-path filter (not every push)
- manual-work mutex gate (~/.ci-gpu-paused / CI_GPU_PAUSED) so hands-on GPU
  testing blocks CI
- if: always() cleanup terminates launched GPU instances; never resets the
  box (no setup-ovn.sh/dev-reset/cluster-reset)
- drives the control plane with spx + the already-configured aws spinifex
  profile directly (no wrapper scripts, nothing to install on wattle)

* ci(gpu-e2e): advisory AMI preflight, fail only if zero GPU AMIs

The suite is self-guarding — each test skips when its AMI is absent. The
hard-fail on the base ubuntu GPU AMI blocked the whole run even when the
ECS GPU AMI (the only one imported) could execute. Inventory the AMIs and
fail only when none are present.

* ci(gpu-e2e): self-provision base GPU AMI, hard-require it in preflight

The GPU E2E workflow was green-because-skipped: with no GPU AMIs imported
the self-guarding suite skipped every test, passing as a false green. Make
the raw-EC2 GPU passthrough test the always-on baseline.

- Add an import-if-absent setup step that pulls the prebuilt base GPU image
  from the catalog by name (ubuntu-26.04-nvidia-gpu-x86_64), naming the AMI
  exactly what tests/e2e/gpu filters for. Idempotent; caches on wattle's
  persistent loop-fs. Runs spx under sg spinifex (local storage write).
- Preflight now hard-requires the base AMI (fail, not skip) so a silent
  import failure can't pass as green; ECS/EKS node AMIs stay opt-in warn.
- Reconcile the local build path: ubuntu-gpu-nvidia.conf AMI_NAME and
  launch-gpu.sh's nvidia default now use the same canonical name, so a
  locally built+imported image also satisfies the suite.

* ci(gpu-e2e): pass --ami-name on base GPU import so the AMI name matches

spx admin images import --name selects the catalog entry but names the
resulting AMI generically (ami-<distro>-<version>-<arch>), so the import
landed as ami-ubuntu-26.04-x86_64 and the tests/e2e/gpu name filter never
matched. Pass --ami-name ubuntu-26.04-nvidia-gpu-x86_64 explicitly (same
flag build-system-image.sh uses) and poll briefly for availability.

* fix(network,vm): reconcile auto-assigned public-IP NAT + scale VFIO QMP timeout

Auto-assigned public IPs were never carried through the network reconciler.
applyEIPs ran AddEIP + ensureGuestPortDatapath for user EIPs only; auto-assigned
IPs (intent.Ports[].PublicIP) got egress exemption but no NAT re-assert and no
gatewayLRP->guest convergence. Their dnat_and_snat was created once at
RunInstances and never touched, so a vpcd/OVN rebuild dropped it and the
guest-port hop never converged: inbound DNATs at the gateway but never reaches
the guest (TCP/SSH dark) while ICMP is answered by the gateway LR (ping OK).

floatingIPSpecs now folds every auto-assigned/ELB public IP into the same
AddEIP + guest-port convergence path as user EIPs (deduped by private IP; a user
EIP wins), making the datapath self-healing across reconcile ticks and deploys.

qmpGreetingTimeout for VFIO guests now scales with guest RAM (base 30s + 8s/GiB,
floored at 180s, capped at 600s) instead of a flat 180s, so a large guest is not
SIGKILLed mid-pin while the whole guest RAM is IOMMU-locked before QMP answers.

* fix(vm): scale QMP handshake command deadline for VFIO launches

The greeting timeout scaling alone did not fix the VFIO launch SIGKILL: a GPU
guest sends its QMP greeting before the RAM pin completes, then cannot service
qmp_capabilities until the pin finishes. sendQMPCommand hardcoded a 30s response
deadline, so the handshake decode timed out (~43s for a 16 GiB g5.xlarge) and
the launch cleanup SIGKILLed a QEMU that would have come up.

Parameterize the command deadline (sendQMPCommandWithTimeout); the handshake now
uses the RAM-scaled qmpGreetingTimeout (base 30s + 8s/GiB, floor 180s, cap 600s)
for qmp_capabilities. Other callers keep the 30s default.

* test(gpu-e2e): share one GPU instance across passthrough subtests to avoid mid-suite IP recycle
2026-07-13 21:28:48 +10:00
Josh Siviour ae70685184 fix(network): re-point EIP and flush stale OVN L2 state on IP reuse (#521)
Recycling both the floating and private IP onto a new ENI left the ingress
datapath targeting the terminated instance, so the successor was unreachable
on its EIP while IMDS stayed up.

- AddEIP re-points the dnat_and_snat row when the owning ENI changed even
  though the external+logical IP are unchanged, keyed on the portable
  spinifex:logical_port external-id (centralised mode leaves the native
  LogicalPort column empty). The genuine same-ENI case still skips row churn.
- Flush the SB MAC_Binding for a private IP on ENI teardown and successor
  bind so a reused address re-resolves to the live owner's MAC, via a new
  MACBindingFlusher backed by ovn-sbctl.
- Reconciler prunes orphan dnat_and_snat rows whose stamped owning ENI is
  absent from intent (leaked by lost vpc.delete-nat across dead VPCs).
- IMDS tap responder suspends retries for an ENI whose record is gone,
  stopping the 15s reconcile churn.
2026-07-13 21:09:48 +10:00
Josh Siviour 2a9fbc8b36 feat(ui): surface GPU passthrough + MIG state across EC2/EKS/ECS console (#519)
* test(daemon): cover GPU inventory in GetNodes and GPU attachment in GetVMs

Adds unit tests for buildGPUInventory/handleNodeStatus (whole-GPU claimed/
free, MIG slices claimed/free, no-GPU node) and resolveVMGPU/handleNodeVMs
(whole-GPU attachment, MIG attachment, no attachment). Production code for
these paths (types.GPUInfo/GPUSliceInfo/VMGPUInfo, buildGPUInventory,
resolveVMGPU) and EC2 DescribeInstanceTypes GpuInfo.Gpus population
(instancetypes.GenerateGPUTypes/GenerateMIGTypes/IsGPUType) already landed
via the daemon GPU-admission work and were already test-covered for part C.

* feat(ui): shared GpuInstanceTypeSelect grouped instance-type picker

Reusable Select wrapper for EC2/EKS/ECS instance-type pickers: groups
Standard vs GPU-Accelerated types via structural GpuInfo.Gpus detection
(AWS SDK shape, no name heuristics), annotates GPU items with VRAM from
MemoryInfo.SizeInMiB and an optional per-type availability count, and
falls back to a single ungrouped list when no GPU types are present.

* feat(ui): EC2 GPU inventory, instance GPU card, shared picker in run-instances

* feat(ui): EKS nodegroup GPU picker + AMI/taint note

Add-nodegroup form now uses the shared GpuInstanceTypeSelect, grouping
and VRAM-annotating GPU instance types instead of a free-text field.
Selecting a GPU type surfaces a note that the GPU node AMI is
auto-selected and the nodegroup is tainted
nvidia.com/gpu=present:NoSchedule by default. Nodegroup rows badge GPU
nodegroups, derived from the primary instance type since
NodegroupRecord does not expose a GPU flag over the API.

* feat(ui): ECS GPU capacity picker, task-def GPU count, task gpuIds

* feat(eks): expose nodegroup GPU signal in Describe/ListNodegroups + UI badge

* chore(ui): rebuild embedded console dist for GPU UI
2026-07-13 16:41:02 +10:00
Josh Siviour 2dbd1e716c chore(deps): bump viperblock to dev (parallel chunk upload + backpressure) (#517)
Bump github.com/mulgadc/viperblock to v1.11.1-0.20260713024608-eb4a376becc8
(dev tip through viperblock #34): bounded parallel chunk upload + coalesced
checkpoint (edou7 sec 7 items 2 and 9), sweeping the require up from #32 and
picking up the #33 write backpressure on the way. The nbdkit plugin the VM
write plane uses is built from the submodule source; this bump aligns the
viperblockd library that spx links.
2026-07-13 12:55:26 +10:00
Josh Siviour 98fc01764b feat(ecs): GPU passthrough to task containers (Epic C) (#512)
* feat(ecs): thread resourceRequirements GPU count through conv, ContainerDef, AssignContainer and RunSpec

* feat(ecs): add GPU placement dimension — InstanceRecord capacity, fits/reserve/release, STRINGSET resource reporting

* feat(ecs): agent GPU discovery via nvidia-smi, pinning ledger, device-UUID report-back and DescribeTasks gpuIds

* feat(ecs): inject assigned GPU devices into containerd OCI spec via CDI (oci.WithCDIDevices)

* test(ecs): add GPU task e2e (nvidia-smi container, gpuIds assertion) to gpu e2e suite

* test(gpu): decouple base-AMI requirement from GPU fixture so ECS e2e runs without the ubuntu EC2 image

* test(gpu): pick smallest advertised GPU instance type to fit a single host

* fix(ecs): preserve pinned GPU UUIDs across STOPPED state change so DescribeTasks reports gpuIds for one-shot tasks
2026-07-13 10:54:55 +10:00
Josh Siviour e7b6b9244d fix(daemon,vm): gate GPU admission on cpu/mem + release leaked GPU slot on crash (#513)
* fix(daemon): gate GPU instance admission on cpu/mem and GPU-slot availability

canAllocateLocked early-returned count for whole-GPU (non-MIG) types,
bypassing the vCPU/memory admission calc and the live MemAvailable gate.
canAllocateCount already had a requiresGPU/availGPU branch, but every
production caller passed requiresGPU=false, making it dead code.

GPU instance types (whole-GPU and MIG) now flow through the same
cpu/mem + live-mem gate as every other type, with GPU-slot count wired
in as an additional hard constraint from gpuManager.Available(). This
also gives MIG a real up-front GPU-slice gate instead of only failing
late at Claim.

* fix(vm): release leaked GPU pool slot on crash-to-error and add reconciler backstop

HandleCrash freed vCPU/memory via deallocateResources but never called
InstanceCleaner.ReleaseGPU, since it bypasses the stop/terminate cleanup
chain that normally carries that call. A successful restart reuses the
still-set GPUAttachments, so that path is untouched, but when restarts
are exhausted, the instance type is unknown, there isn't enough capacity
to restart, or the relaunch itself fails, the instance settles
permanently in StateError still holding its GPU pool entry with nothing
to reclaim it.

Release the GPU and clear GPUAttachments on each of those terminal
paths. Add a GPU-pool reconciler mirroring the existing ENI reconciler,
wired into the daemon's reconcile loop, that periodically frees any pool
entry whose instance is absent or has settled into a non-running state,
as a backstop for a node-down mid-cascade or any other gap in the
synchronous release.
2026-07-12 18:27:02 +10:00
Josh Siviour f7bf921ce7 fix(eks): NLB name overflow, nodegroup readiness scoping, launch retry, GPU konnectivity toleration (#511)
* fix(eks): keep derived ELBv2 names under 32 chars and surface CREATE_FAILED reason

Long cluster names made the derived "eks-<name>[-cp|-konn]" NLB/target-group
names exceed the ELBv2 32-char limit, failing cluster creation asynchronously
with no visible cause. safeELBv2Name now deterministically truncates the name
and appends a stable hash of the full cluster name when needed, so the same
name is derived on every call (create, lookup, delete). DescribeCluster also
now surfaces ClusterMeta.StatusReason as a Health.Issues entry when a cluster
is FAILED, instead of leaving callers to guess why.

* fix(eks): plumb per-nodegroup Ready-node counts through state reports

The on-VM state-report script now emits nodegroup_ready, a per-nodegroup Ready
count keyed by the eks.amazonaws.com/nodegroup label value, alongside the
existing cluster-wide node_count. ServerStateReport, the ClusterReconciler's
observe step, and SetClusterHealthState now carry this map through to
ClusterMeta.NodegroupNodeCounts, so per-nodegroup consumers (wired in the next
commit) can gate on a nodegroup's own workers instead of the cluster total.

* fix(eks): scope nodegroup Ready-gate per nodegroup and retry failed worker launches

waitWorkersReady compared the cluster-wide Ready-node count against a
nodegroup's baseline+desired, so in a multi-nodegroup cluster one nodegroup's
Ready workers could mask another whose own workers never registered. It now
reads meta.NodegroupNodeCounts[ng], scoped to nodes carrying the
eks.amazonaws.com/nodegroup=<name> label.

Separately, launchNodegroupInfra never retried a failed worker launch (a
transient QMP timeout or host capacity pressure), leaving the nodegroup stuck
CREATING with zero workers. The new launchWorkersUntilDesired wrapper relaunches
only the shortfall on failure, logging each retry, until the desired count is
reached or workerLaunchRetryTimeout elapses.

Both fixes are covered by regression tests: a two-nodegroup scenario where only
one has Ready workers, a transient-failure-then-success relaunch, and an
exhausted-retry-budget CREATE_FAILED path. Not covered here: a worker that dies
after the nodegroup is already ACTIVE — that needs an ongoing reconciler, which
this change does not add.

* fix(eks): tolerate GPU NoSchedule taint in konnectivity-agent DaemonSet

A GPU-only worker is tainted nvidia.com/gpu=present:NoSchedule, so the
konnectivity-agent DaemonSet (no tolerations) showed DESIRED=0 there and
kubectl logs/exec against pods on that node failed "No agent available".
Adds an Exists toleration for nvidia.com/gpu:NoSchedule.

Live-cluster validation (DESIRED count, kubectl exec) is deferred — no cluster
available to test against right now.

* fix: tolerate nvidia.com/gpu taint on ebs-csi-node and coredns DaemonSets so GPU workers get storage and DNS
2026-07-12 12:11:16 +10:00
Josh Siviour a620731d9c fix(eks): make CP-VPC teardown converge, GC orphaned OVN topology (#510)
A re-driven DeleteCluster whose managed control-plane VPC was already gone
(EC2 InvalidVpcID.NotFound) kept looping DependencyViolation: the internal
cp-vpc state record (ClusterMeta.ManagedCPVPC) was never cleared unless the
*entire* purge succeeded, so any unrelated flaky step retried CP-VPC/SG API
calls against a stale VpcId forever instead of converging.

purgeClusterInfra now clears ManagedCPVPC (via a new CAS-based
ClearClusterManagedCPVPC) as soon as DeleteClusterCPVPC + its SG cleanup both
succeed, independent of whatever else in that run still fails.

DeleteClusterCPVPC also now republishes vpc.delete for the CP VPC on every
call — including the already-gone fast path, falling back to the persisted
VpcId when the live tag-indexed describe can no longer name it — so vpcd's
topology manager gets another chance to GC an orphaned logical
router/subnets/SubnetEgressPriorityDrop policies if the original delete's
fire-and-forget event was ever lost.
2026-07-12 12:11:00 +10:00
Josh Siviour 34badea0d9 fix(dhcp): wait for renewal loop to quiesce before deleting lease on release (#509)
handleRelease cancelled the per-lease renewal goroutine but deleted the
KV entry without waiting for it to exit. An in-flight reaffirm/renew Put
could land after the Delete, re-creating the lease and leaking a KV
record. Add a per-loop done channel and block on it after cancel so any
pending Put completes before the delete, making DrainAll's release path
deterministically synchronous.
2026-07-12 12:08:34 +10:00
Josh Siviour 9f349e86ca feat(eks): expose NVIDIA GPUs to pods (Epic B) — VFIO QMP, worker registration, device-plugin GPU access (#507)
* feat(eks): expose NVIDIA GPUs to pods via CDI device plugin

GPU nodegroups now label workers nvidia.com/gpu.present=true and taint them
nvidia.com/gpu=present:NoSchedule (via a k3s config.yaml.d drop-in, since k3s
has no K3S_NODE_TAINT env), and stage a hidden nvidia-device-plugin addon that
addon-sync renders into the K3s auto-deploy dir. The device plugin runs with
--device-list-strategy=cdi-cri and is gated to GPU nodes by nodeSelector +
toleration, advertising nvidia.com/gpu allocatable so pods land on GPU nodes
and get the device injected via the CDI spec baked by mulga-cdi-generate.
Relies on containerd 2.0.5 default enable_cdi=true (no config fork).

* fix(vm): scale QMP greeting deadline for VFIO passthrough guests

A GPU passthrough guest (-device vfio-pci + large -m) must lock and DMA-map
its entire guest RAM through the IOMMU before QEMU's QMP monitor answers,
which exceeds the fixed 30s greeting deadline and gets the VM SIGKILLed at
launch. Parameterize the greeting timeout (NewQMPClientWithGreetingTimeout)
and scale it to 180s for VMs with GPU attachments; plain VMs keep the 30s
default and reconnects against an already-running QEMU keep it too.

* fix(eks): register GPU workers reliably and expose GPUs to pods

Three fixes let a GPU nodegroup worker boot, register, and advertise its GPU:
- k3s-agent.service gains Delegate=yes (plus KillMode/TasksMax/limits and
  overlay/br_netfilter modprobe): k3s embeds containerd+kubelet in-process, so
  on cgroup-v2 the kubelet cannot create /kubepods without a delegated subtree
  and ContainerManager.Start fails before Node registration.
- The GPU node label rides a k3s config drop-in (20-gpu.yaml node-label +
  node-taint) instead of K3S_NODE_LABEL, which k3s ignores; without the label
  the nvidia-device-plugin nodeSelector never matches.
- Raise the nodegroup ready timeout to 20m for the ~7min GPU first boot.

Adds the GPU e2e (VPC + NAT-GW egress harness) and a slog-restore unit test.

* fix(eks): install ecr-credential-provider on the GPU node AMI

The GPU worker's kubelet is started (from nodegroup userdata) with
--image-credential-provider-config + --image-credential-provider-bin-dir=
/usr/local/bin, but the eks-node-gpu AMI never built or installed the
ecr-credential-provider binary. kubelet treats a referenced-but-missing
provider binary as a fatal RegisterCredentialProviderPlugins error, so the
node crash-loops and never registers (while the Alpine eks-node worker, which
ships the binary, joins fine). Build + install it in the GPU manifest; the
server-only binaries stay omitted since GPU nodes are agent-only.

Also tee k3s-agent stderr (and its embedded kubelet klog) to the serial
console: Ubuntu logs only to journald, which is off-box-invisible on a worker
in an isolated customer VPC, so a kubelet that fails to register left no trace.

* fix(eks): give the nvidia-device-plugin its own GPU access

The CDI-mode device plugin (--device-list-strategy=cdi-cri) must run
nvml.Init and generate the CDI spec in its own container, but the DS had
no GPU wiring, so nvml.Init crash-looped and nvidia.com/gpu was never
advertised. Run the DS under the k3s-auto-configured nvidia runtimeClass
with NVIDIA_VISIBLE_DEVICES/CAPABILITIES=all, the host root mounted
read-only at /driver-root so it can locate the driver libs + ld.so.cache,
and /var/run/cdi mounted writable so the generated spec is visible to
containerd. No enable_cdi drop-in is needed on k3s v1.32.5 / containerd
2.0.5. GPU workload pods still need only resources.limits nvidia.com/gpu.

Live-validated end-to-end on the single-GPU host (RTX A1000): baked
control-plane AMI, GPU nodegroup, nvidia.com/gpu allocatable, nvidia-smi
pod, zero manual intervention.

* test(ec2): drop obsolete restoreSlogDefault test

Rebased onto dev, which dropped the restoreSlogDefault workaround in #506
(viperblock no longer mutates the global slog). The test referenced the
now-deleted helper and no longer compiles. Real GPU/registration diff is
independently covered (diff coverage 95.3%).
2026-07-12 10:38:36 +10:00
Josh Siviour ff5f2a1ce1 fix(daemon): skip phantom mgmt NIC on single-node system instances (#508)
When no br-mgmt bridge is present (supported single-node config where system
instances reach AWSGW via the advertise IP), LaunchSystemInstance correctly
skips mgmt-tap creation but the NIC list still carried the mgmt slot, so
buildNICNetdevs emitted a QEMU netdev/device for a tap that was never created
and QEMU aborted (could not configure /dev/net/tun: Operation not permitted),
failing every CreateLoadBalancer/CreateCluster. Skip the mgmt NIC (index 1)
when its MAC is unset across netdevs, kernel-cmdline mmio slots, and the guest
fw_cfg netcfg blob; multi-node (br-mgmt present) is unchanged.
2026-07-12 10:11:22 +10:00
joshsiv-mulga bdfefc48ae chore: bump viperblock pseudo-version (io.ops namespace + ES|QL-queryable duration sums) 2026-07-12 10:04:15 +10:00
joshsiv-mulga 47a510f08c chore: bump viperblock + predastore to dev pseudo-versions 2026-07-11 23:05:12 +10:00
Josh Siviour 29c6fd1367 refactor: drop restoreSlogDefault workaround (viperblock no longer mutates global slog) (#506) 2026-07-11 23:00:57 +10:00
Josh Siviour 822db926fe feat(observability): surface AWS error message + show schedulable node capacity (#505)
- 4ncdg: NATSRequest now round-trips the AWS error Message, not just the
  sanitized Code. Server sends GenerateErrorPayloadWithMessage(code,
  err.Error()); the client returns the Message when present so the CLI prints
  the real reason (e.g. "snapshot not found; refusing to restore") instead of
  a bare "ServerInternal". One code path fixes every NATS-driven command.
- fbmhl: the UI node view rendered raw host totals (vCPU:80, 0/125.5GB),
  ignoring the daemon reserve. It now shows schedulable = total - reserved,
  the capacity a tenant can actually consume. reserved_vcpu/reserved_mem_gb
  added to the NodeInfo type (already present in the API payload).
2026-07-11 00:02:03 +10:00
Josh Siviour 183a08353e feat(network): harden routed-NAT v1 (VPC cap, e2e gate, docs) (#504)
Three related nat-mode fixes:

- ura01: nat-transit pool now emits gw_lrp_range_start/end. admin init
  sets 100.127.0.16-.254 (239 VPC gateways) instead of the auto-derived
  top-16 (~15). Adds GwLrpRange* to PoolData + the spinifex.toml template
  so operators can widen any pool.
- 724mm: detectPoolMode returns true only for external_mode=pool. nat and
  disabled clusters now skip Phase 8b/8d, which otherwise failed on
  AllocateAddress UnsupportedOperation.
- pnz2i: document that system instances (ECS/EKS/LB agents) are
  unsupported in nat v1 (require pool mode) in the vpc-networking README
  and COMMANDS.md; document the gw_lrp_range_* fields.
2026-07-10 22:57:21 +10:00
Josh Siviour d663760dec feat(eks): control-plane disaster recovery — etcd snapshot + restore-snapshot (#503)
* fix(eks): write etcd-snapshot.env at CP launch so snapshots actually run

* test(eks): cover resolveSystemPredastoreURL and buildEKSServiceDeps

Diff-coverage gate for 852f61d9 was unmet (48.8% < 60%) because the
mgmt-bridge branches and EKSServiceDeps assembly had no direct test.

* feat(eks): restore-snapshot DR path (fresh CP + cluster-reset restore + NLB re-point)

* fix(eks): make restore-snapshot fail-safe (validate snapshot, unwind on partial failure, honest status)

* fix(eks): sign etcd-snapshot uploads with deployment region (was 403ing)

* fix(eks): apply recovery directive on DR-seed first boot + single-node replacement host

* fix(daemon): log NATS handler failures at Error (were only on the trace span)
2026-07-10 22:14:58 +10:00
Josh Siviour 6879a3425a feat(gpu): Ubuntu NVIDIA GPU node images + GPU AMI selection (Epic A) (#502)
* feat(gpu): Ubuntu NVIDIA GPU node image + GPU AMI selection

Epic A of GPU passthrough to EKS pods / ECS task containers.

- Add gpu-vendor AMI tag constants + GPUVendorForType/IsGPUTypeName helpers
  (instance-family prefix -> vendor from knownGPUModels).
- EKS + ECS worker AMI resolution branches to a GPU-tagged node image for GPU
  instance types; no fallback to the driverless image (hard error instead).
- Exclude gpu-vendor-tagged AMIs from the plain (non-GPU) lookup so a newer GPU
  AMI cannot hijack ordinary nodes (managed-by tag is shared; filters have no
  negation, so exclude client-side).
- NodegroupRecord caches GPUEnabled/GPUVendor from InstanceTypes.
- Catalog entries spinifex-eks-node-gpu / spinifex-ecs-node-gpu (dual-tagged).
- New Ubuntu 26.04 GPU node images (eks-node-gpu, ecs-agent-gpu): reuse the
  proven DKMS -server driver recipe (drop Docker CE + dev payload), add
  nvidia-container-toolkit, OpenRC->systemd port of the worker/agent service
  subset, udev EBS by-id bridge, nvidia modules-load, first-boot CDI generation.
- build-system-image.sh: SYSTEM_TAG supports multiple space-separated tags.

* build: allow SYSTEM_IMAGE_BUILD_DIR override for image build dir

Ubuntu GPU images are 16G; a RAM-backed /tmp tmpfs is too small to hold the
qcow2 + expanded raw. Default stays /tmp; SYSTEM_IMAGE_BUILD_DIR redirects the
build onto a real disk.
2026-07-10 19:52:12 +10:00
Julian Sommer 60c0fbce59 feat(iam): inline policy editors for users, roles and groups (#500) 2026-07-10 16:01:03 +10:00
Josh Siviour 8da043ce76 feat(ecs): tags API + capacity-provider CRUD scaffolding (#499)
* feat(ecs): tags API (TagResource/UntagResource/ListTagsForResource)

* feat(ecs): capacity-provider CRUD scaffolding
2026-07-10 15:01:59 +10:00
Julian Sommer 8fe8a9436c feat(ec2): EC2 Launch Templates (#498) 2026-07-10 14:41:04 +10:00
Josh Siviour e262ee0064 fix(elbv2): reap LBs wedged in provisioning + harden IMDS bootstrap (#496)
An ELBv2 LB whose system VM launches but whose in-guest lb-agent never
delivers a first heartbeat previously stayed in provisioning forever with
an empty StateReason: promotion to active happens only on the first
heartbeat and the only provisioning->failed transitions were at
create/launch time. A single wedged LB in the multi-node e2e lb suite
leaked its listener/target-group and cascaded into ResourceInUse and
DependencyViolation on VPC teardown.

- Add a lifecycle reaper that marks failed any LB still provisioning past
  lbProvisioningTimeout (4m) with no heartbeat, reusing markLBFailed, and
  populate a provisioning StateReason so a stuck LB is self-describing.
  Started once at daemon startup; stopped on service Close.
- Harness: add ErrLBProvisioningTimeout and treat a provisioning-timeout
  as retryable in createActiveLB; register idempotent LB/listener teardown
  up-front so no exit path leaks into the shared VPC.
- IMDS: bounded retry+backoff on the instance-fact and user-data lookups so
  a transient gather timeout under multi-node load does not starve the
  lb-agent bootstrap.
2026-07-10 11:34:39 +10:00
Julian Sommer 07411649ec test: RSA key-size + QMP socket-wait seams to speed up test suites (#497) 2026-07-10 11:07:50 +10:00
Julian Sommer 969efca865 test: speed up slow spinifex suites (#495) 2026-07-10 10:13:58 +10:00
Josh Siviour 293cebee68 fix(vm): resume drain-stopped VMs after host reboot (#494)
The coordinated host-shutdown DRAIN phase transitioned every running VM to
StateStopped and migrated it to the operator-stopped shared KV bucket, evicting
it from node-local running state. On the next boot Restore only loads local
running state and treated every StateStopped instance as user-stopped, so
previously-running VMs never relaunched after a host reboot.

Gate on the existing operator-intent signal Attributes.StopInstance (set only by
StopInstances, cleared by StartInstance): stopOne keeps drain-stopped VMs in
local running state instead of migrating them, and Restore relaunches
drain-stopped VMs while operator-stopped ones stay stopped. markUnschedulable
now stamps StopInstance so capacity-demoted instances are not relaunched.
2026-07-10 10:13:47 +10:00
Julian Sommer 635915b5b0 feat(spx): surface OVN NB/SB Raft roles in get nodes (#493) 2026-07-10 09:48:05 +10:00
Julian Sommer 84ac70824f Merge branch 'main' into dev 2026-07-10 09:04:56 +10:00
Josh Siviour 466dbf1652 fix(ec2): make EBS unmount seal + stopped-instance RMW idempotent (#491)
Close four pre-existing lifecycle races surfaced during env19 hardening.

Volume seal/reaper (42hbm, c7ce2): the ebs.unmount handler now seals the
volume before removing it from MountedVolumes, so a retry after a
timed-out-but-completed seal reliably reports NotFound. EBSUnMountResponse
carries a NotFound flag and unmountResponseError treats it as seal success,
so state reconciles to available/detached instead of leaking. The volume
leak reaper reconciles the attachment record (never the data) for volumes
whose owning instance is terminated, so GC can delete them.

Stopped-instance CAS (ozlbq, wlmea): add UpdateStoppedInstance, a
mutate-callback backed by casUpdate(createIfAbsent=false), and migrate
TagStoppedInstance and ModifyInstanceAttribute off the resurrect-prone
Load->mutate->WriteStoppedInstance path. A tag/attribute write racing a
winning start-claim now fails cleanly instead of re-creating the deleted
entry. StartStoppedInstance returns IncorrectInstanceState (not
InvalidInstanceID.NotFound) when the stopped entry is absent but the
instance is present locally.
2026-07-10 07:57:05 +10:00
Ben Duncan 5dedd02dee Added trademark section 2026-07-09 15:01:44 -05:00
Josh Siviour e78d0dbac9 fix(ci): upload full tofu apply/destroy log as an artifact after teardown (#489)
Every tofu-based e2e workflow set TOFU_LOG to a /tmp path outside
ARTIFACT_DIR. ci-fmt.sh's quiet_run helper appends all tofu init/apply/
destroy output to that file, but the Teardown step (which runs the
destroy) always ran AFTER the existing upload-artifact step, so the
full log — including destroy-time failures — was silently discarded
on every run.

Add an if: always() upload-artifact step after Teardown in each
tofu-based job (e2e.yml, e2e-suites.yml, e2e-nightly.yml, e2e-ddil.yml)
that uploads $TOFU_LOG directly. Since quiet_run appends both apply and
destroy output to the same file, one upload after Teardown now
captures everything, recoverable even when destroy itself fails.

Shipping this log to Elasticsearch/OTLP is deferred to mulga-5n7fe,
which will consume this artifact.
2026-07-09 22:20:48 +10:00
Josh Siviour beee92b468 feat(obs): bridge slog to OTLP LoggerProvider (#490)
Init now installs an sdk/log LoggerProvider inside the existing
exportEnabled() gate, sharing the same resource as the tracer/meter (incl.
ci.run_id via OTEL_RESOURCE_ATTRIBUTES) and joining lp.Shutdown into the
returned shutdown func. The process default slog handler is fanned out to
both the existing stdout/journald handler and an otelslog bridge, so app
logs export over OTLP with trace correlation while journald output is
unchanged. With no OTLP endpoint configured Init remains a no-op: no
LoggerProvider is installed and the default logger is left untouched.
2026-07-09 22:20:30 +10:00
Josh Siviour 8a4534faa8 fix(ebs): repair QMP reconnect ordering + benign DEVICE_DELETED timeout; VolumeInUse mapping (#488)
PR #487 turned attach/detach flaky: reconnectQMP dialed a fresh QMP
client before closing the old connection. QEMU's monitor is a
single-client `server,nowait` listener, so while the old conn holds the
slot the redial gets no greeting, blocks ~30s, and fails, leaving
q.Dead permanently true and every later attach/detach ServerInternal.
Fix the ordering (close, then dial via dialQMPWithRetry to absorb the
brief post-close ECONNREFUSED window).

waitForDeviceDeletedEvent marked q.Dead on any decode error, including
a benign read-deadline timeout — routine, since DEVICE_DELETED is
frequently pre-swallowed by device_del's own reply loop. The
underlying connection survives a timeout, but encoding/json.Decoder
permanently caches read errors on itself, so on a timeout the Decoder
is swapped for a fresh one over the same conn instead of forcing a
full QMP reconnect; q.Dead is now only set on a genuine decode error.

Also: an AttachVolume re-attach to the same instance with a different
device now returns VolumeInUse (matching AWS) instead of
InvalidParameterValue.
2026-07-09 21:09:28 +10:00
Josh Siviour ec9f0e5f2d fix(eks,ebs): EKS multi-node create-cluster + EBS/instance lifecycle hardening (#487)
* feat(ec2,ebs): harden volume attach/detach + instance lifecycle state machine

Batch of related EBS/instance-lifecycle correctness fixes verified together.

- u94vs: add compare-and-swap (casUpdate/casPut via nats KV Update revision)
  to the multi-writer lifecycle KV sites (WriteTerminatedInstance,
  WriteStoppedInstance, WriteClusterShutdown); make the teardown-reaper
  read-modify-write atomic via a merge callback so retries never re-run
  cleaner side effects.
- yi2ed: add ClaimStoppedInstance, a revision-checked delete-as-claim on the
  stopped bucket performed before Allocate/Insert/Run, so a forwarded start
  and a local fallback cannot double-start one instance onto the same volume.
- siv-473: make AttachVolume idempotent — return attached success when the
  volume is already attached to the requesting instance; a conflicting
  requested device returns InvalidParameterValue.
- siv-462: reconnect now re-asserts in-use for all attached volumes (boot and
  non-boot data volumes), not just boot, so a daemon restart no longer leaves
  stateful-pod data volumes split-brain; EFI pflash is skipped.
- 9w2mv: wait for the QMP DEVICE_DELETED event (bounded) before blockdev-del
  instead of a blind sleep, so a slow guest unplug no longer exhausts retries.
- soyah: poll (50ms) instead of fixed 1s sleeps in KillProcess and the
  viperblockd nbdkit confirm-running path, cutting volume-lifecycle latency.
- siv-481: a cross-node start forward that times out now falls back to a local
  start; this is safe because the yi2ed claim is the single serialization point.

* fix(eks): create per-account and leader KV buckets with cluster replica count

The EKS per-account KV bucket and the leader lease bucket were created at
Replicas=1 and never upgraded, since they're created lazily after
upgradeJetStreamReplicas runs at startup. On multi-node clusters this leaves
their single-replica Raft group transiently leaderless during formation,
causing KV timeouts that surface as a deterministic 503 on ListClusters.

Thread clusterSize through GetOrCreateAccountBucket/InitLeaderBucket (backed
by a new utils.GetOrCreateKVBucketWithReplicas) so both buckets are created
at max(clusterSize,1) replicas, matching every other node-count-scaled
JetStream stream.
2026-07-09 16:49:31 +10:00
Julian Sommer 20288dbe5d refactor: dead-code cleanup and helper deduplication (#486) 2026-07-09 14:53:49 +10:00
Julian Sommer ee936ee8ad refactor: embed interface in large IAMService/STSService test mocks (#485) 2026-07-09 12:19:44 +10:00
Julian Sommer 5d199361b6 feat: implement IAM GetAccountSummary (#484) 2026-07-09 11:46:10 +10:00
975 changed files with 55807 additions and 15163 deletions
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env bash
set -e
branch=$(git rev-parse --abbrev-ref HEAD)
# Only enforce preflight on dev and main
if [ "$branch" != "dev" ] && [ "$branch" != "main" ]; then
exit 0
fi
# Only run preflight if Go files were staged
if git diff --cached --name-only | grep -q '\.go$'; then
echo "Go files staged on $branch — running make preflight..."
make preflight
else
echo "No Go files staged, skipping preflight."
fi
+114 -225
View File
@@ -1,225 +1,114 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="620" viewBox="0 0 900 620">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="bar-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#161b22"/>
<stop offset="45%" style="stop-color:#1f2028"/>
<stop offset="100%" style="stop-color:#161b22"/>
</linearGradient>
<linearGradient id="client-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="gw-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#DC262633"/>
<stop offset="100%" style="stop-color:#7F1D1D14"/>
</linearGradient>
<linearGradient id="nats-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#F87171"/>
<stop offset="50%" style="stop-color:#DC2626"/>
<stop offset="100%" style="stop-color:#7F1D1D"/>
</linearGradient>
</defs>
<rect width="900" height="620" fill="url(#bg)" rx="12"/>
<!-- Title bar -->
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">SPINIFEX ARCHITECTURE</text>
<text x="24" y="52" font-family="'Segoe UI', system-ui, sans-serif" font-size="16" fill="#f5f5f5" font-weight="500">AWS SDK → Gateway → NATS → Service daemons</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">stateless · message-driven · horizontally scalable</text>
<!-- ═══ CLIENT LAYER ════════════════════════════════════ -->
<rect x="16" y="72" width="868" height="78" rx="8" fill="url(#client-grad)" stroke="#3fb950" stroke-width="1" stroke-opacity="0.5"/>
<text x="32" y="93" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" font-weight="700" letter-spacing="1">CLIENTS</text>
<text x="32" y="108" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">Standard AWS tooling — unmodified, no shims, no custom SDK</text>
<g transform="translate(32, 120)" font-family="'SF Mono', ui-monospace, monospace" font-size="10">
<rect x="0" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="43" y="15" fill="#7ee787" text-anchor="middle">aws-cli</text>
<rect x="94" y="0" width="96" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="142" y="15" fill="#7ee787" text-anchor="middle">Terraform</text>
<rect x="198" y="0" width="76" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="236" y="15" fill="#7ee787" text-anchor="middle">boto3</text>
<rect x="282" y="0" width="98" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="331" y="15" fill="#7ee787" text-anchor="middle">aws-sdk-go</text>
<rect x="388" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="431" y="15" fill="#7ee787" text-anchor="middle">kubectl</text>
<rect x="482" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="525" y="15" fill="#7ee787" text-anchor="middle">Packer</text>
<rect x="576" y="0" width="120" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="636" y="15" fill="#7ee787" text-anchor="middle">your application</text>
</g>
<!-- ═══ HTTPS + SigV4 connector ═══════════════════════════ -->
<line x1="450" y1="150" x2="450" y2="178" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
<rect x="390" y="154" width="120" height="18" rx="9" fill="#0a0a0a" stroke="#DC2626" stroke-width="0.8" stroke-opacity="0.5"/>
<text x="450" y="166" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#F87171" text-anchor="middle">HTTPS · SigV4 · :9999</text>
<!-- ═══ AWS GATEWAY ════════════════════════════════════ -->
<rect x="16" y="178" width="868" height="68" rx="8" fill="url(#gw-grad)" stroke="#DC2626" stroke-width="1.3" stroke-opacity="0.75"/>
<text x="32" y="200" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" font-weight="700" letter-spacing="1">AWS GATEWAY</text>
<text x="32" y="215" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">spinifex/services/awsgw · TLS termination · SigV4 verification · IAM policy eval · NATS routing</text>
<g transform="translate(32, 224)" font-family="'SF Mono', ui-monospace, monospace" font-size="9">
<rect x="0" y="0" width="78" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="39" y="11" fill="#FCA5A5" text-anchor="middle">TLS 1.3</text>
<rect x="86" y="0" width="78" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="125" y="11" fill="#FCA5A5" text-anchor="middle">SigV4 auth</text>
<rect x="172" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="221" y="11" fill="#FCA5A5" text-anchor="middle">policy eval</text>
<rect x="278" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="327" y="11" fill="#FCA5A5" text-anchor="middle">req routing</text>
<rect x="384" y="0" width="88" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="428" y="11" fill="#FCA5A5" text-anchor="middle">rate-limit</text>
<rect x="480" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="529" y="11" fill="#FCA5A5" text-anchor="middle">audit log</text>
</g>
<!-- ═══ NATS BUS ═══════════════════════════════════════ -->
<line x1="450" y1="246" x2="450" y2="272" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
<rect x="16" y="272" width="868" height="34" rx="6" fill="url(#nats-grad)" opacity="0.92"/>
<text x="32" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#f5f5f5" font-weight="700" letter-spacing="1.5">NATS BUS</text>
<text x="140" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#fee2e2" opacity="0.95">ec2.* ebs.* s3.* vpc.* iam.*</text>
<text x="876" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#fee2e2" text-anchor="end" opacity="0.9">queue groups · fan-out · request/reply</text>
<!-- ═══ DAEMON FANOUT CONNECTORS ═══════════════════════ -->
<g stroke="#DC2626" stroke-width="0.9" stroke-dasharray="3,3" opacity="0.55" fill="none">
<line x1="100" y1="306" x2="100" y2="328"/>
<line x1="275" y1="306" x2="275" y2="328"/>
<line x1="450" y1="306" x2="450" y2="328"/>
<line x1="625" y1="306" x2="625" y2="328"/>
<line x1="800" y1="306" x2="800" y2="328"/>
</g>
<!-- ═══ SERVICE DAEMONS ROW ═══════════════════════════ -->
<!-- EC2 -->
<g transform="translate(28, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">ec2.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">RunInstances</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">TerminateInstances</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">QMP · cloud-init</text>
</g>
<!-- EBS -->
<g transform="translate(204, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">ebs.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">CreateVolume</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">AttachVolume</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">snapshots · WAL</text>
</g>
<!-- S3 -->
<g transform="translate(380, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">s3.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">PutObject · GetObject</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Multipart · presigned</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">erasure-coded</text>
</g>
<!-- VPC -->
<g transform="translate(556, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">vpc.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Subnets · SGs · RTs</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">ALB · NAT · IGW</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">OVN · OVS</text>
</g>
<!-- IAM -->
<g transform="translate(732, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">iam.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Users · Roles · Keys</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Policy evaluation</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">audit trail</text>
</g>
<!-- ═══ DAEMON → BACKEND CONNECTORS ═══════════════════ -->
<g stroke="#71717a" stroke-width="0.8" stroke-dasharray="2,3" opacity="0.55" fill="none">
<line x1="100" y1="410" x2="100" y2="440"/>
<line x1="276" y1="410" x2="276" y2="440"/>
<line x1="452" y1="410" x2="452" y2="440"/>
<line x1="628" y1="410" x2="628" y2="440"/>
<line x1="804" y1="410" x2="804" y2="440"/>
</g>
<!-- ═══ BACKEND RESOURCES BAR ═════════════════════════ -->
<rect x="16" y="440" width="868" height="26" rx="5" fill="url(#bar-grad)"/>
<text x="450" y="458" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle" letter-spacing="1.5">BACKEND RESOURCES · local hardware · no cloud dependency</text>
<!-- ═══ BACKEND ROW ═══════════════════════════════════ -->
<!-- QEMU/KVM -->
<g transform="translate(28, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">hypervisor</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">host kernel · virtio</text>
</g>
<!-- Viperblock -->
<g transform="translate(204, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Viperblock</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">block storage</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">NVMe WAL · NBD</text>
</g>
<!-- Predastore -->
<g transform="translate(380, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Predastore</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">object storage</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">Reed–Solomon</text>
</g>
<!-- OVN/OVS -->
<g transform="translate(556, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">OVN / OVS</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">SDN fabric</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">overlays · flow rules</text>
</g>
<!-- IAM store -->
<g transform="translate(732, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Identity store</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">users · keys · rules</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">local · audit-logged</text>
</g>
<!-- ═══ FOOTER NOTES ══════════════════════════════════ -->
<g transform="translate(16, 558)">
<rect width="868" height="48" rx="6" fill="#0a0a0a" stroke="#27272a" stroke-width="0.8"/>
<text x="24" y="20" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">
<tspan fill="#F87171" font-weight="600">Queue groups</tspan> load-balance ops across daemon replicas.
<tspan fill="#F87171" font-weight="600"> Fan-out</tspan> (no queue group) dispatches describe calls to every node.
</text>
<text x="24" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">
Daemons are <tspan fill="#F87171" font-weight="600">stateless</tspan> — scale horizontally by starting more.
No etcd, no Kubernetes, no external control plane. Systemd units, a NATS cluster, and your hardware.
</text>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1020" height="770" viewBox="0 0 1020 770" role="img" aria-label="Spinifex message-driven architecture: standard AWS clients call the AWS Gateway over HTTPS with SigV4, which publishes to a NATS bus; stateless ec2/ebs/s3/vpc/iam daemons answer and drive local backends (QEMU/KVM, Viperblock, Predastore, OVN/OVS, identity store).">
<defs><linearGradient id="nats" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#F5652E"/><stop offset="1" stop-color="#C63C0E"/></linearGradient></defs>
<rect x="0.0" y="0.0" width="1020.0" height="770.0" rx="20" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="28.0" y="42.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2.5">SPINIFEX ARCHITECTURE</text>
<text x="992.0" y="42.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="end">stateless · message-driven · horizontally scalable</text>
<text x="28.0" y="72.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="23" fill="#F5F3EE" font-weight="800" text-anchor="start">AWS SDK → Gateway → NATS → service daemons</text>
<rect x="28.0" y="92.0" width="964.0" height="92.0" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="48.0" y="119.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">CLIENTS</text>
<text x="48.0" y="140.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#5B636C" font-weight="400" text-anchor="start">Standard AWS tooling, unmodified. No shims, no custom SDK.</text>
<rect x="46.0" y="153.0" width="68.2" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="80.1" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">aws-cli</text>
<rect x="123.2" y="153.0" width="81.4" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="163.9" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">Terraform</text>
<rect x="213.6" y="153.0" width="55.0" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="241.1" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">boto3</text>
<rect x="277.6" y="153.0" width="88.0" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="321.6" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">aws-sdk-go</text>
<rect x="374.6" y="153.0" width="68.2" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="408.7" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">kubectl</text>
<rect x="451.8" y="153.0" width="61.6" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="482.6" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">Packer</text>
<rect x="522.4" y="153.0" width="74.8" height="22.0" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="559.8" y="167.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">your app</text>
<line x1="510.0" y1="186.0" x2="510.0" y2="192.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
<rect x="429.9" y="191.0" width="160.3" height="22.0" rx="11" fill="#1E242B" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.4"/>
<text x="510.0" y="205.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#EE4B12" font-weight="400" text-anchor="middle" fill-opacity="0.9">HTTPS · SigV4 · :9999</text>
<line x1="510.0" y1="212.0" x2="510.0" y2="220.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
<rect x="28.0" y="224.0" width="964.0" height="94.0" rx="13" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
<text x="48.0" y="251.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#EE4B12" font-weight="700" text-anchor="start" letter-spacing="1.5">AWS GATEWAY</text>
<text x="48.0" y="272.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">spinifex/services/awsgw · TLS termination · SigV4 verification · IAM policy eval · NATS routing</text>
<rect x="46.0" y="287.0" width="68.2" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="80.1" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">TLS 1.3</text>
<rect x="123.2" y="287.0" width="88.0" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="167.2" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">SigV4 auth</text>
<rect x="220.2" y="287.0" width="94.6" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="267.5" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">policy eval</text>
<rect x="323.8" y="287.0" width="94.6" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="371.1" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">req routing</text>
<rect x="427.4" y="287.0" width="88.0" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="471.4" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">rate-limit</text>
<rect x="524.4" y="287.0" width="81.4" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="565.1" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">audit log</text>
<line x1="510.0" y1="320.0" x2="510.0" y2="332.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
<rect x="28.0" y="334.0" width="964.0" height="52.0" rx="12" fill="url(#nats)" fill-opacity="1.0" stroke="none" stroke-width="1" stroke-opacity="1.0"/>
<rect x="28.0" y="334.0" width="964.0" height="52.0" rx="12" fill="none" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55"/>
<text x="50.0" y="365.6" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="16" fill="#F5F3EE" font-weight="800" text-anchor="start">NATS BUS</text>
<text x="178.0" y="364.6" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#F5F3EE" font-weight="400" text-anchor="start" fill-opacity="0.92">ec2.* ebs.* s3.* vpc.* iam.*</text>
<text x="972.0" y="363.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F5F3EE" font-weight="400" text-anchor="end" fill-opacity="0.9">queue groups · fan-out · request/reply</text>
<line x1="119.6" y1="388.0" x2="119.6" y2="408.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
<line x1="314.8" y1="388.0" x2="314.8" y2="408.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
<line x1="510.0" y1="388.0" x2="510.0" y2="408.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
<line x1="705.2" y1="388.0" x2="705.2" y2="408.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
<line x1="900.4" y1="388.0" x2="900.4" y2="408.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
<rect x="28.0" y="412.0" width="183.2" height="104.0" rx="11" fill="#EE4B12" fill-opacity="0.045" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="44.0" y="438.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="700" text-anchor="start">ec2.* daemon</text>
<line x1="44.0" y1="448.0" x2="195.2" y2="448.0" stroke="#EE4B12" stroke-opacity="0.22" stroke-width="1"/>
<text x="44.0" y="468.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">RunInstances</text>
<text x="44.0" y="486.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">TerminateInstances</text>
<text x="44.0" y="502.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">QMP · cloud-init</text>
<rect x="223.2" y="412.0" width="183.2" height="104.0" rx="11" fill="#EE4B12" fill-opacity="0.045" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="239.2" y="438.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="700" text-anchor="start">ebs.* daemon</text>
<line x1="239.2" y1="448.0" x2="390.4" y2="448.0" stroke="#EE4B12" stroke-opacity="0.22" stroke-width="1"/>
<text x="239.2" y="468.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">CreateVolume</text>
<text x="239.2" y="486.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">AttachVolume</text>
<text x="239.2" y="502.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">snapshots · WAL</text>
<rect x="418.4" y="412.0" width="183.2" height="104.0" rx="11" fill="#EE4B12" fill-opacity="0.045" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="434.4" y="438.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="700" text-anchor="start">s3.* daemon</text>
<line x1="434.4" y1="448.0" x2="585.6" y2="448.0" stroke="#EE4B12" stroke-opacity="0.22" stroke-width="1"/>
<text x="434.4" y="468.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">PutObject · GetObject</text>
<text x="434.4" y="486.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">Multipart · presigned</text>
<text x="434.4" y="502.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">erasure-coded</text>
<rect x="613.6" y="412.0" width="183.2" height="104.0" rx="11" fill="#EE4B12" fill-opacity="0.045" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="629.6" y="438.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="700" text-anchor="start">vpc.* daemon</text>
<line x1="629.6" y1="448.0" x2="780.8" y2="448.0" stroke="#EE4B12" stroke-opacity="0.22" stroke-width="1"/>
<text x="629.6" y="468.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">Subnets · SGs · RTs</text>
<text x="629.6" y="486.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">ALB · NAT · IGW</text>
<text x="629.6" y="502.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">OVN · OVS</text>
<rect x="808.8" y="412.0" width="183.2" height="104.0" rx="11" fill="#EE4B12" fill-opacity="0.045" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="824.8" y="438.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="700" text-anchor="start">iam.* daemon</text>
<line x1="824.8" y1="448.0" x2="976.0" y2="448.0" stroke="#EE4B12" stroke-opacity="0.22" stroke-width="1"/>
<text x="824.8" y="468.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">Users · Roles · Keys</text>
<text x="824.8" y="486.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">Policy evaluation</text>
<text x="824.8" y="502.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">audit trail</text>
<line x1="119.6" y1="516.0" x2="119.6" y2="540.0" stroke="#5B636C" stroke-width="1.3" stroke-opacity="0.35" stroke-dasharray="2 5"/>
<line x1="314.8" y1="516.0" x2="314.8" y2="540.0" stroke="#5B636C" stroke-width="1.3" stroke-opacity="0.35" stroke-dasharray="2 5"/>
<line x1="510.0" y1="516.0" x2="510.0" y2="540.0" stroke="#5B636C" stroke-width="1.3" stroke-opacity="0.35" stroke-dasharray="2 5"/>
<line x1="705.2" y1="516.0" x2="705.2" y2="540.0" stroke="#5B636C" stroke-width="1.3" stroke-opacity="0.35" stroke-dasharray="2 5"/>
<line x1="900.4" y1="516.0" x2="900.4" y2="540.0" stroke="#5B636C" stroke-width="1.3" stroke-opacity="0.35" stroke-dasharray="2 5"/>
<rect x="28.0" y="530.0" width="964.0" height="30.0" rx="9" fill="#1E242B" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="510.0" y="548.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle" letter-spacing="1">BACKEND RESOURCES · local hardware · no cloud dependency</text>
<rect x="28.0" y="572.0" width="183.2" height="84.0" rx="11" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="119.6" y="602.7" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="13.5" fill="#F5F3EE" font-weight="700" text-anchor="middle">QEMU / KVM</text>
<text x="119.6" y="624.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="middle">hypervisor</text>
<text x="119.6" y="642.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">host kernel · virtio</text>
<rect x="223.2" y="572.0" width="183.2" height="84.0" rx="11" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="314.8" y="602.7" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="13.5" fill="#F5F3EE" font-weight="700" text-anchor="middle">Viperblock</text>
<text x="314.8" y="624.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="middle">block storage</text>
<text x="314.8" y="642.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">NVMe WAL · NBD</text>
<rect x="418.4" y="572.0" width="183.2" height="84.0" rx="11" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="510.0" y="602.7" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="13.5" fill="#F5F3EE" font-weight="700" text-anchor="middle">Predastore</text>
<text x="510.0" y="624.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="middle">object storage</text>
<text x="510.0" y="642.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">Reed-Solomon</text>
<rect x="613.6" y="572.0" width="183.2" height="84.0" rx="11" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="705.2" y="602.7" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="13.5" fill="#F5F3EE" font-weight="700" text-anchor="middle">OVN / OVS</text>
<text x="705.2" y="624.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="middle">SDN fabric</text>
<text x="705.2" y="642.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">overlays · flow rules</text>
<rect x="808.8" y="572.0" width="183.2" height="84.0" rx="11" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="900.4" y="602.7" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="13.5" fill="#F5F3EE" font-weight="700" text-anchor="middle">Identity store</text>
<text x="900.4" y="624.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="middle">users · keys · rules</text>
<text x="900.4" y="642.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">local · audit-logged</text>
<rect x="28.0" y="670.0" width="964.0" height="72.0" rx="11" fill="#181D23" fill-opacity="0.6" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="48.0" y="697.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400"><tspan fill="#EE4B12" font-weight="700">Queue groups</tspan><tspan fill="#5B636C"> load-balance ops across daemon replicas. </tspan><tspan fill="#EE4B12" font-weight="700">Fan-out</tspan><tspan fill="#5B636C"> (no queue group) dispatches describe calls to every node.</tspan></text>
<text x="48.0" y="720.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400"><tspan fill="#5B636C">Daemons are </tspan><tspan fill="#EE4B12" font-weight="700">stateless</tspan><tspan fill="#5B636C">: scale horizontally by starting more. No etcd, no Kubernetes, no external control plane. Systemd units, a NATS cluster, and your hardware.</tspan></text>
</svg>

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+37 -154
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

+84 -144
View File
@@ -1,144 +1,84 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560">
<defs>
<linearGradient id="pf-bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="hw-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#52525b;stop-opacity:0.18"/>
<stop offset="100%" style="stop-color:#52525b;stop-opacity:0.06"/>
</linearGradient>
<linearGradient id="os-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:0.18"/>
<stop offset="100%" style="stop-color:#a855f7;stop-opacity:0.05"/>
</linearGradient>
<linearGradient id="spx-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#DC2626;stop-opacity:0.28"/>
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0.1"/>
</linearGradient>
<linearGradient id="app-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#3fb950;stop-opacity:0.18"/>
<stop offset="100%" style="stop-color:#3fb950;stop-opacity:0.05"/>
</linearGradient>
</defs>
<rect width="900" height="560" fill="url(#pf-bg)" rx="12"/>
<!-- Subtle mesh dots background -->
<g opacity="0.12" fill="#7F1D1D">
<circle cx="40" cy="40" r="1"/><circle cx="120" cy="30" r="1"/><circle cx="220" cy="50" r="1"/>
<circle cx="320" cy="25" r="1"/><circle cx="440" cy="45" r="1"/><circle cx="560" cy="30" r="1"/>
<circle cx="680" cy="50" r="1"/><circle cx="780" cy="30" r="1"/><circle cx="860" cy="45" r="1"/>
<circle cx="60" cy="530" r="1"/><circle cx="160" cy="520" r="1"/><circle cx="260" cy="540" r="1"/>
<circle cx="380" cy="525" r="1"/><circle cx="500" cy="540" r="1"/><circle cx="620" cy="520" r="1"/>
<circle cx="740" cy="540" r="1"/><circle cx="840" cy="525" r="1"/>
</g>
<!-- Title -->
<text x="450" y="32" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" text-anchor="middle" letter-spacing="2.8">THE MULGA PLATFORM</text>
<text x="450" y="58" font-family="'Segoe UI', system-ui, sans-serif" font-size="20" font-weight="600" fill="#f5f5f5" text-anchor="middle">AWS-style workflows, on your own stack</text>
<!-- Layer 4: Applications (top) -->
<g transform="translate(60, 95)">
<rect width="780" height="88" rx="10" fill="url(#app-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="26" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" letter-spacing="1.5" font-weight="600">LAYER 4 · APPLICATIONS &amp; TOOLING</text>
<text x="20" y="48" font-family="'Segoe UI', sans-serif" font-size="15" fill="#f5f5f5" font-weight="500">Your workloads — unmodified AWS tooling</text>
<g transform="translate(20, 60)" font-family="'Segoe UI', sans-serif" font-size="10.5">
<rect x="0" y="0" width="70" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="35" y="14" fill="#7ee787" text-anchor="middle">aws-cli</text>
<rect x="78" y="0" width="78" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="117" y="14" fill="#7ee787" text-anchor="middle">Terraform</text>
<rect x="164" y="0" width="88" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="208" y="14" fill="#7ee787" text-anchor="middle">AWS SDKs</text>
<rect x="260" y="0" width="88" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="304" y="14" fill="#7ee787" text-anchor="middle">Kubernetes</text>
<rect x="356" y="0" width="70" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="391" y="14" fill="#7ee787" text-anchor="middle">Packer</text>
<rect x="434" y="0" width="98" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="483" y="14" fill="#7ee787" text-anchor="middle">Your Services</text>
</g>
</g>
<!-- Down arrow -->
<g transform="translate(450, 188)" opacity="0.45">
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
</g>
<!-- Layer 3: Spinifex (highlighted) -->
<g transform="translate(60, 215)">
<rect width="780" height="140" rx="10" fill="url(#spx-grad)" stroke="#DC2626" stroke-width="1.5" stroke-opacity="0.75"/>
<text x="20" y="26" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" letter-spacing="1.5" font-weight="600">LAYER 3 · SPINIFEX · AWS ORCHESTRATION</text>
<text x="20" y="50" font-family="'Segoe UI', sans-serif" font-size="15" fill="#f5f5f5" font-weight="500">AWS Gateway → NATS → Service Daemons · SigV4, IAM, VPC-aware</text>
<g transform="translate(20, 66)" font-family="'Segoe UI', sans-serif">
<!-- EC2 -->
<rect x="0" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
<text x="59" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">EC2</text>
<text x="59" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">QEMU/KVM VMs</text>
<text x="59" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">cloud-init · AMIs</text>
<!-- EBS -->
<rect x="126" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
<text x="185" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">EBS</text>
<text x="185" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">Viperblock</text>
<text x="185" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">WAL · NBD · snapshots</text>
<!-- S3 -->
<rect x="252" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
<text x="311" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">S3</text>
<text x="311" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">Predastore</text>
<text x="311" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">Reed–Solomon EC</text>
<!-- VPC -->
<rect x="378" y="0" width="118" height="58" rx="6" fill="#7F1D1D" fill-opacity="0.22" stroke="#F87171" stroke-opacity="0.55" stroke-width="1"/>
<text x="437" y="18" font-size="13" fill="#FCA5A5" text-anchor="middle" font-weight="600">VPC</text>
<text x="437" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">OVN / OVS</text>
<text x="437" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">subnets · SG · ALB</text>
<!-- IAM -->
<rect x="504" y="0" width="118" height="58" rx="6" fill="#7F1D1D" fill-opacity="0.22" stroke="#F87171" stroke-opacity="0.55" stroke-width="1"/>
<text x="563" y="18" font-size="13" fill="#FCA5A5" text-anchor="middle" font-weight="600">IAM</text>
<text x="563" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">SigV4 · policies</text>
<text x="563" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">users · roles · keys</text>
<!-- Control -->
<rect x="630" y="0" width="110" height="58" rx="6" fill="#27272a" fill-opacity="0.8" stroke="#71717a" stroke-opacity="0.6" stroke-width="1"/>
<text x="685" y="18" font-size="13" fill="#e4e4e7" text-anchor="middle" font-weight="600">Control</text>
<text x="685" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">NATS bus</text>
<text x="685" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">spx · daemons</text>
</g>
</g>
<!-- Down arrow -->
<g transform="translate(450, 360)" opacity="0.45">
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
</g>
<!-- Layer 2: OS -->
<g transform="translate(60, 387)">
<rect width="780" height="62" rx="10" fill="url(#os-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.5"/>
<text x="20" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#c084fc" letter-spacing="1.5" font-weight="600">LAYER 2 · OPERATING SYSTEM</text>
<text x="20" y="44" font-family="'Segoe UI', sans-serif" font-size="14" fill="#f5f5f5">Debian / Ubuntu LTS · systemd · KVM · Linux bridge · headless autoinstall</text>
</g>
<!-- Down arrow -->
<g transform="translate(450, 454)" opacity="0.45">
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
</g>
<!-- Layer 1: Hardware -->
<g transform="translate(60, 481)">
<rect width="780" height="62" rx="10" fill="url(#hw-grad)" stroke="#71717a" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="20" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#a1a1aa" letter-spacing="1.5" font-weight="600">LAYER 1 · HARDWARE</text>
<text x="20" y="44" font-family="'Segoe UI', sans-serif" font-size="14" fill="#f5f5f5">Single node · multi-node cluster · edge appliance · air-gapped rack · commodity x86 / ARM</text>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="664" viewBox="0 0 1000 664" role="img" aria-label="The Spinifex stack: applications and AWS tooling on top, the Spinifex AWS-compatible service layer, running on standard Linux on CPU/GPU hardware, deployable to Neocloud, on-prem or edge.">
<rect x="0.0" y="0.0" width="1000.0" height="664.0" rx="20" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<rect x="28.0" y="204.0" width="950.0" height="438.0" rx="16" fill="none" fill-opacity="1.0" stroke="#9AA1A8" stroke-width="1" stroke-opacity="0.12" stroke-dasharray="2 4"/>
<rect x="25.0" y="201.0" width="950.0" height="438.0" rx="16" fill="none" fill-opacity="1.0" stroke="#9AA1A8" stroke-width="1" stroke-opacity="0.2" stroke-dasharray="2 4"/>
<rect x="22.0" y="198.0" width="950.0" height="438.0" rx="16" fill="#181D23" fill-opacity="0.25" stroke="#9AA1A8" stroke-width="1.3" stroke-opacity="0.45" stroke-dasharray="2 4"/>
<text x="36.0" y="64.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="500" text-anchor="start">Apps</text>
<rect x="136.0" y="34.0" width="836.0" height="54.0" rx="12" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="156.0" y="66.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="15" fill="#F5F3EE" font-weight="700" text-anchor="start">Your infrastructure &amp; apps</text>
<rect x="516.7" y="51.0" width="89.3" height="20.0" rx="10" fill="#EE4B12" fill-opacity="0.16" stroke="none" stroke-width="1" stroke-opacity="1.0"/>
<text x="561.4" y="64.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#EE4B12" font-weight="500" text-anchor="middle">AI training</text>
<rect x="613.0" y="51.0" width="76.7" height="20.0" rx="10" fill="#EE4B12" fill-opacity="0.16" stroke="none" stroke-width="1" stroke-opacity="1.0"/>
<text x="651.4" y="64.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#EE4B12" font-weight="500" text-anchor="middle">Inference</text>
<rect x="696.7" y="51.0" width="79.0" height="20.0" rx="10" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1"/>
<text x="736.2" y="64.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">Web &amp; APIs</text>
<rect x="782.7" y="51.0" width="72.7" height="20.0" rx="10" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1"/>
<text x="819.1" y="64.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">Databases</text>
<rect x="862.4" y="51.0" width="91.6" height="20.0" rx="10" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1"/>
<text x="908.2" y="64.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">Batch &amp; data</text>
<text x="36.0" y="132.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="500" text-anchor="start">Tooling</text>
<rect x="136.0" y="102.0" width="836.0" height="54.0" rx="12" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="156.0" y="134.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="15" fill="#F5F3EE" font-weight="700" text-anchor="start">AWS tooling</text>
<rect x="600.9" y="119.0" width="64.1" height="20.0" rx="10" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
<text x="633.0" y="132.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.85">aws-cli</text>
<rect x="672.0" y="119.0" width="76.7" height="20.0" rx="10" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
<text x="710.4" y="132.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.85">Terraform</text>
<rect x="755.7" y="119.0" width="64.1" height="20.0" rx="10" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
<text x="787.8" y="132.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.85">kubectl</text>
<rect x="826.8" y="119.0" width="66.4" height="20.0" rx="10" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1"/>
<text x="860.0" y="132.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">AWS SDKs</text>
<rect x="900.2" y="119.0" width="53.8" height="20.0" rx="10" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1"/>
<text x="927.1" y="132.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="middle">Packer</text>
<text x="484.0" y="184.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">↓ AWS API · SigV4</text>
<text x="624.0" y="184.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">RESPONSE ↑</text>
<text x="136.0" y="231.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="15" fill="#F5F3EE" font-weight="700" text-anchor="start">One portable stack.</text>
<text x="136.0" y="249.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="start">Runs the same on a single node, cluster, edge, or air-gapped rack.</text>
<rect x="752.3" y="222.0" width="72.4" height="22.0" rx="6" fill="none" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="788.5" y="236.5" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#EE4B12" font-weight="500" text-anchor="middle">NEOCLOUD</text>
<rect x="832.7" y="222.0" width="66.1" height="22.0" rx="6" fill="none" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="865.7" y="236.5" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#EE4B12" font-weight="500" text-anchor="middle">ON-PREM</text>
<rect x="906.8" y="222.0" width="47.2" height="22.0" rx="6" fill="none" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="930.4" y="236.5" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#EE4B12" font-weight="500" text-anchor="middle">EDGE</text>
<text x="152.0" y="295.6" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="16" fill="#F5F3EE" font-weight="800" text-anchor="start">AWS orchestration</text>
<text x="956.0" y="293.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#EE4B12" font-weight="400" text-anchor="end" fill-opacity="0.9">Gateway → NATS → daemons</text>
<rect x="150.0" y="312.0" width="262.7" height="60.0" rx="9" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="164.0" y="333.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="600" text-anchor="start">EC2</text>
<text x="164.0" y="349.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#F3F1EC" font-weight="400" text-anchor="start">Compute</text>
<text x="164.0" y="363.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">QEMU / KVM</text>
<rect x="422.7" y="312.0" width="262.7" height="60.0" rx="9" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="436.7" y="333.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="600" text-anchor="start">EBS</text>
<text x="436.7" y="349.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#F3F1EC" font-weight="400" text-anchor="start">Block storage</text>
<text x="436.7" y="363.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">Viperblock</text>
<rect x="695.3" y="312.0" width="262.7" height="60.0" rx="9" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="709.3" y="333.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="600" text-anchor="start">S3</text>
<text x="709.3" y="349.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#F3F1EC" font-weight="400" text-anchor="start">Object storage</text>
<text x="709.3" y="363.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">Predastore</text>
<rect x="150.0" y="382.0" width="262.7" height="60.0" rx="9" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="164.0" y="403.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="600" text-anchor="start">VPC</text>
<text x="164.0" y="419.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#F3F1EC" font-weight="400" text-anchor="start">Networking</text>
<text x="164.0" y="433.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">OVN / OVS</text>
<rect x="422.7" y="382.0" width="262.7" height="60.0" rx="9" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="436.7" y="403.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="600" text-anchor="start">IAM</text>
<text x="436.7" y="419.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#F3F1EC" font-weight="400" text-anchor="start">Identity</text>
<text x="436.7" y="433.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">SigV4 · policies</text>
<rect x="695.3" y="382.0" width="262.7" height="60.0" rx="9" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="709.3" y="403.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="13" fill="#EE4B12" font-weight="600" text-anchor="start">EKS</text>
<text x="709.3" y="419.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12.5" fill="#F3F1EC" font-weight="400" text-anchor="start">Kubernetes</text>
<text x="709.3" y="433.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">K8s-conformant</text>
<rect x="150.0" y="452.0" width="808.0" height="32.0" rx="8" fill="#14181D" fill-opacity="0.6" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0" stroke-dasharray="3 3"/>
<text x="164.0" y="472.2" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#9AA1A8" font-weight="400" text-anchor="start">+ ALB · ECS · ECR · RDS and more of the core AWS services</text>
<text x="944.0" y="471.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#EE4B12" font-weight="400" text-anchor="end">See all →</text>
<text x="36.0" y="386.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#EE4B12" font-weight="500" text-anchor="start">Spinifex</text>
<rect x="136.0" y="268.0" width="836.0" height="230.0" rx="12" fill="#EE4B12" fill-opacity="0.07" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
<text x="36.0" y="536.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="500" text-anchor="start">OS</text>
<rect x="136.0" y="510.0" width="836.0" height="46.0" rx="12" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="156.0" y="537.9" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="700" text-anchor="start">Operating system</text>
<text x="952.0" y="536.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="end">Debian / Ubuntu LTS · systemd · KVM · Linux bridge · Open vSwitch</text>
<text x="36.0" y="596.7" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="500" text-anchor="start">Hardware</text>
<rect x="136.0" y="568.0" width="413.0" height="50.0" rx="12" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="156.0" y="597.9" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="700" text-anchor="start">CPU</text>
<text x="533.0" y="596.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="end">Intel · AMD · ARM</text>
<rect x="559.0" y="568.0" width="413.0" height="50.0" rx="12" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="579.0" y="597.9" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="700" text-anchor="start">GPU</text>
<text x="956.0" y="596.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#EE4B12" font-weight="400" text-anchor="end">NVIDIA · AMD</text>
</svg>

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Resolve which branch of each sibling repo (viperblock, predastore) the CI
# workspace should build against. A cross-repo change is developed on a branch
# of the SAME name in each repo; build against that branch when it exists so the
# set can be tested together, and fall back to dev otherwise. This mirrors the
# resolution the e2e path already does in
# mulga/scripts/tofu-cluster/build-install-artifacts.sh, so unit CI and e2e CI
# agree on how sibling branches are selected.
#
# Reads $BRANCH (the triggering head/ref name) and writes viperblock=<ref> and
# predastore=<ref> to $GITHUB_OUTPUT for the checkout steps to consume.
set -euo pipefail
: "${BRANCH:?BRANCH must be set to the triggering branch name}"
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}"
for repo in viperblock predastore; do
if git ls-remote --exit-code --heads \
"https://github.com/mulgadc/${repo}.git" "$BRANCH" >/dev/null 2>&1; then
ref="$BRANCH"
else
ref="dev"
fi
echo "${repo}: building against ${ref}"
echo "${repo}=${ref}" >>"$GITHUB_OUTPUT"
done
+9
View File
@@ -208,3 +208,12 @@ jobs:
quiet_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
ok "teardown complete"
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
with:
name: tofu-log-ddil-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
if-no-files-found: ignore
retention-days: 30
+9
View File
@@ -348,6 +348,15 @@ jobs:
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
with:
name: tofu-log-cell-${{ matrix.cell }}-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
if-no-files-found: ignore
retention-days: 14
# Bare-metal single-node suite. Doesn't fit the tofu matrix shape, so it
# runs as a sibling job via the reusable e2e-baremetal workflow. Gated like a
# matrix cell ("cell 20"): every weeknight cron, or dispatch with 20 selected
+40 -4
View File
@@ -107,7 +107,7 @@ jobs:
with:
path: spinifex
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: spinifex/go.mod
cache: false
@@ -267,6 +267,15 @@ jobs:
"$VM":/tmp/start-net-capture.sh
ssh "${SSH_OPTS[@]}" "$VM" "bash /tmp/start-net-capture.sh" || true
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
with:
name: tofu-log-single-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
if-no-files-found: ignore
retention-days: 14
single_suite:
name: Single env · suite
needs: single_provision
@@ -285,7 +294,7 @@ jobs:
with:
path: spinifex
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: spinifex/go.mod
cache: false
@@ -467,6 +476,15 @@ jobs:
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
ok "teardown complete"
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
with:
name: tofu-log-single-td-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
if-no-files-found: ignore
retention-days: 14
# ---------------------------------------------------------------------------
# Multinode env
# ---------------------------------------------------------------------------
@@ -521,7 +539,7 @@ jobs:
with:
path: spinifex
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: spinifex/go.mod
cache: false
@@ -617,6 +635,15 @@ jobs:
ssh "${SSH_OPTS[@]}" tf-user@"$ip" "bash /tmp/start-net-capture.sh" || true
done
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
with:
name: tofu-log-multi-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
if-no-files-found: ignore
retention-days: 14
multi_suite:
name: Multi env · suite
needs: multi_provision
@@ -636,7 +663,7 @@ jobs:
with:
path: spinifex
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: spinifex/go.mod
cache: false
@@ -789,6 +816,15 @@ jobs:
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
ok "teardown complete"
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
with:
name: tofu-log-multi-td-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
if-no-files-found: ignore
retention-days: 14
e2e_suites_status:
name: E2E suite blocks · summary
if: always()
+230 -45
View File
@@ -37,49 +37,52 @@ env:
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
jobs:
# Shadow-mode suite selector. Computes which suites the diff *would* run and logs it to the job summary.
# Does NOT gate or override the real SUITES env yet — it runs alongside the full matrix so we
# can compare predicted vs actual before flipping it on.
select_suites:
name: Select Suites (shadow)
runs-on: ubuntu-26.04
timeout-minutes: 5
# 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
with:
fetch-depth: 0
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: false
- name: Compute targeted suite selection
run: |
set -euo pipefail
BASE="${{ github.event.before }}"
HEAD="${{ github.sha }}"
# First push to a branch reports an all-zero before-SHA; fall back to
# the first parent so the diff is still meaningful.
if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
BASE="${HEAD}~1"
fi
echo "## Targeted suite selection (shadow)" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "base=$BASE head=$HEAD" >> "$GITHUB_STEP_SUMMARY"
go run ./tests/e2e/select-suites/cmd/select-suites \
--manifest docs/service-interfaces.yaml \
--base-sha "$BASE" --head-sha "$HEAD" --repo-root . \
2>>"$GITHUB_STEP_SUMMARY" \
| tee -a "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "Shadow only — actual run uses SUITES='${SUITES}'." >> "$GITHUB_STEP_SUMMARY"
- name: Run in-process integration tests
run: make test-integration
e2e_single:
name: Single-Node E2E
# Skip when the dispatch input requests no in-VM suites (e.g.
# suites='e2e-multinode' only). Push triggers and the default SUITES list
# both include single/cert/lb so the inputs.suites null-check keeps the
# job running on plain PR pushes.
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-eks') || contains(github.event.inputs.suites, 'e2e-iam')
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
# 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'))
runs-on: [self-hosted, ci-single]
timeout-minutes: 50
env:
@@ -134,7 +137,7 @@ jobs:
with:
path: spinifex
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: spinifex/go.mod
cache: false
@@ -312,14 +315,20 @@ jobs:
id: e2e
run: |
set +e
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(multinode)$' | tr '\n' ' ')
# 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' ' ')
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
echo "no in-VM suites requested (SUITES=${SUITES}); skipping"
echo "rc=0" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -n "${TEST_RUN}" ]; then
TEST_RUN_FLAG="-test.run=${TEST_RUN}"
# The value is expanded into a command that a remote shell re-parses over
# ssh, so single-quote it — otherwise regex metacharacters like ( | ) $ are
# taken as shell syntax on the VM and bash fails before the test binary runs.
TEST_RUN_FLAG="-test.run='${TEST_RUN}'"
else
TEST_RUN_FLAG=""
fi
@@ -466,6 +475,15 @@ jobs:
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
ok "teardown complete"
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
with:
name: tofu-log-single-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
if-no-files-found: ignore
retention-days: 14
- name: Fail job if any suite failed
if: always()
run: |
@@ -477,7 +495,14 @@ jobs:
e2e_multi:
name: Multi-Node E2E
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-multinode')
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'))
runs-on: [self-hosted, ci-multi]
timeout-minutes: 60
env:
@@ -532,7 +557,7 @@ jobs:
with:
path: spinifex
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: spinifex/go.mod
cache: false
@@ -688,14 +713,17 @@ jobs:
id: e2e
run: |
set +e
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(single|eks|iam)$' | tr '\n' ' ')
# 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' ' ')
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
echo "no multinode-eligible suites requested (SUITES=${SUITES}); skipping"
echo "rc=0" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -n "${TEST_RUN}" ]; then
TEST_RUN_FLAG="-test.run=${TEST_RUN}"
# Single-quote for the remote shell — see the single-node job for the
# metacharacter-injection reason.
TEST_RUN_FLAG="-test.run='${TEST_RUN}'"
else
TEST_RUN_FLAG=""
fi
@@ -881,6 +909,15 @@ jobs:
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
ok "teardown complete"
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
with:
name: tofu-log-multi-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
if-no-files-found: ignore
retention-days: 14
- name: Fail job if any suite failed
if: always()
run: |
@@ -893,7 +930,7 @@ jobs:
e2e_status:
name: Report E2E Status
if: always()
needs: [e2e_single, e2e_multi]
needs: [integration, e2e_single, e2e_multi]
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
@@ -902,18 +939,166 @@ 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 $single_ok && $multi_ok; then
if $integration_ok && $single_ok && $multi_ok; then
STATE="success"
DESC="E2E suites passed"
else
STATE="failure"
DESC="E2E failed: single=${{ needs.e2e_single.result }} multi=${{ needs.e2e_multi.result }}"
DESC="E2E failed: integration=${{ needs.integration.result }} single=${{ needs.e2e_single.result }} multi=${{ needs.e2e_multi.result }}"
fi
gh api "repos/${{ github.repository }}/statuses/${REPORT_SHA}" \
-f state="$STATE" \
-f context=e2e \
-f description="$DESC" \
-f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
# Emits one summary doc per run to the central Elastic sink, keyed by
# labels.ci_run_id, so Kibana can list "did run X pass, and where are its
# artifacts" without hand-aggregating traces-apm*/logs-apm.error*. This is
# a separate self-hosted job (not a step on e2e_status) because e2e_status
# runs on GitHub-hosted ubuntu-26.04, which has no route to the private
# 192.168.1.13 sink, and moving e2e_status off GH-hosted would risk the PR
# status-report check.
e2e_es_summary:
name: ES CI Run Summary
needs: [integration, e2e_single, e2e_multi]
if: always()
runs-on: [self-hosted, ci-single]
timeout-minutes: 5
permissions:
actions: read
env:
# Repo var overrides; defaults to the LAN Elastic sink reachable from
# ci-single/ci-multi runners. Anonymous ES requests are superuser on
# this host, so no credentials are needed for the POST.
MULGA_ES_SINK: ${{ vars.MULGA_ES_SINK || '192.168.1.13:9200' }}
steps:
# Either leg may not have run (e.g. a workflow_dispatch that only
# requested single-node suites), so a missing artifact is tolerated —
# the aggregation script below treats an absent suite-status.txt as
# "no failures from that leg".
- name: Download single-node artifacts
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: e2e-artifacts-${{ github.run_id }}
path: ${{ github.workspace }}/es-summary-single
- name: Download multi-node artifacts
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: e2e-multi-artifacts-${{ github.run_id }}
path: ${{ github.workspace }}/es-summary-multi
- name: Build and post CI run summary
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
WORKFLOW: ${{ github.workflow }}
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
MULTI_DIR: ${{ github.workspace }}/es-summary-multi
run: |
set -euo pipefail
SINGLE_STATUS="${SINGLE_DIR}/suite-status.txt"
MULTI_STATUS="${MULTI_DIR}/suite-status.txt"
# Collect "<suite>: FAILED" lines from both legs' suite-status.txt
# (either may be absent — a leg that didn't run, or a passing leg
# that never wrote the file). Accumulate as newline-separated text
# rather than a bash array so this stays correct under `set -u`
# even when nothing failed.
FAILED_SUITES=""
for f in "$SINGLE_STATUS" "$MULTI_STATUS"; do
[ -f "$f" ] || continue
while IFS= read -r line; do
case "$line" in
*": FAILED") FAILED_SUITES="${FAILED_SUITES}${line%: FAILED}"$'\n' ;;
esac
done < "$f"
done
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
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
STATE=failure
fi
RUN_STARTED_AT=$(gh api "repos/${REPO}/actions/runs/${RUN_ID}" --jq .run_started_at)
START_EPOCH=$(date -u -d "${RUN_STARTED_AT}" +%s)
NOW_EPOCH=$(date -u +%s)
DURATION=$((NOW_EPOCH - START_EPOCH))
DOC=$(jq -n \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg run_id "${RUN_ID}" \
--arg workflow "${WORKFLOW}" \
--arg branch "${BRANCH}" \
--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}" \
--arg artifact_url "${SERVER_URL}/${REPO}/actions/runs/${RUN_ID}" \
--argjson failed_suites "${FAILED_JSON}" \
'{
"@timestamp": $ts,
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,
duration_seconds: $duration,
artifact_url: $artifact_url
}')
echo "$DOC" | jq .
HTTP_CODE=$(curl -sS -o /tmp/es-summary-resp.json -w '%{http_code}' \
-X POST "http://${MULGA_ES_SINK}/mulga-ci-runs/_doc" \
-H 'Content-Type: application/json' \
-d "${DOC}")
if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
echo "::error::ES POST to ${MULGA_ES_SINK}/mulga-ci-runs failed (HTTP ${HTTP_CODE})"
cat /tmp/es-summary-resp.json
exit 1
fi
echo "CI run summary doc posted to ${MULGA_ES_SINK}/mulga-ci-runs (HTTP ${HTTP_CODE})"
+369
View File
@@ -0,0 +1,369 @@
# GPU E2E — drives wattle's persistent, shared-host Spinifex stack over SSH to
# exercise GPU passthrough (raw EC2, ECS task GPU exposure, EKS pod GPU
# exposure). See docs/development/feature/wattle-deploy-and-ci.md (Part B).
#
# ---------------------------------------------------------------------------
# ONE-TIME HOST-SIDE SETUP — NOT part of this repo, a human must do this:
#
# 1. Register ONE self-hosted GitHub Actions runner on a banksia host with
# the dedicated label `ci-gpu` (in addition to `self-hosted`). Do
# NOT add it to the `ci-single` / `ci-multi` pool and do NOT give any
# other runner the `ci-gpu` label — a second labelled runner would
# let two GPU jobs race the single RTX A1000. With exactly one runner
# carrying the label, `runs-on: [self-hosted, ci-gpu]` alone forces
# serialization; the `concurrency:` block below queues same-group runs
# when that runner is already busy with a live job.
# 2. Drop the wattle SSH private key on that runner's filesystem and wire
# it into this repo as `secrets.WATTLE_SSH_KEY` (raw private key
# contents). Optionally override `vars.WATTLE_SSH_HOST` /
# `vars.WATTLE_SSH_USER` if they differ from the defaults below.
# 3. Confirm the runner user can drive wattle's control plane over SSH. The
# job calls `spx` (read-only checks) and the `aws` CLI with the already-
# configured `spinifex` profile directly — no wrapper scripts, no install
# on wattle (tf-user is already in the `spinifex` group there):
# ssh tf-user@192.168.1.13 'AWS_PROFILE=spinifex aws ec2 describe-regions'
# 4. Manual-work mutex: an agent doing hands-on GPU testing on wattle can
# block CI by touching a sentinel file in the RUNNER's home directory
# (NOT on wattle):
# touch ~/.ci-gpu-paused # blocks CI
# rm ~/.ci-gpu-paused # resumes CI
# or by setting the repo/environment variable `CI_GPU_PAUSED=true`
# (no runner filesystem access needed). Either one causes this workflow
# to skip cleanly (job succeeds, all work steps skipped) instead of
# queuing behind manual work indefinitely.
#
# Runbook: docs/development/feature/wattle-deploy-and-ci.md (Part B).
# ---------------------------------------------------------------------------
name: GPU E2E
on:
workflow_dispatch:
inputs:
test_run:
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
# Single dedicated runner (see setup note 1 above) + this group together
# enforce single-flight on the one RTX A1000: a second dispatch has nowhere
# else to land (unique label) and, if it somehow overlaps in scheduling,
# queues here rather than cancelling/stomping the in-flight run.
concurrency:
group: ci-gpu
cancel-in-progress: false
env:
# OTLP export to the central Elastic sink (wattle is the sink itself — see
# Part A.2). Repo variable overrides; carried for correlation even though
# 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 }}
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 || '' }}
jobs:
gpu_e2e:
name: GPU E2E (wattle)
runs-on: [self-hosted, ci-gpu]
timeout-minutes: 75
env:
ARTIFACT_DIR: /tmp/spinifex-gpu-e2e-artifacts-${{ github.run_id }}
steps:
# Manual-work mutex gate — see setup note 4 above. Checked BEFORE any
# SSH to wattle so a paused run touches the box zero times.
- name: Manual-work mutex gate
id: gate
env:
REPO_VAR_PAUSED: ${{ vars.CI_GPU_PAUSED }}
run: |
set -euo pipefail
PAUSED=false
if [ -f "$HOME/.ci-gpu-paused" ]; then
PAUSED=true
echo "::warning::ci-gpu runner paused via ~/.ci-gpu-paused sentinel on the runner — skipping this run (manual GPU work in progress on wattle). Remove the file to resume CI."
fi
if [ "$(echo "${REPO_VAR_PAUSED:-}" | tr '[:upper:]' '[:lower:]')" = "true" ]; then
PAUSED=true
echo "::warning::ci-gpu paused via repo/environment variable CI_GPU_PAUSED=true — skipping this run."
fi
echo "paused=${PAUSED}" >> "$GITHUB_OUTPUT"
- name: Paused — nothing to do
if: steps.gate.outputs.paused == 'true'
run: echo "ci-gpu is paused; job exits cleanly without touching wattle. See gate step above for the reason."
- name: Clean Workspace
if: steps.gate.outputs.paused != 'true'
run: sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/checkout@v7
if: steps.gate.outputs.paused != 'true'
- uses: actions/setup-go@v7
if: steps.gate.outputs.paused != 'true'
with:
go-version-file: go.mod
cache: false
- name: Install wattle SSH key
if: steps.gate.outputs.paused != 'true'
run: |
set -euo pipefail
install -m 0600 /dev/null "${RUNNER_TEMP}/wattle-ci"
echo "${{ secrets.WATTLE_SSH_KEY }}" > "${RUNNER_TEMP}/wattle-ci"
echo "ssh_opts=-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${RUNNER_TEMP}/wattle-ci" >> "$GITHUB_ENV"
# Healthcheck: confirm wattle's persistent AWS-compat control plane is
# actually up before spending time building/shipping the test binary.
- name: Healthcheck — describe-regions
if: steps.gate.outputs.paused != 'true'
run: |
set -euo pipefail
ssh ${{ env.ssh_opts }} "${WATTLE_SSH_USER}@${WATTLE_SSH_HOST}" \
'AWS_PROFILE=spinifex aws ec2 describe-regions --output text'
# Ensure the base EC2 GPU AMI is present — the raw-EC2 GPU passthrough
# test is the always-on baseline, so CI self-provisions it rather than
# depending on a manual import. `--name` selects the catalog entry (pulls
# the prebuilt image from iso.mulgadc.com); `--ami-name` is REQUIRED to
# name the AMI ubuntu-26.04-nvidia-gpu-x86_64 — without it the import
# falls back to the generic ami-<distro>-<version>-<arch>, which the
# tests/e2e/gpu filter would never match. Idempotent — skipped once
# wattle's persistent loop-fs already has the named AMI (the 4.4G source
# qcow2 is cached in the images dir, so a re-import wouldn't re-download).
# `spx admin images import` writes local spinifex storage, so it needs
# the `spinifex` group active (sg); aws-CLI calls hit the gateway over
# HTTPS and don't.
- name: Ensure base GPU AMI present (import-if-absent)
if: steps.gate.outputs.paused != 'true'
run: |
set -euo pipefail
REMOTE='
set -e
BASE=ubuntu-26.04-nvidia-gpu-x86_64
present() {
AWS_PROFILE=spinifex aws ec2 describe-images \
--filters "Name=name,Values=$BASE" "Name=state,Values=available" \
--query "Images[0].ImageId" --output text 2>/dev/null
}
ID=$(present)
if [ -n "$ID" ] && [ "$ID" != "None" ]; then
echo "base GPU AMI already present: $BASE = $ID"; exit 0
fi
echo "base GPU AMI $BASE absent — importing prebuilt from catalog..."
sg spinifex -c "AWS_PROFILE=spinifex spx admin images import --name $BASE --ami-name $BASE"
for i in 1 2 3 4 5 6; do
ID=$(present)
[ -n "$ID" ] && [ "$ID" != "None" ] && break
sleep 5
done
[ -n "$ID" ] && [ "$ID" != "None" ] || { echo "import ran but $BASE still not available" >&2; exit 1; }
echo "imported base GPU AMI: $BASE = $ID"
'
ssh ${{ env.ssh_opts }} "${WATTLE_SSH_USER}@${WATTLE_SSH_HOST}" "$REMOTE"
# Preflight — the base GPU AMI is REQUIRED: the setup step above just
# ensured it, so a miss here means the import silently failed. Hard-fail
# rather than run a false-green all-skipped suite. ECS/EKS node AMIs are
# opt-in tiers (heavier — node AMIs + cluster spin-up); their tests
# self-skip when absent, so only warn.
- name: Preflight — verify GPU AMIs
if: steps.gate.outputs.paused != 'true'
run: |
set -euo pipefail
REMOTE='
set -e
present() {
AWS_PROFILE=spinifex aws ec2 describe-images \
--filters "Name=name,Values=$1" "Name=state,Values=available" \
--query "Images[0].ImageId" --output text 2>/dev/null
}
BASE=ubuntu-26.04-nvidia-gpu-x86_64
ID=$(present "$BASE")
if [ -z "$ID" ] || [ "$ID" = "None" ]; then
echo "required base GPU AMI $BASE missing — cannot run the baseline EC2 GPU test" >&2
exit 1
fi
echo "required: $BASE = $ID"
for OPT in spinifex-ecs-node-gpu spinifex-eks-node spinifex-eks-node-gpu; do
OID=$(present "$OPT")
if [ -z "$OID" ] || [ "$OID" = "None" ]; then
echo "::warning::$OPT not imported — its GPU test(s) will skip (opt-in tier)"
else
echo "opt-in: $OPT = $OID"
fi
done
'
ssh ${{ env.ssh_opts }} "${WATTLE_SSH_USER}@${WATTLE_SSH_HOST}" "$REMOTE"
# Same build command CI's other e2e workflows use to cross-compile a
# suite into a shippable binary (tests/e2e/Makefile e2e-build target,
# SUITES_BUILD scoped to just gpu). Building on the runner keeps go
# toolchain / module cache off wattle entirely.
- name: Build GPU e2e test binary on runner
if: steps.gate.outputs.paused != 'true'
working-directory: tests/e2e
run: |
set -euo pipefail
make e2e-build SUITES_BUILD=gpu
ls -la _bin/gpu.test
- name: Install go-junit-report on runner
if: steps.gate.outputs.paused != 'true'
run: |
go install github.com/jstemmer/go-junit-report/v2@latest
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Ship GPU e2e test binary to wattle
if: steps.gate.outputs.paused != 'true'
run: |
set -euo pipefail
VM="${WATTLE_SSH_USER}@${WATTLE_SSH_HOST}"
ssh ${{ env.ssh_opts }} "$VM" "mkdir -p \$HOME/spinifex-gpu-e2e-bin"
scp ${{ env.ssh_opts }} tests/e2e/_bin/gpu.test "$VM":spinifex-gpu-e2e-bin/gpu.test
# Same invocation shape e2e.yml uses to run a pre-built suite binary
# (SPINIFEX_E2E=1 SPINIFEX_MODE=single, ./<suite>.test -test.v
# -test.timeout=NNm), plus SPINIFEX_WAN_IP: wattle's ~24 bridges make
# net.InterfaceAddrs() enumeration pick a co-tenant bridge instead of
# the advertised gateway (mulga-7suds), so the override is required
# here (proper fix tracked separately: tests/e2e/harness/env.go should
# prefer spinifex.toml's advertised bridge).
- name: Run GPU e2e suite on wattle
id: e2e
if: steps.gate.outputs.paused != 'true'
run: |
set +e
VM="${WATTLE_SSH_USER}@${WATTLE_SSH_HOST}"
if [ -n "${TEST_RUN}" ]; then
TEST_RUN_FLAG="-test.run=${TEST_RUN}"
else
TEST_RUN_FLAG=""
fi
ssh -tt ${{ env.ssh_opts }} "$VM" \
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/spinifex-gpu-e2e-bin; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
SPINIFEX_WAN_IP='${WATTLE_SSH_HOST}' \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./gpu.test -test.v -test.timeout=60m ${TEST_RUN_FLAG} \
2>&1 | tee '${ARTIFACT_DIR}/test-gpu.log'; \
exit \${PIPESTATUS[0]}"
E2E_RC=$?
echo "rc=${E2E_RC}" >> "$GITHUB_OUTPUT"
exit 0
- name: Collect artifacts from wattle
if: always() && steps.gate.outputs.paused != 'true'
run: |
set +e
mkdir -p "${ARTIFACT_DIR}"
VM="${WATTLE_SSH_USER}@${WATTLE_SSH_HOST}"
ssh ${{ env.ssh_opts }} "$VM" "tar -C '${ARTIFACT_DIR}' -cf - . 2>/dev/null || true" \
| tar -C "${ARTIFACT_DIR}" -xf - 2>/dev/null
echo "collected: $(ls "${ARTIFACT_DIR}" 2>/dev/null | wc -l) entries"
- name: Convert test log to JUnit XML
if: always() && steps.gate.outputs.paused != 'true'
run: |
shopt -s nullglob
for LOG in "${ARTIFACT_DIR}"/test-*.log; do
BASE=$(basename "$LOG" .log)
SUITE=${BASE#test-}
go-junit-report -in "$LOG" -out "${ARTIFACT_DIR}/junit-${SUITE}.xml" || true
done
- name: Publish test summary
if: always() && steps.gate.outputs.paused != 'true'
uses: ./.github/actions/junit-report
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "GPU E2E results"
- name: Analyze e2e failures
if: always() && steps.gate.outputs.paused != 'true'
uses: ./.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "GPU E2E failure analysis"
- uses: actions/upload-artifact@v7
if: always() && steps.gate.outputs.paused != 'true'
with:
name: gpu-e2e-artifacts-${{ github.run_id }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: warn
retention-days: 14
# Cleanup is NEVER a host teardown: it never runs setup-ovn.sh,
# dev-reset, or cluster-reset. It only reconciles EC2-API-level state —
# terminate any GPU-typed instance still pending/running (the Go
# suite's own t.Cleanup already does this on normal pass/fail exit;
# this is the backstop for a hard `-test.timeout` kill or a workflow
# cancellation, which skip deferred Go cleanups). Termination triggers
# gpuManager.Release() on wattle, returning the A1000 to the pool for
# the next queued run or manual test.
- name: Cleanup — terminate leftover GPU instances, release GPU
if: always() && steps.gate.outputs.paused != 'true'
run: |
set +e
VM="${WATTLE_SSH_USER}@${WATTLE_SSH_HOST}"
REMOTE='
set +e
GPU_TYPES=$(AWS_PROFILE=spinifex aws ec2 describe-instance-types \
--query "InstanceTypes[?GpuInfo!=null].InstanceType" --output text | tr "\t" ",")
if [ -z "$GPU_TYPES" ]; then
echo "no GPU instance types advertised; nothing to reconcile"
exit 0
fi
IDS=$(AWS_PROFILE=spinifex aws ec2 describe-instances \
--filters "Name=instance-type,Values=${GPU_TYPES}" "Name=instance-state-name,Values=pending,running" \
--query "Reservations[].Instances[].InstanceId" --output text)
if [ -z "$IDS" ] || [ "$IDS" = "None" ]; then
echo "no leftover GPU instances — GPU already released"
exit 0
fi
echo "terminating leftover GPU instance(s): $IDS"
AWS_PROFILE=spinifex aws ec2 terminate-instances --instance-ids $IDS
'
ssh ${{ env.ssh_opts }} "$VM" "$REMOTE"
- name: Fail job if GPU suite failed
if: always() && steps.gate.outputs.paused != 'true'
run: |
if [ "${{ steps.e2e.outputs.rc }}" != "0" ]; then
echo "::error::GPU e2e suite failed (rc=${{ steps.e2e.outputs.rc }}) — see analysis above; raw ES/APM traces at ${SPINIFEX_OTLP_ENDPOINT} (ci.run_id=${{ github.run_id }})"
exit 1
fi
+67 -31
View File
@@ -1,18 +1,34 @@
name: Publish EKS + ECS node AMIs
# Builds the eks-node and ecs-agent system AMIs (libguestfs, on the runner) and
# publishes each compressed qcow2 + .sha256 sidecar to the Cloudflare R2 bucket
# that backs iso.mulgadc.com/system-ami/. Operators then install them on any
# cluster with:
# Builds the system AMIs (libguestfs, on the runner) and publishes each
# compressed qcow2 + .sha256 sidecar to the Cloudflare R2 bucket that backs
# iso.mulgadc.com/system-ami/. Four images ship:
# spinifex-eks-node Alpine EKS K3s node (server|agent, role at boot)
# spinifex-ecs-node Alpine ECS container instance
# spinifex-eks-node-gpu Ubuntu EKS K3s GPU worker (NVIDIA driver + toolkit)
# spinifex-ecs-node-gpu Ubuntu ECS GPU container instance
# Operators then install them on any cluster with:
# spx admin images import --name spinifex-eks-node
# spx admin images import --name spinifex-ecs-node
# spx admin images import --name spinifex-eks-node-gpu
# spx admin images import --name spinifex-ecs-node-gpu
#
# Manual-only: publishing overwrites the mirror artifact, so it is a deliberate
# action, not something every push should trigger.
# action, not something every push should trigger. The `variant` input scopes
# which images build — the Ubuntu GPU legs are 16G and much slower (NVIDIA
# driver download + DKMS build), so publishing only the CPU AMIs stays cheap.
on:
workflow_dispatch:
inputs:
variant:
description: "Which images to build"
type: choice
default: all
options:
- all
- cpu
- gpu
dry_run:
description: "Build + convert only; skip the R2 upload"
type: boolean
@@ -26,9 +42,37 @@ concurrency:
cancel-in-progress: false
jobs:
publish:
name: Build + publish eks-node + ecs-agent AMIs
matrix:
name: Resolve image matrix
runs-on: [ self-hosted, ci-single ]
outputs:
images: ${{ steps.gen.outputs.images }}
steps:
- name: Select images for variant
id: gen
env:
VARIANT: ${{ inputs.variant }}
run: |
set -euo pipefail
cpu='{"name":"spinifex-eks-node","manifest":"scripts/images/eks-node/manifest.conf"},{"name":"spinifex-ecs-node","manifest":"scripts/images/ecs-agent/manifest.conf"}'
gpu='{"name":"spinifex-eks-node-gpu","manifest":"scripts/images/eks-node-gpu/manifest.conf"},{"name":"spinifex-ecs-node-gpu","manifest":"scripts/images/ecs-agent-gpu/manifest.conf"}'
case "${VARIANT}" in
cpu) items="${cpu}" ;;
gpu) items="${gpu}" ;;
*) items="${cpu},${gpu}" ;;
esac
echo "images={\"include\":[${items}]}" >> "$GITHUB_OUTPUT"
publish:
name: Build + publish ${{ matrix.name }}
needs: matrix
runs-on: [ self-hosted, ci-single ]
strategy:
fail-fast: false
# ci-single is one runner and each GPU build needs ~16G of scratch disk;
# serialise the legs so they never contend for /_temp or the mirror.
max-parallel: 1
matrix: ${{ fromJSON(needs.matrix.outputs.images) }}
steps:
- name: Checkout Spinifex
@@ -51,7 +95,7 @@ jobs:
path: predastore
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: "spinifex/go.mod"
# Self-hosted runner: the Go module + build cache already persists on
@@ -61,7 +105,13 @@ jobs:
cache: false
- name: Setup Go Workspace
run: go work init ./spinifex ./viperblock ./predastore
# Idempotent: the ci-single runner reuses one workspace across matrix
# legs and runs, and checkout never scrubs the root go.work. Remove any
# stale file so each leg re-inits cleanly instead of failing on
# "go.work already exists".
run: |
rm -f go.work go.work.sum
go work init ./spinifex ./viperblock ./predastore
- name: Install build + upload tooling
run: |
@@ -75,7 +125,7 @@ jobs:
rm -rf /tmp/awscliv2.zip /tmp/aws-install
fi
- name: Build + publish eks-node to R2
- name: Build + publish ${{ matrix.name }} to R2
working-directory: spinifex
env:
# Map the repo's R2 secrets onto the names publish-system-image.sh reads.
@@ -84,34 +134,20 @@ jobs:
R2_ENDPOINT: ${{ secrets.CF_R2_ENDPOINT_URL }}
R2_BUCKET: spinifex-iso
DRY_RUN: ${{ inputs.dry_run }}
MANIFEST: ${{ matrix.manifest }}
# Ubuntu GPU images are 16G — a RAM-backed /tmp cannot hold the qcow2 +
# expanded raw, so point the build scratch at the runner's on-disk
# _temp. Harmless for the small Alpine legs too.
SYSTEM_IMAGE_BUILD_DIR: ${{ runner.temp }}/system-image-build
run: |
set -euo pipefail
# libguestfs needs to read a host kernel; Ubuntu ships it mode 0600.
# direct backend = no libvirt on the runner.
export LIBGUESTFS_BACKEND=direct
sudo chmod 0644 /boot/vmlinuz-* || true
mkdir -p "${SYSTEM_IMAGE_BUILD_DIR}"
FLAGS=(--build)
if [[ "${DRY_RUN}" == "true" ]]; then
FLAGS+=(--dry-run)
fi
./scripts/publish-system-image.sh \
scripts/images/eks-node/manifest.conf "${FLAGS[@]}"
- name: Build + publish ecs-agent to R2
working-directory: spinifex
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.CF_R2_ENDPOINT_URL }}
R2_BUCKET: spinifex-iso
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
export LIBGUESTFS_BACKEND=direct
sudo chmod 0644 /boot/vmlinuz-* || true
FLAGS=(--build)
if [[ "${DRY_RUN}" == "true" ]]; then
FLAGS+=(--dry-run)
fi
./scripts/publish-system-image.sh \
scripts/images/ecs-agent/manifest.conf "${FLAGS[@]}"
./scripts/publish-system-image.sh "${MANIFEST}" "${FLAGS[@]}"
+2 -2
View File
@@ -32,7 +32,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: "1.26.5"
@@ -280,7 +280,7 @@ jobs:
run: sudo make -C mulga/scripts/iso-builder clean
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: spinifex/go.mod
cache: false
+39 -9
View File
@@ -32,22 +32,29 @@ jobs:
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: dev
ref: ${{ steps.siblings.outputs.viperblock }}
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v7
with:
repository: mulgadc/predastore
ref: dev
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
cache-dependency-path: "spinifex/go.sum"
go-version-file: "spinifex/go.mod"
@@ -61,6 +68,15 @@ 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
@@ -80,22 +96,29 @@ jobs:
with:
path: spinifex
- 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: dev
ref: ${{ steps.siblings.outputs.viperblock }}
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v7
with:
repository: mulgadc/predastore
ref: dev
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
cache-dependency-path: "spinifex/go.sum"
go-version-file: "spinifex/go.mod"
@@ -116,22 +139,29 @@ jobs:
with:
path: spinifex
- 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: dev
ref: ${{ steps.siblings.outputs.viperblock }}
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v7
with:
repository: mulgadc/predastore
ref: dev
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
cache-dependency-path: "spinifex/go.sum"
go-version-file: "spinifex/go.mod"
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
package_json_file: spinifex/services/spinifexui/frontend/package.json
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
cache: "pnpm"
cache-dependency-path: spinifex/services/spinifexui/frontend/pnpm-lock.yaml
+22 -2
View File
@@ -15,21 +15,24 @@ linters:
- 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
- errcheck # checks for unchecked errors in type assertions and I/O
- errchkjson # checks types passed to json encoding functions, flags omittable error checks
- errname # checks that sentinel errors are prefixed with Err, error types suffixed with Error
- 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
- forcetypeassert # flags unchecked type assertions that can panic
- 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.)
- ineffassign # detects assignments to existing variables that are not used
- 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)
- nakedret # finds naked returns in functions
@@ -43,6 +46,8 @@ linters:
- recvcheck # checks for receiver type consistency (pointer vs value)
- sloglint # enforces consistent log/slog usage (key-value pairs, no globals)
- staticcheck # extensive static analysis checks
- testifylint # checks common testify mistakes (assert.Len over assert.Equal on len, etc.)
- tparallel # detects inappropriate usage of t.Parallel()
- unconvert # removes unnecessary type conversions
- usestdlibvars # detects hardcoded values that should use stdlib constants
- usetesting # catches os.TempDir() etc. in tests — should use t.TempDir()
@@ -53,8 +58,17 @@ linters:
embeddedstructfieldcheck:
forbid-mutex: true
depguard:
rules:
main:
deny:
- pkg: log$
desc: Use log/slog instead, see https://go.dev/blog/slog
- pkg: math/rand$
desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2
errcheck:
check-type-assertions: true
check-type-assertions: true # replaces forcetypeassert
exclude-functions:
- (*github.com/nats-io/nats.go.Subscription).Unsubscribe # cleanup-only, errors not actionable
@@ -111,6 +125,12 @@ linters:
patterns:
- ".*"
testifylint:
disable:
- encoded-compare # misidentifies non-JSON strings when identifiers contain "JSON"
- float-compare # false positives
- require-error # assert-on-error is deliberate where a test continues to collect further failures
exclusions:
presets:
- std-error-handling
+14 -2
View File
@@ -123,7 +123,7 @@ install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share
# Preflight — runs the same checks as GitHub Actions (lint + vuln + tests).
# Use this before committing to catch CI failures locally.
preflight:
@$(MAKE) --no-print-directory QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-race test-harness
@$(MAKE) --no-print-directory QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-race test-harness test-integration
@echo -e "\n ✅ Preflight passed — safe to commit."
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
@@ -133,6 +133,18 @@ test-harness:
@echo -e "\n....Running e2e harness unit tests...."
$(_Q)LOG_IGNORE=1 go test -tags=e2e -timeout 60s ./tests/e2e/harness/... $(_RACEQ)
# In-process integration tier: the real gateway router against embedded NATS
# 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.
test-integration:
@echo -e "\n....Running in-process integration tests...."
$(_Q)LOG_IGNORE=1 go test -tags=integration -timeout 60s ./tests/integration/... $(_RACEQ)
# Validate docs/service-interfaces.yaml. Schema check + cross-reference
# of services/suites/fixtures + on-disk path existence. Subject content
# vs source is enforced separately in Bead 5 drift lint.
@@ -297,7 +309,7 @@ distro-arm64:
distro-clean:
rm -rf dist/
.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 manifest-check manifest-lint manifest-lint-update \
.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 \
+88 -19
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src=".github/assets/banner.svg" alt="Spinifex — AWS-Compatible Infrastructure Stack" width="900">
<img src=".github/assets/banner.svg" alt="Spinifex: the open, AWS-compatible cloud you run yourself. EC2, EBS, S3, VPC, and IAM on your infrastructure, at the edge, or on a Neocloud." width="900">
</p>
<p align="center">
@@ -11,6 +11,8 @@
<p align="center">
<a href="#what-is-spinifex">What is Spinifex?</a> ·
<a href="#the-platform">Platform</a> ·
<a href="#three-ways-to-deploy">Deploy</a> ·
<a href="#aws-compatibility">AWS services</a> ·
<a href="#core-components">Components</a> ·
<a href="#architecture-at-a-glance">Architecture</a> ·
<a href="#installation">Installation</a> ·
@@ -19,31 +21,90 @@
---
# Spinifex: An Open Source AWS-Compatible Stack for Bare-Metal, Edge, and On-Prem Deployments
# Spinifex: the open, AWS-compatible cloud you run yourself
Spinifex developed by [Mulga Defense Corporation](https://mulgadc.com) is an open source infrastructure platform that brings the core services of AWS—like EC2, VPC, EBS, and S3—to environments where running in the cloud isn't an option. Whether you're deploying to edge sites, private data-centers, or need to operate in low-connectivity or highly contested environments, Spinifex gives you AWS-style workflows on your own hardware.
Spinifex, built by [Mulga](https://mulgadc.com), recreates the AWS services your
software already uses (EC2, EBS, S3, VPC, IAM) on infrastructure you control.
Move workloads off the hyperscalers, run AI at a fraction of the cost, or take
the cloud to the edge, without rewriting a line. The exact build you ship to AWS,
Spinifex serves on your own hardware: same APIs, same Terraform, same SDKs.
## What is Spinifex?
Spinifex replicates essential AWS primitives—virtual machines, block volumes, and object storage—using lightweight, self-contained components that are easy to deploy and integrate.
Spinifex is an open-source infrastructure platform that recreates the AWS service
surface (EC2, EBS, S3, VPC, IAM) on bare-metal, edge, and on-premises
environments. Run cloud-native software without a hyperscaler.
It's designed for developers and operators who need:
Most AWS alternatives wrap an API and call it a cloud. We rebuilt the engineering
underneath: distributed object storage with erasure coding, block storage built
to survive failure, and compute on bare metal. That depth is the reason your
software runs unchanged, instead of rewritten.
- Cloud-like infrastructure without the cloud
- Minimal dependencies, full control
- Drop-in compatibility with tools like the AWS CLI, SDKs, and Terraform
- A secure cloud environment you control and own the entire hardware, network and software stack
It's built for teams that need:
You can run Spinifex on a few servers in a rack, a field site, or anywhere centralized cloud services aren't feasible.
- The AWS tooling they already use (AWS CLI, SDKs, Terraform), with nothing to rewrite
- Full control of the stack: your hardware, your network, your data, your keys
- A cloud that keeps running offline, through disconnection, with no external control plane
- An open core (AGPL-3.0), auditable and yours to keep, with no lock-in
You change *where* your software runs, not *what* it is.
## The Platform
<p align="center">
<img src=".github/assets/platform.svg" alt="The Mulga Platform — Hardware to Applications" width="900">
<img src=".github/assets/platform.svg" alt="The Spinifex stack: your apps and AWS tooling on top, the Spinifex AWS-compatible service layer, running on standard Linux on CPU and GPU hardware, deployable to Neocloud, on-premise, or the edge." width="900">
</p>
From commodity hardware up to unmodified AWS tooling, every layer is replaceable and yours to own.
## Three ways to deploy
One AWS-compatible surface, three deployment shapes. Pick the one that matches
your reality; the platform on top is the same.
### Neocloud
Lift and shift onto a partner Neocloud. Move workloads off the hyperscalers and
onto our Neocloud partner ecosystem without rewriting them. GPU capacity
(H100 / H200 / B200), cheaper, available now. Change an endpoint, keep the
software.
### On-premise
Bring your own hardware and host Spinifex in your own data centre. A real
multi-node HA cluster integrated with your storage and networking, with full
control of stack, data, and jurisdiction. A predictable bill, no egress
surprises, an auditable open-source core.
### Edge
Cloud where the cloud can't reach. Air-gapped sites, vehicles, vessels,
factories, and clinics. Compute next to the data, running through disconnection,
on hardware you own. The same AWS APIs your software already uses.
## AWS compatibility
Speak the AWS API surface, natively. The AWS SDKs, AWS CLI, and Terraform:
everything you deploy on AWS deploys on Spinifex unchanged. At the edge,
on-premise, or on a partner Neocloud.
| Service | What it is | Status |
| --- | --- | --- |
| **EC2** | Compute | Available |
| **EBS** | Block storage | Available |
| **S3** | Object storage | Available |
| **VPC** | Networking | Available |
| **IAM** | Identity & auth | Available |
| **ALB / NLB** | Load balancers | Available |
| **EKS** | Kubernetes | Available |
| **ECR** | Container registry | Available |
| **ECS** | Container service | Available |
| **RDS** | Databases | Q3 2026 |
Roadmap items (Q3 2026) ship under the same AWS API surface. Code written for AWS
today keeps working the moment they land.
[Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
## Core Components
### Spinifex (Compute Service – EC2 Alternative)
@@ -75,19 +136,23 @@ Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs
## Architecture at a Glance
<p align="center">
<img src=".github/assets/architecture.svg" alt="Spinifex message-driven architecture — AWS Gateway, NATS bus, service daemons" width="900">
<img src=".github/assets/architecture.svg" alt="Spinifex message-driven architecture: standard AWS clients call the AWS Gateway over HTTPS with SigV4, which publishes to a NATS bus answered by stateless ec2/ebs/s3/vpc/iam daemons over local backends." width="900">
</p>
Every AWS API call is authenticated at the gateway, published to a NATS subject, and answered by whichever daemon claims it. Daemons are stateless — scale horizontally by starting more. No etcd, no Kubernetes, no external control plane. Deep dive: **[`docs/DESIGN.md`](docs/DESIGN.md)**.
Every AWS API call is authenticated at the gateway, published to a NATS subject,
and answered by whichever daemon claims it. Daemons are stateless: scale
horizontally by starting more. No etcd, no Kubernetes, no external control plane.
Just systemd units, a NATS cluster, and your hardware. Deep dive:
**[`docs/DESIGN.md`](docs/DESIGN.md)**.
## Key Features
AWS-Compatible Interfaces – Provision infrastructure with awscli, Terraform, or SDKs you already use.
- Designed for Control – Run on your own terms, whether that's in a datacenter, remote site, or sensitive environment.
- Minimal Dependencies – Each service is standalone and avoids complex orchestration layers.
- Works Offline – No reliance on centralized cloud services or external networks.
- Open Source – Licensed under AGPL 3.0. Fork it, extend it, or deploy it as-is.
- **AWS-compatible APIs.** Use the AWS CLI, SDKs, and Terraform you already know. Repoint your endpoint and ship; nothing to rewrite.
- **Zero cloud dependency.** Runs entirely on your hardware. No phone-home, no control plane, no external authority. Works fully offline.
- **Bare-metal compute.** QEMU-based with direct hardware access. No hypervisor tax, no abstraction overhead.
- **Built-in storage.** Block and object storage included: NVMe caching, Reed-Solomon erasure coding, and replication out of the box.
- **Edge-first architecture.** Designed for disconnected, contested, and resource-constrained environments from day one.
- **Open core, no lock-in.** AGPL-3.0 with a commercial option. Inspect it, modify it, deploy it. The platform is yours to keep.
## Installation
@@ -192,6 +257,10 @@ While Spinifex is architected and implemented by experienced engineers, we lever
This hybrid approach ensures Spinifex benefits from both proven engineering expertise and modern development acceleration, while maintaining the quality and reliability standards required for production infrastructure.
## Trademarks
"AWS", "Amazon Web Services", and all related service names (EC2, EBS, S3, VPC, IAM, and others) are trademarks of Amazon.com, Inc. or its affiliates. Mulga Defense Corporation and Spinifex are independent and not affiliated with, endorsed by, or sponsored by Amazon.com, Inc. or Amazon Web Services, Inc. References to AWS services describe interoperability and compatibility only.
## License
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE). You're free to use, modify, and deploy it anywhere you need reliable infrastructure without depending on centralized cloud platforms.
+54
View File
@@ -0,0 +1,54 @@
[Unit]
Description=Spinifex Northstar (Authoritative DNS)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-predastore.service
Wants=spinifex-predastore.service
[Service]
EnvironmentFile=-/etc/spinifex/telemetry.env
Type=simple
Slice=spinifex-system.slice
User=spinifex-northstar
Group=spinifex
ExecStart=/usr/local/bin/spx service northstar start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/northstar/
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/northstar /etc/spinifex/spinifex.toml /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/northstar /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
# RG-9: exactly CAP_NET_BIND_SERVICE so the unprivileged user binds :53 (no root).
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+1 -1
View File
@@ -2,7 +2,7 @@
Description=Spinifex Infrastructure Platform
After=network-online.target
Wants=network-online.target
Wants=spinifex-nats.service spinifex-predastore.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer openvswitch-switch.service ovn-controller.service
Wants=spinifex-nats.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer openvswitch-switch.service ovn-controller.service
[Install]
WantedBy=multi-user.target
+19 -7
View File
@@ -14,7 +14,7 @@ import (
"context"
"flag"
"fmt"
"log"
"log/slog"
"net"
"os"
"os/exec"
@@ -46,18 +46,21 @@ func main() {
case *spinifexID != "":
hwAddr, err = net.ParseMAC(generateMAC(*spinifexID))
if err != nil {
log.Fatalf("generateMAC(%q): %v", *spinifexID, err)
slog.Error("Could not parse generated MAC", "spinifex-id", *spinifexID, "error", err)
os.Exit(1)
}
fmt.Printf("spinifex virtual MAC for %q: %s\n", *spinifexID, hwAddr)
case *macStr != "":
hwAddr, err = net.ParseMAC(*macStr)
if err != nil {
log.Fatalf("parse --mac %q: %v", *macStr, err)
slog.Error("Could not parse --mac", "mac", *macStr, "error", err)
os.Exit(1)
}
default:
hwAddr, err = ifaceMAC(*iface)
if err != nil {
log.Fatalf("get MAC for %s: %v", *iface, err)
slog.Error("Could not get MAC for interface", "iface", *iface, "error", err)
os.Exit(1)
}
fmt.Printf("using real interface MAC: %s\n", hwAddr)
}
@@ -83,7 +86,7 @@ func main() {
if *promisc {
if err := setPromisc(*iface, true); err != nil {
log.Printf("WARNING: failed to set promisc on %s: %v", *iface, err)
slog.Warn("Failed to set promisc on interface", "iface", *iface, "error", err)
} else {
fmt.Printf("set %s promisc ON\n\n", *iface)
}
@@ -96,12 +99,13 @@ func main() {
nclient4.WithRetry(*retries),
}
if *verbose {
opts = append(opts, nclient4.WithLogger(nclient4.ShortSummaryLogger{Printfer: log.New(os.Stderr, "[nclient4] ", 0)}))
opts = append(opts, nclient4.WithLogger(nclient4.ShortSummaryLogger{Printfer: slogPrintfer{}}))
}
client, err := nclient4.New(*iface, opts...)
if err != nil {
log.Fatalf("nclient4.New(%s): %v", *iface, err)
slog.Error("Could not create nclient4 client", "iface", *iface, "error", err)
os.Exit(1)
}
defer client.Close()
@@ -141,6 +145,14 @@ func main() {
}
}
// slogPrintfer adapts nclient4's Printfer to slog. nclient4 only emits through
// it under --verbose, so the packet summaries log at info level to stay visible.
type slogPrintfer struct{}
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 {
+13 -3
View File
@@ -43,6 +43,11 @@ type Agent struct {
// cred serves task IAM role credentials at 169.254.170.2 (nil in unit tests).
cred *credEndpoint
// gpu is the local pinning ledger over the host's discovered GPU UUIDs.
// Always non-nil (newAgent seeds an empty one); New() reseeds it from
// nvidia-smi discovery.
gpu *gpuLedger
closers []func() error
}
@@ -61,6 +66,7 @@ func newAgent(cfg config, id identity, cp controlPlane, puller ctrruntime.ImageP
reg: newRegistrar(cp, id),
hb: newHeartbeater(cp, id, cfg.Heartbeat),
netns: newTaskNetns(execNetRunner{}),
gpu: newGPULedger(id.Capacity.GPUIDs),
}
}
@@ -84,7 +90,7 @@ func New(cfg config) (*Agent, error) {
InstanceID: meta.InstanceID,
AZ: meta.AZ,
Hostname: host,
Capacity: detectCapacity(),
Capacity: detectCapacity(discoverNvidiaGPUs(execCommandRunner)),
AgentVersion: version,
}
@@ -153,11 +159,15 @@ func (a *Agent) Stop() error {
return firstErr
}
// detectCapacity reports the host's total CPU units (1 vCPU = 1024) and memory.
func detectCapacity() bus.InstanceCapacity {
// detectCapacity reports the host's total CPU units (1 vCPU = 1024), memory,
// and GPU count/UUIDs from the caller's nvidia-smi discovery (nil on a
// non-GPU host).
func detectCapacity(gpuUUIDs []string) bus.InstanceCapacity {
return bus.InstanceCapacity{
CPU: goruntime.NumCPU() * 1024,
MemoryMiB: memTotalMiB(),
GPU: len(gpuUUIDs),
GPUIDs: gpuUUIDs,
}
}
+14 -1
View File
@@ -42,7 +42,7 @@ func TestAgent_RunRegistersThenStopsOnContext(t *testing.T) {
}
func TestDetectCapacity_Positive(t *testing.T) {
c := detectCapacity()
c := detectCapacity(nil)
if c.CPU <= 0 {
t.Errorf("CPU = %d, want > 0", c.CPU)
}
@@ -50,4 +50,17 @@ func TestDetectCapacity_Positive(t *testing.T) {
if c.MemoryMiB < 0 {
t.Errorf("MemoryMiB = %d, want >= 0", c.MemoryMiB)
}
if c.GPU != 0 || c.GPUIDs != nil {
t.Errorf("no GPU UUIDs given: want GPU=0/GPUIDs=nil, got %d/%v", c.GPU, c.GPUIDs)
}
}
func TestDetectCapacity_WithGPUUUIDs(t *testing.T) {
c := detectCapacity([]string{"GPU-aaa", "GPU-bbb"})
if c.GPU != 2 {
t.Errorf("GPU = %d, want 2", c.GPU)
}
if len(c.GPUIDs) != 2 {
t.Errorf("GPUIDs = %v, want 2 entries", c.GPUIDs)
}
}
+20
View File
@@ -29,6 +29,9 @@ type controlPlane interface {
// assign taskIDs and stop taskIDs accepted on the previous poll. Returns the
// still-pending assignments and stop directives.
PollAssignments(cluster, instance string, ackAssigns, ackStops []string) ([]bus.Assign, []bus.StopDirective, error)
// ReportTaskGPU reports the local ledger's pinned device UUIDs for a task's
// containers, so the control plane can populate DescribeTasks gpuIds.
ReportTaskGPU(cluster, task string, containers []handlers_ecs.ContainerGPUReport) error
}
// gatewayControlPlane implements controlPlane over the SigV4 ECS gateway client.
@@ -74,6 +77,11 @@ func (g *gatewayControlPlane) Register(id identity) error {
},
VersionInfo: &ecs.VersionInfo{AgentVersion: aws.String(id.AgentVersion)},
}
if len(id.Capacity.GPUIDs) > 0 {
in.TotalResources = append(in.TotalResources, &ecs.Resource{
Name: aws.String("GPU"), Type: aws.String("STRINGSET"), StringSetValue: aws.StringSlice(id.Capacity.GPUIDs),
})
}
body, err := json.Marshal(in)
if err != nil {
return fmt.Errorf("marshal register: %w", err)
@@ -133,3 +141,15 @@ func (g *gatewayControlPlane) PollAssignments(cluster, instance string, ackAssig
}
return out.Assignments, out.Stops, nil
}
func (g *gatewayControlPlane) ReportTaskGPU(cluster, task string, containers []handlers_ecs.ContainerGPUReport) error {
in := &handlers_ecs.ReportTaskGPUInput{Cluster: cluster, Task: task, Containers: containers}
body, err := json.Marshal(in)
if err != nil {
return fmt.Errorf("marshal report-task-gpu: %w", err)
}
if _, err := g.client.Call("ReportTaskGPU", body); err != nil {
return fmt.Errorf("report task gpu: %w", err)
}
return nil
}
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/aws/aws-sdk-go/service/ecs"
"github.com/mulgadc/spinifex/cmd/ecs-agent/credentials"
handlers_ecs "github.com/mulgadc/spinifex/spinifex/handlers/ecs"
)
// fixedCredsProvider is a static credentials.CredentialsProvider for tests
// that only need a valid SigV4 signature, not real IMDS rotation.
type fixedCredsProvider struct{}
func (fixedCredsProvider) Retrieve(context.Context) (credentials.Credentials, error) {
return credentials.Credentials{AccessKeyID: "AKIATEST", SecretAccessKey: "secret"}, nil
}
func newTestGatewayControlPlane(t *testing.T, srv *httptest.Server) *gatewayControlPlane {
t.Helper()
cp, err := newGatewayControlPlane(config{GatewayURL: srv.URL, Region: "us-east-1"}, fixedCredsProvider{})
if err != nil {
t.Fatalf("newGatewayControlPlane: %v", err)
}
return cp
}
// Register carries the discovered GPU UUIDs as a STRINGSET resource, matching
// the control plane's RegisterContainerInstance GPU parsing (Epic C3).
func TestGatewayControlPlane_Register_SendsGPUStringSet(t *testing.T) {
var body []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ = io.ReadAll(r.Body)
_, _ = w.Write([]byte(`{}`))
}))
defer srv.Close()
cp := newTestGatewayControlPlane(t, srv)
id := identity{
ClusterName: "default", InstanceID: "i-1",
Capacity: detectCapacity([]string{"GPU-aaa", "GPU-bbb"}),
}
if err := cp.Register(id); err != nil {
t.Fatalf("Register: %v", err)
}
var in ecs.RegisterContainerInstanceInput
if err := json.Unmarshal(body, &in); err != nil {
t.Fatalf("decode body: %v", err)
}
var gpu *ecs.Resource
for _, r := range in.TotalResources {
if r.Name != nil && *r.Name == "GPU" {
gpu = r
}
}
if gpu == nil {
t.Fatal("no GPU resource in TotalResources")
}
if *gpu.Type != "STRINGSET" {
t.Errorf("GPU resource type = %q, want STRINGSET", *gpu.Type)
}
got := make([]string, len(gpu.StringSetValue))
for i, v := range gpu.StringSetValue {
got[i] = *v
}
if len(got) != 2 || got[0] != "GPU-aaa" || got[1] != "GPU-bbb" {
t.Errorf("GPU UUIDs = %v, want [GPU-aaa GPU-bbb]", got)
}
}
// A non-GPU host (no discovered UUIDs) registers with no GPU resource at all.
func TestGatewayControlPlane_Register_NoGPUOmitsResource(t *testing.T) {
var body []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ = io.ReadAll(r.Body)
_, _ = w.Write([]byte(`{}`))
}))
defer srv.Close()
cp := newTestGatewayControlPlane(t, srv)
id := identity{ClusterName: "default", InstanceID: "i-1", Capacity: detectCapacity(nil)}
if err := cp.Register(id); err != nil {
t.Fatalf("Register: %v", err)
}
var in ecs.RegisterContainerInstanceInput
if err := json.Unmarshal(body, &in); err != nil {
t.Fatalf("decode body: %v", err)
}
for _, r := range in.TotalResources {
if r.Name != nil && *r.Name == "GPU" {
t.Fatalf("unexpected GPU resource on a non-GPU host: %+v", r)
}
}
}
// ReportTaskGPU posts the per-container device UUIDs under the ReportTaskGPU
// action, decodable as the handlers_ecs internal shape.
func TestGatewayControlPlane_ReportTaskGPU(t *testing.T) {
var gotTarget string
var body []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotTarget = r.Header.Get("X-Amz-Target")
body, _ = io.ReadAll(r.Body)
_, _ = w.Write([]byte(`{}`))
}))
defer srv.Close()
cp := newTestGatewayControlPlane(t, srv)
err := cp.ReportTaskGPU("web", "t-1", []handlers_ecs.ContainerGPUReport{
{Name: "trainer", GPUIDs: []string{"GPU-aaa"}},
})
if err != nil {
t.Fatalf("ReportTaskGPU: %v", err)
}
if gotTarget == "" {
t.Fatal("no X-Amz-Target header sent")
}
var in handlers_ecs.ReportTaskGPUInput
if err := json.Unmarshal(body, &in); err != nil {
t.Fatalf("decode body: %v", err)
}
if in.Cluster != "web" || in.Task != "t-1" {
t.Errorf("cluster/task = %q/%q, want web/t-1", in.Cluster, in.Task)
}
if len(in.Containers) != 1 || in.Containers[0].Name != "trainer" || len(in.Containers[0].GPUIDs) != 1 {
t.Errorf("containers = %+v, want one trainer entry with one UUID", in.Containers)
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
@@ -143,7 +144,7 @@ func (c *credEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
creds, roleARN, err := c.fetch(r.Context(), credID)
if err != nil {
if err == errUnknownCredID {
if errors.Is(err, errUnknownCredID) {
http.Error(w, "not found", http.StatusNotFound)
return
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"os/exec"
"strings"
)
// commandRunner abstracts exec.Command so discovery is unit-testable without
// shelling out. Production uses execCommandRunner; tests inject a fake.
type commandRunner func(name string, args ...string) ([]byte, error)
// execCommandRunner runs a real host command and returns its stdout.
func execCommandRunner(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).Output()
}
// discoverNvidiaGPUs enumerates local NVIDIA GPU device UUIDs via nvidia-smi.
// A missing binary or any run failure (no GPU host) yields an empty list, not
// an error — the agent still registers with zero GPU capacity.
func discoverNvidiaGPUs(run commandRunner) []string {
out, err := run("nvidia-smi", "--query-gpu=uuid", "--format=csv,noheader")
if err != nil {
return nil
}
return parseNvidiaSMIUUIDs(string(out))
}
// parseNvidiaSMIUUIDs splits nvidia-smi's newline-delimited UUID output,
// dropping blank lines. Exported as a pure function so fixture strings can be
// tested without exec.
func parseNvidiaSMIUUIDs(out string) []string {
lines := strings.Split(strings.TrimSpace(out), "\n")
uuids := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
uuids = append(uuids, line)
}
return uuids
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"errors"
"reflect"
"testing"
)
func TestParseNvidiaSMIUUIDs(t *testing.T) {
cases := []struct {
name string
in string
want []string
}{
{"empty", "", nil},
{"whitespace only", " \n\n ", nil},
{"single", "GPU-11111111-1111-1111-1111-111111111111\n", []string{"GPU-11111111-1111-1111-1111-111111111111"}},
{"multi", "GPU-aaa\nGPU-bbb\nGPU-ccc\n", []string{"GPU-aaa", "GPU-bbb", "GPU-ccc"}},
{"blank lines between", "GPU-aaa\n\nGPU-bbb\n", []string{"GPU-aaa", "GPU-bbb"}},
{"trailing whitespace per line", "GPU-aaa \r\nGPU-bbb\r\n", []string{"GPU-aaa", "GPU-bbb"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := parseNvidiaSMIUUIDs(c.in)
if !reflect.DeepEqual(got, c.want) && (len(got) != 0 || len(c.want) != 0) {
t.Errorf("parseNvidiaSMIUUIDs(%q) = %v, want %v", c.in, got, c.want)
}
})
}
}
func TestDiscoverNvidiaGPUs_AbsentBinaryReturnsEmptyNoError(t *testing.T) {
run := func(name string, args ...string) ([]byte, error) {
return nil, errors.New(`exec: "nvidia-smi": executable file not found in $PATH`)
}
got := discoverNvidiaGPUs(run)
if len(got) != 0 {
t.Errorf("want empty list on absent nvidia-smi, got %v", got)
}
}
func TestDiscoverNvidiaGPUs_ParsesFixtureOutput(t *testing.T) {
var gotName string
var gotArgs []string
run := func(name string, args ...string) ([]byte, error) {
gotName = name
gotArgs = args
return []byte("GPU-aaa\nGPU-bbb\n"), nil
}
got := discoverNvidiaGPUs(run)
want := []string{"GPU-aaa", "GPU-bbb"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
if gotName != "nvidia-smi" {
t.Errorf("command = %q, want nvidia-smi", gotName)
}
if len(gotArgs) != 2 || gotArgs[0] != "--query-gpu=uuid" || gotArgs[1] != "--format=csv,noheader" {
t.Errorf("args = %v, want the uuid/csv,noheader query", gotArgs)
}
}
func TestDiscoverNvidiaGPUs_NoDevicesReturnsEmpty(t *testing.T) {
run := func(name string, args ...string) ([]byte, error) {
return []byte(""), nil
}
got := discoverNvidiaGPUs(run)
if len(got) != 0 {
t.Errorf("want empty list, got %v", got)
}
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"fmt"
"strings"
"sync"
)
// gpuLedger is the agent's local source of truth for which of the host's
// discovered GPU UUIDs are free vs pinned to a running task's container. C4
// reads a task's pinned UUIDs to build the CDI device list for containerd.
type gpuLedger struct {
mu sync.Mutex
free []string
pinned map[string][]string // key ("taskID/containerName") -> uuids
}
// newGPULedger seeds the ledger with the host's discovered device UUIDs. A nil
// or empty slice is a valid non-GPU host: every Pin then fails, which callers
// treat as best-effort (no device, no CDI injection later).
func newGPULedger(uuids []string) *gpuLedger {
free := append([]string(nil), uuids...)
return &gpuLedger{free: free, pinned: make(map[string][]string)}
}
// gpuKey builds the ledger key for one container's pin/release.
func gpuKey(taskID, containerName string) string {
return taskID + "/" + containerName
}
// Pin reserves n free UUIDs under key, returning them. An error means fewer
// than n devices were free; the key is left unpinned so a retry (or a
// best-effort caller) can proceed without a partial reservation.
func (l *gpuLedger) Pin(key string, n int) ([]string, error) {
if n <= 0 {
return nil, nil
}
l.mu.Lock()
defer l.mu.Unlock()
if len(l.free) < n {
return nil, fmt.Errorf("gpu ledger: requested %d GPUs, %d free", n, len(l.free))
}
uuids := append([]string(nil), l.free[:n]...)
l.free = l.free[n:]
l.pinned[key] = uuids
return uuids, nil
}
// ReleaseTask frees every UUID pinned under any container key belonging to
// taskID, called once the task's terminal STOPPED state is reported.
func (l *gpuLedger) ReleaseTask(taskID string) {
prefix := taskID + "/"
l.mu.Lock()
defer l.mu.Unlock()
for key, uuids := range l.pinned {
if !strings.HasPrefix(key, prefix) {
continue
}
l.free = append(l.free, uuids...)
delete(l.pinned, key)
}
}
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"sync"
"testing"
)
func TestGPULedger_PinReturnsDistinctUUIDs(t *testing.T) {
l := newGPULedger([]string{"GPU-a", "GPU-b", "GPU-c"})
got, err := l.Pin("t-1/web", 2)
if err != nil {
t.Fatalf("Pin: %v", err)
}
if len(got) != 2 {
t.Fatalf("want 2 UUIDs, got %v", got)
}
if got[0] == got[1] {
t.Errorf("pinned UUIDs must be distinct, got %v", got)
}
if len(l.free) != 1 {
t.Errorf("free = %d, want 1 remaining", len(l.free))
}
}
func TestGPULedger_PinInsufficientFreeErrors(t *testing.T) {
l := newGPULedger([]string{"GPU-a"})
_, err := l.Pin("t-1/web", 2)
if err == nil {
t.Fatal("want error when requesting more GPUs than free")
}
if len(l.free) != 1 {
t.Errorf("failed pin must not consume any device, free = %d", len(l.free))
}
}
func TestGPULedger_PinZeroIsNoop(t *testing.T) {
l := newGPULedger([]string{"GPU-a"})
got, err := l.Pin("t-1/web", 0)
if err != nil || got != nil {
t.Errorf("Pin(0) = %v, %v; want nil, nil", got, err)
}
}
func TestGPULedger_ReleaseTaskFreesOnlyThatTasksDevices(t *testing.T) {
l := newGPULedger([]string{"GPU-a", "GPU-b", "GPU-c", "GPU-d"})
if _, err := l.Pin(gpuKey("t-1", "web"), 2); err != nil {
t.Fatalf("pin t-1: %v", err)
}
if _, err := l.Pin(gpuKey("t-2", "worker"), 1); err != nil {
t.Fatalf("pin t-2: %v", err)
}
if len(l.free) != 1 {
t.Fatalf("free = %d, want 1 before release", len(l.free))
}
l.ReleaseTask("t-1")
if len(l.free) != 3 {
t.Errorf("free = %d, want 3 after releasing t-1's two devices", len(l.free))
}
if _, ok := l.pinned[gpuKey("t-2", "worker")]; !ok {
t.Error("t-2's pin should survive releasing t-1")
}
if _, ok := l.pinned[gpuKey("t-1", "web")]; ok {
t.Error("t-1's pin should be gone after release")
}
}
// Multi-device pinning across concurrent task assigns must hand out disjoint
// UUID sets — no two tasks may ever share a device.
func TestGPULedger_ConcurrentPinsHandOutDistinctUUIDs(t *testing.T) {
const nDevices = 20
uuids := make([]string, nDevices)
for i := range uuids {
uuids[i] = "GPU-" + string(rune('a'+i))
}
l := newGPULedger(uuids)
var wg sync.WaitGroup
var mu sync.Mutex
seen := make(map[string]bool)
errs := 0
for i := range nDevices {
wg.Add(1)
go func(n int) {
defer wg.Done()
key := gpuKey("t-concurrent", string(rune('A'+n)))
got, err := l.Pin(key, 1)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs++
return
}
for _, u := range got {
if seen[u] {
t.Errorf("UUID %s handed out to more than one concurrent pin", u)
}
seen[u] = true
}
}(i)
}
wg.Wait()
if errs != 0 {
t.Errorf("want all %d pins to succeed (exactly enough devices), got %d errors", nDevices, errs)
}
if len(seen) != nDevices {
t.Errorf("distinct UUIDs handed out = %d, want %d", len(seen), nDevices)
}
if len(l.free) != 0 {
t.Errorf("free = %d, want 0 after all devices pinned", len(l.free))
}
}
// Releasing a task's devices concurrently with new pins never double-hands a
// UUID: a device is only ever free or pinned to exactly one key at a time.
func TestGPULedger_ConcurrentPinAndReleaseNoDoubleAssignment(t *testing.T) {
l := newGPULedger([]string{"GPU-a", "GPU-b"})
var wg sync.WaitGroup
for round := range 50 {
wg.Add(2)
taskID := "t-" + string(rune('a'+round%26))
go func(id string) {
defer wg.Done()
_, _ = l.Pin(gpuKey(id, "web"), 1)
}(taskID)
go func(id string) {
defer wg.Done()
l.ReleaseTask(id)
}(taskID)
}
wg.Wait()
// Invariant: every currently-pinned UUID appears in exactly one pinned entry
// and never simultaneously in free.
l.mu.Lock()
defer l.mu.Unlock()
freeSet := make(map[string]bool, len(l.free))
for _, u := range l.free {
if freeSet[u] {
t.Errorf("duplicate UUID %s in free list", u)
}
freeSet[u] = true
}
pinnedSet := make(map[string]bool)
for _, uuids := range l.pinned {
for _, u := range uuids {
if pinnedSet[u] {
t.Errorf("UUID %s pinned under more than one key", u)
}
if freeSet[u] {
t.Errorf("UUID %s is both free and pinned", u)
}
pinnedSet[u] = true
}
}
}
+40 -2
View File
@@ -7,16 +7,19 @@ import (
"testing"
"time"
handlers_ecs "github.com/mulgadc/spinifex/spinifex/handlers/ecs"
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
)
// fakeCP is a test double for the gateway control-plane. It records registers and
// task-state reports, and replays a scripted queue of poll responses.
// fakeCP is a test double for the gateway control-plane. It records registers,
// task-state reports and GPU reports, and replays a scripted queue of poll
// responses.
type fakeCP struct {
mu sync.Mutex
registers int
states []bus.TaskState
gpuReports []gpuReport
pollAcks [][]string
pollStopAcks [][]string
pollReplies [][]bus.Assign
@@ -27,6 +30,13 @@ type fakeCP struct {
submitErr bool
}
// gpuReport is one recorded ReportTaskGPU call.
type gpuReport struct {
cluster string
task string
containers []handlers_ecs.ContainerGPUReport
}
var _ controlPlane = (*fakeCP)(nil)
func (f *fakeCP) Register(_ identity) error {
@@ -66,6 +76,25 @@ func (f *fakeCP) PollAssignments(_, _ string, ackAssigns, ackStops []string) ([]
return out, stops, nil
}
func (f *fakeCP) ReportTaskGPU(cluster, task string, containers []handlers_ecs.ContainerGPUReport) error {
f.mu.Lock()
defer f.mu.Unlock()
f.gpuReports = append(f.gpuReports, gpuReport{cluster: cluster, task: task, containers: containers})
return nil
}
func (f *fakeCP) gpuReportsFor(taskID string) []gpuReport {
f.mu.Lock()
defer f.mu.Unlock()
var out []gpuReport
for _, r := range f.gpuReports {
if r.task == taskID {
out = append(out, r)
}
}
return out
}
func (f *fakeCP) registerCount() int {
f.mu.Lock()
defer f.mu.Unlock()
@@ -102,6 +131,15 @@ func testIdentity() identity {
}
}
// testIdentityWithGPUs is testIdentity seeded with discovered GPU UUIDs, so
// newAgent's ledger has real devices to pin from.
func testIdentityWithGPUs(uuids ...string) identity {
id := testIdentity()
id.Capacity.GPU = len(uuids)
id.Capacity.GPUIDs = uuids
return id
}
func TestRegistrar_Register(t *testing.T) {
cp := &fakeCP{}
if err := newRegistrar(cp, testIdentity()).Register(); err != nil {
+30
View File
@@ -6,12 +6,41 @@ import (
"sort"
containerd "github.com/containerd/containerd/v2/client"
"github.com/containerd/containerd/v2/pkg/cdi"
"github.com/containerd/containerd/v2/pkg/cio"
"github.com/containerd/containerd/v2/pkg/namespaces"
"github.com/containerd/containerd/v2/pkg/oci"
specs "github.com/opencontainers/runtime-spec/specs-go"
)
// cdiVendorGPU is the NVIDIA CDI vendor/class prefix for whole-GPU devices, per
// the NVIDIA CDI naming convention (nvidia.com/gpu=<uuid>).
const cdiVendorGPU = "nvidia.com/gpu"
// cdiDeviceNames maps a container's pinned GPU device UUIDs to CDI device
// names. Empty input (non-GPU container) returns nil so no CDI opt is added.
func cdiDeviceNames(uuids []string) []string {
if len(uuids) == 0 {
return nil
}
names := make([]string, len(uuids))
for i, id := range uuids {
names[i] = cdiVendorGPU + "=" + id
}
return names
}
// cdiSpecOpts returns the oci.SpecOpts needed to inject gpuIDs (via
// containerd's CDI machinery) as devices into a container's OCI spec. It
// returns nil for a non-GPU container so Run adds nothing to the opts list.
func cdiSpecOpts(gpuIDs []string) []oci.SpecOpts {
devices := cdiDeviceNames(gpuIDs)
if len(devices) == 0 {
return nil
}
return []oci.SpecOpts{cdi.WithCDIDevices(devices...)}
}
var _ Runner = (*containerdPuller)(nil)
// Run creates a container from an already-pulled image and starts its task. The
@@ -51,6 +80,7 @@ func (p *containerdPuller) Run(ctx context.Context, id string, spec RunSpec) (st
if len(spec.Env) > 0 {
specOpts = append(specOpts, oci.WithEnv(envSlice(spec.Env)))
}
specOpts = append(specOpts, cdiSpecOpts(spec.GPUIDs)...)
container, err := p.client.NewContainer(ctx, id,
containerd.WithNewSnapshot(id+"-snapshot", image),
@@ -0,0 +1,47 @@
package runtime
import (
"reflect"
"testing"
)
// cdiDeviceNames maps a container's pinned GPU UUIDs to CDI device names
// (nvidia.com/gpu=<uuid>); Run wires the result into oci.WithCDIDevices when
// non-empty, so this is the injection logic's unit-testable core.
func TestCDIDeviceNames(t *testing.T) {
cases := []struct {
name string
uuids []string
want []string
}{
{name: "empty", uuids: nil, want: nil},
{name: "single", uuids: []string{"GPU-aaaaaaaa-1111-2222-3333-444444444444"},
want: []string{"nvidia.com/gpu=GPU-aaaaaaaa-1111-2222-3333-444444444444"}},
{name: "multiple preserves order", uuids: []string{"GPU-aaa", "GPU-bbb"},
want: []string{"nvidia.com/gpu=GPU-aaa", "nvidia.com/gpu=GPU-bbb"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cdiDeviceNames(tc.uuids)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("cdiDeviceNames(%v) = %v, want %v", tc.uuids, got, tc.want)
}
})
}
}
// TestCDISpecOpts_Wiring confirms Run's specOpts list picks up exactly one CDI
// opt when GPUIDs is non-empty, and none for a non-GPU container — the wiring
// Run relies on (a live oci.Spec is needed to unit test the opt's effect).
func TestCDISpecOpts_Wiring(t *testing.T) {
if opts := cdiSpecOpts(nil); len(opts) != 0 {
t.Errorf("non-GPU container: want no spec opts, got %d", len(opts))
}
opts := cdiSpecOpts([]string{"GPU-aaa", "GPU-bbb"})
if len(opts) != 1 {
t.Fatalf("GPU container: want exactly one CDI spec opt, got %d", len(opts))
}
if opts[0] == nil {
t.Error("GPU container: spec opt is nil")
}
}
+6
View File
@@ -48,6 +48,12 @@ type RunSpec struct {
// (awsvpc task ENI). Empty keeps the container in the host (VM) netns —
// bridge/host mode behaviour.
NetnsPath string
// GPU is the whole-GPU count requested for this container.
GPU int
// GPUIDs are the ledger-pinned device UUIDs assigned to this container, one
// per GPU requested. Empty for non-GPU containers or if pinning fell short.
// The runner injects these as CDI devices into the OCI spec.
GPUIDs []string
}
// RunStatus is a finished container's outcome.
+48 -2
View File
@@ -8,6 +8,7 @@ import (
"time"
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
handlers_ecs "github.com/mulgadc/spinifex/spinifex/handlers/ecs"
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
)
@@ -124,12 +125,15 @@ func (a *Agent) runTask(ctx context.Context, as *bus.Assign) {
}
cid := containerID(as.TaskID, c.Name)
gpuIDs := a.pinContainerGPUs(as.TaskID, c.Name, c.GPU)
spec := ctrruntime.RunSpec{
Image: c.Image,
Command: c.Command,
Env: withCredEnv(c.Environment, credID),
Labels: taskLabels(as, c.Name),
NetnsPath: netnsPath,
GPU: c.GPU,
GPUIDs: gpuIDs,
}
id, err := a.runner.Run(ctx, cid, spec)
if err != nil {
@@ -138,7 +142,9 @@ func (a *Agent) runTask(ctx context.Context, as *bus.Assign) {
a.reportTaskState(as, bus.TaskStatusStopped, "container start failed: "+err.Error(), statuses)
return
}
statuses = append(statuses, bus.ContainerStatus{Name: c.Name, Status: bus.TaskStatusRunning, ContainerID: id})
statuses = append(statuses, bus.ContainerStatus{
Name: c.Name, Status: bus.TaskStatusRunning, ContainerID: id, GPUIDs: gpuIDs,
})
go a.waitContainer(ctx, as, c.Name, id)
}
@@ -234,8 +240,26 @@ func withCredEnv(env map[string]string, credID string) map[string]string {
return out
}
// pinContainerGPUs reserves n device UUIDs for a container from the local
// ledger, before the container is started so the runner can inject them as
// CDI devices. A short ledger (fewer free devices than requested, e.g.
// discovery found none) is logged and treated as best-effort: the container
// still runs without pinned UUIDs (and without GPU access) rather than
// failing the task.
func (a *Agent) pinContainerGPUs(taskID, containerName string, n int) []string {
if n <= 0 || a.gpu == nil {
return nil
}
uuids, err := a.gpu.Pin(gpuKey(taskID, containerName), n)
if err != nil {
slog.Warn("ecs-agent: gpu pin short", "task", taskID, "container", containerName, "err", err)
}
return uuids
}
// reportTaskState reports a task transition through the gateway's
// SubmitTaskStateChange action.
// SubmitTaskStateChange action, then (RUNNING with pinned GPUs) reports the
// per-container device UUIDs, and (STOPPED) releases them back to the ledger.
func (a *Agent) reportTaskState(as *bus.Assign, status, reason string, containers []bus.ContainerStatus) {
if a.cp == nil {
return
@@ -253,6 +277,28 @@ func (a *Agent) reportTaskState(as *bus.Assign, status, reason string, container
if err := a.cp.SubmitTaskState(msg); err != nil {
slog.Error("ecs-agent: report task-state failed", "task", as.TaskID, "err", err)
}
a.reportTaskGPUs(as.TaskID, containers)
if status == bus.TaskStatusStopped && a.gpu != nil {
a.gpu.ReleaseTask(as.TaskID)
}
}
// reportTaskGPUs sends the control plane the pinned device UUIDs for any
// container in the report that has them, so DescribeTasks can surface gpuIds.
// A no-op when nothing was pinned (the common non-GPU-task case).
func (a *Agent) reportTaskGPUs(taskID string, containers []bus.ContainerStatus) {
reports := make([]handlers_ecs.ContainerGPUReport, 0, len(containers))
for _, c := range containers {
if len(c.GPUIDs) > 0 {
reports = append(reports, handlers_ecs.ContainerGPUReport{Name: c.Name, GPUIDs: c.GPUIDs})
}
}
if len(reports) == 0 {
return
}
if err := a.cp.ReportTaskGPU(a.id.ClusterName, taskID, reports); err != nil {
slog.Error("ecs-agent: report task-gpu failed", "task", taskID, "err", err)
}
}
// containerID composes a containerd-valid container ID from a task + container.
+154
View File
@@ -70,6 +70,160 @@ func TestRunTask_PullRunReportsRunning(t *testing.T) {
}
}
// TestRunTask_GPUCarriedToRunSpec covers mulga-11opz (Epic C task C1): the
// AssignContainer's GPU count reaches the runtime RunSpec unchanged; a
// container with no GPU request keeps RunSpec.GPU at zero (regression).
func TestRunTask_GPUCarriedToRunSpec(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newRunAgent(cp, rt)
as := testAssign()
as.Containers = append(as.Containers, bus.AssignContainer{
Name: "trainer", Image: "registry/trainer:1", GPU: 2,
})
a.runTask(context.Background(), as)
if len(rt.Runs) != 2 {
t.Fatalf("expected two runs, got %+v", rt.Runs)
}
if rt.Runs[0].GPU != 0 {
t.Errorf("web container: want GPU=0, got %d", rt.Runs[0].GPU)
}
if rt.Runs[1].GPU != 2 {
t.Errorf("trainer container: want GPU=2, got %d", rt.Runs[1].GPU)
}
}
// TestRunTask_GPUPinsAndReportsUUIDs covers C3: a container's GPU request is
// pinned from the agent's local ledger, carried on the RUNNING report's
// ContainerStatus, and forwarded to the control plane via ReportTaskGPU.
func TestRunTask_GPUPinsAndReportsUUIDs(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa", "GPU-bbb"), cp, rt, rt, nil)
as := testAssign()
as.Containers = append(as.Containers, bus.AssignContainer{
Name: "trainer", Image: "registry/trainer:1", GPU: 1,
})
a.runTask(context.Background(), as)
st := cp.taskStates()
if len(st) == 0 || st[len(st)-1].LastStatus != bus.TaskStatusRunning {
t.Fatalf("want RUNNING final state, got %+v", st)
}
last := st[len(st)-1]
var trainer *bus.ContainerStatus
for i := range last.Containers {
if last.Containers[i].Name == "trainer" {
trainer = &last.Containers[i]
}
}
if trainer == nil || len(trainer.GPUIDs) != 1 {
t.Fatalf("trainer container missing pinned GPU UUID: %+v", last.Containers)
}
if trainer.GPUIDs[0] != "GPU-aaa" && trainer.GPUIDs[0] != "GPU-bbb" {
t.Errorf("unexpected pinned UUID %q", trainer.GPUIDs[0])
}
reports := cp.gpuReportsFor(as.TaskID)
if len(reports) != 1 {
t.Fatalf("want one ReportTaskGPU call, got %d", len(reports))
}
if reports[0].cluster != as.ClusterName {
t.Errorf("report cluster = %q, want %q", reports[0].cluster, as.ClusterName)
}
if len(reports[0].containers) != 1 || reports[0].containers[0].Name != "trainer" {
t.Errorf("report containers = %+v, want one trainer entry", reports[0].containers)
}
}
// TestRunTask_GPUIDsReachRunSpec covers C4: the ledger's pinned UUIDs for a
// container reach RunSpec.GPUIDs (not just the RunSpec.GPU count), so the
// runner can inject them as CDI devices; a non-GPU container's RunSpec
// carries no GPUIDs.
func TestRunTask_GPUIDsReachRunSpec(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa", "GPU-bbb"), cp, rt, rt, nil)
as := testAssign()
as.Containers = append(as.Containers, bus.AssignContainer{
Name: "trainer", Image: "registry/trainer:1", GPU: 1,
})
a.runTask(context.Background(), as)
if len(rt.Runs) != 2 {
t.Fatalf("expected two runs, got %+v", rt.Runs)
}
if len(rt.Runs[0].GPUIDs) != 0 {
t.Errorf("web container: want no GPUIDs, got %+v", rt.Runs[0].GPUIDs)
}
trainer := rt.Runs[1]
if len(trainer.GPUIDs) != 1 {
t.Fatalf("trainer container: want one pinned GPUID, got %+v", trainer.GPUIDs)
}
if trainer.GPUIDs[0] != "GPU-aaa" && trainer.GPUIDs[0] != "GPU-bbb" {
t.Errorf("unexpected pinned UUID %q", trainer.GPUIDs[0])
}
}
// A container with no GPU request never triggers a ReportTaskGPU call.
func TestRunTask_NoGPUSkipsReport(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newRunAgent(cp, rt)
as := testAssign()
a.runTask(context.Background(), as)
if reports := cp.gpuReportsFor(as.TaskID); len(reports) != 0 {
t.Errorf("want no GPU report for a non-GPU task, got %+v", reports)
}
}
// A GPU request exceeding the ledger's free devices (discovery found none, or
// fewer than requested) is best-effort: the container still runs, it just
// carries no pinned UUIDs and so no CDI devices are injected.
func TestRunTask_GPUShortLedgerStillRuns(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newRunAgent(cp, rt) // testIdentity() has no discovered GPU UUIDs
as := testAssign()
as.Containers = append(as.Containers, bus.AssignContainer{
Name: "trainer", Image: "registry/trainer:1", GPU: 2,
})
a.runTask(context.Background(), as)
if len(rt.Runs) != 2 {
t.Fatalf("expected both containers to run despite the short ledger, got %+v", rt.Runs)
}
if reports := cp.gpuReportsFor(as.TaskID); len(reports) != 0 {
t.Errorf("want no GPU report when nothing was pinned, got %+v", reports)
}
}
// stopTask releases the task's pinned GPU UUIDs back to the ledger so a later
// task on the same instance can reuse them.
func TestStopTask_ReleasesGPUsBackToLedger(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{Containers: []ctrruntime.Container{
{ID: "t-001-trainer", Running: true, Labels: map[string]string{
"mulga.ecs.taskID": "t-001", "mulga.ecs.containerName": "trainer",
}},
}}
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa"), cp, rt, rt, nil)
if _, err := a.gpu.Pin(gpuKey("t-001", "trainer"), 1); err != nil {
t.Fatalf("seed pin: %v", err)
}
if len(a.gpu.free) != 0 {
t.Fatalf("setup: want ledger fully pinned, free = %v", a.gpu.free)
}
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "bye"})
if len(a.gpu.free) != 1 {
t.Errorf("want the GPU released back to the ledger, free = %v", a.gpu.free)
}
}
// A pull failure reports STOPPED and never starts the container.
func TestRunTask_PullFailureReportsStopped(t *testing.T) {
cp := &fakeCP{}
+13 -6
View File
@@ -156,9 +156,10 @@ func emitAddonsTSV(out io.Writer, body []byte) error {
// recoveryDirective mirrors the gateway's RecoveryDirective wire shape; duplicated
// here to keep this tiny VM binary free of the handler package's dependency graph.
type recoveryDirective struct {
Epoch int64 `json:"epoch"`
Action string `json:"action"`
Snapshot string `json:"snapshot"`
Epoch int64 `json:"epoch"`
Action string `json:"action"`
Snapshot string `json:"snapshot"`
SnapshotRequired bool `json:"snapshotRequired"`
}
type internalRecoveryResponse struct {
@@ -166,8 +167,10 @@ type internalRecoveryResponse struct {
}
// emitRecoveryTSV decodes the recovery directive and writes a single
// epoch\taction\tsnapshot line the on-VM k3s-recovery agent consumes without a
// JSON parser. An unset action defaults to "none" (steady state).
// epoch\taction\tsnapshot\tsnapshotRequired line the on-VM k3s-recovery agent
// consumes without a JSON parser. An unset action defaults to "none" (steady
// state); snapshotRequired is 1 when the snapshot MUST restore (fresh DR seed
// aborts boot on fetch failure) else 0.
func emitRecoveryTSV(out io.Writer, body []byte) error {
var resp internalRecoveryResponse
if err := json.Unmarshal(body, &resp); err != nil {
@@ -177,7 +180,11 @@ func emitRecoveryTSV(out io.Writer, body []byte) error {
if d.Action == "" {
d.Action = "none"
}
if _, err := fmt.Fprintf(out, "%d\t%s\t%s\n", d.Epoch, d.Action, d.Snapshot); err != nil {
required := "0"
if d.SnapshotRequired {
required = "1"
}
if _, err := fmt.Fprintf(out, "%d\t%s\t%s\t%s\n", d.Epoch, d.Action, d.Snapshot, required); err != nil {
return err
}
return nil
+1 -1
View File
@@ -539,7 +539,7 @@ func installBootloader(disk string) error {
); biosErr != nil {
if efiErr != nil {
// Both targets failed — the system will not boot.
return fmt.Errorf("both bootloader targets failed (EFI: %v; BIOS: %w)", efiErr, biosErr)
return fmt.Errorf("both bootloader targets failed (EFI: %w; BIOS: %w)", efiErr, biosErr)
}
return biosErr
}
+370 -181
View File
@@ -60,13 +60,16 @@ var natsConfTemplate string
//go:embed templates/predastore-multinode.toml
var predastoreMultiNodeTemplate string
//go:embed templates/northstar.toml
var northstarTomlTemplate string
var supportedArchs = map[string]bool{
"x86_64": true,
"aarch64": true, // alias for arm64
"arm64": true,
}
// TODO: Confirm suppported platform types
// TODO: Confirm suppported platform types.
var supportedPlatforms = map[string]bool{
"Linux/UNIX": true,
"Windows": true,
@@ -334,7 +337,6 @@ func init() {
adminInitCmd.Flags().String("external-gateway", "", "WAN gateway IP (auto-detected from default route)")
adminInitCmd.Flags().String("gateway-ip", "", "OVN gateway router's external IP for SNAT (default: pool range_start for pool mode, required for nat mode without DHCP)")
adminInitCmd.Flags().Int("external-prefix-len", 24, "External pool subnet prefix length (auto-detected)")
adminInitCmd.Flags().Bool("no-external", false, "Disable external networking (overlay-only, no internet for VMs)")
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")
@@ -386,6 +388,27 @@ func init() {
}
}
// amiVolumeSizeGiB returns the smallest whole GiB that still holds sizeBytes.
//
// Rounding up is load-bearing. The image is copied into a root volume of
// exactly this size, so a volume smaller than the image truncates it and the
// guest comes up with no root partition — it stalls on the root device until
// systemd drops it to an emergency shell, and nothing on the way there reports
// an undersized volume. Flooring (plain integer division) undersizes every
// image that is not an exact multiple of a GiB, which is why this went unseen
// while every system image happened to be a round 16 GiB.
//
// The downstream guard cannot cover for a wrong answer here: floorVolumeSizeToAMI
// raises a caller's requested size to this value, so it inherits the mistake
// rather than catching it.
func amiVolumeSizeGiB(sizeBytes int64) uint64 {
const bytesPerGiB = 1024 * 1024 * 1024
if sizeBytes <= 0 {
return 0
}
return utils.SafeInt64ToUint64((sizeBytes + bytesPerGiB - 1) / bytesPerGiB)
}
func runimagesImportCmd(cmd *cobra.Command, args []string) {
var image utils.Images
@@ -592,7 +615,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
manifest.AMIMetadata.RootDeviceType = "ebs"
manifest.AMIMetadata.Virtualization = "hvm"
manifest.AMIMetadata.ImageOwnerAlias = "system"
manifest.AMIMetadata.VolumeSizeGiB = utils.SafeInt64ToUint64(imageStat.Size() / 1024 / 1024 / 1024)
manifest.AMIMetadata.VolumeSizeGiB = amiVolumeSizeGiB(imageStat.Size())
manifest.AMIMetadata.BootMode = image.BootMode
manifest.AMIMetadata.Distro = image.Distro
manifest.AMIMetadata.DistroFamily = utils.DistroFamily(image.Distro)
@@ -656,6 +679,12 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
os.Exit(1)
}
// Import is an interactive command whose output is a progress bar, so the
// volume's routine mount chatter is noise here: a fresh import has no prior
// state, making the "no state found" / 404 lines expected rather than
// notable. Scoping the logger to this VB (New copies it onto the backend
// too) keeps it off the process-wide default. Errors still surface, both
// through this logger and as the returned error the caller prints.
vbConfig := viperblock.VB{
VolumeName: volumeId,
VolumeSize: utils.SafeInt64ToUint64(imageStat.Size()),
@@ -668,6 +697,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
VolumeConfig: manifest,
MasterKey: mkey,
EncryptionEnabled: mkey != nil,
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})),
}
// Bake the deployment CA into the image trust store so a stock cloud image
@@ -938,7 +968,7 @@ func runimagesPromoteCmd(cmd *cobra.Command, args []string) {
fmt.Printf("✅ Promoted %s to system image (owner: %s).\n", imageID, admin.SystemOwnerAlias)
}
// List remote images available
// List remote images available.
func runimagesListCmd(cmd *cobra.Command, args []string) {
//fmt.Println(availableImages)
@@ -965,7 +995,7 @@ func runimagesListCmd(cmd *cobra.Command, args []string) {
pterm.Println("spx admin images import --name <image-name>")
}
// TODO: Move all logic to a module, use minimal application logic in viper commands
// TODO: Move all logic to a module, use minimal application logic in viper commands.
func runAdminInit(cmd *cobra.Command, args []string) {
if os.Getuid() != 0 {
fmt.Fprintln(os.Stderr, "⚠️ Warning: 'spx admin init' is not running as root.")
@@ -1018,7 +1048,6 @@ func runAdminInit(cmd *cobra.Command, args []string) {
externalGateway, _ := cmd.Flags().GetString("external-gateway")
externalPrefixLen, _ := cmd.Flags().GetInt("external-prefix-len")
gatewayIP, _ := cmd.Flags().GetString("gateway-ip")
noExternal, _ := cmd.Flags().GetBool("no-external")
gpuPassthrough, _ := cmd.Flags().GetBool("gpu-passthrough")
ipsecEnabled, _ := cmd.Flags().GetBool("ipsec")
@@ -1051,59 +1080,56 @@ func runAdminInit(cmd *cobra.Command, args []string) {
// Auto-detect network topology
var poolStart, poolEnd string
var detectedNet *admin.DetectedNetwork
if !noExternal {
detected, err := admin.DetectNetwork()
if err != nil {
fmt.Fprintf(os.Stderr, "⚠️ Network auto-detection failed: %v\n", err)
fmt.Fprintf(os.Stderr, " Use --no-external to skip, or specify flags manually.\n")
detected, err := admin.DetectNetwork()
if err != nil {
fmt.Fprintf(os.Stderr, "⚠️ Network auto-detection failed: %v\n", err)
fmt.Fprintf(os.Stderr, " Use --external-mode=nat for outbound-only VMs on a non-bridgeable uplink, or specify --external-* flags manually.\n")
} else {
detectedNet = detected
// Print detected topology
fmt.Println("\n🔍 Detected network topology:")
fmt.Printf(" %-14s %-18s %-20s %-16s %s\n", "Interface", "IP", "Subnet", "Gateway", "Role")
for _, iface := range detected.Interfaces {
gw := "—"
if iface.Gateway != "" {
gw = iface.Gateway
}
fmt.Printf(" %-14s %-18s %-20s %-16s %s\n", iface.Name, iface.IP, iface.Subnet, gw, strings.ToUpper(iface.Role))
}
if detected.LANCount == 0 {
fmt.Println("\n Mode: single-NIC (veth-bridged external)")
} else {
detectedNet = detected
fmt.Printf("\n Mode: %d LAN + 1 WAN (veth-bridged external)\n", detected.LANCount)
}
// Print detected topology
fmt.Println("\n🔍 Detected network topology:")
fmt.Printf(" %-14s %-18s %-20s %-16s %s\n", "Interface", "IP", "Subnet", "Gateway", "Role")
for _, iface := range detected.Interfaces {
gw := "—"
if iface.Gateway != "" {
gw = iface.Gateway
}
fmt.Printf(" %-14s %-18s %-20s %-16s %s\n", iface.Name, iface.IP, iface.Subnet, gw, strings.ToUpper(iface.Role))
// Apply auto-detected values when flags not explicitly set
if detected.WAN != nil {
if externalIface == "" {
externalIface = detected.WAN.Name
}
if detected.LANCount == 0 {
fmt.Println("\n Mode: single-NIC (veth-bridged external)")
} else {
fmt.Printf("\n Mode: %d LAN + 1 WAN (veth-bridged external)\n", detected.LANCount)
if externalGateway == "" {
externalGateway = detected.WAN.Gateway
}
if !cmd.Flags().Changed("external-prefix-len") {
externalPrefixLen = detected.WAN.PrefixLen
}
// Apply auto-detected values when flags not explicitly set
if detected.WAN != nil {
if externalIface == "" {
externalIface = detected.WAN.Name
}
if externalGateway == "" {
externalGateway = detected.WAN.Gateway
}
if !cmd.Flags().Changed("external-prefix-len") {
externalPrefixLen = detected.WAN.PrefixLen
}
// Default mode: always "pool". Source defaults to "static"; if
// --external-pool is omitted the validator below will error with
// a SuggestPoolRange hint.
if externalMode == "" && !cmd.Flags().Changed("external-mode") {
if isNonBridgeableUplink(detected.WAN.Name) {
fmt.Fprintf(os.Stderr, "\n❌ Detected WAN interface %s cannot be bridged (WiFi/cellular/PPP).\n", detected.WAN.Name)
fmt.Fprintf(os.Stderr, " Use routed NAT mode instead (outbound-only VM networking):\n")
fmt.Fprintf(os.Stderr, " ./scripts/setup-ovn.sh --management --nat-uplink\n")
fmt.Fprintf(os.Stderr, " spx admin init --external-mode=nat\n")
os.Exit(1)
}
externalMode = "pool"
// Default mode: always "pool". Source defaults to "static"; if
// --external-pool is omitted the validator below will error with
// a SuggestPoolRange hint.
if externalMode == "" && !cmd.Flags().Changed("external-mode") {
if isNonBridgeableUplink(detected.WAN.Name) {
fmt.Fprintf(os.Stderr, "\n❌ Detected WAN interface %s cannot be bridged (WiFi/cellular/PPP).\n", detected.WAN.Name)
fmt.Fprintf(os.Stderr, " Use routed NAT mode instead (outbound-only VM networking):\n")
fmt.Fprintf(os.Stderr, " ./scripts/setup-ovn.sh --management --nat-uplink\n")
fmt.Fprintf(os.Stderr, " spx admin init --external-mode=nat\n")
os.Exit(1)
}
externalMode = "pool"
}
}
}
// Validate external networking flags
if externalMode != "" && externalMode != "pool" && externalMode != "nat" {
fmt.Fprintf(os.Stderr, "❌ Error: --external-mode must be 'pool', 'nat', or empty, got: %s\n", externalMode)
@@ -1180,6 +1206,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
externalPools = append(externalPools, admin.PoolData{
Name: host.NATTransitPoolName, Gateway: host.NATTransitGatewayIP,
PrefixLen: 24, DNSServers: dnsServers,
GwLrpRangeStart: host.NATTransitGwLrpStart, GwLrpRangeEnd: host.NATTransitGwLrpEnd,
})
if natPublicPool {
// WiFi/WWAN uplinks drop frames with foreign source MACs, so DHCP
@@ -1208,8 +1235,8 @@ func runAdminInit(cmd *cobra.Command, args []string) {
os.Exit(1)
}
// Resolve the off-host advertise IP. DetectNetwork may have been skipped
// when --no-external is set; detect lazily if we need the WAN IP.
// 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
@@ -1266,66 +1293,91 @@ func runAdminInit(cmd *cobra.Command, args []string) {
}
fmt.Printf("✅ Created config directory: %s\n", configDir)
// Generate system credentials (for service-to-service auth in config files)
accessKey, err := admin.GenerateAWSAccessKey()
// Identity and crypto material is load-or-generate: a fresh install mints a
// new identity bundle, but a --force re-init preserves the existing one so
// data sealed under it (NATS KV secrets, sealed fragments, encrypted volumes)
// stays decryptable.
masterKey, masterKeyExisted, err := ensureMasterKey(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating access key: %v\n", err)
os.Exit(1)
}
secretKey, err := admin.GenerateAWSSecretKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating secret key: %v\n", err)
fmt.Fprintf(os.Stderr, "Error preparing IAM master key: %v\n", err)
os.Exit(1)
}
accountID := admin.SystemAccountID()
// Generate IAM master key (AES-256, used to encrypt secrets in NATS KV)
masterKey, err := handlers_iam.GenerateMasterKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating IAM master key: %v\n", err)
os.Exit(1)
}
bootstrapDir := filepath.Join(spxRoot, "awsgw")
bootstrapResult, err := writeBootstrapFiles(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error writing bootstrap files: %v\n", err)
os.Exit(1)
}
if err := writeSystemCredentials(configDir, accessKey, secretKey); err != nil {
fmt.Fprintf(os.Stderr, "Error writing system credentials: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated IAM master key")
fmt.Printf(" Master key: %s\n", filepath.Join(configDir, "master.key"))
fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
fmt.Printf(" System creds: %s\n", filepath.Join(configDir, "system-credentials.json"))
// Predastore encryption key is per-node and never transmitted; generate
// it locally now so the service has it on first start.
var accessKey, secretKey, adminAccessKey, adminSecretKey string
if masterKeyExisted {
// Preserve path: reuse the existing identity. The system credentials must
// match what seeded the NATS KV `system` secret, so load them rather than
// mint new ones. The admin credentials are not recovered: bootstrap.json is
// consumed and deleted by awsgw after first boot, and the operator's copy
// already lives in ~/.aws/credentials. Leaving them empty makes
// finalizeNodeSetup refresh only ~/.aws/config (endpoint/CA for a changed
// bind IP), not the credentials.
accessKey, secretKey, err = loadSystemCredentials(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading preserved system credentials: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Preserved existing identity (master key, credentials and CA unchanged)")
fmt.Printf(" Master key: %s\n", filepath.Join(configDir, "master.key"))
} else {
// Fresh install: mint system + admin credentials and seed the bootstrap files.
accessKey, err = admin.GenerateAWSAccessKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating access key: %v\n", err)
os.Exit(1)
}
secretKey, err = admin.GenerateAWSSecretKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating secret key: %v\n", err)
os.Exit(1)
}
bootstrapResult, err := writeBootstrapFiles(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error writing bootstrap files: %v\n", err)
os.Exit(1)
}
if err := writeSystemCredentials(configDir, accessKey, secretKey); err != nil {
fmt.Fprintf(os.Stderr, "Error writing system credentials: %v\n", err)
os.Exit(1)
}
adminAccessKey = bootstrapResult.AdminAccessKey
adminSecretKey = bootstrapResult.AdminSecretKey
fmt.Println("\n🔐 Generated IAM master key")
fmt.Printf(" Master key: %s\n", filepath.Join(configDir, "master.key"))
fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
fmt.Printf(" System creds: %s\n", filepath.Join(configDir, "system-credentials.json"))
}
// Predastore encryption key is per-node and never transmitted; load-or-generate
// so the service has it on first start and a re-init keeps sealed fragments.
predastoreKeyPath, err := writePredastoreEncryptionKey(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating predastore encryption key: %v\n", err)
fmt.Fprintf(os.Stderr, "Error preparing predastore encryption key: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated predastore encryption key (per-node, never transmitted)")
fmt.Println("\n🔐 Predastore encryption key ready (per-node, never transmitted)")
fmt.Printf(" Key: %s\n", predastoreKeyPath)
// Viperblock at-rest encryption key is cluster-wide; on a single-node init
// there are no joiners, so generate it locally and enable encryption by
// default for all volumes created on this install.
viperblockKeyPath, err := writeViperblockEncryptionKey(configDir)
// Viperblock at-rest encryption key is cluster-wide; load-or-generate so a
// re-init keeps the existing key (and its sealed volumes). The bytes feed the
// multi-node leader's key distribution below.
viperblockKey, viperblockKeyPath, err := ensureViperblockEncryptionKey(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating viperblock encryption key: %v\n", err)
fmt.Fprintf(os.Stderr, "Error preparing viperblock encryption key: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated viperblock at-rest encryption key")
fmt.Println("\n🔐 Viperblock at-rest encryption key ready")
fmt.Printf(" Key: %s\n", viperblockKeyPath)
fmt.Printf("\n🔑 Generated admin credentials (save these — they won't be shown again):\n")
fmt.Printf(" Access Key: %s\n", bootstrapResult.AdminAccessKey)
fmt.Printf(" Secret Key: %s\n", bootstrapResult.AdminSecretKey)
fmt.Printf(" Account: %s (%s)\n", admin.DefaultAccountName(), admin.DefaultAccountID())
fmt.Printf(" AWS Profile: spinifex\n")
if !masterKeyExisted {
fmt.Printf("\n🔑 Generated admin credentials (save these — they won't be shown again):\n")
fmt.Printf(" Access Key: %s\n", adminAccessKey)
fmt.Printf(" Secret Key: %s\n", adminSecretKey)
fmt.Printf(" Account: %s (%s)\n", admin.DefaultAccountName(), admin.DefaultAccountID())
fmt.Printf(" AWS Profile: spinifex\n")
}
// Generate SSL certificates (with bind IP in SANs for multi-node support)
certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP, region, config.DefaultAWSInternalSuffix)
@@ -1358,6 +1410,29 @@ func runAdminInit(cmd *cobra.Command, args []string) {
}
spxRoot = filepath.Clean(spxRoot)
// Generate dedicated, bucket-scoped credentials for the northstar DNS
// service. Rendered into predastore.toml ([[auth]]) and northstar.toml so
// the resolver reads zone files read-only from its own S3 bucket.
//
// Generated above the multi-node dispatch because the pair is cluster-wide:
// a node's predastore only honours the keys rendered into its own config, so
// every node must present this same pair to read the distributed zone bucket.
northstarAccessKey, err := admin.GenerateAWSAccessKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating northstar access key: %v\n", err)
os.Exit(1)
}
northstarSecretKey, err := admin.GenerateAWSSecretKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating northstar secret key: %v\n", err)
os.Exit(1)
}
northstarCreds := admin.NorthstarCredentials{
AccessKey: northstarAccessKey,
SecretKey: northstarSecretKey,
Bucket: admin.NorthstarBucketName,
}
// Determine if this is a multi-node formation. Operator intent comes from
// --nodes, not from whether --bind was left at the 0.0.0.0 default.
isMultiNode := nodes >= 2
@@ -1391,9 +1466,10 @@ func runAdminInit(cmd *cobra.Command, args []string) {
networkConfig.BootstrapSubnetCidr = handlers_ec2_vpc.DefaultSubnetCidr
}
runAdminInitMultiNode(cmd, accessKey, secretKey, accountID, natsToken, clusterName,
runAdminInitMultiNode(cmd, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey,
masterKey, viperblockKey, natsToken, clusterName,
configDir, spxRoot, certPath, region, az, node, bindIP, advertiseIP, clusterBind, email,
port, nodes, formationTimeoutStr, tokenTTLStr, services, networkConfig)
port, nodes, formationTimeoutStr, tokenTTLStr, services, networkConfig, northstarCreds)
return
}
@@ -1410,6 +1486,10 @@ func runAdminInit(cmd *cobra.Command, args []string) {
portStr := strconv.Itoa(port)
// The keys are cluster-wide and generated above the dispatch; the config path
// is node-local, so it is derived here from this node's own config dirs.
northstarConfigPath := filepath.Join(dirs.Northstar, "northstar.toml")
// Parse multi-node predastore configuration (legacy flag-based approach for single-node)
var predastoreNodeID int
if predastoreNodesStr != "" {
@@ -1439,7 +1519,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
}
// Generate multi-node predastore.toml
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP, compactionInterval)
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP, compactionInterval, northstarCreds)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err)
os.Exit(1)
@@ -1504,6 +1584,14 @@ func runAdminInit(cmd *cobra.Command, args []string) {
IPSecEnabled: ipsecEnabled,
EncryptionKeyFile: viperblockKeyPath,
NorthstarAccessKey: northstarCreds.AccessKey,
NorthstarSecretKey: northstarCreds.SecretKey,
NorthstarBucket: northstarCreds.Bucket,
NorthstarDefaultDomain: admin.NorthstarDefaultDomain,
NorthstarInternalDomain: admin.NorthstarInternalDomain,
NorthstarConfigPath: northstarConfigPath,
PoolDNSServers: dnsServers,
}
// Print external networking summary
@@ -1543,7 +1631,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
os.Exit(1)
}
finalizeNodeSetup(spxRoot, certPath, bootstrapResult.AdminAccessKey, bootstrapResult.AdminSecretKey, region, bindIP, advertiseIP)
finalizeNodeSetup(spxRoot, certPath, adminAccessKey, adminSecretKey, region, bindIP)
// Write node.conf so spx admin banner works on source installs (not just ISO).
nodeHostname, _ := os.Hostname()
@@ -1574,9 +1662,15 @@ func runAdminInit(cmd *cobra.Command, args []string) {
// runAdminInitMultiNode handles the multi-node formation path for admin init.
// It starts a formation server, registers this node, waits for all nodes to join,
// then generates configs with complete cluster topology.
func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, natsToken, clusterName,
//
// The northstar credentials are generated by the caller and provision this
// node's predastore with the zone bucket. The same pair is distributed to
// joiners, since each node's predastore only honours the keys in its own config.
func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey string,
masterKey, viperblockKey []byte, natsToken, clusterName,
configDir, spxRoot, certPath, region, az, node, bindIP, advertiseIP, clusterBind, email string,
port, expectedNodes int, formationTimeoutStr, tokenTTLStr string, services []string, networkConfig *formation.NetworkConfig) {
port, expectedNodes int, formationTimeoutStr, tokenTTLStr string, services []string, networkConfig *formation.NetworkConfig,
northstarCreds admin.NorthstarCredentials) {
formationTimeout, err := time.ParseDuration(formationTimeoutStr)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error: Invalid --formation-timeout: %v\n", err)
@@ -1601,47 +1695,11 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
os.Exit(1)
}
// Generate IAM master key for the cluster
masterKey, err := handlers_iam.GenerateMasterKey()
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error generating IAM master key: %v\n", err)
os.Exit(1)
}
bootstrapDir := filepath.Join(spxRoot, "awsgw")
bootstrapResult, err := writeBootstrapFiles(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error writing bootstrap files: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated IAM master key")
fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
// Predastore encryption key is per-node and never distributed via the
// formation server. Generate only the leader's own key here; each
// joiner generates its own during `spx admin join`.
predastoreKeyPath, err := writePredastoreEncryptionKey(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error generating predastore encryption key: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated predastore encryption key (per-node, never transmitted)")
fmt.Printf(" Key: %s\n", predastoreKeyPath)
// Viperblock at-rest encryption key is cluster-wide and shared: the leader
// generates it once and distributes it to joiners via the formation server
// so a volume sealed on any node can be opened on any other.
viperblockKey, err := handlers_iam.GenerateMasterKey()
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error generating viperblock encryption key: %v\n", err)
os.Exit(1)
}
viperblockKeyPath, err := saveViperblockEncryptionKey(configDir, viperblockKey)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error saving viperblock encryption key: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated viperblock at-rest encryption key (cluster-wide, shared with joiners)")
fmt.Printf(" Key: %s\n", viperblockKeyPath)
// Identity, predastore and viperblock keys were prepared load-or-generate by
// the caller: reuse them here so a re-init never rotates cluster crypto and
// the leader distributes the same viperblock key to joiners. The path is
// deterministic and already written on disk.
viperblockKeyPath := filepath.Join(configDir, "viperblock", "encryption.key")
// Read CA cert/key for distribution to joining nodes
caCertData, err := os.ReadFile(filepath.Join(configDir, "ca.pem"))
@@ -1662,8 +1720,14 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
NatsToken: natsToken,
ClusterName: clusterName,
Region: region,
AdminAccessKey: bootstrapResult.AdminAccessKey,
AdminSecretKey: bootstrapResult.AdminSecretKey,
AdminAccessKey: adminAccessKey,
AdminSecretKey: adminSecretKey,
// Joiners provision their own predastore with this pair, so every
// node's resolver can read the distributed zone bucket via its local
// endpoint. The bucket is a constant, so it is derived node-side.
NorthstarAccessKey: northstarCreds.AccessKey,
NorthstarSecretKey: northstarCreds.SecretKey,
}
fs := formation.NewFormationServer(expectedNodes, creds, string(caCertData), string(caKeyData), networkConfig, joinToken, tokenTTL)
@@ -1735,11 +1799,15 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
portStr := strconv.Itoa(port)
// The keys are cluster-wide and generated above the dispatch; the config path
// is node-local, so it is derived here from this node's own config dirs.
northstarConfigPath := filepath.Join(dirs.Northstar, "northstar.toml")
// Generate multi-node predastore config
var predastoreNodeID int
hasPredastoreConfig := len(predastoreNodes) >= 2
if hasPredastoreConfig {
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP, compactionInterval)
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP, compactionInterval, northstarCreds)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err)
os.Exit(1)
@@ -1779,12 +1847,19 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
PredastoreNodeID: predastoreNodeID,
CompactionIntervalSeconds: compactionInterval,
Services: services,
RemoteNodes: buildRemoteNodes(allNodes, node),
RemoteNodes: buildRemoteNodes(allNodes, node, northstarConfigPath),
OperatorEmail: email,
EncryptionKeyFile: viperblockKeyPath,
NorthstarAccessKey: northstarCreds.AccessKey,
NorthstarSecretKey: northstarCreds.SecretKey,
NorthstarBucket: northstarCreds.Bucket,
NorthstarDefaultDomain: admin.NorthstarDefaultDomain,
NorthstarInternalDomain: admin.NorthstarInternalDomain,
NorthstarConfigPath: northstarConfigPath,
// Multi-endpoint OVN NB/SB list across the RAFT quorum; the init node's
// own address leads, the rest provide failover.
OVNNBAddr: ovnNBAddr,
@@ -1800,7 +1875,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
os.Exit(1)
}
finalizeNodeSetup(spxRoot, certPath, bootstrapResult.AdminAccessKey, bootstrapResult.AdminSecretKey, region, bindIP, advertiseIP)
finalizeNodeSetup(spxRoot, certPath, adminAccessKey, adminSecretKey, region, bindIP)
// Keep formation server running briefly so joining nodes can fetch complete status
fmt.Println("\n⏳ Waiting for joining nodes to fetch cluster data...")
@@ -2187,12 +2262,15 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
portStr := strconv.Itoa(port)
northstarCreds, northstarConfigPath := northstarFromFormation(creds, dirs)
// Generate multi-node predastore config
var predastoreNodeID int
hasPredastoreConfig := len(predastoreNodes) >= 2
if hasPredastoreConfig {
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, creds.AccessKey, creds.SecretKey, creds.Region, creds.NatsToken, configDir, bindIP, compactionInterval)
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, creds.AccessKey, creds.SecretKey, creds.Region, creds.NatsToken, configDir, bindIP, compactionInterval,
northstarCreds)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err)
os.Exit(1)
@@ -2236,12 +2314,19 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
PredastoreNodeID: predastoreNodeID,
CompactionIntervalSeconds: compactionInterval,
Services: services,
RemoteNodes: buildRemoteNodes(statusResp.Nodes, node),
RemoteNodes: buildRemoteNodes(statusResp.Nodes, node, northstarConfigPath),
OperatorEmail: email,
EncryptionKeyFile: viperblockKeyPath,
NorthstarAccessKey: northstarCreds.AccessKey,
NorthstarSecretKey: northstarCreds.SecretKey,
NorthstarBucket: northstarCreds.Bucket,
NorthstarDefaultDomain: admin.NorthstarDefaultDomain,
NorthstarInternalDomain: admin.NorthstarInternalDomain,
NorthstarConfigPath: northstarConfigPath,
// Multi-endpoint OVN NB/SB list across the RAFT quorum so the client
// fails over instead of pinning to a single init node.
OVNNBAddr: ovnNBAddr,
@@ -2257,7 +2342,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
os.Exit(1)
}
finalizeNodeSetup(dataDir, caCertPath, creds.AdminAccessKey, creds.AdminSecretKey, creds.Region, bindIP, advertiseIP)
finalizeNodeSetup(dataDir, caCertPath, creds.AdminAccessKey, creds.AdminSecretKey, creds.Region, bindIP)
// Print cluster summary
fmt.Println("\n🎉 Node successfully joined cluster!")
@@ -2298,7 +2383,16 @@ func resolveAdvertiseIP(bindIP, advertiseFlag string, detected *admin.DetectedNe
// buildRemoteNodes converts formation NodeInfo into RemoteNode entries,
// excluding the local node. This puts all cluster members into spinifex.toml
// so config is the source of truth for expected cluster membership.
func buildRemoteNodes(allNodes map[string]formation.NodeInfo, localNode string) []admin.RemoteNode {
//
// northstarConfigPath is the local node's own path, republished for every peer:
// every node in a formed cluster runs northstar, and the seed set must be
// identical on all of them or the base zone's NS records get pinned to whichever
// node wins the create-if-absent race. --config-dir is per-node and does not
// cross the wire, so the value is accurate whenever nodes share a config dir and
// inert when they do not — only its emptiness is ever read back. Passing it
// empty (no credentials distributed) leaves peers with no stanza, so no node is
// advertised as a resolver it cannot be.
func buildRemoteNodes(allNodes map[string]formation.NodeInfo, localNode, northstarConfigPath string) []admin.RemoteNode {
var remote []admin.RemoteNode
for name, n := range allNodes {
if name == localNode {
@@ -2311,11 +2405,12 @@ func buildRemoteNodes(allNodes map[string]formation.NodeInfo, localNode string)
host = n.BindIP
}
remote = append(remote, admin.RemoteNode{
Name: name,
Host: host,
Region: n.Region,
AZ: n.AZ,
Services: n.Services,
Name: name,
Host: host,
Region: n.Region,
AZ: n.AZ,
Services: n.Services,
NorthstarConfigPath: northstarConfigPath,
})
}
sort.Slice(remote, func(i, j int) bool {
@@ -2531,6 +2626,7 @@ type configDirs struct {
Viperblock string
NATS string
Spinifex string
Northstar string
}
// createConfigSubdirs creates the standard config subdirectories under configDir.
@@ -2541,8 +2637,9 @@ func createConfigSubdirs(configDir string) (configDirs, error) {
Viperblock: filepath.Join(configDir, "viperblock"),
NATS: filepath.Join(configDir, "nats"),
Spinifex: filepath.Join(configDir, "spinifex"),
Northstar: filepath.Join(configDir, "northstar"),
}
for _, dir := range []string{dirs.AWSGW, dirs.Predastore, dirs.Viperblock, dirs.NATS, dirs.Spinifex} {
for _, dir := range []string{dirs.AWSGW, dirs.Predastore, dirs.Viperblock, dirs.NATS, dirs.Spinifex, dirs.Northstar} {
if err := os.MkdirAll(dir, 0700); err != nil {
return configDirs{}, fmt.Errorf("create directory %s: %w", dir, err)
}
@@ -2550,14 +2647,55 @@ func createConfigSubdirs(configDir string) (configDirs, error) {
return dirs, nil
}
// northstarFromFormation derives a joining node's northstar credentials and
// node-local config path from the cluster-wide pair distributed at formation.
//
// The keys cross the wire because a node's predastore only honours the keys in
// its own config, so every node must present the same pair to read the
// distributed zone bucket. The bucket name and config path are node-local
// constants, so they are derived here rather than carried.
//
// A leader that predates credential distribution sends no keys. That yields a
// zero pair and an empty path, so the node renders no northstar config at all
// rather than a resolver holding a key its own predastore would reject.
func northstarFromFormation(creds *formation.SharedCredentials, dirs configDirs) (admin.NorthstarCredentials, string) {
if creds.NorthstarAccessKey == "" || creds.NorthstarSecretKey == "" {
return admin.NorthstarCredentials{}, ""
}
return admin.NorthstarCredentials{
AccessKey: creds.NorthstarAccessKey,
SecretKey: creds.NorthstarSecretKey,
Bucket: admin.NorthstarBucketName,
}, filepath.Join(dirs.Northstar, "northstar.toml")
}
// generateAndWriteConfigs renders the standard config files (spinifex.toml,
// awsgw.toml, nats.conf, and optionally predastore.toml) from templates.
func generateAndWriteConfigs(dirs configDirs, spinifexTomlPath string, settings admin.ConfigSettings, skipPredastore bool) error {
// A one-sided pair must disable Northstar wholesale. Rendering only the
// public stanza or only the secret file advertises a resolver that cannot run.
northstarEnabled := settings.NorthstarAccessKey != "" && settings.NorthstarSecretKey != ""
if !northstarEnabled {
settings.NorthstarAccessKey = ""
settings.NorthstarSecretKey = ""
settings.NorthstarBucket = ""
settings.NorthstarConfigPath = ""
}
configs := []admin.ConfigFile{
{Name: "spinifex.toml", Path: spinifexTomlPath, Template: spinifexTomlTemplate},
{Name: filepath.Join(dirs.AWSGW, "awsgw.toml"), Path: filepath.Join(dirs.AWSGW, "awsgw.toml"), Template: awsgwTomlTemplate},
{Name: filepath.Join(dirs.NATS, "nats.conf"), Path: filepath.Join(dirs.NATS, "nats.conf"), Template: natsConfTemplate},
}
// northstar.toml is only rendered when scoped DNS credentials were
// provisioned. A cluster formed by a leader that predates their distribution
// leaves the keys empty, yielding no northstar config rather than a partial
// one pointing at a bucket the local predastore does not serve.
if northstarEnabled {
configs = append(configs, admin.ConfigFile{
Name: filepath.Join(dirs.Northstar, "northstar.toml"), Path: filepath.Join(dirs.Northstar, "northstar.toml"), Template: northstarTomlTemplate,
})
}
if !skipPredastore {
configs = append(configs, admin.ConfigFile{
Name: filepath.Join(dirs.Predastore, "predastore.toml"), Path: filepath.Join(dirs.Predastore, "predastore.toml"), Template: predastoreTomlTemplate,
@@ -2567,12 +2705,10 @@ func generateAndWriteConfigs(dirs configDirs, spinifexTomlPath string, settings
}
// finalizeNodeSetup configures AWS credentials, creates service directories,
// and sets ownership when running as root. advertiseIP is the off-host dial
// target for this node; it is threaded through SetupAWSCredentials's wanIP
// param for a future --operator-endpoint flag (see SetupAWSCredentials doc).
func finalizeNodeSetup(dataDir, certPath, adminAccessKey, adminSecretKey, region, bindIP, advertiseIP string) {
// and sets ownership when running as root.
func finalizeNodeSetup(dataDir, certPath, adminAccessKey, adminSecretKey, region, bindIP string) {
fmt.Println("\n🔧 Configuring AWS credentials...")
if err := admin.SetupAWSCredentials(adminAccessKey, adminSecretKey, region, certPath, bindIP, advertiseIP); err != nil {
if err := admin.SetupAWSCredentials(adminAccessKey, adminSecretKey, region, certPath, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not update AWS credentials: %v\n", err)
} else {
fmt.Println("✅ AWS credentials configured")
@@ -2590,6 +2726,7 @@ func finalizeNodeSetup(dataDir, certPath, adminAccessKey, adminSecretKey, region
func applyNetworkConfig(settings *admin.ConfigSettings, nc *formation.NetworkConfig) {
settings.IPSecEnabled = nc.IPSecEnabled
settings.ExternalMode = nc.ExternalMode
settings.PoolDNSServers = nc.PoolDNSServers
if nc.ExternalMode != "" {
settings.Pools = []admin.PoolData{{
Name: nc.PoolName,
@@ -2626,6 +2763,48 @@ type writeBootstrapResult struct {
AdminSecretKey string
}
// ensureMasterKey load-or-generates the IAM master key at <configDir>/master.key.
// It returns the key bytes and whether the key already existed on disk. On a
// --force re-init the existing key is preserved: rotating it would orphan every
// IAM secret in NATS KV (e.g. the ECR signing key) that was encrypted under it.
func ensureMasterKey(configDir string) (key []byte, existed bool, err error) {
keyPath := filepath.Join(configDir, "master.key")
if admin.FileExists(keyPath) {
key, err = handlers_iam.LoadMasterKey(keyPath)
if err != nil {
return nil, false, fmt.Errorf("load master key: %w", err)
}
return key, true, nil
}
key, err = handlers_iam.GenerateMasterKey()
if err != nil {
return nil, false, fmt.Errorf("generate master key: %w", err)
}
return key, false, nil
}
// loadSystemCredentials reads the preserved system access/secret key from
// <configDir>/system-credentials.json. On a re-init the configs must render the
// same system credentials that seeded the NATS KV `system` secret, else SigV4
// auth between services fails.
func loadSystemCredentials(configDir string) (accessKey, secretKey string, err error) {
data, err := os.ReadFile(filepath.Join(configDir, "system-credentials.json"))
if err != nil {
return "", "", fmt.Errorf("read system credentials: %w", err)
}
var creds struct {
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
}
if err := json.Unmarshal(data, &creds); err != nil {
return "", "", fmt.Errorf("parse system credentials: %w", err)
}
if creds.AccessKeyID == "" || creds.SecretAccessKey == "" {
return "", "", fmt.Errorf("system credentials missing access/secret key")
}
return creds.AccessKeyID, creds.SecretAccessKey, nil
}
// writeBootstrapFiles generates new admin credentials and writes the bootstrap
// files (master.key to configDir, bootstrap.json to bootstrapDir).
// Used by init flows (single and multi-node).
@@ -2647,47 +2826,57 @@ func writeBootstrapFiles(configDir, bootstrapDir string, masterKey []byte, acces
}, nil
}
// writePredastoreEncryptionKey generates a fresh 32-byte AES-256 master key
// for this node's predastore data dir and writes it to
// <configDir>/predastore/encryption.key with mode 0600. The key is per-node:
// every node generates its own at init/join time and never transmits it,
// because predastore only opens fragments on the node that sealed them.
// writePredastoreEncryptionKey load-or-generates this node's per-node predastore
// key at <configDir>/predastore/encryption.key (mode 0600). The key is per-node:
// every node generates its own at init/join time and never transmits it, because
// predastore only opens fragments on the node that sealed them. An existing key
// is preserved so a --force re-init does not orphan already-sealed fragments.
func writePredastoreEncryptionKey(configDir string) (string, error) {
predastoreDir := filepath.Join(configDir, "predastore")
if err := os.MkdirAll(predastoreDir, 0750); err != nil {
return "", fmt.Errorf("create predastore config dir: %w", err)
}
keyPath := filepath.Join(predastoreDir, "encryption.key")
if admin.FileExists(keyPath) {
return keyPath, nil
}
key, err := handlers_iam.GenerateMasterKey()
if err != nil {
return "", fmt.Errorf("generate predastore encryption key: %w", err)
}
keyPath := filepath.Join(predastoreDir, "encryption.key")
if err := handlers_iam.SaveMasterKey(keyPath, key); err != nil {
return "", fmt.Errorf("save predastore encryption key: %w", err)
}
return keyPath, nil
}
// writeViperblockEncryptionKey generates a fresh 32-byte AES-256 master key for
// viperblock at-rest encryption and writes it to
// <configDir>/viperblock/encryption.key with mode 0600. Unlike the predastore
// key, this key is cluster-wide and shared: the leader generates it once at init
// and distributes it to joiners via the formation server, so a volume sealed on
// any node can be opened on any other. Loaded at startup via masterkey.LoadShared.
func writeViperblockEncryptionKey(configDir string) (string, error) {
// ensureViperblockEncryptionKey load-or-generates the cluster-wide viperblock
// at-rest key at <configDir>/viperblock/encryption.key (mode 0600) and returns
// its bytes and path. Unlike the predastore key it is shared: the leader loads
// these bytes to distribute the same key to joiners via the formation server, so
// a volume sealed on any node can be opened on any other. An existing key is
// preserved so a --force re-init does not orphan already-encrypted volumes.
func ensureViperblockEncryptionKey(configDir string) ([]byte, string, error) {
viperblockDir := filepath.Join(configDir, "viperblock")
if err := os.MkdirAll(viperblockDir, 0750); err != nil {
return "", fmt.Errorf("create viperblock config dir: %w", err)
return nil, "", fmt.Errorf("create viperblock config dir: %w", err)
}
keyPath := filepath.Join(viperblockDir, "encryption.key")
if admin.FileExists(keyPath) {
key, err := handlers_iam.LoadMasterKey(keyPath)
if err != nil {
return nil, "", fmt.Errorf("load viperblock encryption key: %w", err)
}
return key, keyPath, nil
}
key, err := handlers_iam.GenerateMasterKey()
if err != nil {
return "", fmt.Errorf("generate viperblock encryption key: %w", err)
return nil, "", fmt.Errorf("generate viperblock encryption key: %w", err)
}
keyPath := filepath.Join(viperblockDir, "encryption.key")
if err := handlers_iam.SaveMasterKey(keyPath, key); err != nil {
return "", fmt.Errorf("save viperblock encryption key: %w", err)
return nil, "", fmt.Errorf("save viperblock encryption key: %w", err)
}
return keyPath, nil
return key, keyPath, nil
}
// saveViperblockEncryptionKey writes an already-generated 32-byte viperblock
@@ -2871,7 +3060,7 @@ func detectDNSServers(iface string) []string {
}
// parseDNSFromResolvectl extracts IP addresses from resolvectl dns output.
// Format: "Link 2 (enp0s3): 192.168.1.1 8.8.8.8 1.1.1.1"
// Format: "Link 2 (enp0s3): 192.168.1.1 8.8.8.8 1.1.1.1".
func parseDNSFromResolvectl(output string) []string {
var servers []string
for line := range strings.SplitSeq(output, "\n") {
+77
View File
@@ -0,0 +1,77 @@
package cmd
import (
"context"
"fmt"
"os"
"time"
"github.com/mulgadc/spinifex/spinifex/admin"
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
"github.com/spf13/cobra"
)
var adminEksCmd = &cobra.Command{
Use: "eks",
Short: "Manage EKS control-plane disaster recovery",
}
var adminEksRestoreSnapshotCmd = &cobra.Command{
Use: "restore-snapshot",
Short: "Rebuild a single-CP cluster's control plane from an etcd snapshot",
Long: `restore-snapshot drives the single-CP total-loss DR path: it launches a fresh
control-plane VM as a cluster-init seed, directs its boot-time recovery agent to
run k3s server --cluster-reset (restoring the given etcd snapshot, or the latest
one found in predastore when --snapshot is omitted), and re-points the cluster
NLB target groups at the new CP.
Only single-CP clusters are supported. An HA cluster has a potentially
surviving etcd quorum and should be recovered via quorum reformation instead
of this destructive single-node rebuild.`,
Run: runAdminEksRestoreSnapshot,
}
func init() {
adminCmd.AddCommand(adminEksCmd)
adminEksCmd.AddCommand(adminEksRestoreSnapshotCmd)
adminEksRestoreSnapshotCmd.Flags().String("cluster", "", "Cluster name to restore (required)")
adminEksRestoreSnapshotCmd.Flags().String("snapshot", "", "Etcd snapshot key to restore; defaults to the latest snapshot found in predastore")
adminEksRestoreSnapshotCmd.Flags().String("account", "", "Account ID that owns the cluster; defaults to the bootstrap account")
_ = adminEksRestoreSnapshotCmd.MarkFlagRequired("cluster")
}
func runAdminEksRestoreSnapshot(cmd *cobra.Command, _ []string) {
clusterName, _ := cmd.Flags().GetString("cluster")
snapshot, _ := cmd.Flags().GetString("snapshot")
accountID, _ := cmd.Flags().GetString("account")
if accountID == "" {
accountID = admin.DefaultAccountID()
}
_, nc, err := loadConfigAndConnect()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer nc.Close()
svc := handlers_eks.NewNATSEKSService(nc)
// The server side launches a VM (30-60s) then fences the old CP with retries,
// so the RPC needs generous headroom over a bare launch.
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
fmt.Printf("Restoring control plane for cluster %q (account %s)...\n", clusterName, accountID)
out, err := svc.RestoreSnapshot(ctx, &handlers_eks.RestoreSnapshotInput{
ClusterName: clusterName,
Snapshot: snapshot,
}, accountID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("New control plane %s launched (snapshot %s)\n", out.NewInstanceID, out.Snapshot)
fmt.Printf("Status: %s\n", out.Status)
}
+51 -2
View File
@@ -46,6 +46,55 @@ func TestSpinifexTomlTemplate_NATMode(t *testing.T) {
assert.NotContains(t, content, "range_start", "nat mode has no public IP range")
}
func TestSpinifexTomlTemplate_GwLrpRange(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
settings := admin.ConfigSettings{
Node: "node1", Az: "ap-southeast-2a", Port: "4432",
Region: "ap-southeast-2", BindIP: "10.11.12.1",
AccessKey: "AKIATEST", SecretKey: "SECRET", AccountID: "123456789012",
NatsToken: "token", ConfigDir: dir,
OVNNBAddr: "tcp:127.0.0.1:6641", OVNSBAddr: "tcp:127.0.0.1:6642",
ExternalMode: "nat", BridgeMode: "nat",
Pools: []admin.PoolData{{
Name: "nat-transit", Gateway: "100.127.0.1", PrefixLen: 24,
GwLrpRangeStart: "100.127.0.16", GwLrpRangeEnd: "100.127.0.254",
}},
}
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
data, err := os.ReadFile(path)
require.NoError(t, err)
content := string(data)
assert.Contains(t, content, `gw_lrp_range_start = "100.127.0.16"`)
assert.Contains(t, content, `gw_lrp_range_end = "100.127.0.254"`)
}
func TestSpinifexTomlTemplate_GwLrpRangeOmittedWhenUnset(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
settings := admin.ConfigSettings{
Node: "node1", Az: "ap-southeast-2a", Port: "4432",
Region: "ap-southeast-2", BindIP: "10.11.12.1",
AccessKey: "AKIATEST", SecretKey: "SECRET", AccountID: "123456789012",
NatsToken: "token", ConfigDir: dir,
OVNNBAddr: "tcp:127.0.0.1:6641", OVNSBAddr: "tcp:127.0.0.1:6642",
ExternalMode: "pool", ExternalIface: "enp0s3",
Pools: []admin.PoolData{{
Name: "wan", Source: "static",
Start: "192.168.1.150", End: "192.168.1.250",
Gateway: "192.168.1.1", PrefixLen: 24,
}},
}
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
data, err := os.ReadFile(path)
require.NoError(t, err)
assert.NotContains(t, string(data), "gw_lrp_range", "unset range must not render")
}
func TestSpinifexTomlTemplate_PoolModeOmitsBridgeMode(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
@@ -89,8 +138,8 @@ func TestIsNonBridgeableUplink(t *testing.T) {
func TestBridgeModeFor(t *testing.T) {
assert.Equal(t, "nat", bridgeModeFor("nat"))
assert.Equal(t, "", bridgeModeFor("pool"))
assert.Equal(t, "", bridgeModeFor(""))
assert.Empty(t, bridgeModeFor("pool"))
assert.Empty(t, bridgeModeFor(""))
}
func TestSpinifexTomlTemplate_NATModeWithPublicPool(t *testing.T) {
+575 -25
View File
@@ -10,7 +10,10 @@ import (
"testing"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/config"
"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/utils"
toml "github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
@@ -50,7 +53,7 @@ func TestBuildRemoteNodes_AdvertiseFallback(t *testing.T) {
"node2": {Name: "node2", BindIP: "10.0.0.2"}, // legacy joiner
"node3": {Name: "node3", BindIP: "10.0.0.3", AdvertiseIP: "203.0.113.3"},
}
got := buildRemoteNodes(nodes, "node3")
got := buildRemoteNodes(nodes, "node3", "")
if assert.Len(t, got, 2) {
assert.Equal(t, "node1", got[0].Name)
assert.Equal(t, "203.0.113.1", got[0].Host, "advertise wins when set")
@@ -59,6 +62,29 @@ func TestBuildRemoteNodes_AdvertiseFallback(t *testing.T) {
}
}
// Every peer must carry the northstar stanza, since a node that describes only
// its own northstar computes a single-entry seed set and pins the base zone's NS
// records to whichever node won the create-if-absent race.
func TestBuildRemoteNodes_NorthstarConfigPath(t *testing.T) {
nodes := map[string]formation.NodeInfo{
"node1": {Name: "node1", BindIP: "10.0.0.1"},
"node2": {Name: "node2", BindIP: "10.0.0.2"},
}
got := buildRemoteNodes(nodes, "node2", "/etc/spinifex/northstar/northstar.toml")
if assert.Len(t, got, 1) {
assert.Equal(t, "/etc/spinifex/northstar/northstar.toml", got[0].NorthstarConfigPath)
}
// No distributed credentials means no node runs northstar. Publishing peers
// as resolvers anyway would point guests at 169.254.169.253 for a service
// that never starts.
got = buildRemoteNodes(nodes, "node2", "")
if assert.Len(t, got, 1) {
assert.Empty(t, got[0].NorthstarConfigPath)
}
}
func TestResolveAdvertiseIP(t *testing.T) {
wan := &admin.DetectedNetwork{WAN: &admin.DetectedInterface{IP: "192.168.1.21"}}
noWAN := &admin.DetectedNetwork{}
@@ -145,6 +171,44 @@ func TestSpinifexTomlTemplate_AdvertiseOmittedWhenEmpty(t *testing.T) {
assert.NotContains(t, string(data), "advertise =")
}
// The northstar stanza carries the non-secret domains so confined services
// (vpcd) resolve DNS names without reading the 0600 northstar.toml.
func TestSpinifexTomlTemplate_NorthstarDomains(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
settings := admin.ConfigSettings{
Node: "node1",
Az: "ap-southeast-2a",
Port: "4432",
Region: "ap-southeast-2",
BindIP: "10.11.12.1",
AccessKey: "AKIATEST",
SecretKey: "SECRET",
AccountID: "123456789012",
NatsToken: "token",
ConfigDir: dir,
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
NorthstarConfigPath: "/etc/spinifex/northstar/northstar.toml",
NorthstarDefaultDomain: "spx3.net",
NorthstarInternalDomain: "compute.internal",
}
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
data, err := os.ReadFile(path)
require.NoError(t, err)
content := string(data)
assert.Contains(t, content, `config_path = "/etc/spinifex/northstar/northstar.toml"`)
assert.Contains(t, content, `default_domain = "spx3.net"`)
assert.Contains(t, content, `internal_domain = "compute.internal"`)
// The stanza must round-trip into NorthstarConfig so the resolvers read it.
cfg, err := config.LoadConfig(path)
require.NoError(t, err)
assert.Equal(t, "spx3.net", cfg.Nodes["node1"].Northstar.DefaultDomain)
assert.Equal(t, "compute.internal", cfg.Nodes["node1"].Northstar.InternalDomain)
}
// Legacy `wan_bridge` TOML key must fail-start vpcd with guidance, not silently
// alias (per mulga-998 D3). Prevents the footgun where operators inherited the
// old key pointing at 'br-ext' and got broken DHCP on veth-mode hosts.
@@ -209,7 +273,7 @@ func TestPredastoreMultinodeTemplate_NatsURLLoopbackShim(t *testing.T) {
{ID: 3, Host: "10.0.0.3"},
}
content, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "0.0.0.0", 0,
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "0.0.0.0", 0, admin.NorthstarCredentials{},
)
require.NoError(t, err)
assert.Contains(t, content, `nats_url = "nats://localhost:4222"`)
@@ -217,7 +281,7 @@ func TestPredastoreMultinodeTemplate_NatsURLLoopbackShim(t *testing.T) {
// Specific bind IP → stays as-is.
content2, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "10.11.12.1", 0,
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "10.11.12.1", 0, admin.NorthstarCredentials{},
)
require.NoError(t, err)
assert.Contains(t, content2, `nats_url = "nats://10.11.12.1:4222"`)
@@ -255,23 +319,6 @@ func TestWritePredastoreEncryptionKey(t *testing.T) {
assert.NotEqual(t, key1, key2, "per-node keys must differ")
}
// The viperblock key gates at-rest encryption for every volume on a fresh
// install. masterkey.LoadShared rejects anything that isn't exactly 32 bytes at
// 0640-or-stricter, so a regression in size/mode would silently fall back to
// cleartext (empty path) or fail service startup.
func TestWriteViperblockEncryptionKey(t *testing.T) {
configDir := t.TempDir()
keyPath, err := writeViperblockEncryptionKey(configDir)
require.NoError(t, err)
assert.Equal(t, filepath.Join(configDir, "viperblock", "encryption.key"), keyPath)
info, err := os.Stat(keyPath)
require.NoError(t, err)
assert.Equal(t, int64(32), info.Size(), "viperblock master key must be exactly 32 bytes")
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "viperblock master key must be mode 0600")
}
// Joiners persist the leader's shared key via saveViperblockEncryptionKey
// against a configDir with no viperblock/ subdir yet. The bytes round-trip
// verbatim — the whole cluster shares one key, so any mutation would orphan
@@ -292,6 +339,181 @@ func TestSaveViperblockEncryptionKey_RoundTrip(t *testing.T) {
assert.Equal(t, key, got, "shared key must be written verbatim")
}
// A re-init must not rotate the per-node predastore key: rotating it would orphan
// every fragment already sealed under the old key. The helper preserves an
// existing key byte-for-byte.
func TestWritePredastoreEncryptionKey_PreservesExisting(t *testing.T) {
configDir := t.TempDir()
keyPath, err := writePredastoreEncryptionKey(configDir)
require.NoError(t, err)
orig, err := os.ReadFile(keyPath)
require.NoError(t, err)
keyPath2, err := writePredastoreEncryptionKey(configDir)
require.NoError(t, err)
assert.Equal(t, keyPath, keyPath2)
after, err := os.ReadFile(keyPath2)
require.NoError(t, err)
assert.Equal(t, orig, after, "existing predastore key must be preserved on re-init")
}
// ensureViperblockEncryptionKey generates a shared 32-byte 0600 key on a fresh
// dir and returns bytes that match the on-disk file; a second call preserves it
// so a re-init keeps every encrypted volume readable.
func TestEnsureViperblockEncryptionKey(t *testing.T) {
configDir := t.TempDir()
key, keyPath, err := ensureViperblockEncryptionKey(configDir)
require.NoError(t, err)
assert.Equal(t, filepath.Join(configDir, "viperblock", "encryption.key"), keyPath)
assert.Len(t, key, 32, "viperblock key must be 32 bytes")
info, err := os.Stat(keyPath)
require.NoError(t, err)
assert.Equal(t, int64(32), info.Size(), "viperblock master key must be exactly 32 bytes")
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "viperblock master key must be mode 0600")
onDisk, err := os.ReadFile(keyPath)
require.NoError(t, err)
assert.Equal(t, key, onDisk, "returned bytes must match the file")
key2, _, err := ensureViperblockEncryptionKey(configDir)
require.NoError(t, err)
assert.Equal(t, key, key2, "existing viperblock key must be preserved on re-init")
}
// A present-but-corrupt viperblock key must fail loud rather than regenerate,
// which would orphan every already-encrypted volume in the cluster.
func TestEnsureViperblockEncryptionKey_CorruptFailsLoud(t *testing.T) {
configDir := t.TempDir()
keyDir := filepath.Join(configDir, "viperblock")
require.NoError(t, os.MkdirAll(keyDir, 0750))
keyPath := filepath.Join(keyDir, "encryption.key")
require.NoError(t, os.WriteFile(keyPath, []byte("bad"), 0600))
_, _, err := ensureViperblockEncryptionKey(configDir)
require.Error(t, err, "a truncated viperblock key must error, not regenerate")
after, err := os.ReadFile(keyPath)
require.NoError(t, err)
assert.Equal(t, []byte("bad"), after, "corrupt viperblock key must not be overwritten")
}
// ensureMasterKey generates a fresh in-memory key without touching disk (the
// bootstrap writer persists it), and preserves an existing on-disk key so a
// re-init never rotates the key that encrypts IAM secrets in NATS KV.
func TestEnsureMasterKey(t *testing.T) {
t.Run("FreshGeneratesWithoutWriting", func(t *testing.T) {
configDir := t.TempDir()
key, existed, err := ensureMasterKey(configDir)
require.NoError(t, err)
assert.False(t, existed, "no master.key present yet")
assert.Len(t, key, 32)
assert.NoFileExists(t, filepath.Join(configDir, "master.key"),
"ensureMasterKey must not persist on the fresh path")
})
t.Run("PreservesExisting", func(t *testing.T) {
configDir := t.TempDir()
seed := make([]byte, 32)
for i := range seed {
seed[i] = byte(200 - i)
}
require.NoError(t, handlers_iam.SaveMasterKey(filepath.Join(configDir, "master.key"), seed))
key, existed, err := ensureMasterKey(configDir)
require.NoError(t, err)
assert.True(t, existed, "existing master.key must be detected")
assert.Equal(t, seed, key, "existing master key must be loaded verbatim")
})
// A present-but-corrupt master.key must fail loud, never be silently
// regenerated: rotating it would orphan every IAM secret in NATS KV.
t.Run("CorruptFailsLoud", func(t *testing.T) {
configDir := t.TempDir()
keyPath := filepath.Join(configDir, "master.key")
require.NoError(t, os.WriteFile(keyPath, []byte("too-short"), 0600))
_, _, err := ensureMasterKey(configDir)
require.Error(t, err, "a truncated master.key must error, not regenerate")
after, err := os.ReadFile(keyPath)
require.NoError(t, err)
assert.Equal(t, []byte("too-short"), after, "corrupt master key must not be overwritten")
})
}
// The identity bundle is preserved holistically on re-init: the load helpers
// recover the exact system credentials and never rewrite bootstrap.json. Admin
// credentials are not recovered from disk — awsgw consumes and deletes
// bootstrap.json after first boot, so the preserve path must not read it back.
func TestPreservedIdentityBundle(t *testing.T) {
configDir := t.TempDir()
bootstrapDir := filepath.Join(configDir, "awsgw")
masterKey, err := handlers_iam.GenerateMasterKey()
require.NoError(t, err)
const (
sysAccess = "AKIASYSTEM0000000000"
sysSecret = "system-secret-value"
adminAccess = "AKIAADMIN00000000000"
adminSecret = "admin-secret-value"
accountID = "123456789012"
)
require.NoError(t, writeBootstrapFilesWithAdmin(configDir, bootstrapDir, masterKey,
sysAccess, sysSecret, accountID, adminAccess, adminSecret))
require.NoError(t, writeSystemCredentials(configDir, sysAccess, sysSecret))
bootstrapPath := filepath.Join(bootstrapDir, "bootstrap.json")
bootstrapBefore, err := os.ReadFile(bootstrapPath)
require.NoError(t, err)
// master.key round-trips and is flagged as pre-existing.
loadedKey, existed, err := ensureMasterKey(configDir)
require.NoError(t, err)
assert.True(t, existed)
assert.Equal(t, masterKey, loadedKey)
// System credentials come back verbatim (they must match the NATS KV seed).
gotSysAccess, gotSysSecret, err := loadSystemCredentials(configDir)
require.NoError(t, err)
assert.Equal(t, sysAccess, gotSysAccess)
assert.Equal(t, sysSecret, gotSysSecret)
// The preserve helpers must not rewrite the seed file.
bootstrapAfter, err := os.ReadFile(bootstrapPath)
require.NoError(t, err)
assert.Equal(t, bootstrapBefore, bootstrapAfter, "bootstrap.json must not be rewritten on re-init")
}
// loadSystemCredentials surfaces a clear error when the file is absent rather
// than returning empty credentials that would silently break SigV4 auth.
func TestLoadSystemCredentials_Missing(t *testing.T) {
_, _, err := loadSystemCredentials(t.TempDir())
require.Error(t, err)
}
// A present-but-empty or malformed system-credentials.json must error rather
// than yield blank credentials that would silently break SigV4 between services.
func TestLoadSystemCredentials_Invalid(t *testing.T) {
t.Run("EmptyValues", func(t *testing.T) {
configDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(configDir, "system-credentials.json"),
[]byte(`{"access_key_id":"","secret_access_key":""}`), 0600))
_, _, err := loadSystemCredentials(configDir)
require.Error(t, err)
})
t.Run("MalformedJSON", func(t *testing.T) {
configDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(configDir, "system-credentials.json"),
[]byte("not json"), 0600))
_, _, err := loadSystemCredentials(configDir)
require.Error(t, err)
})
}
// A fresh install must render encryption_key_file so viperblockd enables
// at-rest encryption; an empty field (upgrade / legacy) must omit the line
// entirely so the volume stays in cleartext legacy mode.
@@ -365,15 +587,15 @@ const cobraRequiredAnnotation = "cobra_annotation_bash_completion_one_required_f
func TestAdminInitCmd_ExternalDHCPFlagSchema(t *testing.T) {
sourceFlag := adminInitCmd.Flags().Lookup("external-source")
require.NotNil(t, sourceFlag, "--external-source must be defined")
assert.Equal(t, "", sourceFlag.DefValue)
assert.Empty(t, sourceFlag.DefValue)
bindFlag := adminInitCmd.Flags().Lookup("external-bind-bridge")
require.NotNil(t, bindFlag, "--external-bind-bridge must be defined")
assert.Equal(t, "", bindFlag.DefValue)
assert.Empty(t, bindFlag.DefValue)
poolFlag := adminInitCmd.Flags().Lookup("external-pool")
require.NotNil(t, poolFlag)
assert.Equal(t, "", poolFlag.DefValue)
assert.Empty(t, poolFlag.DefValue)
}
// DHCP-sourced pool must emit source + bind_bridge into spinifex.toml so the
@@ -472,6 +694,297 @@ func predastoreMultinodeNodes() []admin.PredastoreNodeConfig {
}
}
// predastoreBucketAuth parses the buckets and auth entries a northstar-enabled
// multi-node predastore must render.
type predastoreBucketAuth struct {
Buckets []struct {
Name string `toml:"name"`
Type string `toml:"type"`
} `toml:"buckets"`
Auth []struct {
AccessKeyID string `toml:"access_key_id"`
SecretAccessKey string `toml:"secret_access_key"`
Policy []struct {
Bucket string `toml:"bucket"`
Actions []string `toml:"actions"`
} `toml:"policy"`
} `toml:"auth"`
}
// policyFor returns the actions the given key is granted on the given bucket,
// and whether the key holds any policy for it at all.
func (c predastoreBucketAuth) policyFor(accessKey, bucket string) ([]string, bool) {
for _, auth := range c.Auth {
if auth.AccessKeyID != accessKey {
continue
}
for _, p := range auth.Policy {
if p.Bucket == bucket {
return p.Actions, true
}
}
}
return nil, false
}
func (c predastoreBucketAuth) hasBucket(name string) bool {
for _, b := range c.Buckets {
if b.Name == name {
return true
}
}
return false
}
// Northstar on a multi-node cluster needs three things from predastore, all
// gated on the same credential: the zone bucket, write access for the system
// key (which is what BootstrapBaseZone seeds zones with), and the resolver's
// own read-only key. The template rendered all three correctly, but the caller
// never populated the credential, so multi-node clusters silently got none of
// them.
func TestPredastoreMultinodeTemplate_NorthstarProvisioned(t *testing.T) {
content, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "10.0.0.1", 0,
admin.NorthstarCredentials{AccessKey: "NSAK", SecretKey: "NSSK", Bucket: admin.NorthstarBucketName},
)
require.NoError(t, err)
var cfg predastoreBucketAuth
require.NoError(t, toml.Unmarshal([]byte(content), &cfg))
assert.True(t, cfg.hasBucket(admin.NorthstarBucketName), "northstar zone bucket not rendered")
// The zone bucket must be readable from every node's local predastore, so
// a resolver on any node can serve it.
for _, b := range cfg.Buckets {
if b.Name == admin.NorthstarBucketName {
assert.Equal(t, "distributed", b.Type)
}
}
// BootstrapBaseZone writes zone files with the system key, not the
// resolver's. Without this grant, seeding fails on every node.
systemActions, ok := cfg.policyFor("AK", admin.NorthstarBucketName)
require.True(t, ok, "system key has no policy for the northstar bucket")
assert.Contains(t, systemActions, "s3:GetObject", "bootstrap must check whether the zone already exists")
assert.Contains(t, systemActions, "s3:PutObject", "bootstrap must create a missing zone")
// The resolver's key stays read-only and scoped to the zone bucket alone.
nsActions, ok := cfg.policyFor("NSAK", admin.NorthstarBucketName)
require.True(t, ok, "northstar key has no policy for the northstar bucket")
assert.ElementsMatch(t, []string{"s3:ListBucket", "s3:GetObject"}, nsActions)
for _, auth := range cfg.Auth {
if auth.AccessKeyID == "NSAK" {
assert.Equal(t, "NSSK", auth.SecretAccessKey)
}
}
_, ok = cfg.policyFor("NSAK", "predastore")
assert.False(t, ok, "northstar key must not reach the predastore bucket")
}
// Without a credential the northstar stanzas must be omitted wholesale rather
// than half-rendered: an empty-keyed [[auth]] entry would let anyone read the
// zone bucket.
func TestPredastoreMultinodeTemplate_NorthstarOmittedWithoutCredentials(t *testing.T) {
content, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "10.0.0.1", 0,
admin.NorthstarCredentials{},
)
require.NoError(t, err)
var cfg predastoreBucketAuth
require.NoError(t, toml.Unmarshal([]byte(content), &cfg))
assert.False(t, cfg.hasBucket(admin.NorthstarBucketName))
_, ok := cfg.policyFor("AK", admin.NorthstarBucketName)
assert.False(t, ok, "system key granted northstar access without a northstar credential")
assert.Len(t, cfg.Auth, 1, "only the system key should be rendered")
}
// A joiner must provision its predastore with the leader's pair verbatim, since
// each node's predastore only honours the keys in its own config. Per-node
// generation would have node A's predastore reject node B's resolver.
func TestNorthstarFromFormation_UsesDistributedPair(t *testing.T) {
dirs := configDirs{Northstar: "/etc/spinifex/northstar"}
creds := &formation.SharedCredentials{NorthstarAccessKey: "NSAK", NorthstarSecretKey: "NSSK"}
got, path := northstarFromFormation(creds, dirs)
assert.Equal(t, "NSAK", got.AccessKey)
assert.Equal(t, "NSSK", got.SecretKey)
assert.Equal(t, admin.NorthstarBucketName, got.Bucket, "bucket is a constant, derived node-side")
assert.Equal(t, "/etc/spinifex/northstar/northstar.toml", path)
}
// A leader predating credential distribution, or returning only half a pair,
// must yield no Northstar config. Advertising a resolver without usable keys
// would send guests to a service that cannot read its zones.
func TestNorthstarFromFormation_IncompletePairYieldsNoConfig(t *testing.T) {
tests := []struct {
name string
access string
secret string
}{
{name: "legacy leader"},
{name: "access key only", access: "NSAK"},
{name: "secret key only", secret: "NSSK"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, path := northstarFromFormation(&formation.SharedCredentials{
NorthstarAccessKey: tt.access,
NorthstarSecretKey: tt.secret,
}, configDirs{Northstar: "/etc/spinifex/northstar"})
assert.Equal(t, admin.NorthstarCredentials{}, got)
assert.Empty(t, path, "config path without credentials advertises a resolver that cannot read its zones")
})
}
}
// multinodeNorthstarSettings mirrors what both formation paths hand
// generateAndWriteConfigs once the distributed pair is wired through.
func multinodeNorthstarSettings(configPath string) admin.ConfigSettings {
s := northstarSettings()
s.Node = "node1"
s.Az = "ap-southeast-2a"
s.Port = "4432"
s.AccountID = "123456789012"
s.OVNNBAddr = "tcp:127.0.0.1:6641"
s.OVNSBAddr = "tcp:127.0.0.1:6642"
s.NorthstarConfigPath = configPath
return s
}
// The multi-node formation paths must produce both halves of a working
// resolver: the northstar.toml the service reads, and the spinifex.toml stanza
// that makes configuredNorthstarNodeNames count this node. Rendering was
// previously reachable only from the single-node path.
func TestGenerateAndWriteConfigs_MultinodeRendersNorthstar(t *testing.T) {
dirs, err := createConfigSubdirs(t.TempDir())
require.NoError(t, err)
nsPath := filepath.Join(dirs.Northstar, "northstar.toml")
spinifexTomlPath := filepath.Join(t.TempDir(), "spinifex.toml")
require.NoError(t, generateAndWriteConfigs(dirs, spinifexTomlPath, multinodeNorthstarSettings(nsPath), true))
nsContent, err := os.ReadFile(nsPath)
require.NoError(t, err)
assert.Contains(t, string(nsContent), `access_key = "AKIANORTHSTAR"`)
// config_path is the sole predicate for "this node runs northstar", so its
// presence is what puts the node in the nameserver set and the resolver list.
cfg, err := config.LoadConfig(spinifexTomlPath)
require.NoError(t, err)
assert.Equal(t, nsPath, cfg.Nodes["node1"].Northstar.ConfigPath)
}
// Incomplete credentials must yield neither file nor stanza. A stanza without
// a usable northstar.toml would point guests at a resolver that never starts.
func TestGenerateAndWriteConfigs_NoNorthstarWithoutCompleteCredentials(t *testing.T) {
tests := []struct {
name string
access string
secret string
}{
{name: "no credentials"},
{name: "access key only", access: "AKIANORTHSTAR"},
{name: "secret key only", secret: "northstar-secret"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dirs, err := createConfigSubdirs(t.TempDir())
require.NoError(t, err)
spinifexTomlPath := filepath.Join(t.TempDir(), "spinifex.toml")
settings := multinodeNorthstarSettings(filepath.Join(dirs.Northstar, "northstar.toml"))
settings.NorthstarAccessKey = tt.access
settings.NorthstarSecretKey = tt.secret
require.NoError(t, generateAndWriteConfigs(dirs, spinifexTomlPath, settings, false))
_, err = os.Stat(filepath.Join(dirs.Northstar, "northstar.toml"))
assert.True(t, os.IsNotExist(err), "northstar.toml rendered without complete credentials")
cfg, err := config.LoadConfig(spinifexTomlPath)
require.NoError(t, err)
assert.Empty(t, cfg.Nodes["node1"].Northstar.ConfigPath)
predastoreContent, err := os.ReadFile(filepath.Join(dirs.Predastore, "predastore.toml"))
require.NoError(t, err)
var predastoreCfg predastoreBucketAuth
require.NoError(t, toml.Unmarshal(predastoreContent, &predastoreCfg))
assert.False(t, predastoreCfg.hasBucket(admin.NorthstarBucketName))
assert.Len(t, predastoreCfg.Auth, 1, "incomplete pair rendered a Northstar auth entry")
})
}
}
// renderClusterNode renders one node's spinifex.toml as its own formation path
// would: its local northstar stanza plus a stanza for every peer.
func renderClusterNode(t *testing.T, node string, allNodes map[string]formation.NodeInfo) *config.ClusterConfig {
t.Helper()
const nsPath = "/etc/spinifex/northstar/northstar.toml"
settings := multinodeNorthstarSettings(nsPath)
settings.Node = node
settings.BindIP = allNodes[node].BindIP
settings.AdvertiseIP = allNodes[node].AdvertiseIP
settings.RemoteNodes = buildRemoteNodes(allNodes, node, nsPath)
path := filepath.Join(t.TempDir(), "spinifex.toml")
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
cfg, err := config.LoadConfig(path)
require.NoError(t, err)
return cfg
}
// Every node must derive the same nameserver set from its own rendered config.
// EnsureBaseZone is create-if-absent and never overwrites, so if each node saw
// only itself the zone's NS records would be pinned to whichever node won the
// startup race — permanently, for the life of the zone object. Identical sets
// make that race benign: whoever writes, writes the same bytes.
func TestSpinifexTomlTemplate_PeerNorthstarSeedsAreUniform(t *testing.T) {
allNodes := map[string]formation.NodeInfo{
"node1": {Name: "node1", BindIP: "10.0.0.1", AdvertiseIP: "192.168.1.31"},
"node2": {Name: "node2", BindIP: "10.0.0.2", AdvertiseIP: "192.168.1.32"},
"node3": {Name: "node3", BindIP: "10.0.0.3", AdvertiseIP: "192.168.1.33"},
}
wantIPs := []string{"192.168.1.31", "192.168.1.32", "192.168.1.33"}
wantHosts := []string{"ns1", "ns2", "ns3"}
for _, node := range []string{"node1", "node2", "node3"} {
cfg := renderClusterNode(t, node, allNodes)
seeds := handlers_dns.NameserverSeeds(cfg)
require.Len(t, seeds, 3)
for i, seed := range seeds {
assert.Equal(t, wantHosts[i], seed.Host)
assert.Equal(t, wantIPs[i], seed.IP)
}
assert.Equal(t, wantIPs, handlers_dns.ResolverNameserverIPs(cfg),
"%s derived a different nameserver set from its own config", node)
}
}
// A peer's stanza carries config_path alone: it is the only field ever read back
// from a peer, and the domains would be fiction — the local node cannot know
// what a peer configured.
func TestSpinifexTomlTemplate_PeerNorthstarStanzaIsPathOnly(t *testing.T) {
allNodes := map[string]formation.NodeInfo{
"node1": {Name: "node1", BindIP: "10.0.0.1", AdvertiseIP: "192.168.1.31"},
"node2": {Name: "node2", BindIP: "10.0.0.2", AdvertiseIP: "192.168.1.32"},
}
cfg := renderClusterNode(t, "node1", allNodes)
peer := cfg.Nodes["node2"].Northstar
assert.Equal(t, "/etc/spinifex/northstar/northstar.toml", peer.ConfigPath)
assert.Empty(t, peer.DefaultDomain, "peer domains are unknowable from here")
assert.Empty(t, peer.InternalDomain)
}
// Unset knob must leave production config byte-identical: no [compaction] block
// and the original trailing bytes unchanged (single-node has no trailing
// newline, multinode keeps one).
@@ -485,7 +998,7 @@ func TestPredastoreTemplates_UnsetCompactionEmitsNoBlock(t *testing.T) {
"unset single-node tail changed: %q", single[len(single)-40:])
multi, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "10.0.0.1", 0,
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "10.0.0.1", 0, admin.NorthstarCredentials{},
)
require.NoError(t, err)
assert.NotContains(t, multi, "[compaction]")
@@ -506,7 +1019,7 @@ func TestPredastoreTemplates_SetCompactionEmitsParseableBlock(t *testing.T) {
assert.Equal(t, interval, singleCfg.Compaction.IntervalSeconds)
multi, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "10.0.0.1", interval,
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "10.0.0.1", interval, admin.NorthstarCredentials{},
)
require.NoError(t, err)
var multiCfg compactionConfig
@@ -543,3 +1056,40 @@ func TestAdminInitFlag_CompactionIntervalReachesRenderedConfig(t *testing.T) {
})
assert.Contains(t, out, "interval_seconds = 45")
}
// 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.
func TestAMIVolumeSizeGiB(t *testing.T) {
const giB int64 = 1024 * 1024 * 1024
tests := []struct {
name string
bytes int64
want uint64
}{
// The regression: a real 4.94 GiB mkosi image floored to 4 GiB and the
// guest hung with no root partition.
{name: "non-round image rounds up", bytes: 5306470400, want: 5},
// Exact multiples must not gain a spare GiB — the round sizes are the
// common case and were the only ones the old code got right.
{name: "exact multiple is unchanged", bytes: 16 * giB, want: 16},
{name: "one byte over a multiple rounds up", bytes: 16*giB + 1, want: 17},
{name: "one byte under a multiple rounds up", bytes: 16*giB - 1, want: 16},
// Sub-GiB images still need a whole GiB to live in.
{name: "sub-GiB image gets a full GiB", bytes: 1, want: 1},
{name: "empty image", bytes: 0, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := amiVolumeSizeGiB(tt.bytes)
assert.Equal(t, tt.want, got)
// The invariant the guest depends on, stated directly.
if tt.bytes > 0 {
assert.GreaterOrEqual(t, int64(got)*giB, tt.bytes,
"volume must be large enough to hold the image")
}
})
}
}
+12 -29
View File
@@ -123,7 +123,7 @@ func runClusterShutdown(cmd *cobra.Command, args []string) {
phaseStart := time.Now()
fmt.Printf("[%s] Sending to %d node(s)...\n", strings.ToUpper(phase), nodeCount)
acks, err := collectShutdownACKs(nc, topic, reqData, nodeCount, timeout)
acks, err := collectShutdownACKs(nc, topic, reqData, nodeCount, "", timeout)
if err != nil {
fmt.Fprintf(os.Stderr, "[%s] Error: %v\n", strings.ToUpper(phase), err)
if !force {
@@ -216,36 +216,14 @@ func runNodeDrainLocal(cmd *cobra.Command, args []string) {
// from the local node, ignoring ACKs from any other node. Returns an error if
// the local node does not respond within timeout.
func collectLocalShutdownACK(nc *nats.Conn, topic string, reqData []byte, node string, timeout time.Duration) (daemon.ShutdownACK, error) {
inbox := nats.NewInbox()
sub, err := nc.SubscribeSync(inbox)
acks, err := collectShutdownACKs(nc, topic, reqData, 1, node, timeout)
if err != nil {
return daemon.ShutdownACK{}, fmt.Errorf("failed to subscribe to inbox: %w", err)
return daemon.ShutdownACK{}, err
}
defer sub.Unsubscribe()
if err := nc.PublishRequest(topic, inbox, reqData); err != nil {
return daemon.ShutdownACK{}, fmt.Errorf("failed to publish request: %w", err)
}
nc.Flush()
deadline := time.Now().Add(timeout)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return daemon.ShutdownACK{}, fmt.Errorf("timeout waiting for local node %q ACK", node)
}
msg, err := sub.NextMsg(remaining)
if err != nil {
return daemon.ShutdownACK{}, fmt.Errorf("timeout waiting for local node %q ACK", node)
}
var ack daemon.ShutdownACK
if err := json.Unmarshal(msg.Data, &ack); err != nil {
continue
}
if ack.Node == node {
return ack, nil
}
if len(acks) == 0 {
return daemon.ShutdownACK{}, fmt.Errorf("timeout waiting for local node %q ACK", node)
}
return acks[0], nil
}
// runClusterDrainDHCP asks every vpcd to DHCPRELEASE all external-pool leases
@@ -322,7 +300,9 @@ func drainDHCPLeases(nc *nats.Conn, timeout time.Duration) (released, responders
}
// collectShutdownACKs publishes a shutdown request and collects ACKs from nodes.
func collectShutdownACKs(nc *nats.Conn, topic string, reqData []byte, nodeCount int, timeout time.Duration) ([]daemon.ShutdownACK, error) {
// When nodeFilter is non-empty, only ACKs from that node count toward nodeCount
// and all others are ignored (used by the single-node local-drain path).
func collectShutdownACKs(nc *nats.Conn, topic string, reqData []byte, nodeCount int, nodeFilter string, timeout time.Duration) ([]daemon.ShutdownACK, error) {
inbox := nats.NewInbox()
sub, err := nc.SubscribeSync(inbox)
if err != nil {
@@ -350,6 +330,9 @@ func collectShutdownACKs(nc *nats.Conn, topic string, reqData []byte, nodeCount
if err := json.Unmarshal(msg.Data, &ack); err != nil {
continue
}
if nodeFilter != "" && ack.Node != nodeFilter {
continue
}
acks = append(acks, ack)
}
return acks, nil
+6
View File
@@ -138,6 +138,12 @@ func formatRoles(resp types.NodeStatusResponse) string {
if resp.NATSRole != "" {
roles = append(roles, "nats:"+resp.NATSRole)
}
if resp.OVNNBRole != "" {
roles = append(roles, "ovn-nb:"+resp.OVNNBRole)
}
if resp.OVNSBRole != "" {
roles = append(roles, "ovn-sb:"+resp.OVNSBRole)
}
if resp.PredastoreRole != "" {
roles = append(roles, "predastore:"+resp.PredastoreRole)
}
+20
View File
@@ -38,6 +38,26 @@ func TestFormatRoles(t *testing.T) {
resp: types.NodeStatusResponse{NATSRole: "leader", PredastoreRole: "leader"},
want: "nats:leader,predastore:leader",
},
{
name: "ovn roles only",
resp: types.NodeStatusResponse{OVNNBRole: "leader", OVNSBRole: "follower"},
want: "ovn-nb:leader,ovn-sb:follower",
},
{
name: "all four roles ordered",
resp: types.NodeStatusResponse{
NATSRole: "leader",
OVNNBRole: "leader",
OVNSBRole: "follower",
PredastoreRole: "follower",
},
want: "nats:leader,ovn-nb:leader,ovn-sb:follower,predastore:follower",
},
{
name: "ovn-nb leader with nats, sb absent",
resp: types.NodeStatusResponse{NATSRole: "follower", OVNNBRole: "leader"},
want: "nats:follower,ovn-nb:leader",
},
}
for _, tt := range tests {
@@ -0,0 +1,89 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/mulgadc/spinifex/spinifex/admin"
toml "github.com/pelletier/go-toml/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func renderTemplate(t *testing.T, tmpl string, settings admin.ConfigSettings) string {
t.Helper()
path := filepath.Join(t.TempDir(), "out.toml")
require.NoError(t, admin.GenerateConfigFile(path, tmpl, settings))
data, err := os.ReadFile(path)
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, toml.Unmarshal(data, &parsed), "rendered template must be valid TOML")
return string(data)
}
func northstarSettings() admin.ConfigSettings {
return admin.ConfigSettings{
Region: "ap-southeast-2",
BindIP: "0.0.0.0",
AdvertiseIP: "192.168.1.31",
AccessKey: "AKIASYSTEM",
SecretKey: "SYSTEMSECRET",
NatsToken: "NATSTOKEN",
PoolDNSServers: []string{"1.1.1.1", "8.8.8.8"},
NorthstarAccessKey: "AKIANORTHSTAR",
NorthstarSecretKey: "NORTHSTARSECRET",
NorthstarBucket: admin.NorthstarBucketName,
NorthstarDefaultDomain: admin.NorthstarDefaultDomain,
NorthstarInternalDomain: admin.NorthstarInternalDomain,
}
}
func TestNorthstarTomlTemplate(t *testing.T) {
content := renderTemplate(t, northstarTomlTemplate, northstarSettings())
// Northstar binds :5300 on the wildcard and the privileged :53 on the node
// WAN IP (avoids the systemd-resolved stub on 127.0.0.53:53).
assert.Contains(t, content, `listen = "0.0.0.0:5300,192.168.1.31:53"`)
assert.Contains(t, content, `default_domain = "spx3.net"`)
assert.Contains(t, content, `bucket = "northstar"`)
assert.Contains(t, content, `access_key = "AKIANORTHSTAR"`)
assert.Contains(t, content, `secret_key = "NORTHSTARSECRET"`)
// 0.0.0.0 bind must resolve to a loopback S3 endpoint, not a dial to 0.0.0.0.
assert.Contains(t, content, `endpoint = "https://127.0.0.1:8443"`)
assert.Contains(t, content, `nameservers = ["1.1.1.1:53", "8.8.8.8:53"]`)
assert.Contains(t, content, `internal_domain = "compute.internal"`)
var parsed struct {
Quotas struct {
Enabled bool `toml:"enabled"`
RecordsPerHostedZone int `toml:"records_per_hosted_zone"`
} `toml:"quotas"`
}
require.NoError(t, toml.Unmarshal([]byte(content), &parsed))
assert.False(t, parsed.Quotas.Enabled)
assert.Equal(t, 10_000, parsed.Quotas.RecordsPerHostedZone)
assert.NotContains(t, content, "nats_url")
assert.NotContains(t, content, "NATSTOKEN")
}
// When provisioned, predastore.toml gains the northstar bucket plus a scoped
// read-only [[auth]] entry — the isolation guarantee.
func TestPredastoreTemplateWithNorthstar(t *testing.T) {
content := renderTemplate(t, predastoreTomlTemplate, northstarSettings())
assert.Contains(t, content, `name = "northstar"`)
assert.Contains(t, content, `access_key_id = "AKIANORTHSTAR"`)
assert.Contains(t, content, `{ bucket = "northstar", actions = ["s3:ListBucket", "s3:GetObject"] }`)
}
// Without northstar creds (e.g. cluster-join paths), predastore.toml renders
// exactly as before — no empty bucket/auth stanzas.
func TestPredastoreTemplateWithoutNorthstar(t *testing.T) {
s := northstarSettings()
s.NorthstarAccessKey = ""
s.NorthstarSecretKey = ""
content := renderTemplate(t, predastoreTomlTemplate, s)
assert.NotContains(t, content, `name = "northstar"`)
assert.NotContains(t, content, "AKIANORTHSTAR")
}
+3 -3
View File
@@ -8,7 +8,7 @@ import (
// DefaultConfigDir returns the default configuration directory.
// Production: /etc/spinifex (when /etc/spinifex exists)
// Development: ~/spinifex/config
// Development: ~/spinifex/config.
func DefaultConfigDir() string {
if isProductionLayout() {
return "/etc/spinifex"
@@ -18,7 +18,7 @@ func DefaultConfigDir() string {
// DefaultDataDir returns the default data directory.
// Production: /var/lib/spinifex
// Development: ~/spinifex
// Development: ~/spinifex.
func DefaultDataDir() string {
if isProductionLayout() {
return "/var/lib/spinifex"
@@ -28,7 +28,7 @@ func DefaultDataDir() string {
// LogDirFor returns the log directory for a given data directory.
// Production: /var/log/spinifex (matches systemd ReadWritePaths)
// Development: <dataDir>/logs (supports custom per-node data dirs)
// Development: <dataDir>/logs (supports custom per-node data dirs).
func LogDirFor(dataDir string) string {
if isProductionLayout() {
return "/var/log/spinifex"
+1 -1
View File
@@ -15,7 +15,7 @@ var (
appConfig *config.ClusterConfig
)
// rootCmd represents the base command when called without any subcommands
// rootCmd represents the base command when called without any subcommands.
var rootCmd = &cobra.Command{
Use: "spx",
Short: "Spinifex - Open source AWS-compatible platform for secure edge deployments",
+193 -22
View File
@@ -8,11 +8,13 @@ import (
"strings"
"github.com/mulgadc/spinifex/spinifex/config"
handlers_dns "github.com/mulgadc/spinifex/spinifex/handlers/dns"
"github.com/mulgadc/spinifex/spinifex/network/external"
"github.com/mulgadc/spinifex/spinifex/otelsetup"
"github.com/mulgadc/spinifex/spinifex/service"
"github.com/mulgadc/spinifex/spinifex/services/awsgw"
"github.com/mulgadc/spinifex/spinifex/services/nats"
"github.com/mulgadc/spinifex/spinifex/services/northstar"
"github.com/mulgadc/spinifex/spinifex/services/predastore"
"github.com/mulgadc/spinifex/spinifex/services/qmpcollector"
"github.com/mulgadc/spinifex/spinifex/services/spinifexui"
@@ -201,7 +203,7 @@ var predastoreStatusCmd = &cobra.Command{
},
}
// Repeat for viperblock
// Repeat for viperblock.
var viperblockStartCmd = &cobra.Command{
Use: "start",
Short: "Start the viperblock service",
@@ -295,6 +297,14 @@ var viperblockStartCmd = &cobra.Command{
shardWAL = *nodeConfig.Viperblock.ShardWAL
}
// Resolve chunk GC setting: default false unless explicitly set to true.
// GC is not a per-volume toggle — flipping it on applies to every VB this
// node constructs (nbdkit plugin + volume service).
gcEnabled := false
if nodeConfig.Viperblock.GCEnabled != nil {
gcEnabled = *nodeConfig.Viperblock.GCEnabled
}
encryptionKeyFile := nodeConfig.Viperblock.EncryptionKeyFile
if envKey := viper.GetString("viperblock-encryption-key-file"); envKey != "" {
encryptionKeyFile = envKey
@@ -315,6 +325,7 @@ var viperblockStartCmd = &cobra.Command{
BaseDir: nodeConfig.Predastore.BaseDir,
NodeName: clusterConfig.Node,
ShardWAL: shardWAL,
GCEnabled: gcEnabled,
EncryptionKeyFile: encryptionKeyFile,
})
@@ -365,7 +376,7 @@ var viperblockStatusCmd = &cobra.Command{
},
}
// Repeat for nats
// Repeat for nats.
var natsStartCmd = &cobra.Command{
Use: "start",
Short: "Start the nats service",
@@ -434,7 +445,7 @@ var natsStatusCmd = &cobra.Command{
},
}
// Repeat for spinifex
// Repeat for spinifex.
var spinifexStartCmd = &cobra.Command{
Use: "start",
Short: "Start the spinifex service",
@@ -793,20 +804,23 @@ var vpcdStartCmd = &cobra.Command{
defer initTelemetry("vpcd", false)()
svc, err := service.New("vpcd", &vpcd.Config{
NatsHost: nodeConfig.NATS.Host,
NatsToken: nodeConfig.NATS.ACL.Token,
NatsCACert: nodeConfig.NATS.CACert,
OVNNBAddr: nodeConfig.VPCD.OVNNBAddr,
OVNSBAddr: nodeConfig.VPCD.OVNSBAddr,
BaseDir: nodeConfig.BaseDir,
Debug: false,
ExternalMode: clusterConfig.Network.ExternalMode,
ExternalPools: extPools,
Bootstrap: bootstrap,
ExternalInterface: nodeConfig.VPCD.ExternalInterface,
BridgeMode: nodeConfig.VPCD.BridgeMode,
AZ: nodeConfig.AZ,
NATExemptCIDRs: clusterConfig.Network.NATExemptCIDRs,
NatsHost: nodeConfig.NATS.Host,
NatsToken: nodeConfig.NATS.ACL.Token,
NatsCACert: nodeConfig.NATS.CACert,
OVNNBAddr: nodeConfig.VPCD.OVNNBAddr,
OVNSBAddr: nodeConfig.VPCD.OVNSBAddr,
BaseDir: nodeConfig.BaseDir,
Debug: false,
ExternalMode: clusterConfig.Network.ExternalMode,
ExternalPools: extPools,
Bootstrap: bootstrap,
ExternalInterface: nodeConfig.VPCD.ExternalInterface,
BridgeMode: nodeConfig.VPCD.BridgeMode,
AZ: nodeConfig.AZ,
NorthstarBaseDomain: handlers_dns.ResolveBaseDomain(&nodeConfig),
NorthstarInternalDomain: handlers_dns.ResolveInternalDomain(&nodeConfig),
ResolverNameservers: handlers_dns.ResolverNameserverIPs(clusterConfig),
NATExemptCIDRs: clusterConfig.Network.NATExemptCIDRs,
})
if err != nil {
fmt.Println("Error starting vpcd service:", err)
@@ -849,6 +863,157 @@ var vpcdStatusCmd = &cobra.Command{
},
}
var northstarCmd = &cobra.Command{
Use: "northstar",
Short: "Manage the northstar (DNS) service",
}
// northstarStartOptions contains the command inputs that select Northstar configuration.
type northstarStartOptions struct {
configFile string
configOverride string
baseDirOverride string
}
// northstarStarter is the service operation required by Northstar activation.
type northstarStarter interface {
Start() (int, error)
}
// northstarStartDependencies isolates startup side effects for behavioral tests.
type northstarStartDependencies struct {
loadConfig func(string) (*config.ClusterConfig, error)
bootstrapBaseZone func(string, *config.ClusterConfig) error
newService func(*northstar.Config) (northstarStarter, error)
}
var northstarStartCmd = &cobra.Command{
Use: "start",
Short: "Start the northstar service",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Starting northstar service...")
defer initTelemetry("northstar", false)()
return runNorthstarStart(northstarStartOptions{
configFile: viper.GetString("config"),
configOverride: viper.GetString("northstar-config"),
baseDirOverride: viper.GetString("base-dir"),
}, northstarStartDependencies{
loadConfig: loadRequiredClusterConfig,
bootstrapBaseZone: northstar.BootstrapBaseZone,
newService: func(cfg *northstar.Config) (northstarStarter, error) {
return service.New("northstar", cfg)
},
})
},
}
// loadRequiredClusterConfig loads the service's explicit cluster configuration.
func loadRequiredClusterConfig(path string) (*config.ClusterConfig, error) {
if path == "" {
return nil, fmt.Errorf("config file is not set")
}
if _, err := os.Stat(path); err != nil {
return nil, fmt.Errorf("stat cluster config %s: %w", path, err)
}
clusterConfig, err := config.LoadConfig(path)
if err != nil {
return nil, fmt.Errorf("load cluster config %s: %w", path, err)
}
return clusterConfig, nil
}
// runNorthstarStart starts configured Northstar nodes and cleanly skips nodes where it is optional.
func runNorthstarStart(options northstarStartOptions, deps northstarStartDependencies) error {
clusterConfig, err := deps.loadConfig(options.configFile)
if err != nil {
return err
}
nodeConfig, ok := clusterConfig.Nodes[clusterConfig.Node]
if !ok {
return fmt.Errorf("node %q not found in cluster config", clusterConfig.Node)
}
configPath := resolveNorthstarConfigPath(nodeConfig.Northstar.ConfigPath, options.configOverride)
if options.configOverride != "" {
fmt.Println("Overwriting northstar config path to:", options.configOverride)
}
if configPath == "" {
// An absent path disables this optional service and must not trigger systemd restarts.
slog.Info("northstar is not configured on this node; skipping service start")
return nil
}
baseDir := nodeConfig.BaseDir
if options.baseDirOverride != "" {
baseDir = options.baseDirOverride
}
// Seed the default_domain base zone before the read-only daemon starts.
// The S3 polling path remains the backstop when this best-effort seed fails.
if err := deps.bootstrapBaseZone(configPath, clusterConfig); err != nil {
slog.Warn("northstar base zone bootstrap failed (continuing)", "error", err)
}
svc, err := deps.newService(&northstar.Config{
ConfigPath: configPath,
BasePath: baseDir,
NodeID: nodeConfig.Predastore.NodeID,
NatsHost: nodeConfig.NATS.Host,
NatsToken: nodeConfig.NATS.ACL.Token,
NatsCACert: nodeConfig.NATS.CACert,
})
if err != nil {
return fmt.Errorf("create northstar service: %w", err)
}
if _, err := svc.Start(); err != nil {
return fmt.Errorf("start northstar service: %w", err)
}
fmt.Println("northstar service started")
return nil
}
// resolveNorthstarConfigPath applies the explicit override before the node config.
func resolveNorthstarConfigPath(nodePath, override string) string {
if override != "" {
return override
}
return nodePath
}
var northstarStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the northstar service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping northstar service...")
svc, err := service.New("northstar", &northstar.Config{})
if err != nil {
fmt.Println("Error stopping northstar service:", err)
return
}
if err = svc.Stop(); err != nil {
fmt.Println("Error stopping northstar service:", err)
os.Exit(1)
}
fmt.Println("northstar service stopped")
},
}
var northstarStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the northstar service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("northstar service status: ...")
},
}
var qmpCollectorCmd = &cobra.Command{
Use: "qmp-collector",
Short: "Manage the qmp-collector (guest metrics) service",
@@ -970,11 +1135,6 @@ func init() {
viper.BindEnv("encryption-key-file", "SPINIFEX_PREDASTORE_ENCRYPTION_KEY_FILE")
viper.BindPFlag("encryption-key-file", predastoreCmd.PersistentFlags().Lookup("encryption-key-file"))
// Predastore Backend
predastoreCmd.PersistentFlags().String("backend", "distributed", "Predastore (S3) backend")
viper.BindEnv("backend", "SPINIFEX_PREDASTORE_BACKEND")
viper.BindPFlag("backend", predastoreCmd.PersistentFlags().Lookup("backend"))
// Predastore Node ID. Default -1 is dev mode (launch every configured
// QUIC node in-process). Production deployments set this to the node's
// real ID (>= 1) via SPINIFEX_PREDASTORE_NODE_ID or --node-id.
@@ -1120,6 +1280,17 @@ func init() {
vpcdCmd.AddCommand(vpcdStopCmd)
vpcdCmd.AddCommand(vpcdStatusCmd)
// northstar
serviceCmd.AddCommand(northstarCmd)
northstarCmd.PersistentFlags().String("northstar-config", "", "Path to northstar.toml (overrides config file)")
viper.BindEnv("northstar-config", "SPINIFEX_NORTHSTAR_CONFIG")
viper.BindPFlag("northstar-config", northstarCmd.PersistentFlags().Lookup("northstar-config"))
northstarCmd.AddCommand(northstarStartCmd)
northstarCmd.AddCommand(northstarStopCmd)
northstarCmd.AddCommand(northstarStatusCmd)
// qmp-collector
serviceCmd.AddCommand(qmpCollectorCmd)
+207
View File
@@ -0,0 +1,207 @@
package cmd
import (
"errors"
"testing"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/services/northstar"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type stubNorthstarService struct {
starts int
startErr error
}
func (s *stubNorthstarService) Start() (int, error) {
s.starts++
return 0, s.startErr
}
func TestRunNorthstarStartActivation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
nodePath string
override string
wantPath string
wantConfigured bool
}{
{
name: "unconfigured node is skipped",
},
{
name: "node config starts service",
nodePath: "/etc/spinifex/northstar/northstar.toml",
wantPath: "/etc/spinifex/northstar/northstar.toml",
wantConfigured: true,
},
{
name: "override starts service",
override: "/run/spinifex/northstar.toml",
wantPath: "/run/spinifex/northstar.toml",
wantConfigured: true,
},
{
name: "override takes precedence",
nodePath: "/etc/spinifex/northstar/northstar.toml",
override: "/run/spinifex/northstar.toml",
wantPath: "/run/spinifex/northstar.toml",
wantConfigured: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
clusterConfig := &config.ClusterConfig{
Node: "node1",
Nodes: map[string]config.Config{
"node1": {
BaseDir: "/var/lib/spinifex/northstar",
Northstar: config.NorthstarConfig{ConfigPath: tt.nodePath},
},
},
}
stub := &stubNorthstarService{}
bootstrapCalls := 0
factoryCalls := 0
var startedConfig *northstar.Config
deps := northstarStartDependencies{
loadConfig: func(string) (*config.ClusterConfig, error) {
return clusterConfig, nil
},
bootstrapBaseZone: func(path string, cfg *config.ClusterConfig) error {
bootstrapCalls++
assert.Equal(t, tt.wantPath, path)
assert.Same(t, clusterConfig, cfg)
return nil
},
newService: func(cfg *northstar.Config) (northstarStarter, error) {
factoryCalls++
startedConfig = cfg
return stub, nil
},
}
err := runNorthstarStart(northstarStartOptions{
configFile: "/etc/spinifex/spinifex.toml",
configOverride: tt.override,
}, deps)
require.NoError(t, err)
if !tt.wantConfigured {
assert.Zero(t, bootstrapCalls)
assert.Zero(t, factoryCalls)
assert.Zero(t, stub.starts)
assert.Nil(t, startedConfig)
return
}
assert.Equal(t, 1, bootstrapCalls)
assert.Equal(t, 1, factoryCalls)
assert.Equal(t, 1, stub.starts)
require.NotNil(t, startedConfig)
assert.Equal(t, tt.wantPath, startedConfig.ConfigPath)
})
}
}
func TestLoadRequiredClusterConfigRejectsMissingFile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
}{
{name: "empty path"},
{name: "missing path", path: t.TempDir() + "/missing.toml"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := loadRequiredClusterConfig(tt.path)
require.Error(t, err)
})
}
}
func TestRunNorthstarStartReturnsLoadFailure(t *testing.T) {
t.Parallel()
loadErr := errors.New("cluster config unavailable")
deps := northstarStartDependencies{
loadConfig: func(string) (*config.ClusterConfig, error) {
return nil, loadErr
},
}
err := runNorthstarStart(northstarStartOptions{configFile: "/etc/spinifex/spinifex.toml"}, deps)
require.ErrorIs(t, err, loadErr)
}
func TestRunNorthstarStartContinuesAfterBootstrapFailure(t *testing.T) {
t.Parallel()
bootstrapErr := errors.New("predastore unavailable")
stub := &stubNorthstarService{}
deps := northstarStartDependencies{
loadConfig: func(string) (*config.ClusterConfig, error) {
return &config.ClusterConfig{
Node: "node1",
Nodes: map[string]config.Config{
"node1": {Northstar: config.NorthstarConfig{ConfigPath: "/etc/spinifex/northstar/northstar.toml"}},
},
}, nil
},
bootstrapBaseZone: func(string, *config.ClusterConfig) error { return bootstrapErr },
newService: func(*northstar.Config) (northstarStarter, error) {
return stub, nil
},
}
err := runNorthstarStart(northstarStartOptions{configFile: "/etc/spinifex/spinifex.toml"}, deps)
require.NoError(t, err)
assert.Equal(t, 1, stub.starts)
}
func TestRunNorthstarStartRejectsMissingNode(t *testing.T) {
t.Parallel()
deps := northstarStartDependencies{
loadConfig: func(string) (*config.ClusterConfig, error) {
return &config.ClusterConfig{Node: "missing", Nodes: map[string]config.Config{}}, nil
},
}
err := runNorthstarStart(northstarStartOptions{configFile: "/etc/spinifex/spinifex.toml"}, deps)
require.EqualError(t, err, `node "missing" not found in cluster config`)
}
func TestRunNorthstarStartReturnsConfiguredFailure(t *testing.T) {
t.Parallel()
startErr := errors.New("configured startup failed")
stub := &stubNorthstarService{startErr: startErr}
deps := northstarStartDependencies{
loadConfig: func(string) (*config.ClusterConfig, error) {
return &config.ClusterConfig{
Node: "node1",
Nodes: map[string]config.Config{
"node1": {Northstar: config.NorthstarConfig{ConfigPath: "/etc/spinifex/northstar/northstar.toml"}},
},
}, nil
},
bootstrapBaseZone: func(string, *config.ClusterConfig) error { return nil },
newService: func(*northstar.Config) (northstarStarter, error) {
return stub, nil
},
}
err := runNorthstarStart(northstarStartOptions{configFile: "/etc/spinifex/spinifex.toml"}, deps)
require.ErrorIs(t, err, startErr)
assert.Equal(t, 1, stub.starts)
}
+40
View File
@@ -0,0 +1,40 @@
# Northstar DNS service configuration (TOML).
# Written by `spx admin init`; read by the spinifex northstar service.
# Contains bucket-scoped S3 credentials — keep mode 0600.
# UDP+TCP bind addresses (comma separated). :5300 binds the wildcard for local
# tooling; :53 binds the node WAN IP specifically (via CAP_NET_BIND_SERVICE) so
# instances resolve against the node directly. Binding the WAN IP rather than
# 0.0.0.0 avoids the systemd-resolved stub on 127.0.0.53:53. A future per-VPC
# resolver VIP (OVN) will front :53.
listen = "0.0.0.0:5300,{{if .AdvertiseIP}}{{.AdvertiseIP}}{{else}}{{.BindIP}}{{end}}:53"
dot_listen = ""
doh_listen = ""
tls_cert = ""
tls_key = ""
# Authoritative base domain for internal resource names.
default_domain = "{{.NorthstarDefaultDomain}}"
# AWS-parity private DNS zone for instance ip-<addr> records (IMDS hostname).
internal_domain = "{{.NorthstarInternalDomain}}"
# Seconds between S3 zone re-scans (anti-entropy; live changes arrive via NATS).
sync_interval = 30
# DNS (Route 53) service quotas (default off, mirrors [quota] in awsgw.toml).
# Applied at daemon start; restart to change.
[quotas]
enabled = false
records_per_hosted_zone = 10000 # AWS default; enforced only when enabled
[s3]
endpoint = "https://{{if eq .BindIP "0.0.0.0"}}127.0.0.1{{else}}{{.BindIP}}{{end}}:8443"
bucket = "{{.NorthstarBucket}}"
region = "{{.Region}}"
access_key = "{{.NorthstarAccessKey}}"
secret_key = "{{.NorthstarSecretKey}}"
# Forwarders for non-authoritative (recursive) queries. Empty => REFUSED.
[upstream]
nameservers = [{{range $i, $s := .PoolDNSServers}}{{if $i}}, {{end}}"{{$s}}:53"{{end}}]
@@ -46,13 +46,40 @@ public = false
encryption = ""
account_id = "000000000000"
{{- if .NorthstarAccessKey}}
# Northstar DNS zone storage. Read-only for the northstar service; the control
# plane writes zone files with the system credentials.
[[buckets]]
name = "{{.NorthstarBucket}}"
region = "{{.Region}}"
type = "distributed"
public = false
encryption = ""
account_id = "000000000000"
{{- end}}
[[auth]]
access_key_id = "{{.AccessKey}}"
secret_access_key = "{{.SecretKey}}"
account_id = "000000000000"
policy = [
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
{{- if .NorthstarAccessKey}}
{ bucket = "{{.NorthstarBucket}}", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject"] },
{{- end}}
]
{{- if .NorthstarAccessKey}}
# Bucket-scoped, read-only credentials for the northstar service (isolation
# guarantee: ListBucket + GetObject on the northstar bucket only).
[[auth]]
access_key_id = "{{.NorthstarAccessKey}}"
secret_access_key = "{{.NorthstarSecretKey}}"
account_id = "000000000000"
policy = [
{ bucket = "{{.NorthstarBucket}}", actions = ["s3:ListBucket", "s3:GetObject"] },
]
{{- end}}
# API request throttling (per-account, per-action token bucket)
[ratelimit]
@@ -75,13 +75,40 @@ public = false
encryption = ""
account_id = "000000000000"
{{- if .NorthstarAccessKey}}
# Northstar DNS zone storage. Read-only for the northstar service; the control
# plane writes zone files with the system credentials.
[[buckets]]
name = "{{.NorthstarBucket}}"
region = "{{.Region}}"
type = "distributed"
public = false
encryption = ""
account_id = "000000000000"
{{- end}}
[[auth]]
access_key_id = "{{.AccessKey}}"
secret_access_key = "{{.SecretKey}}"
account_id = "000000000000"
policy = [
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
{{- if .NorthstarAccessKey}}
{ bucket = "{{.NorthstarBucket}}", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject"] },
{{- end}}
]
{{- if .NorthstarAccessKey}}
# Bucket-scoped, read-only credentials for the northstar service (isolation
# guarantee: ListBucket + GetObject on the northstar bucket only).
[[auth]]
access_key_id = "{{.NorthstarAccessKey}}"
secret_access_key = "{{.NorthstarSecretKey}}"
account_id = "000000000000"
policy = [
{ bucket = "{{.NorthstarBucket}}", actions = ["s3:ListBucket", "s3:GetObject"] },
]
{{- end}}
# API request throttling (per-account, per-action token bucket)
[ratelimit]
+17
View File
@@ -39,6 +39,10 @@ gateway = "{{.Gateway}}"
{{- if .GatewayIP}}
gateway_ip = "{{.GatewayIP}}"
{{- end}}
{{- if .GwLrpRangeStart}}
gw_lrp_range_start = "{{.GwLrpRangeStart}}"
gw_lrp_range_end = "{{.GwLrpRangeEnd}}"
{{- end}}
prefix_len = {{if .PrefixLen}}{{.PrefixLen}}{{else}}24{{end}}
{{- if .DNSServers}}
dns_servers = [{{range $i, $s := .DNSServers}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
@@ -125,6 +129,14 @@ secretkey = "{{.SecretKey}}"
{{- if gt .PredastoreNodeID 0}}
node_id = {{.PredastoreNodeID}}
{{- end}}
{{- if .NorthstarConfigPath}}
[nodes.{{.Node}}.northstar]
config_path = "{{.NorthstarConfigPath}}"
default_domain = "{{.NorthstarDefaultDomain}}"
internal_domain = "{{.NorthstarInternalDomain}}"
{{- end}}
{{- range .RemoteNodes}}
[nodes.{{.Name}}]
@@ -135,4 +147,9 @@ az = "{{.AZ}}"
{{- if .Services}}
services = [{{range $i, $s := .Services}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
{{- end}}
{{- if .NorthstarConfigPath}}
[nodes.{{.Name}}.northstar]
config_path = "{{.NorthstarConfigPath}}"
{{- end}}
{{- end}}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
// Version variables set via ldflags at build time.
// Example: go build -ldflags "-X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=v1.0.0"
// Example: go build -ldflags "-X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=v1.0.0".
var (
Version = "dev"
Commit = "unknown"
+23 -7
View File
@@ -31,6 +31,9 @@ Service lifecycle commands for starting, stopping, and checking status of all Sp
| `spx service vpcd start` | — | Loads cluster config → starts VPC daemon (subscribes to `vpc.*` NATS events, translates to OVN logical switches/ports/routers) |
| `spx service vpcd stop` | — | Stops the vpcd service |
| `spx service vpcd status` | — | Reports vpcd service status |
| `spx service northstar start` | `--northstar-config` (overrides `nodes.<node>.northstar.config_path`) | Loads cluster config → reads `northstar.toml` → starts the northstar DNS server (authoritative for internal `*.spx3.net`, recursive via upstream forwarders), syncing zones from its S3 bucket. Guests resolve via `169.254.169.253`, served by vpcd's per-tap DNS shim which forwards to northstar |
| `spx service northstar stop` | — | Stops the northstar service |
| `spx service northstar status` | — | Reports northstar service status |
| `spx service qmp-collector start` | — | Starts the guest-metrics collector (polls per-VM telemetry QMP sockets + tap counters, publishes CloudWatch-shaped series to NATS `metrics.ec2.*`) |
| `spx service qmp-collector stop` | — | Stops the qmp-collector service |
| `spx service qmp-collector status` | — | Reports qmp-collector service status |
@@ -54,7 +57,7 @@ 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), `--no-external` | 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/` |
| `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 |
### Version
@@ -171,6 +174,11 @@ read.
| `spx admin gpu mig enable` | `--profile <name>` (required, e.g. `1g.10gb`), `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → enables MIG mode on each target (`gpu.EnableMIGMode`) → lists available profiles (`gpu.ListProfiles`) and validates requested profile name → destroys any existing instances (`gpu.DestroyAllInstances`) → creates new instances filling GPU capacity (`gpu.CreateInstances`) → writes `mig_profile` to `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. |
| `spx admin gpu mig disable` | `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → destroys all GPU instances (`gpu.DestroyAllInstances`) → disables MIG mode (`gpu.DisableMIGMode`) → clears `mig_profile` in `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. |
### EKS Control-Plane Disaster Recovery
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin eks restore-snapshot` | `--cluster` (required), `--snapshot` (optional, defaults to the latest snapshot in predastore), `--account` (optional, defaults to the bootstrap account) | Single-CP total-loss DR path (fail-safe): validates the snapshot exists in predastore BEFORE any mutation (a typo'd/missing key hard-fails, never resets into an empty datastore) → launches a fresh control-plane VM as a cluster-init seed (replaying the persisted create-time launch template) → sets a required-snapshot `RecoveryDirective` (`cluster-reset`) so the boot-time recovery agent aborts rather than resets-into-empty if it cannot fetch the snapshot → persists the replacement in cluster meta BEFORE re-pointing the NLB (so an NLB failure is convergeable by the reconciler, returned as a provisional status, not a hard error) → re-points the cluster NLB's apiserver and konnectivity target groups from the old CP's ENI to the new one → fences the old CP with retries, failing loudly if it cannot be confirmed terminated (split-brain guard). Any failure before the meta commit unwinds the fresh CP (terminate + clear directive) so a re-run does not stack a second resetting control plane. The returned status is provisional — success means the sequence completed, not that etcd is restored and serving; verify cluster health. HA clusters (a spread with a potentially surviving quorum) are rejected — recover those via quorum reformation instead. |
## AWS-Compatible API
@@ -178,7 +186,7 @@ read.
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `run-instances` | `--image-id`, `--instance-type`, `--count`, `--key-name`, `--user-data`, `--subnet-id`, `--security-group-ids`, `--tag-specifications` (instance-scoped), `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination), `--placement` (GroupName), `--iam-instance-profile` (Name/Arn), `--capacity-reservation-specification` (CapacityReservationTarget.CapacityReservationId, targeted-by-id only), `--metadata-options` (HttpPutResponseHopLimit; IMDSv2-only enforced — rejects `http-tokens=optional`) | `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--launch-template`, `--hibernate-options` | **DONE** |
| `run-instances` | `--image-id`, `--instance-type`, `--count`, `--key-name`, `--user-data`, `--subnet-id`, `--security-group-ids`, `--tag-specifications` (instance-scoped), `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination), `--placement` (GroupName), `--iam-instance-profile` (Name/Arn), `--capacity-reservation-specification` (CapacityReservationTarget.CapacityReservationId, targeted-by-id only), `--metadata-options` (HttpPutResponseHopLimit; IMDSv2-only enforced — rejects `http-tokens=optional`), `--launch-template` (LaunchTemplateId/LaunchTemplateName, Version — resolves `$Default`/`$Latest`; direct params override the template) | `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--hibernate-options` | **DONE** |
| `describe-instances` | `--instance-ids`, `--filters` (instance-state-name, instance-id, instance-type, vpc-id, subnet-id, tag:*, tag-key, tag-value) | `--max-results`, `--next-token`, `--dry-run` | **DONE** |
| `start-instances` | `--instance-ids` | `--dry-run`, `--force` | **DONE** |
| `stop-instances` | `--instance-ids` | `--force`, `--hibernate`, `--dry-run` | **DONE** |
@@ -480,11 +488,13 @@ EIP handlers are always registered. Without a public IPAM pool (external mode di
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `create-launch-template` | — | `--launch-template-name`, `--launch-template-data`, `--tag-specifications` | **NOT STARTED** |
| `create-launch-template-version` | — | `--launch-template-id`/`--launch-template-name`, `--launch-template-data`, `--source-version` | **NOT STARTED** |
| `delete-launch-template` | — | `--launch-template-id`/`--launch-template-name`, `--dry-run` | **NOT STARTED** |
| `describe-launch-templates` | — | `--launch-template-ids`, `--launch-template-names`, `--filters` | **NOT STARTED** |
| `describe-launch-template-versions` | — | `--launch-template-id`/`--launch-template-name`, `--versions`, `--min-version`, `--max-version` | **NOT STARTED** |
| `create-launch-template` | `--launch-template-name`, `--launch-template-data` (full nested RequestLaunchTemplateData), `--version-description`, `--tag-specifications` (launch-template-scoped), `--dry-run` (no-op) | `--client-token` (idempotency) | **DONE** |
| `create-launch-template-version` | `--launch-template-id`/`--launch-template-name`, `--launch-template-data`, `--source-version` (clone-and-override), `--version-description`, `--dry-run` (no-op) | `--client-token`, `--resolve-alias` | **DONE** |
| `delete-launch-template` | `--launch-template-id`/`--launch-template-name`, `--dry-run` (no-op) | — | **DONE** |
| `delete-launch-template-versions` | `--launch-template-id`/`--launch-template-name`, `--versions` (rejects the current default version), `--dry-run` (no-op) | — | **DONE** |
| `modify-launch-template` | `--launch-template-id`/`--launch-template-name`, `--default-version`, `--dry-run` (no-op) | — | **DONE** |
| `describe-launch-templates` | `--launch-template-ids`, `--launch-template-names`, `--filters` (launch-template-id, launch-template-name, create-time, tag:*, tag-key) | `--max-results`, `--next-token`, `--dry-run` | **DONE** |
| `describe-launch-template-versions` | `--launch-template-id`/`--launch-template-name`, `--versions` (`$Default`/`$Latest`/numeric), `--min-version`, `--max-version`, `--filters` (is-default-version, image-id, instance-type, kernel-id, ram-disk-id, ebs-optimized) | `--max-results`, `--next-token`, `--dry-run`, `--resolve-alias` | **DONE** |
### EC2 — Dedicated Hosts, IPv4 Pools, DHCP, Capacity Reservations
@@ -662,6 +672,12 @@ All IAM operations are account-scoped. Root user (account `000000000000`) bypass
| `delete-group-policy` | `--group-name`, `--policy-name` | — | **DONE** |
| `list-group-policies` | `--group-name` | `--max-items`, `--marker` | **DONE** |
### IAM — Account
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `get-account-summary` | — | — | **DONE** |
---
## STS
+7
View File
@@ -198,6 +198,12 @@ public IPs, no Elastic IPs, and no inbound from WAN — all subnets behave as
private subnets with internet access. On routed-NAT nodes, adding a public pool
restores full public IP parity (see below).
> **Limitation (routed-NAT v1):** System instances (ECS/EKS/load-balancer
> agents) source egress from ExternalIPAM pool IPs, which do not exist in
> `external_mode=nat`. Those features require `external_mode=pool`. A v2 will
> either allocate transit IPs for system instances or reject the feature at the
> API level in nat mode.
The `gateway_ip` is the IP that OVN uses for SNAT. You can set it statically or
use `setup-ovn.sh --dhcp` to obtain one from the router. This is the router's
DHCP — not Spinifex's internal OVN DHCP for VMs.
@@ -549,6 +555,7 @@ dns_servers = ["8.8.8.8"] # DNS for VMs (optional)
| `region` | No | Scopes pool to a region. Instances in this region prefer this pool. |
| `az` | No | Scopes pool to an AZ. More specific than region. |
| `dns_servers` | No | DNS servers propagated to VMs via OVN DHCP. |
| `gw_lrp_range_start` / `gw_lrp_range_end` | No | Reserve gateway-LRP IPs for per-VPC OVN routers. When unset, the allocator auto-derives the top 16 host IPs of the pool subnet (~15 concurrent VPCs). Widen to raise the concurrent-VPC ceiling — the `nat-transit` pool defaults to `100.127.0.16`-`100.127.0.254` (239 VPCs). Must not overlap `range_start`/`range_end`. |
### Why range_start/range_end Instead of CIDR?
@@ -0,0 +1,453 @@
---
title: "EKS AI Platform on Bare Metal: llama.cpp + YOLO on Spinifex"
description: "Deploy a GPU-accelerated AI inference platform — an OpenAI-compatible LLM API and a real-time CV stream — in Kubernetes on bare-metal hardware, managed entirely with standard AWS tooling."
category: "Reference Architectures"
tags:
- nvidia
- rtx-pro-6000
- eks
- kubernetes
- llama.cpp
- yolo
- ecr
- bare-metal
- terraform
resources:
- title: "eks-ai-platform (Terraform workbook, Dockerfiles, scripts)"
url: "https://github.com/mulgadc/eks-ai-platform"
- title: "GPU Passthrough"
url: "/docs/gpu-passthrough"
- title: "Launching EKS Clusters"
url: "/docs/eks"
- title: "llama.cpp"
url: "https://github.com/ggml-org/llama.cpp"
- title: "Ultralytics YOLO"
url: "https://docs.ultralytics.com"
sections:
- overview
- prerequisites
- instructions
---
## Overview
Spinifex is an open-source infrastructure platform that brings core AWS services such as EC2, S3, EBS and EKS, to bare-metal, edge, and on-prem deployments. It exposes a fully AWS-compatible API, so any tooling that works against AWS — the `aws` CLI, OpenTofu, `kubectl` — works against a Spinifex node unchanged, with a single profile swap.
This guide walks through the use of Spinifex to deploy a self-contained AI inference platform on Supermicro's bare metal X14 platform using only standard AWS tooling. We use Terraform to create resources (wrapped in simple `make` commands) in the exact same way you would create AWS resources.
Specifically, we create an EKS cluster with two worker nodes, each consisting of a g7e.2xlarge EC2 instance with an attached GPU via VFIO passthrough, an ALB to route traffic to each node, ECR for storing and managing our workload images, and all of the associated security and certificate management requirements (IAM, ACM) you would expect from real AWS.
### Platform
| Component | Specification |
|---|---|
| **Chassis** | Supermicro X14 2U CloudDC with Intel Xeon 6730P |
| **GPUs** | 2× NVIDIA RTX Pro 6000 Blackwell Server Edition (96 GiB GDDR7 each, 192 GiB total) |
| **Storage** | 4× NVMe SSD: 2× 1.5 TB, 2× 880 GB — one 1.5 TB drive carries the OS; the remaining three back Predastore |
| **Spinifex instance family** | `g7e` — one RTX Pro 6000 per instance via VFIO PCIe passthrough |
| **Kubernetes** | k3s, managed via Spinifex EKS API |
| **API endpoint** | `https://<host>:9999` (AWS-compatible) |
### Workloads
Three workloads run in the `inference` namespace of an EKS cluster backed by two GPU worker nodes:
| Pod | Role | GPU | Model | Image source |
|---|---|---|---|---|
| `llm-server` | OpenAI-compatible chat API (llama.cpp) | 1× RTX Pro 6000 | Llama 3.2 3B Instruct Q4_K_M GGUF | ECR `llm-server:latest` |
| `yolo-stream` | MJPEG object-detection stream (CUDA) | 1× RTX Pro 6000 | YOLO11x | ECR `yolo-stream:latest` |
| `ai-dashboard` | Web UI: chat + live detection feed | CPU only | — | ECR `ai-dashboard:latest` |
Both GPU workloads bake their model weights into the Docker image at build time — `llm-server`'s GGUF weights (~2 GiB) and `yolo-stream`'s YOLO11x checkpoint (~109 MB) are present in the image when it starts.
`yolo-stream` renders a 1280×720 sample video through YOLO11x once on startup (~25 s), caches the annotated frames in memory, then serves `/stream` from that cache — smooth MJPEG playback decoupled from per-frame inference cost after the initial warm-up.
The two GPU nodes are split one-per-workload. With one RTX Pro 6000 per node and both pods requesting `nvidia.com/gpu: "1"`, the scheduler cannot fit both on the same node, so the split happens naturally without explicit affinity rules.
## Architecture
<p align="center"><img src="../../../.github/assets/images/rtx6000/ai-platform-request-flow-2.png" alt="AI Platform request flow: HTTPS ingress → ALB routing → EKS ai-platform GPU workers"></p>
### AWS services exercised
| Service | Role |
|---|---|
| **IAM** | Cluster role, node role (with ECR, LBC, EBS-CSI permissions inline), viewer access entry |
| **ECR** | Private registry for all three images; source of truth for builds, though nodes receive images via sideload rather than live OCI pull for this demo |
| **EC2** | 2× GPU microVM (`g7e.2xlarge`), one RTX Pro 6000 each via VFIO PCIe passthrough |
| **EKS** | Cluster, GPU nodegroup (`desired_size = 2`), LBC and EBS-CSI managed addons, access entries |
| **ELBv2** | ALB provisioned by the LBC addon; single shared IngressGroup across all three services |
| **ACM** | Self-signed cert (`ai-platform.spinifex.local`) imported and attached to the ALB HTTPS listener |
| **EBS (Viperblock)** | 200 GB root volume per GPU worker node, provisioned by the nodegroup (`disk_size = 200`)|
All permissions for the LBC and EBS-CSI addons are attached directly to the node role. Both addons support IRSA, but fall back to the node's instance profile when no `service_account_role_arn` is supplied — sufficient for a single-cluster deployment.
## Prerequisites
### On the Spinifex host
**1. Configure host-local VPC networking**
Spinifex uses bridged networking via OVN. The X14 exposes a single public IP on one physical NIC — before installing Spinifex, ensure `br-wan` exists with the physical NIC enslaved to it, and attach a host-local address range for EC2 instance communication:
```yaml
# /etc/netplan/…
bridges:
br-wan:
addresses:
- 192.168.10.1/24 # VM pool gateway — host-local
- <existing-wan-ip>/<prefix> # existing WAN IP — unchanged
routes:
- to: default
via: <upstream-gateway>
```
Apply with `sudo netplan apply`. For outbound connectivity from instances through the host's WAN interface:
```bash
sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -s 192.168.10.0/24 -o br-wan -j MASQUERADE
```
The full process is described in the [VPC Networking](/docs/vpc-networking#host-local-subnet-no-upstream-router) guide.
**2. Install Spinifex**
Follow the [Single Node Install](/docs/install) guide. This installs Spinifex and starts all services, however `spinifex.toml` needs to be edited to wire up the bridge created in the previous step, as described in the following section.
**3. Configure spinifex.toml and restart services**
Edit `/etc/spinifex/spinifex.toml` to point the external pool at the bridge address range created in step 1:
```toml
[network]
external_mode = "pool"
[[network.external_pools]]
name = "wan"
source = "static"
range_start = "192.168.10.2"
range_end = "192.168.10.100"
gateway = "192.168.10.1"
prefix_len = 24
dns_servers = ["8.8.8.8"]
```
Then restart all services:
```bash
sudo systemctl restart spinifex.target
sudo systemctl status spinifex.target
```
**4. Bind the GPUs to VFIO**
```bash
sudo spx admin gpu setup
# Reboot, then:
sudo spx admin gpu enable
```
Confirm both GPUs are bound:
```bash
lspci -d 10de: -nn
# Expect: NVIDIA Corporation Device [10de:2bb5] appearing twice
```
**5. Attach Predastore storage**
The X14 has four NVMe drives: two 1.5 TB SSDs (one carries the OS) and two ~880 GB SSDs. Predastore is distributed across the three non-OS drives — one storage node per physical drive, with Reed–Solomon redundancy so a single drive failure is recoverable.
Confirm drive assignments with `lsblk` before proceeding, as device names vary between systems.
```bash
lsblk # identify the OS drive and the three data drives
# Stop services so Predastore isn't writing while we relocate its data directories
sudo systemctl stop spinifex.target
# --- Repeat the block below for each data drive (node-1/nvme-1, node-2/nvme-2, node-3/nvme-3) ---
# Drive 1: non-OS 1.5 TB (e.g. /dev/nvme1n1)
sudo mkdir -p /mnt/nvme-1
sudo mount /dev/nvme1n1 /mnt/nvme-1
sudo mkdir -p /mnt/nvme-1/nodes /mnt/nvme-1/db
sudo mv /var/lib/spinifex/predastore/distributed/nodes/node-1 /mnt/nvme-1/nodes/node-1
sudo mv /var/lib/spinifex/predastore/distributed/db/node-1 /mnt/nvme-1/db/node-1
sudo ln -s /mnt/nvme-1/nodes/node-1 /var/lib/spinifex/predastore/distributed/nodes/node-1
sudo ln -s /mnt/nvme-1/db/node-1 /var/lib/spinifex/predastore/distributed/db/node-1
echo "/dev/nvme1n1 /mnt/nvme-1 auto defaults 0 2" | sudo tee -a /etc/fstab
# Drive 2: first ~880 GB drive (e.g. /dev/nvme2n1)
sudo mkdir -p /mnt/nvme-2
sudo mount /dev/nvme2n1 /mnt/nvme-2
sudo mkdir -p /mnt/nvme-2/nodes /mnt/nvme-2/db
sudo mv /var/lib/spinifex/predastore/distributed/nodes/node-2 /mnt/nvme-2/nodes/node-2
sudo mv /var/lib/spinifex/predastore/distributed/db/node-2 /mnt/nvme-2/db/node-2
sudo ln -s /mnt/nvme-2/nodes/node-2 /var/lib/spinifex/predastore/distributed/nodes/node-2
sudo ln -s /mnt/nvme-2/db/node-2 /var/lib/spinifex/predastore/distributed/db/node-2
echo "/dev/nvme2n1 /mnt/nvme-2 auto defaults 0 2" | sudo tee -a /etc/fstab
# Drive 3: second ~880 GB drive (e.g. /dev/nvme3n1)
sudo mkdir -p /mnt/nvme-3
sudo mount /dev/nvme3n1 /mnt/nvme-3
sudo mkdir -p /mnt/nvme-3/nodes /mnt/nvme-3/db
sudo mv /var/lib/spinifex/predastore/distributed/nodes/node-3 /mnt/nvme-3/nodes/node-3
sudo mv /var/lib/spinifex/predastore/distributed/db/node-3 /mnt/nvme-3/db/node-3
sudo ln -s /mnt/nvme-3/nodes/node-3 /var/lib/spinifex/predastore/distributed/nodes/node-3
sudo ln -s /mnt/nvme-3/db/node-3 /var/lib/spinifex/predastore/distributed/db/node-3
echo "/dev/nvme3n1 /mnt/nvme-3 auto defaults 0 2" | sudo tee -a /etc/fstab
# --- End of per-drive block ---
sudo systemctl start spinifex.target
```
Verify the nodes are healthy before proceeding:
```bash
export AWS_PROFILE=spinifex
aws s3 ls
# Should return without error (empty bucket list is fine)
```
**6. Verify the GPU instance type**
```bash
sudo spx admin gpu status
```
This will print confirmation that GPU passthrough has been configured correctly along with the available GPU instance types. The RTX Pro 6000 Blackwell Server Edition (PCI device `10de:2bb5`) maps to the `g7e` family. The workbook defaults to `g7e.2xlarge` (one GPU per node); override with `GPU_TYPE=g7e.4xlarge` (or the size your host reports) if needed. Do not use `g7e.12xlarge` — that is the 2× GPU size.
### Local tooling
- [OpenTofu](https://opentofu.org/) >= 1.6
- [kubectl](https://kubernetes.io/docs/tasks/tools/)
- [Docker](https://docs.docker.com/get-docker/)
### Clone the workbook
```bash
git clone https://github.com/mulgadc/eks-ai-platform
cd eks-ai-platform
```
## Instructions
### 1. Import the EKS GPU node AMI
GPU worker nodes require the `ecr-credential-provider` binary so kubelet can call `GetAuthorizationToken` against ECR. This binary is included in the dedicated EKS GPU node AMI in the Spinifex image catalogue. List available images and import it:
```bash
spx admin images list
# Look for the EKS GPU node image
spx admin images import --name spinifex-eks-node-gpu
```
Confirm the AMI is registered:
```bash
aws ec2 describe-images --query 'Images[*].[Name,ImageId]' --output table
```
### 2. Provision the cluster
```bash
make infra ENDPOINT=https://<host>:9999
```
This provisions the VPC (`10.33.0.0/16`, two public and two private subnets with a NAT gateway), IAM roles, ECR repositories, EKS cluster, GPU nodegroup (2× `g7e.2xlarge`, 200 GB disk each), LBC and EBS-CSI managed addons, a self-signed ACM certificate, and the NodePort security group rules the ALB needs to reach the worker nodes.
Update your kubeconfig once the cluster reports `ACTIVE`:
```bash
$(tofu -chdir=workbook output -raw update_kubeconfig)
kubectl get nodes
# Expect: 2 Ready nodes in the gpu-workers nodegroup
```
The Makefile wraps `tofu -chdir=workbook apply -var spinifex_endpoint=... -var gpu_instance_type=...` — running Tofu directly is equivalent and lets you pass any additional variables. The workbook provisions `aws_vpc`, `aws_subnet` (two public, two private), `aws_eks_cluster`, `aws_eks_node_group` (two `g7e.2xlarge` nodes, each with a 200 GB Viperblock root volume via `disk_size = 200`), `aws_eks_addon` for LBC and EBS-CSI, three `aws_ecr_repository` resources, three IAM roles, and a self-signed `aws_acm_certificate` — all via Spinifex's AWS-compatible endpoint at `:9999`.
### 3. Build and push container images
```bash
make images
```
This authenticates to ECR, then builds and pushes all three images:
- **`llm-server`** — based on `ghcr.io/ggml-org/llama.cpp:server-cuda`; downloads Llama 3.2 3B Instruct Q4_K_M GGUF (~2 GiB) from Hugging Face at build time and bakes it into the image. Exposes an OpenAI-compatible `/v1/chat/completions` API.
- **`yolo-stream`** — based on `pytorch/pytorch:2.7.1-cuda12.8-cudnn9-runtime`; installs Ultralytics and downloads YOLO11x weights at build time. PyTorch 2.7.1+cu128 is required: the RTX Pro 6000 Blackwell is compute capability sm_120, and earlier PyTorch releases ship no sm_120 kernels.
- **`ai-dashboard`** — lightweight Flask proxy (`FROM python:3.11-slim`) that aggregates the LLM API and YOLO stream into a single page.
The ECR registry URI always includes `:9999` — for example, `<account>.dkr.ecr.ap-southeast-2.<suffix>:9999`. Use the `ecr_registry` Tofu output directly in `docker login` and image references; do not construct the hostname manually.
Image URIs come from `tofu -chdir=workbook output -raw ecr_registry`. ECR authentication uses the same API as AWS: `aws ecr get-login-password` calls `GetAuthorizationToken` against the Spinifex ECR endpoint and returns a short-lived JWT that Docker accepts as a registry password. The `make images` target is equivalent to running those `docker build` and `docker push` commands directly against `$REGISTRY` from that Tofu output.
### 4. Sideload images onto GPU worker nodes
```bash
make sideload
```
ECR is the source of truth for all three images — authentication, push, and registry management all work identically to AWS. In a standard EKS deployment, nodes would pull images directly from ECR at scheduling time. In this demo, live pulls of these image sizes (~2 GiB for `llm-server`, ~4.5 GiB for `yolo-stream`) through the Spinifex ECR gateway proved unreliable — large transfers stalled or failed mid-stream, a combination of network conditions on this single-host setup and a rough edge in early Spinifex ECR support. This step works around that by staging the images directly into each node's containerd store before the pods are scheduled.
It exports each image from the local Docker daemon, serves the tarballs over HTTP from the Spinifex host, and imports them directly into each worker node's containerd store via short-lived privileged pods. All three Deployments use `imagePullPolicy: IfNotPresent` and depend on the images already being present in containerd.
The script also labels the two GPU nodes deterministically (`workload=llm-server` / `workload=yolo-stream`, sorted by node name), and the Deployments use matching `nodeSelector` values so each pod lands on the node that already has its image. `ai-dashboard` has no GPU requirement and is imported on both nodes since it can schedule onto either.
Expect several minutes for `yolo-stream`'s image (CUDA + PyTorch + YOLO11x weights, ~4.5 GiB).
This step has no Tofu equivalent — it operates directly on the running cluster via `kubectl`. The ECR registry URI is read from the Tofu state; node labelling uses `kubectl label` and image import uses short-lived privileged pods that run `ctr images import` into each node's containerd store.
### 5. Deploy workloads
```bash
make workloads ENDPOINT=https://<host>:9999
```
This deploys the NVIDIA GPU Operator via Helm, then all three application Deployments, ClusterIP and NodePort services, and the shared ALB Ingresses.
The GPU Operator requires two adjustments for k3s:
- `driver.enabled=false` — the NVIDIA driver is pre-built into the GPU AMI at image creation time; the Operator installs only the toolkit and device plugin.
- `CONTAINERD_SOCKET=/run/k3s/containerd/containerd.sock` — k3s bundles its own containerd at a different socket and config path than the standalone containerd default. Without this override the toolkit DaemonSet crash-loops with `no such file or directory`.
Watch the GPU Operator complete, then the inference pods come up:
```bash
kubectl -n gpu-operator get pods -w
kubectl -n inference get pods -w
```
Confirm each GPU node reports `nvidia.com/gpu: 1` in allocatables:
```bash
$(tofu -chdir=workbook output -raw gpu_verify_hint)
```
All three routes share a single ALB via `alb.ingress.kubernetes.io/group.name`. Explicit `group.order` values (`/v1` = 10, `/stream` = 20, `/` = 100) ensure the dashboard's catch-all path evaluates last — without them, the LBC sorts Ingress resources alphabetically, which places the catch-all first and swallows the other routes.
Retrieve the ALB IP (the DNS name `*.elb.spinifex.local` is a label, not a resolvable entry):
```bash
$(tofu -chdir=workbook output -raw alb_ip_hint)
ALB_IP=<output>
```
Validate the LLM endpoint:
```bash
curl -sk https://$ALB_IP/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"llama-3.2-3b-instruct","messages":[{"role":"user","content":"What is Spinifex?"}]}' \
| python3 -m json.tool
```
Open `https://$ALB_IP/` to reach the dashboard. If accessing from a remote machine:
```bash
ssh -L 8443:$ALB_IP:443 <spinifex-host>
# Open https://ai-platform.spinifex.local:8443/
# Add 127.0.0.1 ai-platform.spinifex.local to /etc/hosts if the browser requires hostname match
```
The workloads module reads cluster coordinates, ECR image URIs, NodePort values, and the ACM cert ARN from the parent module's Tofu state, then creates the NVIDIA GPU Operator `helm_release`, three `kubernetes_deployment_v1` resources, six `kubernetes_service_v1` resources (ClusterIP + NodePort per workload), and three `kubernetes_ingress_v1` resources — all through the Terraform Kubernetes and Helm providers, which authenticate to the cluster via `aws eks get-token`.
### Dashboard
After the successful completion of the `make workloads` step, the dashboard should be available and displaying the outputs of the two worker nodes; YOLO computer vision in the left pane, and a LLM chat in the right pane, as shown in the images below.
<p align="center"><img src="../../../.github/assets/images/rtx6000/yolo.png" alt="YOLO real-time object detection stream"></p>
<p align="center"><img src="../../../.github/assets/images/rtx6000/llm-chat.png" alt="LLM chat interface"></p>
### 6. Teardown
```bash
make destroy ENDPOINT=https://<host>:9999
```
Workloads are destroyed before infra. Both GPU worker instances terminate, immediately returning their RTX Pro 6000s to the Spinifex pool.
The Makefile runs the two-module destroy sequence — `tofu -chdir=workbook/workloads destroy` first, then `tofu -chdir=workbook destroy` — because the parent module's security group rules are referenced by the ALB created in the workloads layer. Running Tofu directly in that order is equivalent.
## Troubleshooting
### `llm-server` or `yolo-stream` pod stuck in `Pending`
The GPU Operator DaemonSet must complete before `nvidia.com/gpu` appears in node allocatables:
```bash
kubectl -n gpu-operator get daemonset -w
kubectl get node -o custom-columns=NAME:.metadata.name,GPU:.status.allocatable.'nvidia\.com/gpu'
```
If a pod shows `Insufficient nvidia.com/gpu` on a node that looks healthy, the `workload=` labels from `make sideload` may be stale — for example, after a nodegroup recreation that assigned new node names. Re-run `make sideload` to relabel the nodes and re-import the images.
### GPU worker instances fail to launch (`bind ... to vfio-pci: invalid argument`)
On hosts where the GPU's IOMMU group contains an upstream PCIe root-port bridge (no ACS isolation), Spinifex attempts to bind the bridge to `vfio-pci`. Bridges are non-endpoint devices that `vfio-pci` refuses to bind, causing the instance to crash immediately after launch:
```
GPU claim failed... bind IOMMU group member 0000:14:02.0: bind 0000:14:02.0 to vfio-pci: invalid argument
```
Check IOMMU group membership with `lspci -nnk` and `/sys/kernel/iommu_groups/*/devices/`. This is fixed in Spinifex by excluding bridge-class PCI devices from the bind lifecycle — ensure your Spinifex build includes that fix. If the failed bind left a bridge without a driver, restore it:
```bash
echo | sudo tee /sys/bus/pci/devices/<bridge-addr>/driver_override
echo <bridge-addr> | sudo tee /sys/bus/pci/drivers/pcieport/bind
```
If the nodegroup got wedged in `CREATING` after hitting this, delete it and let Terraform recreate it:
```bash
aws eks delete-nodegroup --cluster-name ai-platform --nodegroup-name gpu-workers
```
### Nodegroup stuck `CREATING` with healthy nodes, Tofu times out
`tofu apply` hangs for 20 minutes and fails with `workers did not become Ready: timed out`, even though `kubectl get nodes` shows both workers `Ready`. The cause is a missing `eks.amazonaws.com/nodegroup` label on the node — without it, the control plane never tallies the nodegroup as satisfied. Confirm with:
```bash
kubectl get node <worker> -o jsonpath='{.metadata.labels}'
```
This is fixed upstream in Spinifex. If stuck, delete both the nodegroup and cluster and let Terraform recreate them cleanly:
```bash
aws eks delete-nodegroup --cluster-name ai-platform --nodegroup-name gpu-workers
aws eks delete-cluster --name ai-platform
```
### ALB returns 502 immediately after rollout
Both `llm-server` and `yolo-stream` require a few seconds after container start before their readiness probes pass — CUDA and GPU Operator initialisation contribute to the delay. The ALB marks targets unhealthy during this window. Watch the pods become ready:
```bash
kubectl -n inference get pods -w
kubectl -n inference logs -f deploy/llm-server
```
## Conclusion
This guide demonstrates how Spinifex turns a single bare-metal chassis into a production-shaped AI serving platform managed entirely with standard AWS tooling. IAM roles, ECR repositories, an EKS cluster, GPU worker nodes, addons, and an ALB are all provisioned with the same Terraform resources and AWS CLI commands that work on real AWS — with a single `AWS_PROFILE` swap.
The RTX Pro 6000 Blackwell Server Edition's 96 GiB GDDR7 fits substantial GPU workloads in a single PCIe slot, and Spinifex's `g7e` instance family exposes each GPU as a standard EC2 instance. Teams already operating AWS infrastructure can point their existing tooling at a Spinifex node and retain the full EKS workflow — from `aws ecr get-login-password` to `kubectl get ingress` — on hardware they own. ECR acts as the canonical registry throughout: image build, push, and authentication are identical to AWS, with direct kubelet pulls being the intended path as Spinifex's ECR gateway matures.
+8
View File
@@ -300,6 +300,14 @@ suites:
# spinifex_daemon. docker/crane/skopeo data-plane subtests skip when the
# client binary or registry-host resolution is absent.
e2e-bedrock:
path: tests/e2e/bedrock
covers: [awsgw]
# Ochre (AWS-Bedrock) control plane: static catalog list/get served
# straight from awsgw (gateway/bedrock.go, gateway/bedrock/) — no NATS
# hop to spinifex_daemon. Converse (Anthropic-direct / self-host vLLM)
# subtests are gated on backend availability and skip by default.
e2e-ecs:
path: tests/e2e/ecs
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
+38 -23
View File
@@ -5,44 +5,51 @@ 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/config v1.32.27
github.com/aws/aws-sdk-go-v2/credentials v1.19.26
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/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/containerd/containerd/v2 v2.3.2
github.com/containerd/containerd/v2 v2.3.3
github.com/distribution/reference v0.6.0
github.com/go-chi/chi/v5 v5.3.0
github.com/go-chi/chi/v5 v5.3.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/insomniacslk/dhcp v0.0.0-20260407060928-11b94ed970f2
github.com/klauspost/cpuid/v2 v2.4.0
github.com/mulgadc/predastore v1.11.0
github.com/mulgadc/viperblock v1.11.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/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
github.com/ovn-kubernetes/libovsdb v0.8.1
github.com/pelletier/go-toml/v2 v2.4.2
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pterm/pterm v0.12.83
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
go.opentelemetry.io/otel/log v0.20.0
go.opentelemetry.io/otel/metric v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/log v0.20.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
go.uber.org/goleak v1.3.0
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/crypto v0.53.0
golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0
golang.org/x/crypto v0.54.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
gopkg.in/ini.v1 v1.67.3
)
@@ -60,12 +67,15 @@ require (
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/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/signin v1.2.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect
github.com/aws/smithy-go v1.27.3 // 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/smithy-go v1.27.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boltdb/bolt v1.3.1 // indirect
@@ -94,13 +104,13 @@ 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.2 // indirect
github.com/dgraph-io/badger/v4 v4.9.4 // 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
github.com/fatih/color v1.19.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -139,6 +149,7 @@ require (
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/sys/capability v0.4.0 // indirect
github.com/moby/sys/mountinfo v0.7.2 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/signal v0.7.1 // indirect
@@ -153,6 +164,7 @@ require (
github.com/nats-io/nuid v1.0.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116 // indirect
github.com/pierrec/lz4/v4 v4.1.18 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@@ -176,19 +188,22 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/vuln v1.1.4 // indirect
golang.org/x/tools v0.47.0 // indirect
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/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
tags.cncf.io/container-device-interface v1.1.0 // indirect
tags.cncf.io/container-device-interface/specs-go v1.1.0 // indirect
)
// go.opencensus.io -> grpc@v1.33.2 drags in the pre-split monolithic genproto,
+90 -47
View File
@@ -402,6 +402,8 @@ codeberg.org/go-latex/latex v0.0.1/go.mod h1:AiC91vVG2uURZRd4ZN1j3mAac0XBrLsxK6+
codeberg.org/go-latex/latex v0.1.0/go.mod h1:LA0q/AyWIYrqVd+A9Upkgsb+IqPcmSTKc9Dny04MHMw=
codeberg.org/go-pdf/fpdf v0.10.0/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU=
contrib.go.opencensus.io/exporter/stackdriver v0.13.15-0.20230702191903-2de6d2748484/go.mod h1:uxw+4/0SiKbbVSD/F2tk5pJTdVcfIBBcsQ8gwcu4X+E=
cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8=
cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20221208032759-85de2813cf6b/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
@@ -529,13 +531,13 @@ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDk
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.27 h1:SJwJ9Q4kM7v5QVSYYyXj3znRr6lNyZEhSgAXmXXcVbI=
github.com/aws/aws-sdk-go-v2/config v1.32.27/go.mod h1:uBfrzTRedDmB2u+b6+UlaKJy2O6VSH5un2jP24t/KvQ=
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/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.26 h1:Si8kk1kyJnuJWCEgiwpBtTdtgSdR7i611596NnC0YIQ=
github.com/aws/aws-sdk-go-v2/credentials v1.19.26/go.mod h1:lBckz+W9SAdNtSDw3pYgQUJDJFcBBWry0GSzw+bK0TY=
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/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=
@@ -569,31 +571,31 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrK
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.104.2 h1:bAY6O/TDv1HQnvylh9E247IyIKsUWUt2G965S7qX110=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.2 h1:69JEZSDTQ+UNbTWQJCZMmbpQb5sfc79KUt0O7Pyfjmo=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.2/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
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/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.31.5 h1:xlK3Tdc8FO7Tq1k0+hL+otF33glj+dE+qeM5iINiDvU=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.5/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
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/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.36.8 h1:yX1IbiBfC7SdEgDwIGnRaZyPPDRbQPDOJxl8102PcGk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
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/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.43.5 h1:T3ANO8QWDbzQD8f4+UaX+fvJlyGnOFMKLbW+NGBHg04=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.5/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
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/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=
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/smithy-go v1.27.4 h1:JQcphmBN4f0q/sPqXqROIItRNV/hy10cgu7CsFy616M=
github.com/aws/smithy-go v1.27.4/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bazelbuild/rules_go v0.49.0/go.mod h1:Dhcz716Kqg1RHNWos+N6MlXNkjNP2EwZQ0LukRKJfMs=
@@ -602,6 +604,8 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -697,8 +701,8 @@ github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/q
github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/containerd/containerd/api v1.11.1 h1:h8nfoDW9+fNsC/9TwiAHj8B1GzXKtR4eFtkhi/X5RLU=
github.com/containerd/containerd/api v1.11.1/go.mod h1:CaQFRu+N1MtbgL6JDOJLUB1hCKESU1lD6MuTJhgtdlw=
github.com/containerd/containerd/v2 v2.3.2 h1:eLven1YxRMkeiKu7IcMrPKE+gn8sGR1DqHbbshMEvWM=
github.com/containerd/containerd/v2 v2.3.2/go.mod h1:rHKGm3VW6wNrINb3x8mNT+w7qYXFVElTt/8HTuxVhD4=
github.com/containerd/containerd/v2 v2.3.3 h1:MUNBVVBTBpPll7KPh5GTvkC3cfG03PQLAHVdsUoue9k=
github.com/containerd/containerd/v2 v2.3.3/go.mod h1:rHKGm3VW6wNrINb3x8mNT+w7qYXFVElTt/8HTuxVhD4=
github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg=
github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -725,12 +729,14 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is=
github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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.2 h1:Wb5qw8gElqwV1a8msHTeQKova9b1V10heFKMIiPd80E=
github.com/dgraph-io/badger/v4 v4.9.2/go.mod h1:nJjaJTUOSsQEBhsq209FmwCvMJzEA3e74RjZw6V2pQI=
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/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=
@@ -808,13 +814,13 @@ github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g=
github.com/go-fonts/dejavu v0.3.2/go.mod h1:m+TzKY7ZEl09/a17t1593E4VYW8L1VaBXHzFZOIjGEY=
github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks=
@@ -1103,6 +1109,8 @@ github.com/hamba/avro/v2 v2.17.2/go.mod h1:Q9YK+qxAhtVrNqOhwlZTATLgLA8qxG2vtvkhK
github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
@@ -1118,6 +1126,8 @@ github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iP
github.com/hashicorp/go-msgpack/v2 v2.1.5 h1:Ue879bPnutj/hXfmUk6s/jtIK90XxgiUIcXRl656T44=
github.com/hashicorp/go-msgpack/v2 v2.1.5/go.mod h1:bjCsRXpZ7NsJdk45PoCQnzRGDaK8TKm5ZnDI/9y3J4M=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
@@ -1266,6 +1276,8 @@ github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
@@ -1282,6 +1294,8 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk=
github.com/moby/sys/capability v0.4.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
@@ -1303,10 +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/predastore v1.11.0 h1:FD/Gk7cXhMZNcRBXectwBcR7jsMoS77WM3jcFgNVzY0=
github.com/mulgadc/predastore v1.11.0/go.mod h1:gPrTLe9jY3CQyNtlpFurzUp2D0e8wPtVw/v48BmkhKA=
github.com/mulgadc/viperblock v1.11.0 h1:/xIDWhwRIKkWiD1pvXH/f3aQe2SBH6MYQAlStNdek9Y=
github.com/mulgadc/viperblock v1.11.0/go.mod h1:B4upzru+3opexPHNp6jkIqFvr8rdNs9lVVb+FJUd+9M=
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/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=
@@ -1340,6 +1356,10 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg=
github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116 h1:tAKu3NkKWZYpqBSOJKwTxT1wIGueiF7gcmcNgr5pNTY=
github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116/go.mod h1:DKDEfzxvRkoQ6n9TGhxQgg2IM1lY4aM0eaQP4e3oElw=
github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE=
github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg=
github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
@@ -1355,8 +1375,8 @@ github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FI
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY=
github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
@@ -1557,6 +1577,12 @@ github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gV
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xhit/go-str2duration v1.2.0/go.mod h1:3cPSlfZlUHVlneIVfePFWcJZsuwf+P1v2SRTV4cUmp4=
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
@@ -1596,6 +1622,8 @@ go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
go.opentelemetry.io/contrib/detectors/gcp v1.28.0/go.mod h1:9BIqH22qyHWAiZxQh0whuJygro59z+nbMVuc7ciiGug=
go.opentelemetry.io/contrib/detectors/gcp v1.29.0/go.mod h1:GW2aWZNwR2ZxDLdv8OyC2G8zkRoQBuURgV7RPQgcPoU=
go.opentelemetry.io/contrib/detectors/gcp v1.31.0/go.mod h1:tzQL6E1l+iV44YFTkcAeNQqzXUiekSYP9jjJjXwEd00=
@@ -1658,6 +1686,8 @@ go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOX
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
@@ -1669,6 +1699,8 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zb
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
go.opentelemetry.io/otel/metric v0.20.0/go.mod h1:598I5tYlH1vzBjn+BTuhzTCSb/9debfNp6R3s7Pr1eU=
go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM=
go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY=
@@ -1716,6 +1748,10 @@ go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWm
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
go.opentelemetry.io/otel/sdk/metric v1.28.0/go.mod h1:cWPjykihLAPvXKi4iZc1dpER3Jdq2Z0YLse3moQUCpg=
go.opentelemetry.io/otel/sdk/metric v1.29.0/go.mod h1:6zZLdCl2fkauYoZIOn/soQIDSWFmNSRcICarHfuhNJQ=
go.opentelemetry.io/otel/sdk/metric v1.30.0/go.mod h1:waS6P3YqFNzeP01kuo/MBBYqaoBJl7efRQHOaydhy1Y=
@@ -1842,8 +1878,8 @@ golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVo
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -1955,8 +1991,8 @@ golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -2162,8 +2198,9 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -2306,8 +2343,8 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4=
@@ -2320,8 +2357,8 @@ golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3c
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc=
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -2369,8 +2406,8 @@ golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -2415,8 +2452,8 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -2531,14 +2568,14 @@ golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I=
golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s=
golang.org/x/vuln v1.4.0 h1:FpmTZiV4PyqY3lFfuCkz1JftEXb/+8M2NEkjJM5TF4g=
golang.org/x/vuln v1.4.0/go.mod h1:FJ7XyKs83nAdxQ7PMsia2PoynwZMJ/QajXVMBBIgFe8=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -3182,4 +3219,10 @@ rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
tags.cncf.io/container-device-interface v1.1.0 h1:RnxNhxF1JOu6CJUVpetTYvrXHdxw9j9jFYgZpI+anSY=
tags.cncf.io/container-device-interface v1.1.0/go.mod h1:76Oj0Yqp9FwTx/pySDc8Bxjpg+VqXfDb50cKAXVJ34Q=
tags.cncf.io/container-device-interface/specs-go v1.1.0 h1:QRZVeAceQM+zTZe12eyfuJuuzp524EKYwhmvLd+h+yQ=
tags.cncf.io/container-device-interface/specs-go v1.1.0/go.mod h1:u86hoFWqnh3hWz3esofRFKbI261bUlvUfLKGrDhJkgQ=
+4 -3
View File
@@ -12,7 +12,7 @@ import (
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/mulgadc/predastore/auth"
"github.com/mulgadc/predastore/pkg/sigv4"
)
// failingProvider models a cold IMDS datapath: Retrieve always errors, as the
@@ -40,13 +40,14 @@ func TestSign_StaticVerifies(t *testing.T) {
var verifyErr error
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
req, err := auth.ParseReq(r)
// sigv4.Parse reads and hashes the body itself, so no explicit body hash.
sr, err := sigv4.Parse(r)
if err != nil {
verifyErr = err
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
verifyErr = req.Verify(testSecretKey, testService, testRegion, auth.WithBodyHash(payloadHash))
_, verifyErr = sr.Verify(testSecretKey, testRegion, testService)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
+8 -4
View File
@@ -72,8 +72,10 @@ for field in IMAGE_NAME IMAGE_SIZE; do
fi
done
# Derived paths
BUILD_DIR="/tmp/${IMAGE_NAME}-image-build"
# Derived paths. BUILD_DIR defaults to /tmp, but Ubuntu GPU images are 16G and a
# typical /tmp is a RAM-backed tmpfs too small to hold the qcow2 + expanded raw,
# so allow an override onto a real disk (SYSTEM_IMAGE_BUILD_DIR).
BUILD_DIR="${SYSTEM_IMAGE_BUILD_DIR:-/tmp}/${IMAGE_NAME}-image-build"
case "$DISTRO" in
alpine)
@@ -116,7 +118,7 @@ import_ami() {
args+=(--ami-name "$AMI_NAME")
fi
if [[ -n "${SYSTEM_TAG:-}" ]]; then
args+=(--tag "$SYSTEM_TAG")
for _tag in ${SYSTEM_TAG}; do args+=(--tag "$_tag"); done
fi
spx admin images import "${args[@]}"
}
@@ -367,7 +369,9 @@ else
fi
if [[ -n "${SYSTEM_TAG:-}" ]]; then
echo -e " --distro ${DISTRO} --version ${DISTRO_VERSION} --arch x86_64 --boot-mode ${BOOT_MODE}${NAME_HINT} \\"
echo " --tag ${SYSTEM_TAG}"
_tag_line=""
for _tag in ${SYSTEM_TAG}; do _tag_line="${_tag_line} --tag ${_tag}"; done
echo " ${_tag_line}"
else
echo -e " --distro ${DISTRO} --version ${DISTRO_VERSION} --arch x86_64 --boot-mode ${BOOT_MODE}${NAME_HINT}"
fi
@@ -0,0 +1,22 @@
[Unit]
Description=Spinifex ECS container-instance agent
# Resolves host identity from IMDSv2, registers + heartbeats + polls task
# assignments through the AWS gateway over TLS+SigV4 (no NATS), and pulls
# task images through containerd.
Requires=containerd.service
After=containerd.service ecs-ca-install.service network-online.target mulga-cdi-generate.service
Wants=network-online.target
[Service]
Type=simple
# Static config (gateway URL, CA, region, cluster, seeded IAM creds,
# containerd socket) comes from /etc/spinifex-ecs/agent.env, dropped by
# cloud-init at launch. Absent file is tolerated — the agent falls back to
# its built-in defaults.
EnvironmentFile=-/etc/spinifex-ecs/agent.env
ExecStart=/usr/local/bin/ecs-agent
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,22 @@
[Unit]
Description=Install Spinifex gateway CA into the system trust (first boot)
# The CA is delivered per-instance (not baked) at
# /etc/spinifex-ecs/gateway-ca.pem by cloud-init user-data, mirroring how EKS
# delivers EKS_GATEWAY_CA. No-op when the file is absent — containerd then
# relies on the stock system trust.
Before=containerd.service ecs-agent.service
After=local-fs.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c '\
if [ -f /etc/spinifex-ecs/gateway-ca.pem ]; then \
install -D -m 0644 /etc/spinifex-ecs/gateway-ca.pem /usr/local/share/ca-certificates/spinifex-gateway.crt && \
update-ca-certificates >/dev/null 2>&1; \
else \
echo "no gateway CA at /etc/spinifex-ecs/gateway-ca.pem; using stock system trust"; \
fi'
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,36 @@
IMAGE_NAME="ecs-agent-gpu"
IMAGE_DESCRIPTION="Ubuntu ECS GPU container-instance — containerd + runc + ecs-agent + NVIDIA server driver + nvidia-container-toolkit"
DISTRO="ubuntu"
UBUNTU_VERSION="26.04"
UBUNTU_CODENAME="resolute"
# 16G — same headroom rationale as eks-node-gpu: kernel headers, NVIDIA
# driver + DKMS build artefacts, nvidia-container-toolkit.
IMAGE_SIZE="16G"
# Ubuntu's containerd apt package ships its own systemd unit (unlike Alpine's
# containerd-openrc, which needed one written) — nothing to author for it
# here, just `systemctl enable containerd` below. nvidia-container-toolkit
# lives in its own NVIDIA-hosted apt repo, not enabled on the base cloud
# image, so it is added+installed in setup.sh rather than here (see
# scripts/images/ubuntu-gpu-nvidia-setup.sh).
APT_PACKAGES="containerd runc containernetworking-plugins ca-certificates curl iproute2 initramfs-tools"
# ecs-agent is the only built binary (static, FIPS boot like the host build) —
# BUILD_COMMANDS/INSTALL_BINARIES copied verbatim from scripts/images/ecs-agent/.
BUILD_COMMANDS="CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -ldflags \"-s -w\" -o bin/ecs-agent ./cmd/ecs-agent"
INSTALL_BINARIES="bin/ecs-agent:/usr/local/bin/ecs-agent"
INSTALL_FILES="\
scripts/images/ecs-agent/10-bridge.conflist:/etc/cni/net.d/10-bridge.conflist \
scripts/images/ecs-agent-gpu/ecs-agent.service:/etc/systemd/system/ecs-agent.service \
scripts/images/ecs-agent-gpu/ecs-ca-install.service:/etc/systemd/system/ecs-ca-install.service \
scripts/images/ecs-agent-gpu/mulga-cdi-generate.service:/etc/systemd/system/mulga-cdi-generate.service"
ENABLE_SERVICES="containerd ecs-ca-install ecs-agent mulga-cdi-generate"
SETUP_SCRIPT="scripts/images/ecs-agent-gpu/setup.sh"
AMI_NAME="spinifex-ecs-node-gpu"
SYSTEM_TAG="spinifex:managed-by=ecs gpu-vendor=nvidia"
@@ -0,0 +1,16 @@
[Unit]
Description=Generate NVIDIA CDI spec for the container runtime (GPU-gated, first boot)
After=systemd-modules-load.service
Before=containerd.service ecs-agent.service
[Service]
Type=oneshot
RemainAfterExit=yes
# Silently skip on non-GPU boots: only generate when an NVIDIA GPU is
# actually present and the driver has bound.
ExecCondition=/bin/sh -c 'command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1'
ExecStartPre=/bin/mkdir -p /etc/cdi
ExecStart=/usr/bin/nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
[Install]
WantedBy=multi-user.target
+155
View File
@@ -0,0 +1,155 @@
#!/bin/sh
set -eu
# setup.sh — guest customisation for the ecs-agent-gpu AMI (Ubuntu ECS GPU
# container-instance).
#
# Runs inside the libguestfs appliance (via virt-customize --run) under
# build-system-image.sh, after packages, binaries and INSTALL_FILES are
# placed. Wires the CNI bin path containerd's CRI plugin expects (mirroring
# scripts/images/ecs-agent/setup.sh, but Ubuntu's containernetworking-plugins
# package lays out differently to Alpine's — see below), preps the containerd
# content store + agent config dir, and bakes the headless NVIDIA driver +
# nvidia-container-toolkit (steps reused from
# scripts/images/ubuntu-gpu-nvidia-setup.sh — see install_nvidia_gpu_stack()
# below, duplicated verbatim from scripts/images/eks-node-gpu/setup.sh so
# both GPU node images stay consistent; each image builds standalone from
# its own manifest so there is no shared-script mechanism to source from).
#
# Unlike scripts/images/ecs-agent/setup.sh there is no chmod dance for OpenRC
# init scripts (systemd unit files don't need an exec bit) and no SYSLINUX/
# serial-console tweaks (Ubuntu boots via GRUB/UEFI, not Alpine's extlinux).
# Ubuntu's containernetworking-plugins package installs plugin binaries
# directly under /usr/lib/cni (verified via `dpkg -L containernetworking-plugins`
# — no /bin subdir, unlike Alpine's /usr/libexec/cni). containerd's CRI plugin
# defaults its bin dir to /opt/cni/bin. Symlink so the baked bridge conflist
# resolves its plugins when task networking is wired up.
mkdir -p /opt/cni
ln -sf /usr/lib/cni /opt/cni/bin
# Content-store + agent config dirs.
mkdir -p /var/lib/containerd /etc/spinifex-ecs
# ── NVIDIA headless driver + container toolkit (node-image subset) ──────────
# Reused from scripts/images/ubuntu-gpu-nvidia-setup.sh: kernel detection,
# linux-headers-<kver>, the latest versioned nvidia-dkms-<ver>-server +
# nvidia-utils-<ver>-server, `dkms install`, nvidia.ko verification,
# `apt-mark hold` on the kernel/nvidia/libnvidia packages, the nouveau
# modprobe blacklist, and `update-initramfs -u -k <kver>`.
#
# Dropped for a node image: pciutils/python3/git/curl-extras/htop/tmux/ffmpeg/
# libgl1/libglib dev payload, and Docker CE entirely — GPU nodes run
# containerd (this image) or k3s (scripts/images/eks-node-gpu), never Docker.
# `nvidia-ctk runtime configure --runtime=docker` and the docker-group
# cloud-init drop-in are dropped for the same reason; the runtime is instead
# wired via CDI at first boot by mulga-cdi-generate.service, gated on an
# NVIDIA GPU actually being present.
install_nvidia_gpu_stack() {
KVER=$(ls /boot/vmlinuz-* 2>/dev/null | sort -V | tail -1 | sed 's|/boot/vmlinuz-||') || true
if [ -z "${KVER}" ]; then
# Ubuntu 26.04 minimal cloud image keeps the kernel on the ESP (not
# mounted in the appliance chroot), so /boot is empty — fall back to
# /lib/modules.
KVER=$(ls /lib/modules/ 2>/dev/null | sort -V | tail -1)
fi
if [ -z "${KVER}" ]; then
echo "ERROR: No kernel found in /boot or /lib/modules — base image may be missing a kernel"
exit 1
fi
echo "[nvidia-gpu-stack] target kernel: ${KVER}"
# Prevent kernel postinst hooks from attempting grub-install against the
# host disk — this fails silently inside the appliance and can abort the
# postinst, leaving /boot incomplete.
cat > /etc/kernel-img.conf <<'EOF'
do_symlinks = yes
do_bootloader = no
do_initrd = yes
link_in_boot = yes
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}"
apt-get install -y --no-install-recommends \
"nvidia-dkms-${NVIDIA_VER}-server" \
"nvidia-utils-${NVIDIA_VER}-server"
NVIDIA_DKMS_NAME=$(dkms status 2>/dev/null | awk -F'[,/ ]+' '/nvidia/{print $1; exit}')
NVIDIA_DKMS_VER=$(dkms status 2>/dev/null | awk -F'[,/ ]+' '/nvidia/{print $2; exit}')
if [ -n "${NVIDIA_DKMS_NAME}" ] && [ -n "${NVIDIA_DKMS_VER}" ]; then
echo "[nvidia-gpu-stack] building NVIDIA DKMS module ${NVIDIA_DKMS_NAME}/${NVIDIA_DKMS_VER} for kernel ${KVER}..."
dkms install -m "${NVIDIA_DKMS_NAME}" -v "${NVIDIA_DKMS_VER}" --kernelver "${KVER}" --force
else
echo "WARNING: nvidia DKMS module not found in dkms status — driver install may have failed"
fi
NVIDIA_KO=$(find "/lib/modules/${KVER}/updates" -name "nvidia.ko*" 2>/dev/null | head -1)
if [ -z "${NVIDIA_KO}" ]; then
echo "ERROR: nvidia.ko not found under /lib/modules/${KVER}/updates after DKMS build"
exit 1
fi
echo "[nvidia-gpu-stack] NVIDIA kernel module: ${NVIDIA_KO}"
# Pin the exact kernel + headers + nvidia/libnvidia packages so
# unattended-upgrades cannot replace what the DKMS module was built for.
apt-mark hold "linux-image-${KVER}" "linux-headers-${KVER}"
NVIDIA_PKGS=$(dpkg --get-selections 'nvidia-*' 2>/dev/null | awk '/install$/{print $1}' || true)
LIBNVIDIA_PKGS=$(dpkg --get-selections 'libnvidia-*' 2>/dev/null | awk '/install$/{print $1}' || true)
# shellcheck disable=SC2086
[ -n "${NVIDIA_PKGS}" ] && apt-mark hold ${NVIDIA_PKGS}
# shellcheck disable=SC2086
[ -n "${LIBNVIDIA_PKGS}" ] && apt-mark hold ${LIBNVIDIA_PKGS}
# nvidia.ko and nouveau conflict; without this the NVIDIA driver will not
# bind after boot.
cat > /etc/modprobe.d/blacklist-nouveau.conf <<'EOF'
blacklist nouveau
options nouveau modeset=0
EOF
# Load the NVIDIA modules at boot so /dev/nvidia* exist before CDI
# generation and the container runtime start — a headless node never runs
# an X server or CUDA app to trigger the on-demand load.
cat > /etc/modules-load.d/nvidia.conf <<'EOF'
nvidia
nvidia_uvm
nvidia_modeset
EOF
# nvidia-container-toolkit lives in its own NVIDIA-hosted apt repo, which
# is not enabled on the base Ubuntu cloud image — add it here (same
# gpg key + list as scripts/images/ubuntu-gpu-nvidia-setup.sh) and
# install only the headless toolkit (no Docker CE).
apt-get install -y --no-install-recommends gnupg
mkdir -p /usr/share/keyrings /etc/apt/sources.list.d
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's|deb https://|deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://|g' \
> /etc/apt/sources.list.d/nvidia-container-toolkit.list
apt-get update -qq
apt-get install -y --no-install-recommends nvidia-container-toolkit
apt-mark hold nvidia-container-toolkit nvidia-container-toolkit-base \
libnvidia-container1 libnvidia-container-tools 2>/dev/null || true
# Rebuild initramfs with nouveau blacklisted and the NVIDIA module included.
update-initramfs -u -k "${KVER}"
echo "[nvidia-gpu-stack] done: kernel=${KVER}, NVIDIA headless driver + nvidia-container-toolkit installed"
}
install_nvidia_gpu_stack
echo "[ecs-agent-gpu-setup] done"
@@ -0,0 +1,14 @@
# Mint the EBS-style /dev/disk/by-id symlink the upstream aws-ebs-csi-driver
# node plugin resolves, for hot-attached Viperblock virtio-blk volumes (vd*,
# serial = volume-id). This is the udev equivalent of the Alpine mdev rule
# wired by scripts/images/eks-node/setup.sh into /etc/mdev.conf.
#
# The shared helper /usr/local/sbin/mulga-ebs-byid (reused verbatim from
# scripts/images/eks-node/mulga-ebs-byid.sh) reads the device name from
# $MDEV or, if unset, $1 — so passing the kernel name via %k as the sole
# argument works without any udev-specific shim. udev already sets ACTION to
# add/remove, matching the values the helper's case statement expects.
# Already-attached devices at boot are coldplugged separately by
# mulga-ebs-byid.service; this rule only covers hotplug events.
ACTION=="add", SUBSYSTEM=="block", KERNEL=="vd[a-z]", RUN+="/usr/local/sbin/mulga-ebs-byid %k"
ACTION=="remove", SUBSYSTEM=="block", KERNEL=="vd[a-z]", RUN+="/usr/local/sbin/mulga-ebs-byid %k"
@@ -0,0 +1,48 @@
#!/bin/sh
set -eu
# k3s-agent-prestart — reproduces the start_pre logic from
# scripts/images/eks-node/k3s-agent.initd for the systemd unit: requires
# K3S_URL + K3S_TOKEN from /etc/spinifex-eks/agent.env (sourced by the unit's
# own EnvironmentFile=), then resolves the kubelet provider-id from IMDSv2
# (aws:///<az>/<instance-id>, EKS parity so the AWS Load Balancer Controller
# can resolve nodes for ip-mode target registration) and writes it as an
# extra k3s agent argument for the unit's ExecStart to source.
#
# Run as ExecStartPre=; a nonzero exit here fails k3s-agent.service before k3s
# is ever invoked, same as the OpenRC start_pre returning 1.
ENV_FILE=/etc/spinifex-eks/agent.env
DROPIN=/run/k3s-agent-extra-args.env
LOGTAG="k3s-agent-prestart"
# EnvironmentFile= already injects agent.env into this process' environment,
# but source it directly too so this script also works if invoked standalone.
if [ -f "${ENV_FILE}" ]; then
# shellcheck disable=SC1090
. "${ENV_FILE}"
fi
if [ -z "${K3S_URL:-}" ] || [ -z "${K3S_TOKEN:-}" ]; then
echo "[${LOGTAG}] K3S_URL and K3S_TOKEN must be set in ${ENV_FILE}" >&2
exit 1
fi
IMDS=http://169.254.169.254/latest
CURL="curl -fsS --connect-timeout 3 --max-time 5"
EXTRA_ARGS=""
TOK=$(${CURL} -X PUT -H 'X-aws-ec2-metadata-token-ttl-seconds: 120' "${IMDS}/api/token" 2>/dev/null || true)
if [ -n "${TOK}" ]; then
IID=$(${CURL} -H "X-aws-ec2-metadata-token: ${TOK}" "${IMDS}/meta-data/instance-id" 2>/dev/null || true)
AZ=$(${CURL} -H "X-aws-ec2-metadata-token: ${TOK}" "${IMDS}/meta-data/placement/availability-zone" 2>/dev/null || true)
if [ -n "${IID}" ] && [ -n "${AZ}" ]; then
EXTRA_ARGS="--kubelet-arg=provider-id=aws:///${AZ}/${IID}"
else
echo "[${LOGTAG}] IMDS instance-id/az empty; keeping default providerID" >&2
fi
else
echo "[${LOGTAG}] IMDS token request failed; keeping default providerID" >&2
fi
echo "K3S_AGENT_EXTRA_ARGS=${EXTRA_ARGS}" > "${DROPIN}"
@@ -0,0 +1,41 @@
[Unit]
Description=K3s Kubernetes agent (Mulga EKS GPU worker)
# K3S_URL points at the cluster's NLB endpoint (https://{cluster}.{account}
# .eks.{region}.{suffix}:443) so the agent join path stays stable across the
# v2 HA migration (single-VM v1 -> 3-node etcd quorum v2). No AMI rebuild
# needed when the control plane expands.
After=network-online.target mulga-eks-provider-id.service mulga-vpc-mtu.service mulga-ebs-byid.service mulga-cdi-generate.service
Wants=network-online.target
[Service]
Type=simple
# k3s embeds containerd + kubelet in-process, so on a cgroup-v2 host the unit
# must delegate a cgroup subtree or the kubelet cannot create /kubepods and
# ContainerManager.Start fails before Node registration (OpenRC ran k3s in the
# root cgroup, so this was implicit). overlay/br_netfilter feed the embedded
# containerd snapshotter + kube-proxy.
Delegate=yes
KillMode=process
TasksMax=infinity
LimitNOFILE=1048576
LimitNPROC=infinity
LimitCORE=infinity
# Cloud-init drops K3S_URL/K3S_TOKEN/K3S_NODE_NAME (plus SPINIFEX_NODEGROUP and,
# for GPU workers, SPINIFEX_GPU_NODE) here at RunInstances time per the Nodegroup
# bootstrap flow; mulga-eks-provider-id reads the SPINIFEX_* vars for node-labels.
EnvironmentFile=-/etc/spinifex-eks/agent.env
# Tee k3s (and its embedded kubelet's klog) stderr to the serial console as well
# as journald: Ubuntu logs to journald only, which is off-box-invisible on a
# worker in an isolated customer VPC, so a kubelet that fails to register the
# Node leaves no trace. The serial console is captured per-VM by the daemon.
StandardOutput=journal+console
StandardError=journal+console
ExecStartPre=-/sbin/modprobe overlay
ExecStartPre=-/sbin/modprobe br_netfilter
ExecStartPre=/usr/local/sbin/k3s-agent-prestart
ExecStart=/bin/sh -c '. /run/k3s-agent-extra-args.env 2>/dev/null || true; exec /usr/local/bin/k3s agent ${K3S_AGENT_EXTRA_ARGS:-}'
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+52
View File
@@ -0,0 +1,52 @@
IMAGE_NAME="eks-node-gpu"
IMAGE_DESCRIPTION="Ubuntu EKS K3s GPU worker node — NVIDIA server driver (DKMS pre-built) + nvidia-container-toolkit; agent-only (GPU nodes never run the EKS control plane)"
DISTRO="ubuntu"
UBUNTU_VERSION="26.04"
UBUNTU_CODENAME="resolute"
# 16G — headroom for kernel headers, NVIDIA driver + DKMS build artefacts, and
# nvidia-container-toolkit, mirroring scripts/images/ubuntu-gpu-nvidia.conf.
IMAGE_SIZE="16G"
# K3s bundles its own containerd, so no containerd/runc package here. The
# NVIDIA server driver + nvidia-utils come from the stock Ubuntu repos and are
# installed in setup.sh (kernel-version-pinned DKMS build, like the ubuntu-gpu
# recipe). nvidia-container-toolkit lives in its own NVIDIA-hosted apt repo,
# which is not enabled on the base cloud image, so it is also added+installed
# in setup.sh rather than here (see scripts/images/ubuntu-gpu-nvidia-setup.sh).
APT_PACKAGES="ca-certificates curl jq iptables conntrack cgroup-tools initramfs-tools"
# ecr-credential-provider is the ONLY server binary a GPU worker needs: the
# nodegroup userdata points kubelet at it (image-credential-provider-config +
# bin-dir=/usr/local/bin) for ECR pulls, and kubelet treats a referenced-but-
# missing provider binary as a FATAL init error (RegisterCredentialProviderPlugins),
# so the node never registers. The Alpine eks-node ships it; the GPU AMI must too.
# Server-only binaries (token-webhook, gateway-*, konnectivity, k3s server) stay
# omitted — GPU nodes are agent-only.
BUILD_COMMANDS="CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/ecr-credential-provider ./cmd/ecr-credential-provider"
INSTALL_BINARIES="bin/ecr-credential-provider:/usr/local/bin/ecr-credential-provider"
# Role-agnostic helper scripts are reused verbatim from scripts/images/eks-node/
# (only the init wiring changes: OpenRC -> systemd). k3s-agent-prestart is new —
# it reproduces the k3s-agent.initd start_pre logic (env require + IMDS
# provider-id) for the systemd unit. Server-only units (role selector, k3s
# server, eks-token-webhook, konnectivity, state-report, k3s recovery,
# mgmt-net) are intentionally not ported — GPU nodes are single-NIC workers.
INSTALL_FILES="\
scripts/images/eks-node/mulga-ebs-byid.sh:/usr/local/sbin/mulga-ebs-byid \
scripts/images/eks-node/mulga-eks-provider-id.sh:/usr/local/sbin/mulga-eks-provider-id \
scripts/images/eks-node/mulga-vpc-mtu.sh:/usr/local/sbin/mulga-vpc-mtu \
scripts/images/eks-node-gpu/k3s-agent-prestart.sh:/usr/local/sbin/k3s-agent-prestart \
scripts/images/eks-node-gpu/k3s-agent.service:/etc/systemd/system/k3s-agent.service \
scripts/images/eks-node-gpu/mulga-ebs-byid.service:/etc/systemd/system/mulga-ebs-byid.service \
scripts/images/eks-node-gpu/mulga-eks-provider-id.service:/etc/systemd/system/mulga-eks-provider-id.service \
scripts/images/eks-node-gpu/mulga-vpc-mtu.service:/etc/systemd/system/mulga-vpc-mtu.service \
scripts/images/eks-node-gpu/mulga-cdi-generate.service:/etc/systemd/system/mulga-cdi-generate.service \
scripts/images/eks-node-gpu/99-mulga-ebs-byid.rules:/etc/udev/rules.d/99-mulga-ebs-byid.rules"
ENABLE_SERVICES="k3s-agent mulga-ebs-byid mulga-eks-provider-id mulga-vpc-mtu mulga-cdi-generate"
SETUP_SCRIPT="scripts/images/eks-node-gpu/setup.sh"
AMI_NAME="spinifex-eks-node-gpu"
SYSTEM_TAG="spinifex:managed-by=eks gpu-vendor=nvidia"
@@ -0,0 +1,16 @@
[Unit]
Description=Generate NVIDIA CDI spec for the container runtime (GPU-gated, first boot)
After=systemd-modules-load.service
Before=k3s-agent.service
[Service]
Type=oneshot
RemainAfterExit=yes
# Silently skip on non-GPU boots: only generate when an NVIDIA GPU is
# actually present and the driver has bound.
ExecCondition=/bin/sh -c 'command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1'
ExecStartPre=/bin/mkdir -p /etc/cdi
ExecStart=/usr/bin/nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,18 @@
[Unit]
# Role-agnostic boot oneshot that coldplugs the EBS by-id symlink bridge for
# the aws-ebs-csi-driver node plugin — any virtio-blk volume already attached
# at boot. Hot-attached volumes are handled separately by the udev rule
# 99-mulga-ebs-byid.rules, which fires the same helper (see
# scripts/images/eks-node-gpu/99-mulga-ebs-byid.rules).
Description=Mulga EBS /dev/disk/by-id symlink bridge (coldplug)
After=local-fs.target
Before=k3s-agent.service
[Service]
Type=oneshot
RemainAfterExit=yes
Environment=ACTION=coldplug
ExecStart=/usr/local/sbin/mulga-ebs-byid
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,20 @@
[Unit]
Description=Set K3s provider-id + topology labels from IMDS for EBS CSI
# After cloud-config.service so cloud-init has written /etc/spinifex-eks/agent.env
# (write_files completes by the config stage): this script folds the nodegroup +
# GPU node-labels (read from agent.env) into the same node-label list, so agent.env
# must exist before it runs. NOT cloud-final.service: that unit is ordered after
# multi-user.target and k3s-agent.service (ordered After this unit) is
# WantedBy=multi-user.target, so chaining onto cloud-final forms an ordering cycle
# that makes systemd skip both this unit and k3s-agent, so the worker never joins.
After=network-online.target cloud-config.service
Wants=network-online.target
Before=k3s-agent.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/mulga-eks-provider-id
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,13 @@
[Unit]
Description=Pin VPC data NIC MTU so flannel fits the OVN geneve underlay
After=network-online.target
Wants=network-online.target
Before=k3s-agent.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/mulga-vpc-mtu
[Install]
WantedBy=multi-user.target
+174
View File
@@ -0,0 +1,174 @@
#!/bin/sh
set -eu
# setup.sh — guest customisation for the eks-node-gpu AMI (Ubuntu, K3s agent
# worker only — GPU nodes never run the EKS control plane, so there is no
# server/role-selector path here, unlike scripts/images/eks-node/setup.sh).
#
# Runs inside the libguestfs appliance (via virt-customize --run) under
# build-system-image.sh, after packages, binaries and INSTALL_FILES are
# placed. Downloads + verifies the pinned K3s binary (same recipe as
# scripts/images/eks-node/setup.sh), sets exec bits on the uploaded shell
# helpers, and bakes the headless NVIDIA driver + nvidia-container-toolkit
# (steps reused from scripts/images/ubuntu-gpu-nvidia-setup.sh — see
# install_nvidia_gpu_stack() below, duplicated verbatim in
# scripts/images/ecs-agent-gpu/setup.sh so both images stay consistent).
#
# Network access inside the appliance is provided by virt-customize --network.
K3S_VERSION="v1.32.5+k3s1"
K3S_URL_BASE="https://github.com/k3s-io/k3s/releases/download/${K3S_VERSION}"
echo "[eks-node-gpu-setup] fetching k3s checksums ${K3S_VERSION}"
curl -fsSL -o /tmp/k3s-checksums.txt "${K3S_URL_BASE}/sha256sum-amd64.txt"
K3S_SHA256=$(awk '/[ \t]k3s$/{print $1; exit}' /tmp/k3s-checksums.txt)
if [ -z "${K3S_SHA256}" ]; then
echo "[eks-node-gpu-setup] could not parse k3s sha256 from upstream checksums"
cat /tmp/k3s-checksums.txt
exit 1
fi
echo "[eks-node-gpu-setup] downloading k3s ${K3S_VERSION} (sha256=${K3S_SHA256})"
curl -fsSL -o /usr/local/bin/k3s "${K3S_URL_BASE}/k3s"
echo "${K3S_SHA256} /usr/local/bin/k3s" > /tmp/k3s.sha256
if ! sha256sum -c /tmp/k3s.sha256; then
echo "[eks-node-gpu-setup] k3s SHA256 mismatch — refusing to bake AMI"
exit 1
fi
chmod 0755 /usr/local/bin/k3s
ln -sf /usr/local/bin/k3s /usr/local/bin/kubectl
ln -sf /usr/local/bin/k3s /usr/local/bin/crictl
ln -sf /usr/local/bin/k3s /usr/local/bin/ctr
# Reused shell helpers + the new prestart wrapper land 0644 via INSTALL_FILES;
# make them executable. The systemd unit files and the udev rules file do not
# need an exec bit (unlike OpenRC's /etc/init.d/*).
chmod 0755 /usr/local/sbin/mulga-ebs-byid /usr/local/sbin/mulga-eks-provider-id \
/usr/local/sbin/mulga-vpc-mtu /usr/local/sbin/k3s-agent-prestart
# K3s config dir (mulga-eks-provider-id's drop-in + cloud-init/first-boot
# agent config) and the shared spinifex-eks state dir (agent.env lands here
# from cloud-init at launch).
mkdir -p /etc/rancher/k3s/config.yaml.d /etc/spinifex-eks
# ── NVIDIA headless driver + container toolkit (node-image subset) ──────────
# Reused from scripts/images/ubuntu-gpu-nvidia-setup.sh: kernel detection,
# linux-headers-<kver>, the latest versioned nvidia-dkms-<ver>-server +
# nvidia-utils-<ver>-server, `dkms install`, nvidia.ko verification,
# `apt-mark hold` on the kernel/nvidia/libnvidia packages, the nouveau
# modprobe blacklist, and `update-initramfs -u -k <kver>`.
#
# Dropped for a node image: pciutils/python3/git/curl-extras/htop/tmux/ffmpeg/
# libgl1/libglib dev payload, and Docker CE entirely — GPU nodes run k3s
# (this image) or containerd (scripts/images/ecs-agent-gpu), never Docker.
# `nvidia-ctk runtime configure --runtime=docker` and the docker-group
# cloud-init drop-in are dropped for the same reason; the runtime is instead
# wired via CDI at first boot by mulga-cdi-generate.service, gated on an
# NVIDIA GPU actually being present.
install_nvidia_gpu_stack() {
KVER=$(ls /boot/vmlinuz-* 2>/dev/null | sort -V | tail -1 | sed 's|/boot/vmlinuz-||') || true
if [ -z "${KVER}" ]; then
# Ubuntu 26.04 minimal cloud image keeps the kernel on the ESP (not
# mounted in the appliance chroot), so /boot is empty — fall back to
# /lib/modules.
KVER=$(ls /lib/modules/ 2>/dev/null | sort -V | tail -1)
fi
if [ -z "${KVER}" ]; then
echo "ERROR: No kernel found in /boot or /lib/modules — base image may be missing a kernel"
exit 1
fi
echo "[nvidia-gpu-stack] target kernel: ${KVER}"
# Prevent kernel postinst hooks from attempting grub-install against the
# host disk — this fails silently inside the appliance and can abort the
# postinst, leaving /boot incomplete.
cat > /etc/kernel-img.conf <<'EOF'
do_symlinks = yes
do_bootloader = no
do_initrd = yes
link_in_boot = yes
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}"
apt-get install -y --no-install-recommends \
"nvidia-dkms-${NVIDIA_VER}-server" \
"nvidia-utils-${NVIDIA_VER}-server"
NVIDIA_DKMS_NAME=$(dkms status 2>/dev/null | awk -F'[,/ ]+' '/nvidia/{print $1; exit}')
NVIDIA_DKMS_VER=$(dkms status 2>/dev/null | awk -F'[,/ ]+' '/nvidia/{print $2; exit}')
if [ -n "${NVIDIA_DKMS_NAME}" ] && [ -n "${NVIDIA_DKMS_VER}" ]; then
echo "[nvidia-gpu-stack] building NVIDIA DKMS module ${NVIDIA_DKMS_NAME}/${NVIDIA_DKMS_VER} for kernel ${KVER}..."
dkms install -m "${NVIDIA_DKMS_NAME}" -v "${NVIDIA_DKMS_VER}" --kernelver "${KVER}" --force
else
echo "WARNING: nvidia DKMS module not found in dkms status — driver install may have failed"
fi
NVIDIA_KO=$(find "/lib/modules/${KVER}/updates" -name "nvidia.ko*" 2>/dev/null | head -1)
if [ -z "${NVIDIA_KO}" ]; then
echo "ERROR: nvidia.ko not found under /lib/modules/${KVER}/updates after DKMS build"
exit 1
fi
echo "[nvidia-gpu-stack] NVIDIA kernel module: ${NVIDIA_KO}"
# Pin the exact kernel + headers + nvidia/libnvidia packages so
# unattended-upgrades cannot replace what the DKMS module was built for.
apt-mark hold "linux-image-${KVER}" "linux-headers-${KVER}"
NVIDIA_PKGS=$(dpkg --get-selections 'nvidia-*' 2>/dev/null | awk '/install$/{print $1}' || true)
LIBNVIDIA_PKGS=$(dpkg --get-selections 'libnvidia-*' 2>/dev/null | awk '/install$/{print $1}' || true)
# shellcheck disable=SC2086
[ -n "${NVIDIA_PKGS}" ] && apt-mark hold ${NVIDIA_PKGS}
# shellcheck disable=SC2086
[ -n "${LIBNVIDIA_PKGS}" ] && apt-mark hold ${LIBNVIDIA_PKGS}
# nvidia.ko and nouveau conflict; without this the NVIDIA driver will not
# bind after boot.
cat > /etc/modprobe.d/blacklist-nouveau.conf <<'EOF'
blacklist nouveau
options nouveau modeset=0
EOF
# Load the NVIDIA modules at boot so /dev/nvidia* exist before CDI
# generation and the container runtime start — a headless node never runs
# an X server or CUDA app to trigger the on-demand load.
cat > /etc/modules-load.d/nvidia.conf <<'EOF'
nvidia
nvidia_uvm
nvidia_modeset
EOF
# nvidia-container-toolkit lives in its own NVIDIA-hosted apt repo, which
# is not enabled on the base Ubuntu cloud image — add it here (same
# gpg key + list as scripts/images/ubuntu-gpu-nvidia-setup.sh) and
# install only the headless toolkit (no Docker CE).
apt-get install -y --no-install-recommends gnupg
mkdir -p /usr/share/keyrings /etc/apt/sources.list.d
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's|deb https://|deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://|g' \
> /etc/apt/sources.list.d/nvidia-container-toolkit.list
apt-get update -qq
apt-get install -y --no-install-recommends nvidia-container-toolkit
apt-mark hold nvidia-container-toolkit nvidia-container-toolkit-base \
libnvidia-container1 libnvidia-container-tools 2>/dev/null || true
# Rebuild initramfs with nouveau blacklisted and the NVIDIA module included.
update-initramfs -u -k "${KVER}"
echo "[nvidia-gpu-stack] done: kernel=${KVER}, NVIDIA headless driver + nvidia-container-toolkit installed"
}
install_nvidia_gpu_stack
echo "[eks-node-gpu-setup] done"
@@ -46,6 +46,13 @@ spec:
- a1.4xlarge
nodeSelector:
kubernetes.io/os: linux
# The node plugin must run on every worker, including GPU-only workers
# tainted nvidia.com/gpu=present:NoSchedule, or GPU pods there cannot
# mount EBS volumes (no node plugin = no CSI NodeStage/NodePublish).
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
serviceAccountName: ebs-csi-node-sa
terminationGracePeriodSeconds: 30
priorityClassName: system-node-critical
@@ -0,0 +1,75 @@
# nvidia-device-plugin 0.17.4 — CDI-mode DaemonSet exposing nvidia.com/gpu
# allocatable on GPU-tainted nodes. Gated to GPU nodes via nodeSelector on the
# node-label nvidia.com/gpu.present=true (written by mulga-eks-provider-id from
# the SPINIFEX_GPU_NODE agent.env signal); inert
# elsewhere. --device-list-strategy=cdi-cri makes the plugin generate a CDI spec
# for GPU workloads (containerd 2.x resolves the CRI-passed CDI devices; no
# enable_cdi drop-in needed on k3s v1.32.5). The plugin's OWN container needs
# NVML + the driver tree to run nvml.Init and generate that spec, so it runs
# under the k3s-auto-configured "nvidia" runtimeClass with the host root mounted
# at /driver-root and the host /var/run/cdi mounted writable so the generated
# spec is visible to containerd. GPU workloads themselves need only
# resources.limits nvidia.com/gpu — no runtimeClass.
---
kind: DaemonSet
apiVersion: apps/v1
metadata:
name: nvidia-device-plugin-daemonset
namespace: kube-system
labels:
app.kubernetes.io/name: nvidia-device-plugin
spec:
selector:
matchLabels:
name: nvidia-device-plugin-ds
updateStrategy:
type: RollingUpdate
template:
metadata:
labels:
name: nvidia-device-plugin-ds
spec:
nodeSelector:
nvidia.com/gpu.present: "true"
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
priorityClassName: "system-node-critical"
runtimeClassName: nvidia
containers:
- image: nvcr.io/nvidia/k8s-device-plugin:v0.17.4
name: nvidia-device-plugin-ctr
args:
- --device-list-strategy=cdi-cri
env:
- name: FAIL_ON_INIT_ERROR
value: "false"
- name: NVIDIA_VISIBLE_DEVICES
value: all
- name: NVIDIA_DRIVER_CAPABILITIES
value: all
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: kubelet-device-plugins-dir
mountPath: /var/lib/kubelet/device-plugins
- name: driver-root
mountPath: /driver-root
readOnly: true
- name: cdi-root
mountPath: /var/run/cdi
volumes:
- name: kubelet-device-plugins-dir
hostPath:
path: /var/lib/kubelet/device-plugins
type: Directory
- name: driver-root
hostPath:
path: /
- name: cdi-root
hostPath:
path: /var/run/cdi
type: DirectoryOrCreate
@@ -135,6 +135,13 @@ spec:
operator: DoesNotExist
nodeSelector:
kubernetes.io/os: linux
# With internalTrafficPolicy=Local each worker resolves through its OWN
# CoreDNS pod, so a GPU-only worker (tainted nvidia.com/gpu=present:
# NoSchedule) with no local pod would have no DNS at all for its pods.
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
containers:
- name: coredns
image: rancher/mirrored-coredns-coredns:1.12.1
+20 -7
View File
@@ -48,14 +48,26 @@ if [ -z "${ROLE}" ] && [ -f "${AGENT_ENVFILE}" ]; then
ROLE="agent"
fi
case "${ROLE}" in
server | server-join | agent) ;;
*)
die "SPINIFEX_K3S_ROLE missing or invalid: '${ROLE}'"
;;
esac
# Written before the rc-service calls below: mulga-eks-k3s-recovery, started
# inline for server/server-join, reads ROLE_FILE at the top of its own logic
# and must find it already in place, not empty.
mkdir -p "$(dirname "${ROLE_FILE}")"
printf '%s\n' "${ROLE}" > "${ROLE_FILE}"
case "${ROLE}" in
server)
log "configuring server role"
rc-update add eks-token-webhook default
# Pre-k3s etcd recovery: added to the runlevel so it runs before k3s on
# every subsequent boot, but not started now — a fresh cluster has no
# directive and the role file it guards on is written at the end of this
# script. It first acts on the reboot the reconciler triggers.
# Pre-k3s etcd recovery: enabled for later boots and started inline
# now too, so a restore-snapshot DR seed's directive applies before
# k3s starts on this first boot. A plain create has no directive, so it's a no-op.
rc-update add mulga-eks-k3s-recovery default
rc-update add k3s default
# konnectivity-server fronts apiserver egress; every server replica runs
@@ -69,6 +81,7 @@ case "${ROLE}" in
# (mulga-siv-231.7); server-join nodes do not run it.
rc-update add mulga-eks-addon-sync default
rc-service eks-token-webhook start
rc-service mulga-eks-k3s-recovery start
rc-service k3s start
rc-service konnectivity-server start
rc-service k3s-first-boot start
@@ -79,12 +92,14 @@ case "${ROLE}" in
log "configuring server-join role"
rc-update add eks-token-webhook default
# Pre-k3s etcd recovery (see server role): server-join members are the
# wipe-rejoin followers of a cluster-reset, so they need it too.
# wipe-rejoin followers of a cluster-reset, so they need it too, and for
# the same first-boot reason must be started here, not deferred.
rc-update add mulga-eks-k3s-recovery default
rc-update add k3s default
rc-update add konnectivity-server default
rc-update add mulga-eks-state-report default
rc-service eks-token-webhook start
rc-service mulga-eks-k3s-recovery start
rc-service k3s start
rc-service konnectivity-server start
rc-service mulga-eks-state-report start
@@ -99,7 +114,5 @@ case "${ROLE}" in
;;
esac
mkdir -p "$(dirname "${ROLE_FILE}")"
printf '%s\n' "${ROLE}" > "${ROLE_FILE}"
rc-update del eks-node-role default 2>/dev/null || true
log "role '${ROLE}' configured"
@@ -65,8 +65,11 @@ check "server: enables k3s-first-boot" "yes" "$(grep -q 'rc-update add k3s-first
check "server: starts k3s" "yes" "$(grep -q 'rc-service k3s start' "${CALLS}" && echo yes || echo no)"
check "server: enables konnectivity-server" "yes" "$(grep -q 'rc-update add konnectivity-server default' "${CALLS}" && echo yes || echo no)"
check "server: enables k3s-recovery" "yes" "$(grep -q 'rc-update add mulga-eks-k3s-recovery default' "${CALLS}" && echo yes || echo no)"
# Recovery is added to the runlevel (pre-k3s on later boots) but not started now.
check "server: does not start k3s-recovery" "no" "$(grep -q 'rc-service mulga-eks-k3s-recovery' "${CALLS}" && echo yes || echo no)"
# Recovery is added to the runlevel (so it also runs pre-k3s on later boots)
# AND started inline now — a restore-snapshot DR seed's directive must apply
# on this very first boot, before k3s starts.
check "server: starts k3s-recovery" "yes" "$(grep -q 'rc-service mulga-eks-k3s-recovery start' "${CALLS}" && echo yes || echo no)"
check "server: starts k3s-recovery before k3s" "yes" "$(awk '/rc-service mulga-eks-k3s-recovery start/{r=NR} /rc-service k3s start/{k=NR} END{print (r>0 && k>0 && r<k) ? "yes" : "no"}' "${CALLS}")"
check "server: no k3s-agent" "no" "$(grep -q 'k3s-agent' "${CALLS}" && echo yes || echo no)"
check "server: self-disables" "yes" "$(grep -q 'rc-update del eks-node-role default' "${CALLS}" && echo yes || echo no)"
@@ -81,6 +84,7 @@ check "server-join: enables k3s" "yes" "$(grep -q 'rc-update add k3s default' "$
check "server-join: enables state-report" "yes" "$(grep -q 'rc-update add mulga-eks-state-report default' "${CALLS}" && echo yes || echo no)"
check "server-join: enables konnectivity-server" "yes" "$(grep -q 'rc-update add konnectivity-server default' "${CALLS}" && echo yes || echo no)"
check "server-join: enables k3s-recovery" "yes" "$(grep -q 'rc-update add mulga-eks-k3s-recovery default' "${CALLS}" && echo yes || echo no)"
check "server-join: starts k3s-recovery" "yes" "$(grep -q 'rc-service mulga-eks-k3s-recovery start' "${CALLS}" && echo yes || echo no)"
# Join servers must NOT re-publish bootstrap; the first server already did.
check "server-join: no k3s-first-boot" "no" "$(grep -q 'k3s-first-boot' "${CALLS}" && echo yes || echo no)"
check "server-join: no k3s-agent" "no" "$(grep -q 'k3s-agent' "${CALLS}" && echo yes || echo no)"
+1 -1
View File
@@ -29,7 +29,7 @@ start_pre() {
if [ -f /etc/spinifex-eks/agent.env ]; then
# shellcheck disable=SC1091
. /etc/spinifex-eks/agent.env
export K3S_URL K3S_TOKEN K3S_NODE_NAME K3S_NODE_LABEL
export K3S_URL K3S_TOKEN K3S_NODE_NAME
fi
if [ -z "${K3S_URL:-}" ] || [ -z "${K3S_TOKEN:-}" ]; then
eerror "K3S_URL and K3S_TOKEN must be set in /etc/spinifex-eks/agent.env"
+10
View File
@@ -28,6 +28,16 @@ depend() {
}
start_pre() {
# mulga-eks-k3s-recovery drops this marker when a required-snapshot restore
# could not fetch its etcd snapshot. Refuse to start: a fresh DR seed with no
# snapshot would cluster-init an EMPTY datastore (silent total data loss). The
# marker is in /run tmpfs, cleared on reboot, so recovery retries the fetch
# next boot and k3s starts once the snapshot restores.
if [ -f /run/spinifex-eks/k3s-restore-block ]; then
eerror "restore-snapshot could not fetch the required etcd snapshot; refusing to start k3s on an empty datastore (see mulga-eks-k3s-recovery log)"
return 1
fi
# config.yaml is written by k3s-first-boot from the skeleton + cloud-init
# data. Refuse to start if neither exists.
if [ ! -f /etc/rancher/k3s/config.yaml ]; then
@@ -9,7 +9,9 @@
#
# Runs on workers only: it carries no toleration for the control-plane
# CriticalAddonsOnly=true:NoExecute taint, so it is never scheduled onto the CP
# nodes (which run the server, not an agent).
# nodes (which run the server, not an agent). It DOES tolerate the GPU
# nvidia.com/gpu=present:NoSchedule taint — a GPU-only worker still needs
# kubectl logs/exec against pods there, which routes through this agent.
apiVersion: v1
kind: ServiceAccount
metadata:
@@ -39,6 +41,13 @@ spec:
spec:
serviceAccountName: konnectivity-agent
priorityClassName: system-node-critical
# Without this, a GPU-only worker (tainted nvidia.com/gpu=present:NoSchedule)
# shows DESIRED=0 for this DaemonSet and kubectl logs/exec against pods
# scheduled there fails "No agent available".
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
# hostNetwork so the agent reaches the NLB over the node network and
# identifies itself to the server by the node's own IP (the address the
# apiserver dials back through for kubelet/pod egress).
+1
View File
@@ -61,6 +61,7 @@ scripts/images/eks-node/addons/aws-ebs-csi-driver/1.40.1/00-rbac.yaml:/usr/share
scripts/images/eks-node/addons/aws-ebs-csi-driver/1.40.1/10-controller.yaml:/usr/share/spinifex-eks/addons/aws-ebs-csi-driver/1.40.1/10-controller.yaml \
scripts/images/eks-node/addons/aws-ebs-csi-driver/1.40.1/20-node.yaml:/usr/share/spinifex-eks/addons/aws-ebs-csi-driver/1.40.1/20-node.yaml \
scripts/images/eks-node/addons/aws-ebs-csi-driver/1.40.1/30-storageclass.yaml:/usr/share/spinifex-eks/addons/aws-ebs-csi-driver/1.40.1/30-storageclass.yaml \
scripts/images/eks-node/addons/nvidia-device-plugin/0.17.4/10-daemonset.yaml:/usr/share/spinifex-eks/addons/nvidia-device-plugin/0.17.4/10-daemonset.yaml \
scripts/images/eks-node/coredns/coredns-mulga.yaml:/usr/share/spinifex-eks/coredns/coredns-mulga.yaml \
scripts/images/eks-node/cloud-init-ec2.cfg:/etc/cloud/cloud.cfg.d/90-spinifex-ec2-imds.cfg \
scripts/images/eks-node/mulga-eks-etcd-snapshot.sh:/etc/periodic/daily/mulga-eks-etcd-snapshot \
@@ -66,14 +66,24 @@ fi
#
# Note: --aws-sigv4 requires curl >= 7.75 (Alpine 3.21 ships 8.x).
URL="${SPINIFEX_PREDASTORE_ENDPOINT%/}/${BUCKET}/${KEY}"
curl -fsSL \
# Capture curl's own exit: piping straight into logger masks it behind logger's
# (near-always 0), so a failed upload would log "uploaded" and delete the only
# copy — snapshots would silently never land. Keep the local snapshot and exit
# non-zero on failure so cron surfaces it and the next run still has data on disk.
if UPLOAD_OUT=$(curl -fsSL \
--aws-sigv4 "aws:amz:${AWS_REGION:-au-mel-1}:s3" \
--user "${SPINIFEX_PREDASTORE_AKID}:${SPINIFEX_PREDASTORE_SECRET}" \
-H "x-amz-content-sha256: UNSIGNED-PAYLOAD" \
--upload-file "${SNAPSHOT_FILE}" \
"${URL}" 2>&1 | logger -t mulga-eks-etcd-snapshot
# Remove the local snapshot to bound disk usage; the canonical copy lives in
# predastore. Restore path (`spx admin eks restore-snapshot`) pulls from there.
rm -f "${SNAPSHOT_FILE}"
logger -t mulga-eks-etcd-snapshot "uploaded ${KEY}"
"${URL}" 2>&1); then
[ -n "${UPLOAD_OUT}" ] && printf '%s\n' "${UPLOAD_OUT}" | logger -t mulga-eks-etcd-snapshot
# Remove the local snapshot only after a confirmed upload to bound disk usage;
# the canonical copy lives in predastore (`spx admin eks restore-snapshot`).
rm -f "${SNAPSHOT_FILE}"
logger -t mulga-eks-etcd-snapshot "uploaded ${KEY}"
else
UPLOAD_RC=$?
[ -n "${UPLOAD_OUT}" ] && printf '%s\n' "${UPLOAD_OUT}" | logger -t mulga-eks-etcd-snapshot
logger -t mulga-eks-etcd-snapshot "ERROR: upload of ${KEY} failed (curl rc=${UPLOAD_RC}); keeping local snapshot ${SNAPSHOT_FILE}"
exit 1
fi
@@ -29,10 +29,13 @@ done
:
EOF
# curl stub: record the full argument line (carries --upload-file + URL).
# curl stub: record the full argument line (carries --upload-file + URL). Exits
# non-zero when CURL_FAIL=1 so the harness can exercise the failed-upload path.
cat > "${STUBBIN}/curl" <<'EOF'
#!/bin/sh
echo "curl $*" >> "${CURL_CALLS}"
[ "${CURL_FAIL:-0}" = "1" ] && { echo "curl: (7) connection refused" >&2; exit 7; }
exit 0
EOF
# logger stub: swallow.
@@ -102,6 +105,24 @@ run server "${WORK}/15min/mulga-eks-etcd-snapshot"
[ -s "${WORK}/curl.calls" ] && fail "no-env: must not upload" || pass "no-env: no upload without creds"
ENVFILE="${ENVFILE_SAVE}"
# --- Case 5: upload FAILS -> non-zero exit + local snapshot KEPT (not masked) ---
# Set up like run() but expect a non-zero exit and assert the snapshot survives.
K3S_CALLS="${WORK}/k3s.calls"; CURL_CALLS="${WORK}/curl.calls"
SNAPSHOT_DIR="${WORK}/snapshots"; ROLE_FILE="${WORK}/role"; K3S_BIN=k3s
: > "${K3S_CALLS}"; : > "${CURL_CALLS}"; rm -rf "${SNAPSHOT_DIR}"
printf '%s' server > "${ROLE_FILE}"
export K3S_CALLS CURL_CALLS SNAPSHOT_DIR ROLE_FILE K3S_BIN ENVFILE
export TIER=frequent CURL_FAIL=1
if sh "${WORK}/15min/mulga-eks-etcd-snapshot" </dev/null; then
fail "upload-fail: must exit non-zero when curl fails"
else
pass "upload-fail: non-zero exit surfaces the failure"
fi
ls "${SNAPSHOT_DIR}"/etcd-frequent-* >/dev/null 2>&1 \
&& pass "upload-fail: local snapshot kept for the next run" \
|| fail "upload-fail: local snapshot deleted despite failed upload"
unset TIER CURL_FAIL
if [ "${FAILS}" -eq 0 ]; then
echo "PASS: all mulga-eks-etcd-snapshot cases"
exit 0
@@ -7,10 +7,10 @@ set -eu
# but embedded etcd never reformed quorum after a simultaneous restart).
#
# The reconciler cannot push into the guest, so recovery is pull-based: this
# fetches a per-member directive epoch<TAB>action<TAB>snapshot through the AWS
# gateway (eks-gateway-fetch -resource recovery), epoch-guarded so a directive
# applies exactly once. Ordered `before k3s` so the etcd datastore is mutated
# while the apiserver is stopped. Actions:
# fetches a per-member directive epoch<TAB>action<TAB>snapshot<TAB>required
# through the AWS gateway (eks-gateway-fetch -resource recovery), epoch-guarded
# so a directive applies exactly once. Ordered `before k3s` so the etcd datastore
# is mutated while the apiserver is stopped. Actions:
# none steady state — nothing to do
# cluster-reset seed member: `k3s server --cluster-reset` reforms a
# single-member etcd from this node's intact data (optionally
@@ -18,6 +18,13 @@ set -eu
# wipe-rejoin follower: delete the local etcd datastore so k3s re-joins the
# reset seed's quorum fresh on the next k3s start
#
# required=1 marks a snapshot that MUST restore (fresh DR seed via
# `spx admin eks restore-snapshot`): a fresh seed has no intact local etcd, so a
# cluster-reset without the snapshot would boot an EMPTY cluster. On a fetch
# failure this drops BLOCK_MARKER and aborts boot; k3s.initd refuses to start on
# the marker, so k3s never resets into an empty datastore. The fetch is retried
# on the next boot (BLOCK_MARKER lives in /run tmpfs, cleared on reboot).
#
# Env knobs default to production paths; overridable so the logic is unit-testable
# without root, IMDS, a gateway, or a real k3s.
@@ -25,6 +32,7 @@ ROLE_FILE=${ROLE_FILE:-/etc/spinifex-eks/role}
ENVFILE=${ENVFILE:-/etc/spinifex-eks/first-boot.env}
SNAPSHOT_ENVFILE=${SNAPSHOT_ENVFILE:-/etc/spinifex-eks/etcd-snapshot.env}
EPOCH_FILE=${EPOCH_FILE:-/var/lib/spinifex-eks/recovery.epoch}
BLOCK_MARKER=${BLOCK_MARKER:-/run/spinifex-eks/k3s-restore-block}
K3S_BIN=${K3S_BIN:-/usr/local/bin/k3s}
K3S_CONFIG=${K3S_CONFIG:-/etc/rancher/k3s/config.yaml}
ETCD_DIR=${ETCD_DIR:-/var/lib/rancher/k3s/server/db/etcd}
@@ -72,12 +80,26 @@ fetch_snapshot() {
[ -n "${SPINIFEX_PREDASTORE_SECRET:-}" ] || return 1
_url="${SPINIFEX_PREDASTORE_ENDPOINT%/}/eks-backups-system/${EKS_ACCOUNT_ID}/${EKS_CLUSTER_NAME}/${_name}"
mkdir -p "${SNAPSHOT_DIR}"
curl -fsSL \
# Capture curl's own exit — piping straight into logger would mask it behind
# logger's (near-always 0), so a failed download would look like success and
# the caller would wrongly cluster-reset from local etcd.
if _out=$(curl -fsSL \
--aws-sigv4 "aws:amz:${AWS_REGION:-au-mel-1}:s3" \
--user "${SPINIFEX_PREDASTORE_AKID}:${SPINIFEX_PREDASTORE_SECRET}" \
-H "x-amz-content-sha256: UNSIGNED-PAYLOAD" \
-o "${SNAPSHOT_DIR}/${_name}" \
"${_url}" 2>&1 | logger -t "${LOGTAG}"
"${_url}" 2>&1); then
[ -n "${_out}" ] && printf '%s\n' "${_out}" | logger -t "${LOGTAG}"
return 0
else
# Capture rc as the first else statement: $? read after `fi` would be the
# if's own 0, re-masking the failure we are here to surface.
_rc=$?
[ -n "${_out}" ] && printf '%s\n' "${_out}" | logger -t "${LOGTAG}"
rm -f "${SNAPSHOT_DIR}/${_name}"
log "snapshot ${_name} download failed (curl rc=${_rc})"
return 1
fi
}
# Instance ID from IMDS keys the per-member directive. IMDSv2 token if enforced,
@@ -109,8 +131,11 @@ fi
epoch=$(printf '%s' "${line}" | cut -f1)
action=$(printf '%s' "${line}" | cut -f2)
snapshot=$(printf '%s' "${line}" | cut -f3)
snapshot_required=$(printf '%s' "${line}" | cut -f4)
[ -n "${action}" ] || action=none
case "${epoch}" in '' | *[!0-9]*) epoch=0 ;; esac
# Absent 4th field (older gateway) reads as not-required, preserving HA-reform semantics.
case "${snapshot_required}" in 1) snapshot_required=1 ;; *) snapshot_required=0 ;; esac
if [ "${action}" = "none" ]; then
log "directive action=none (epoch ${epoch}); nothing to do"
@@ -136,6 +161,13 @@ case "${action}" in
if fetch_snapshot "${snapshot}"; then
restore_arg="--cluster-reset-restore-path=${SNAPSHOT_DIR}/${snapshot}"
log "cluster-reset restoring snapshot ${snapshot}"
elif [ "${snapshot_required}" = "1" ]; then
# Fresh DR seed: no intact local etcd, so a reset without the snapshot
# would boot an EMPTY cluster. Block k3s and abort; retry next boot.
log "ERROR: required snapshot ${snapshot} could not be fetched; aborting boot to avoid resetting into an EMPTY datastore (retries next boot)"
mkdir -p "$(dirname "${BLOCK_MARKER}")"
: > "${BLOCK_MARKER}"
exit 1
else
log "WARN: snapshot ${snapshot} fetch failed; cluster-reset from local etcd only"
fi
@@ -24,7 +24,9 @@ printf '%s\n' "${DIRECTIVE_LINE}"
EOF
# curl stub: answers the IMDS token PUT + instance-id GET, and materialises a
# snapshot file for any `-o <path>` download. Records every call.
# snapshot file for any `-o <path>` download. Records every call. A snapshot
# download (an `-o` arg) fails with a non-zero exit when CURL_SNAP_FAIL=1, so the
# harness can exercise the fetch-failure path.
cat > "${STUBBIN}/curl" <<'EOF'
#!/bin/sh
echo "curl $*" >> "${CURL_CALLS}"
@@ -36,7 +38,10 @@ for a in "$@"; do
done
prev=""
for a in "$@"; do
[ "$prev" = "-o" ] && echo snapdata > "$a"
if [ "$prev" = "-o" ]; then
[ "${CURL_SNAP_FAIL:-0}" = "1" ] && { echo "curl: (22) 404" >&2; exit 22; }
echo snapdata > "$a"
fi
prev="$a"
done
exit 0
@@ -80,7 +85,7 @@ pass() { echo "ok: $*"; }
# run <role> <directive-line>: reset capture files + state, then run the script
# with every path knob pointed into the temp dir.
run() {
_setup_run() {
_role=$1
DIRECTIVE_LINE=$2
K3S_CALLS="${WORK}/k3s.calls"
@@ -89,13 +94,29 @@ run() {
EPOCH_FILE="${WORK}/recovery.epoch"
ETCD_DIR="${WORK}/etcd"
SNAPSHOT_DIR="${WORK}/snapshots"
BLOCK_MARKER="${WORK}/run/k3s-restore-block"
: > "${K3S_CALLS}"
: > "${CURL_CALLS}"
rm -f "${BLOCK_MARKER}"
printf '%s' "${_role}" > "${ROLE_FILE}"
export DIRECTIVE_LINE K3S_CALLS CURL_CALLS ROLE_FILE EPOCH_FILE ETCD_DIR SNAPSHOT_DIR
export DIRECTIVE_LINE K3S_CALLS CURL_CALLS ROLE_FILE EPOCH_FILE ETCD_DIR SNAPSHOT_DIR BLOCK_MARKER
export ENVFILE SNAPSHOT_ENVFILE
export K3S_BIN=k3s FETCH_BIN=eks-gateway-fetch
sh "${SCRIPT}" </dev/null || fail "role=${_role}: non-zero exit"
}
# run <role> <directive-line>: expects a zero exit (the steady-state path).
run() {
_setup_run "$1" "$2"
sh "${SCRIPT}" </dev/null || fail "role=${1}: non-zero exit"
}
# run_abort <role> <directive-line>: expects a NON-zero exit (the required-snapshot
# fetch-failure abort). CURL_SNAP_FAIL must be set by the caller.
run_abort() {
_setup_run "$1" "$2"
if sh "${SCRIPT}" </dev/null; then
fail "role=${1}: expected non-zero exit (required-snapshot abort)"
fi
}
TAB=$(printf '\t')
@@ -149,6 +170,36 @@ grep -q 'aws-sigv4' "${WORK}/curl.calls" \
[ -f "${WORK}/snapshots/etcd-daily-20260706T000000Z.snap" ] \
&& pass "snapshot: fetched into snapshot dir" || fail "snapshot: file not materialised"
# --- Case 7: REQUIRED snapshot, fetch OK -> restore-path + no block marker ---
rm -f "${WORK}/recovery.epoch"
CURL_SNAP_FAIL=0; export CURL_SNAP_FAIL
run server "5${TAB}cluster-reset${TAB}etcd-frequent-20260709T010000Z.snap${TAB}1"
grep -q 'cluster-reset-restore-path=.*etcd-frequent-20260709T010000Z.snap' "${WORK}/k3s.calls" \
&& pass "required-ok: restore-path passed" || fail "required-ok: no restore-path: $(cat "${WORK}/k3s.calls")"
[ -f "${WORK}/run/k3s-restore-block" ] && fail "required-ok: must not block k3s on success" || pass "required-ok: no block marker"
# --- Case 8: REQUIRED snapshot, fetch FAILS -> abort: block marker, no k3s, no epoch ---
rm -f "${WORK}/recovery.epoch"
CURL_SNAP_FAIL=1; export CURL_SNAP_FAIL
run_abort server "6${TAB}cluster-reset${TAB}etcd-frequent-20260709T010000Z.snap${TAB}1"
[ -f "${WORK}/run/k3s-restore-block" ] \
&& pass "required-fail: block marker dropped to stop k3s" || fail "required-fail: no block marker"
[ -s "${WORK}/k3s.calls" ] && fail "required-fail: must NOT cluster-reset into empty datastore" || pass "required-fail: k3s not reset"
[ -f "${WORK}/recovery.epoch" ] && fail "required-fail: epoch must not record (retry next boot)" || pass "required-fail: epoch not recorded"
# --- Case 9: NON-required snapshot, fetch FAILS -> local reset, no block, epoch recorded ---
rm -f "${WORK}/recovery.epoch"
CURL_SNAP_FAIL=1; export CURL_SNAP_FAIL
run server "7${TAB}cluster-reset${TAB}etcd-frequent-20260709T010000Z.snap${TAB}0"
grep -q 'server --config .* --cluster-reset' "${WORK}/k3s.calls" \
&& pass "optional-fail: falls back to local cluster-reset" || fail "optional-fail: no reset: $(cat "${WORK}/k3s.calls")"
grep -q 'cluster-reset-restore-path' "${WORK}/k3s.calls" \
&& fail "optional-fail: must not pass a restore-path for a failed fetch" || pass "optional-fail: local-data reset only"
[ -f "${WORK}/run/k3s-restore-block" ] && fail "optional-fail: must not block k3s (HA-reform semantics)" || pass "optional-fail: no block marker"
[ "$(cat "${WORK}/recovery.epoch" 2>/dev/null)" = 7 ] \
&& pass "optional-fail: epoch recorded" || fail "optional-fail: epoch not recorded"
CURL_SNAP_FAIL=0; export CURL_SNAP_FAIL
if [ "${FAILS}" -eq 0 ]; then
echo "PASS: all mulga-eks-k3s-recovery cases"
exit 0
@@ -2,14 +2,16 @@
# mulga-eks-provider-id — boot oneshot that writes a K3s drop-in from IMDS
# before K3s starts: the kubelet provider-id plus region/zone topology labels,
# the node metadata the aws-ebs-csi-driver needs for NodeGetInfo + provisioning.
# Ordered after local (which installs the IMDS on-link route) and before both
# K3s roles. Role-agnostic: enabled on every node (server + agent).
# Also folds the worker's nodegroup + GPU node-labels (from agent.env) into the
# same node-label list, so it must run after cloud-init has written agent.env
# (cloud-config/cloud-final stages, same as eks-node-role) and before the role
# selector starts K3s. Role-agnostic: enabled on every node (server + agent).
description="Set K3s provider-id + topology labels from IMDS for EBS CSI"
depend() {
after net local
before k3s k3s-agent
after net local cloud-config cloud-final
before k3s k3s-agent eks-node-role
}
start() {
@@ -17,13 +17,22 @@ set -eu
# family"). This drop-in supplies all four; region is derived from the AZ
# (trailing zone letters stripped).
#
# K3s merges /etc/rancher/k3s/config.yaml.d/*.yaml over config.yaml; list keys
# such as kubelet-arg are concatenated, so this adds the flag without touching
# the cloud-init-written main config. Role-agnostic: runs on server + agent.
# This drop-in is also the SINGLE writer of the node-label list. K3s replaces
# (not merges) node-label across config.yaml.d files, so the last-sorted file
# wins outright and any other node-label drop-in would silently wipe the rest.
# The worker's nodegroup label (and, on GPU workers, the nvidia.com/gpu.present
# label + NoSchedule taint) therefore ride here too, read from agent.env, so
# they share the one list with the topology labels. On the K3s server there is
# no agent.env, so only the topology labels are written — behavior unchanged.
#
# kubelet-arg lists ARE concatenated by k3s across drop-ins, so the provider-id
# flag coexists with other files' kubelet-arg without touching the main config.
# Role-agnostic: runs on server + agent.
IMDS="http://169.254.169.254/latest"
DROPIN_DIR=/etc/rancher/k3s/config.yaml.d
DROPIN="${DROPIN_DIR}/10-provider-id.yaml"
AGENT_ENV=/etc/spinifex-eks/agent.env
LOGTAG="mulga-eks-provider-id"
log() { echo "[${LOGTAG}] $*"; }
@@ -79,13 +88,32 @@ case "${itype}" in
*) itype="m5.large" ;;
esac
# Fold the worker's nodegroup + GPU node-labels into the same list. agent.env is
# written by cloud-init and absent on the server; source it only if present.
nodegroup=""
gpu_node=""
if [ -f "${AGENT_ENV}" ]; then
# shellcheck disable=SC1090
. "${AGENT_ENV}"
nodegroup="${SPINIFEX_NODEGROUP:-}"
gpu_node="${SPINIFEX_GPU_NODE:-}"
fi
mkdir -p "${DROPIN_DIR}"
cat > "${DROPIN}" <<EOF
kubelet-arg:
- "provider-id=aws:///${az}/${iid}"
node-label:
- "topology.kubernetes.io/region=${region}"
- "topology.kubernetes.io/zone=${az}"
- "node.kubernetes.io/instance-type=${itype}"
EOF
log "wrote provider-id aws:///${az}/${iid}, region=${region} zone=${az} type=${itype}"
{
echo "kubelet-arg:"
echo " - \"provider-id=aws:///${az}/${iid}\""
echo "node-label:"
echo " - \"topology.kubernetes.io/region=${region}\""
echo " - \"topology.kubernetes.io/zone=${az}\""
echo " - \"node.kubernetes.io/instance-type=${itype}\""
if [ -n "${nodegroup}" ]; then
echo " - \"eks.amazonaws.com/nodegroup=${nodegroup}\""
fi
if [ "${gpu_node}" = "true" ]; then
echo " - \"nvidia.com/gpu.present=true\""
echo "node-taint:"
echo " - \"nvidia.com/gpu=present:NoSchedule\""
fi
} > "${DROPIN}"
log "wrote provider-id aws:///${az}/${iid}, region=${region} zone=${az} type=${itype} nodegroup=${nodegroup:-<none>} gpu=${gpu_node:-false}"
@@ -70,11 +70,21 @@ publish_report() {
# Probe /healthz through the node's admin kubeconfig (apiserver runs
# anonymous-auth=false, CIS): an unauthenticated curl would return 401.
reason=
nodegroup_ready='{}'
if kubectl get --raw='/healthz' 2>/dev/null | grep -q '^ok$'; then
health=ok
# Count Ready nodes only — k3s retains terminated workers as NotReady
# until manually pruned, so a raw node count never drops on scale-down.
node_count=$(kubectl get nodes --no-headers 2>/dev/null | awk '$2=="Ready"' | wc -l | tr -d ' ')
# Per-nodegroup Ready breakdown: -L appends the node's
# eks.amazonaws.com/nodegroup label as the last column ("<none>" when
# absent, e.g. the control-plane node itself). Grouping by that label —
# rather than only totalling — lets each nodegroup's own Ready-gate be
# scoped to its own workers instead of the cluster-wide count, so one
# nodegroup's Ready nodes can never mask another's stuck launch.
nodegroup_ready=$(kubectl get nodes --no-headers -L eks.amazonaws.com/nodegroup 2>/dev/null \
| awk '$2=="Ready" && $NF!="<none>"{c[$NF]++} END{sep=""; printf "{"; for (k in c) {printf "%s\"%s\":%d", sep, k, c[k]; sep=","}; printf "}"}')
[ -n "${nodegroup_ready}" ] || nodegroup_ready='{}'
else
health=fail
node_count=0
@@ -91,11 +101,11 @@ publish_report() {
} > "${CONSOLE:-/dev/console}" 2>&1 || true
fi
if [ -n "${reason}" ]; then
payload=$(printf '{"healthz":"%s","node_count":%s,"reason":"%s","ts":%s}' \
"${health}" "${node_count}" "${reason}" "$(date +%s)")
payload=$(printf '{"healthz":"%s","node_count":%s,"nodegroup_ready":%s,"reason":"%s","ts":%s}' \
"${health}" "${node_count}" "${nodegroup_ready}" "${reason}" "$(date +%s)")
else
payload=$(printf '{"healthz":"%s","node_count":%s,"ts":%s}' \
"${health}" "${node_count}" "$(date +%s)")
payload=$(printf '{"healthz":"%s","node_count":%s,"nodegroup_ready":%s,"ts":%s}' \
"${health}" "${node_count}" "${nodegroup_ready}" "$(date +%s)")
fi
printf '%s' "${payload}" | eks-gateway-publish -channel state 2>&1 \
| logger -t mulga-eks-state-report
@@ -24,6 +24,7 @@ case "$*" in
*"--raw=/healthz/etcd"*) printf '%s\n' "${ETCD_BODY:-ok}" ;;
*"--raw=/readyz?verbose"*) printf '%s\n' "${READYZ_BODY:-}" ;;
*"--raw=/healthz"*) printf '%s\n' "${HEALTHZ_BODY:-ok}" ;;
*"get nodes"*" -L "*) printf '%s\n' "${NODES_LABELED_BODY:-}" ;;
*"get nodes"*) printf '%s\n' "${NODES_BODY:-}" ;;
*) : ;;
esac
@@ -75,15 +76,23 @@ run_agent() {
}
# --- Case 1: healthy apiserver, 3 Ready nodes -> no reason, quiet console ---
# NODES_LABELED_BODY carries the -L eks.amazonaws.com/nodegroup breakdown: a
# CP node ("<none>", excluded), 2 Ready ng-a workers, 1 Ready ng-b worker, and
# a NotReady ng-b worker that must not count — proving the per-nodegroup tally
# scopes to THAT nodegroup's own Ready nodes, not the cluster-wide total.
HEALTHZ_BODY=ok
NODES_BODY=$(printf 'ip-a Ready s\nip-b Ready s\nip-c Ready s\n')
NODES_LABELED_BODY=$(printf 'ip-cp Ready s <none>\nip-a Ready s ng-a\nip-b Ready s ng-a\nip-c Ready s ng-b\nip-d NotReady s ng-b\n')
ETCD_DB_DIR="${WORK}"
export HEALTHZ_BODY NODES_BODY
export HEALTHZ_BODY NODES_BODY NODES_LABELED_BODY
run_agent
P=$(cat "${WORK}/payload.json")
case "${P}" in *'"healthz":"ok"'*) pass "healthy: healthz ok" ;; *) fail "healthy: healthz not ok: ${P}" ;; esac
case "${P}" in *'"node_count":3'*) pass "healthy: node_count 3" ;; *) fail "healthy: node_count wrong: ${P}" ;; esac
case "${P}" in *'"reason"'*) fail "healthy: reason must be absent: ${P}" ;; *) pass "healthy: no reason field" ;; esac
case "${P}" in *'"nodegroup_ready":{'*) pass "healthy: nodegroup_ready object present" ;; *) fail "healthy: nodegroup_ready missing: ${P}" ;; esac
case "${P}" in *'"ng-a":2'*) pass "healthy: ng-a Ready count scoped to its own 2 workers" ;; *) fail "healthy: ng-a count wrong: ${P}" ;; esac
case "${P}" in *'"ng-b":1'*) pass "healthy: ng-b Ready count excludes its NotReady worker" ;; *) fail "healthy: ng-b count wrong: ${P}" ;; esac
[ -s "${WORK}/console.out" ] && fail "healthy: console must be quiet" || pass "healthy: console quiet"
# --- Case 2: unhealthy, etcd subcheck failing, etcd unreachable, disk ok ---
@@ -95,6 +104,7 @@ export HEALTHZ_BODY READYZ_BODY ETCD_BODY DF_AVAIL_KB
run_agent
P=$(cat "${WORK}/payload.json")
case "${P}" in *'"healthz":"fail"'*) pass "unhealthy: healthz fail" ;; *) fail "unhealthy: healthz wrong: ${P}" ;; esac
case "${P}" in *'"nodegroup_ready":{}'*) pass "unhealthy: nodegroup_ready empty (no kubectl node query on a failing apiserver)" ;; *) fail "unhealthy: nodegroup_ready wrong: ${P}" ;; esac
case "${P}" in *'"reason":"readyz:[etcd poststarthook/start-service-ip-repair-controllers]; etcd:unreachable; disk:ok"'*) pass "unhealthy: reason names failing subchecks + etcd + disk" ;; *) fail "unhealthy: reason wrong: ${P}" ;; esac
case "${P}" in *'"reason":"'*'"'*'"'*) : ;; esac
C=$(cat "${WORK}/console.out")
+1 -1
View File
@@ -10,6 +10,6 @@ IMAGE_SIZE="16G"
SETUP_SCRIPT="scripts/images/ubuntu-gpu-nvidia-setup.sh"
AMI_NAME="ubuntu-nvidia-gpu"
AMI_NAME="ubuntu-26.04-nvidia-gpu-x86_64"
SYSTEM_TAG="gpu-vendor=nvidia"
+3 -3
View File
@@ -22,9 +22,9 @@ Usage:
Options:
--type TYPE EC2 instance type to launch (required)
--disk GB Root disk size in GB (default: 50)
--ami NAME Override the AMI name to use (default: ubuntu-amd-gpu or ubuntu-nvidia-gpu)
--ami NAME Override the AMI name to use (default: ubuntu-amd-gpu or ubuntu-26.04-nvidia-gpu-x86_64)
--amd Force AMD GPU AMI (ubuntu-amd-gpu)
--nvidia Force NVIDIA GPU AMI (ubuntu-nvidia-gpu)
--nvidia Force NVIDIA GPU AMI (ubuntu-26.04-nvidia-gpu-x86_64)
Env overrides:
SSH_KEY Path to SSH private key (default: ~/.ssh/spinifex-key)
@@ -96,7 +96,7 @@ fi
case "$GPU_BRAND" in
amd) AMI_NAME="ubuntu-amd-gpu" ;;
nvidia) AMI_NAME="ubuntu-nvidia-gpu" ;;
nvidia) AMI_NAME="ubuntu-26.04-nvidia-gpu-x86_64" ;;
esac
# --- Resolve AMI ---
+4 -1
View File
@@ -67,7 +67,10 @@ if [[ -z "${IMAGE_NAME:-}" ]]; then
fi
# Mirror build-system-image.sh's derived paths so we reuse its cached raw.
BUILD_DIR="/tmp/${IMAGE_NAME}-image-build"
# Honor SYSTEM_IMAGE_BUILD_DIR exactly as the build script does — the workflow
# points it off tmpfs so the 16G Ubuntu GPU raw fits, and the publish step must
# look where the build actually wrote, not a hardcoded /tmp.
BUILD_DIR="${SYSTEM_IMAGE_BUILD_DIR:-/tmp}/${IMAGE_NAME}-image-build"
RAW_IMAGE="${BUILD_DIR}/${IMAGE_NAME}-${DISTRO}.raw"
OBJECT_BASE="${AMI_NAME:-$IMAGE_NAME}-${ARCH}"
+36
View File
@@ -275,5 +275,41 @@ sleep 5
echo "==> Building and installing microVM artifacts"
cd "$PROJECT_ROOT" && make build-lb-agent install-microvm
# --- Authorize SSH ingress on the default security group ---
# Default SG denies all inbound, so smoke-test.sh's SSH probe times out.
# Open port 22 on the default VPC's default SG before the smoke test runs.
aws_as_user() { sudo -u "$INVOKING_USER" env HOME="$INVOKING_HOME" AWS_PROFILE=spinifex aws "$@"; }
echo "==> Waiting for EC2 daemon"
DAEMON_TIMEOUT=60
DAEMON_ELAPSED=0
while [ $DAEMON_ELAPSED -lt $DAEMON_TIMEOUT ]; do
if aws_as_user ec2 describe-security-groups --output text >/dev/null 2>&1; then
break
fi
sleep 2
DAEMON_ELAPSED=$((DAEMON_ELAPSED + 2))
done
if [ $DAEMON_ELAPSED -ge $DAEMON_TIMEOUT ]; then
echo "❌ EC2 daemon not ready after ${DAEMON_TIMEOUT}s"
exit 1
fi
echo "==> Authorizing SSH (port 22) on default security group"
DEFAULT_VPC_ID=$(aws_as_user ec2 describe-vpcs \
--filters "Name=is-default,Values=true" \
--query 'Vpcs[0].VpcId' --output text)
DEFAULT_SG_ID=$(aws_as_user ec2 describe-security-groups \
--filters "Name=vpc-id,Values=$DEFAULT_VPC_ID" "Name=group-name,Values=default" \
--query 'SecurityGroups[0].GroupId' --output text)
if [ -z "$DEFAULT_SG_ID" ] || [ "$DEFAULT_SG_ID" = "None" ]; then
echo "❌ Could not locate default security group in VPC $DEFAULT_VPC_ID"
exit 1
fi
aws_as_user ec2 authorize-security-group-ingress \
--group-id "$DEFAULT_SG_ID" --protocol tcp --port 22 --cidr 0.0.0.0/0 \
>/dev/null 2>&1 || true
echo " Authorized SSH on $DEFAULT_SG_ID ($DEFAULT_VPC_ID)"
# --- Smoke test ---
sudo -u "$INVOKING_USER" "$SCRIPT_DIR/smoke-test.sh"

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