Compare commits

..

142 Commits

Author SHA1 Message Date
Julian Sommer bafcf7240a fix: range-check uid, gid and sysfs integer conversions (#1601) 2026-11-05 10:26:46 +11:00
Josh Siviour 9a766abc09 feat(ec2): add account storage cleanup for volumes and tag residue (#1599)
* feat(ec2): add CleanupStorage to delete free volumes and clear instance, volume and spot tag residue

* fix(ec2): read teardown before deleting volumes and report volumes that appear during cleanup

* test: align the fake deleter fields

* feat(ec2): add CleanupStorage to delete free volumes and clear instance, volume and spot tag residue

* fix(ec2): read teardown before deleting volumes and report volumes that appear during cleanup

* test: align the fake deleter fields
2026-11-05 09:22:22 +10:00
Julian Sommer 7f1bf2aa4f fix: drop password-tainted values from daemon log lines (#1600)
CodeQL go/clear-text-logging flagged the marshal-failure type attribute in
respondWithJSON and the subscription key in the OnInstanceUp rollback log.
2026-11-05 09:57:18 +11:00
Julian Sommer bc35613570 fix: failing test 2026-11-05 08:52:23 +10:00
Julian Sommer 3c42e03cbe fix(ui): remove exponential backtracking from the Route 53 host validator (#1597) 2026-11-05 09:46:17 +11:00
Julian Sommer 3ac4797632 fix: cancel superseded PR builds and pick the newest dev prerelease by publish time (#1596) 2026-11-05 09:37:04 +11:00
Julian Sommer 8e1d37d3e0 fix(ecs): refuse CreateCluster logging and settings values and capacity-provider strategy weight and base outside AWS's ranges with InvalidParameterException (#1595) 2026-11-05 09:29:03 +11:00
Julian Sommer e7c0df3ceb test(rds): stop the lease-expiry test from deleting its bucket under the context the expiry cancels (#1594) 2026-11-05 09:26:21 +11:00
Julian Sommer e16991f3c9 fix(ecr): refuse maxResults, imageStatus, exclusion filter, kmsKey and registryIds breaks with AWS's InvalidParameterException (#1593) 2026-11-05 09:24:49 +11:00
Julian Sommer 58e65ef282 fix(ecs): refuse tag key, value and count breaks on every tagging call with AWS's errors (#1591) 2026-11-05 09:23:15 +11:00
Julian Sommer f6aa5669bf fix(ecs): answer RunTask, StartTask, CreateService and UpdateService with ClientException 'TaskDefinition not found.' for an unknown task definition (#1590) 2026-11-05 09:22:59 +11:00
Josh Siviour 0dd9a9c0bd feat(ec2): add account compute cleanup for spot requests and instances (#1588)
* feat(ec2): add CleanupCompute to cancel spot requests and terminate an account's instances

* fix(ec2): do not read a missing teardown record as completion; cancel disabled spot requests

* fix(ec2): terminate nothing when the instance inventory holds an unidentified entry
2026-11-05 08:15:08 +10:00
Ben Duncan 6daac4ab13 feat(ui): add a Disks tab to the Nodes panel (#1587)
* feat(ui): add a Disks tab to the Nodes panel with drives and filesystem usage

The daemon reads drives from /sys/block and filesystems from its mountinfo when it builds node facts. Usage comes from statfs on a 30 s loop outside the facts lock, each mount bounded by a 2 s deadline, so a hung mount is reported as not answering and never holds up GetNodeFacts or network-test setup.

* fix(nodes): key disk usage by mount, show hidden mounts, keep zero space

A filesystem replaced at the same path is read afresh and a stuck read of
the old one no longer blocks it. A mount covered in the daemon's namespace,
as ProtectHome covers /home, is listed as not visible instead of left out.
Usage is nested and sent whole, so a full filesystem reports 0 available.

* fix(nodes): leave the daemon's own sandbox mounts out of its filesystems

On dev-prod the daemon listed /var/lib/spinifex/spinifex, which
ReadWritePaths binds over itself, and /var/tmp, which PrivateTmp binds from
a systemd-private directory. Neither is a mount on the host.

* fix(ui): fit the Disks tables to the panel without scrolling

Drives carry the media beside the transport instead of in a column of its
own. Filesystems show one Use% cell, the bar with the used and total sizes
beneath it, in place of the size, used and available columns.

* fix(nodes): keep a filesystem mounted over another visible

Only the covered mount and what lies beneath it are hidden. The covering
mount, and anything mounted on it, inherited the hidden flag and was never
read.

* feat(ui): group Spinifex's mounts apart from the system's on the Disks tab

Spinifex mounts are its data directory, what is mounted under it, and any
path binding the same directory. Usage reads as used / size so the cell
fits the panel, and rows are keyed by mount and source.
2026-11-04 16:02:45 -06:00
Josh Siviour cda007c2cb fix(lambda): return a 6 MiB response from a 128 MiB test-link guest (#1584)
* fix(lambda): admit one Runtime API response body and cap it early

* feat(lambda): stream the test-link result frame without whole-frame copies

* fix(lambda): bound the result write and send nothing for a lost response

* fix(lambda): end the invocation at once when an admitted body is lost

* fix(lambda): keep a posted response when the bootstrap exits and wait for host shutdown

* test(lambda): host-side outcomes for a response lost or kept across bootstrap exit

* test(lambda): bound the exit tests' waits and release the write gate on cleanup

* fix(lambda): let the test carrier's reader finish after an unread frame

* test(lambda): host-session outcomes for partial frames, late results and slow bodies

* revert(lambda): drop the test carrier's input buffer

* test(lambda): drain the harness stream and prove admission before the deadline passes

* test(lambda): order admission before the host deadline and show the guest reader finishes

* test(lambda): confirm admission preceded the host outcome

* test(lambda): bound every channel wait in the new tests

* test(lambda): mark the new in-package tests that need unexported access

* test(lambda): write the expired invocation's timeout frame over a real connection

* test(lambda): drive a bootstrap exit before any invocation through the host

* test(lambda): drain the guest stream in the before-invocation exit test
2026-11-05 07:56:02 +10:00
Josh Siviour 1b43d2b652 feat: add an unwired durable management IP record store (#1583)
* refactor: move the durable KV contract into kvutil

* feat: add the unwired durable management IP record store

* docs: mark the store test in-package and state the freshness boundary

* test: cover stale Lookup confirmation and in-place snapshot entry mutation

* test: drive the stale Lookup case through a wrapped KV instead of a production seam

* test: satisfy lint on the wrapped KV double
2026-11-05 07:55:01 +10:00
Ben Duncan 950d2dc35c feat(ui): group launch subnets as public or private by their route tables (#1586)
* feat(ui): group launch subnets as public or private by their route tables

A subnet is public when its explicit route table, or else its VPC's main table, routes to an internet gateway. The NAT gateway form filters by the same rule instead of MapPublicIpOnLaunch.

* fix(ui): keep the subnet forms usable when route tables cannot be read
2026-11-04 15:31:00 -06:00
Ben Duncan dafd494ea6 fix(ui): encode ARN path parameters once so breadcrumbs show the ARN (#1585)
* fix(ui): encode ARN path parameters once so breadcrumbs show the ARN

Links passed encodeURIComponent output as route params, which the router encodes again, so the breadcrumb showed arn%3Aaws.... The test router mock now encodes params like the real router.

* test(ui): prove ARN routing through the real router and old bookmarks

* test(ui): type the routing test's SDK mock and keep its hooks in the describe
2026-11-04 13:31:53 -06:00
Ben Duncan 52f0678414 fix(rds): grow and delete a DB instance whose VM is stopped with its volume attached (#1513)
* fix(rds): grow and delete a DB instance whose VM is stopped with its volume attached

* test(rds): run the stopped-terminate commander test in parallel

* fix(rds): hold a delete behind an active modify and detach on terminate only the terminated instance's volume

* fix(rds): repeat the guarded detach when a stopped terminate left the data volume attached

* fix(rds): take the data volume out of a stopped DB VM's record before releasing it and terminating the VM

* fix(rds): tear down from the re-read record and keep an accepted delete when a modify fails

* fix(rds): release a stopped DB VM's data volume the way a stopped-instance detach does
2026-11-04 10:08:36 -06:00
Ben Duncan a605171807 fix(ec2): relaunch a direct-boot system guest without demanding a root volume (#1512) 2026-11-04 09:07:39 -06:00
Josh Siviour b9bce93877 feat(lambda): test-only Invoke path on a digest-pinned microVM (draft) (#1582)
* feat(lambda): add the unregistered RequestResponse Invoke path

Invoke resolves the function and qualifier with the existing lookup,
authorizes lambda:InvokeFunction on the function ARN, and hands one
synchronous attempt to an injected Executor. With no executor it fails
closed; a Pending function is ResourceConflictException. An accepted
attempt is not cancelled by a caller disconnect: only the wait is bounded.
Event, DryRun, LogType Tail and other unimplemented options are refused
explicitly. Lambda stays unregistered and no user code runs.

Also makes two flaky tests deterministic: the workerboot key-mismatch test
could pick an identical key 1 time in 256, and the loadgen first-breach
test depended on a 2ms op staying under a 30ms wall-clock p99.

* feat: track each Lambda code upload before storing it

CreateFunction now writes a per-Region upload record before the name
claim and before any code Put, so a failed upload, rollback, expired
claim or late original writer cannot leave an untracked copy. Refused
or ambiguous outcomes keep the record; nothing is deleted or inferred
complete from an expired claim.

CodeStored validates the upload record before committing and refuses a
missing, unreadable, future-format, mismatched or non-owed record
without changing either record. Attach CASes only the checked record.

* feat(lambda): run test-link Invoke in a daemon-owned microVM

Behind the lambdatestlink build tag only. The gateway executor presigns
the exact code digest and sends one NATS request to this node's daemon;
the daemon fetches and verifies the code, then runs one fresh microVM
per invocation through a runner that holds QEMU's creating thread,
kills only through a verified pidfd, and counts a stop only on a
recorded exit status. The host enforces the function timeout from
dispatch, and a late result is a timeout.

Each attempt writes a marker before allocating an address or tap and
refuses further test invocations while any marker remains, including
after a restart. Taps are created exclusively and removed only while
their ifindex and owner alias still match. Anything whose stop or
release is not proven stays held.

The default build is unchanged: Invoke refuses before presigning,
sending or allocating, and Lambda stays unrouted.

Limits: functions never leave Pending (the env15 run activated its
test record by hand); no grant mechanism; a hung daemon leaves the hard
timeout unenforced; held attempts are not recovered after a restart;
requests are bounded by the 1 MiB NATS message; a timeout is an unknown
outcome rather than AWS's Unhandled error.

* feat(lambda): enforce attempt grants and a QEMU hard-kill backstop on the test link

Test-only and local: everything here is behind the lambdatestlink build tag
except the new domains/lambda/execution owner package. The default gateway
still refuses Invoke before presigning, sending or allocating, the normal
guest agent stays closed, and Lambda stays unregistered.

Attempt grants (domains/lambda/execution):
- One owner record per node holds the current attempt; every change is a
  conditional write, and an unrecognised format is left untouched.
- The gateway issues a grant binding request ID, account, Region, function,
  revision and code digest before anything is sent; a second attempt is
  refused while one is unresolved.
- The daemon starts only under the current matching grant, before it
  downloads code or allocates anything; stale, replayed or mismatched
  grants are refused with the record unchanged.
- A result counts only if the owner accepts it under the dispatch deadline
  while the attempt is running; a late or uncertain acceptance is reported
  as an unknown outcome, never success.
- An attempt ends only after the VM stop is proven and resources are
  released, or when nothing was launched; otherwise it is held as
  ambiguous. Time alone never releases it. An uncertain stop write is an
  unknown outcome: it may already have landed.

QEMU hard-kill backstop (runtime/compute/lambda/testlink):
- A preloaded initializer arms a SIGKILL timer inside QEMU before main and
  acknowledges with QEMU's PID, the deadline and a per-attempt nonce. Any
  failure after it loads kills the process before main.
- The daemon checks the initializer against a pinned SHA-256 before launch,
  builds QEMU's environment without inherited LD_* or backstop settings,
  and sends no invocation frame without the exact acknowledgement; a
  missing or wrong acknowledgement kills QEMU and keeps state until the
  stop is proven.

Limits, not production readiness:
- Not the M1 decision. The grant binds request identity and code digest,
  not payload, memory or function ARN, and the guest does not yet bind
  generation or sequence.
- If the preload never loads (missing file, static or setuid QEMU), nothing
  arms; a daemon hung before refusing can leave QEMU alive, and the only
  guarantee is that no invocation frame was sent.
- Hash-then-load has a replacement race and is not a trust boundary; the
  initializer is built and installed by hand, with no packaging.
- A killed QEMU stays a zombie until the daemon reaps it; held attempts are
  not recovered after restart; duplicate attempt IDs are checked only
  against the current attempt.

* feat(lambda): seal the test-link timer library and bind the guest to its grant

Test-link (lambdatestlink) build only. Lambda stays publicly unregistered and
the normal guest agent stays fail-closed.

Timer library: the daemon reads the hard-kill initializer once, checks those
bytes against the pinned digest, copies them into a sealed memfd, verifies the
seals and re-hashes the sealed copy, then passes it to QEMU as an inherited fd
loaded through /proc/self/fd. The initializer closes that fd before it
acknowledges. A kernel that rejects MFD_EXEC, or a memfd_noexec policy that
refuses it, refuses the attempt; there is no fallback.

Guest binding: the daemon writes a test-grant fw_cfg entry with the owner's
grant ID, attempt ID, environment ID and generation, and the guest's scope now
comes from that grant. Before installing code or starting the bootstrap, the
guest refuses unless the grant names the authenticated environment generation,
the first frame carries that grant and attempt ID, and the frame deadline is
present and still ahead on the guest clock.

Limits:
- The digest is only as trustworthy as the build input that pins it; the memfd
  closes the swap window between check and load, it does not authenticate it.
- Not yet shown under the real spinifex-daemon systemd sandbox
  (MemoryDenyWriteExecute, ProtectProc) or with memfd_noexec set to 1 or 2.
- fw_cfg and the request frame both come from the executor, so matching them
  does not independently authenticate owner authority. This is not the M1
  decision.
- The guest clock check is a local guard only; it cannot detect clock skew or a
  delayed, superseded grant. The guest has no channel to the owner and checks
  the generation only on the first frame.
- Held attempts are still not recovered after a daemon restart.

* feat(lambda): recover held test-link attempts after a daemon restart

Test-link (lambdatestlink) build only. Lambda stays publicly unregistered and
the normal guest agent stays fail-closed.

Launch identity: attempt.Run passes the session callback its own duplicate of
the clone-time pidfd and closes SessionDone before publishing the result.
Before the guest is sent anything, the daemon takes the VM's pidfs inode from
that exact handle (refusing if the pidfd is not on pidfs or the VM has
exited), with the host machine ID, boot ID, PID namespace and the launching
daemon's own pidfs identity, and records it in the owner record with a
conditional Launched transition. A failed write holds the attempt as
ambiguous.

Recovery: RecoverLoop retries for a bounded number of rounds on the daemon
context, each round holding the node slot so it never runs beside a request.
It holds anything it cannot scope to the recorded boot and PID namespace with
the launcher gone, and holds an attempt with no launch identity. A PID with no
process, or one now held by a process with another pidfs inode, proves the
stop without signalling; the recorded VM still running is killed through its
own pidfd and counted stopped only once the pidfd reports the exit.

Cleanup order: files, tap and address (MgmtIPAllocator.ReleaseChecked, which
reports an unconfirmed release; Release is unchanged for its callers), then a
new owner state released that still blocks Issue, then the marker, then
ended. Recovery finishes from either side of each step.

Limits:
- Reboot and partition recovery stay held until host identity or fencing is
  established; a machine ID can be copied, so it is not host proof.
- A launch that crashes before its identity is recorded stays held.
- No recovery if the daemon cannot restart or hangs.
- Not yet run under the real spinifex-daemon systemd unit, and no kernel
  without pidfs was tested.
- This is not the M1 decision.

* fix(lambda): refuse ownerless checked mgmt IP releases

ReleaseChecked now refuses an entry that names no node, or an allocator
with no node bound, and leaves both the cluster and local entries in place.
The checked path deletes its local entry only after a confirmed release
and only if it still holds the same address. Legacy Release is unchanged.
Callers must serialize Allocate and ReleaseChecked for one instance ID;
the Lambda test-link slot does.

Recovery tests that need real pidfs now skip only when the kernel's pidfds
are not on pidfs, and a non-pidfs descriptor is shown to be refused. The
runtime still fails closed. Adds an owner test for Store.Launched.

* fix(lambda): retry test-link recovery until it settles

RecoverLoop now retries on the daemon context until the held attempt
settles, starting at 5s and doubling to a 5 minute cap, instead of
giving up after ten rounds. Each round still takes the node slot, so it
never runs beside Handle. It covers only what a previous daemon left:
an attempt a later Handle in the same daemon holds stays held until the
next restart.

The serving log now says the guest does not verify owner grant
authority.

* feat(lambda): build the test-link daemon and initializer as one pair

Add make build-lambda-testlink. It compiles the hard-kill initializer,
checks it against the 2.34 glibc ceiling, pins its SHA-256 into a
lambdatestlink spx build, and proves with TestLambdaHardKillPin that the
daemon's own pin accepts that file and refuses a changed one. Both files
are built in a staging directory and moved together only on success; any
earlier pair is removed first, so a failed rebuild leaves no pair. The
target refuses to build unless Go targets linux on the host architecture.

The initializer now uses _POSIX_C_SOURCE with -std=c17, so it no longer
needs __isoc23_strtol (glibc 2.38). The daemon refuses an initializer
built for another ELF machine or class.

Startup now begins recovery with only the owner store, tap and address
cleanup. A missing bridge address, pin, trust store or matching
initializer stops new invocations but not recovery.

Limits: test-only and unregistered. The pin is only as trustworthy as
the build host and toolchain; the initializer is still installed by
hand and ships in no release artefact. linux/arm64 is untested. Recovery
needs pidfs, which older kernels may lack. This is not the M1 decision.

* feat(lambda): fix one CLOCK_BOOTTIME backstop deadline per test-link attempt

Test-only (lambdatestlink); the default build and public Lambda route are
unchanged.

- The daemon reads CLOCK_BOOTTIME once before the grant Start and fixes one
  absolute deadline: bounded code fetch + function timeout + boot allowance +
  kill margin. Nothing recomputes or extends it; Handle never retries and
  recovery never relaunches.
- The hard-kill initializer arms its SIGKILL timer at that absolute deadline
  (SPX_HARDKILL_DEADLINE_NS, TIMER_ABSTIME) instead of relative to QEMU start.
- Dispatch is refused once the deadline has passed: before QEMU starts, when
  the initializer ack is read at or after it (VM killed, stop proven), and
  immediately before the invocation frame is sent. A clock read failure counts
  as expired.

Limits: tap/IP setup, image read, initializer seal and QEMU start have no caps
of their own beyond the boot allowance. An overdue timer is not shown to fire
before vCPUs resume after host suspend. Owner-side retention, cross-node
ordering, clock-rate assumptions and M1 remain unsolved; nothing is released by
time and held attempts still need stop proof.

* feat(lambda): boot the test-link runtime image from a pinned digest

The test-link daemon now prepares its runtime image once at startup: each
boot file is opened without following symlinks or blocking, capped by size,
copied into a sealed memfd and hashed there, and the image digest over those
sealed copies must equal LAMBDA_IMAGE_DIGEST pinned into the build. QEMU boots
only the sealed copies (fds 3-5), and firmware comes from the image directory
rather than the shared microvm qboot.rom. The digest identifies the bytes; it
does not authenticate where they came from.

build-lambda-testlink-image builds a separately tagged guest into its own
outputs and prints the digest with the daemon's own file selection. Both
test-link targets refuse anything but an x86_64 host building linux/amd64,
and the digest input is read raw and validated before it reaches ldflags.

* test: cover test-link image load failures and daemon startup release

Exercise each sealed-copy failure path through the memfd hook and check
partial copies are closed. Extract the daemon's readiness and release
steps so startup tests can show the image is released when serving fails
and that Handle refuses without a prepared image.

* fix: align test-link Invoke errors with AWS and verify guest build tag

Malformed JSON payloads return 400 InvalidRequestContentException and
oversized requests return 413 with X-Amzn-Errortype
RequestEntityTooLargeException, both before the executor runs. Timeouts
and lost or failed sends are reported as unknown rather than as a
function timeout, and any Sandbox.Timedout result is treated as unknown.
Oversized function responses report Unhandled with
Function.ResponseSizeTooLarge in the body.

Add lambda-image-agent-tag, which reads the agent's build information
from each built initramfs and refuses an image whose lambdatestlink tag
does not match the target. Add tests that the default gateway cannot
invoke and that management IP Release drops the local entry when the
address is held elsewhere or the KV is down.

* test: use the shared RequireAWSCode helper in Lambda tests

The per-package requireCode helpers were replaced by
testutil.RequireAWSCode, which makes the same assertions; the Lambda
Invoke and upload tests still called the removed helper.

* fix: validate and authorize qualified Lambda Invoke requests

Invoke now runs every deterministic check before authorization and
authorizes the qualified function ARN derived from the same validated
target. Qualifiers follow the documented FunctionName and Qualifier
patterns and length limits, and a malformed query string is refused.

Code download errors no longer carry transport detail, so a presigned
credential in a bad redirect cannot reach the reply or the gateway log.

A seam built only with lambdatestlink and lambdatestlinkseam drives real
session outcomes through the executor and composed gateway in one test.

* test: restore the loadgen and workerboot tests to dev

* test: drive a bootstrap-posted timeout through the composed Invoke path

* fix: carry handler and qualifier to the test-link runtime and refuse function environments

The test link now passes the stored handler to the guest as _HANDLER and the
validated qualifier into the invoked-function ARN. Functions with environment
variables are refused before any presign, grant or dispatch, since delivering
them needs a separate secret-transport design.

* test: stub QEMU for the test-link package so it runs on hosts without it
2026-11-04 22:51:49 +10:00
Josh Siviour e21afeafd3 fix(vm): delete volumes only once QEMU is proven gone (#1581)
* fix(vm): delete volumes only once QEMU is proven gone on a forced terminate

* fix(vm): treat an unreadable QEMU pid file as unknown and keep the UEFI obligation on a failed kill

* fix: judge QEMU exit only on the node that ran the guest

* fix(vm): keep unknown PID state and the local UEFI store obligation across reapers

* fix(vm): treat a non-positive QEMU pid as unknown state in both exit gates

* fix(vm): keep PID files that do not name a positive process at every deletion site

* fix(vm): treat only ESRCH as proof a QEMU exited, not any failed signal probe

* refactor: expose the signal probe as a plain setter instead of importing testing

* fix(daemon): complete a terminated UEFI store mark only once QEMU teardown is done

* fix(daemon): treat an expired terminated record as unproven QEMU exit for the UEFI store reaper

* test: separate the embedded field in the expired-record fake
2026-11-04 22:19:23 +10:00
Josh Siviour 6f1c9a939c feat: run Spinifex on latitude.sh bare metal (#1578)
* feat: run Spinifex on latitude.sh bare metal

Add a latitude.sh provider and deploy script. One node uses a static Elastic IP pool. Three nodes use a latitude floating pool over a fixed Elastic IP set, with the cluster and Geneve planes on a latitude.sh VLAN.

The floating engine gains a FixedSet provider interface. Its allocation token, re-read before a move and holder label apply to fixed sets only, so Vultr records and behaviour are unchanged.

Bead mulga-c43h3.3

* fix: check a server's project before destroy and refuse mismatched response IDs

A recorded server must sit in the project this deployment's Terraform holds, so a recycled ID with the same hostname elsewhere stops destroy. A missing project or ID refuses too.
The client rejects an Elastic IP response whose ID is not the requested one, without showing it.

Bead mulga-c43h3.3

* feat: cap a latitude pool at five Elastic IPs

Config validation rejects a source="latitude" pool listing more than MaxLatitudeElasticIPs addresses, so an allocation makes at most five bounded provider reads.

Bead mulga-c43h3.3

* fix: remember the project ID before destroy for the cleanup sweep

The Terraform output that names the project is gone once destroy has run, so the sweep could not tell a matching server from a foreign one and kept both.

Bead mulga-c43h3.3
2026-11-04 21:30:07 +10:00
Bryn Mailer 290ec05aab feat(ebs): record an unpublish obligation on force detach and terminate (#1504)
* feat(ebs): record an unpublish obligation on force detach and terminate

A force detach, or a terminate whose unmount did not complete, clears a
volume's attachment while the node that served it may still export it.
The request now records an UnpublishObligation naming that publish in the
new spinifex-ebs-unpublish-obligations bucket before the clear, and
returns without waiting on the node.

The node-local unpublishObligationReaper settles its own node's
obligations under the mounter's per-volume lock, through the existing
UnpublishVolume client: it seals a surviving volume, drops a deleting or
absent one unsealed, and closes without a provider call when a newer
attachment or a newer publish on the node owns the export. A cluster-wide
pass closes the obligations of nodes removed from the cluster, and
spx admin volumes obligations lists the open ones.

The force-complete terminate and a stopped-instance terminate after a
failed seal record the same obligation; a terminate whose unmount
completed records none. ForceDetachVolume now answers with the attachment
in state detaching, as AWS answers a detach.

* fix(ebs): refuse a publish while the obligation reaper seals the volume

A publish waited on the volume lock while the unpublish obligation reaper
held it through a seal, so AttachVolume waited on work that scales with
the volume. A publish now refuses at once with ErrVolumeSettling, which
AttachVolume answers as IncorrectState, and the reaper takes the lock only
when nobody holds or awaits it.

* fix(ebs): refuse a publish behind any seal of the volume, a stop's included

A stop's or detach's sealing unpublish now marks the volume lock sealing,
so an attach after a force detach answers IncorrectState instead of
waiting out the seal. A waiting seal lets waiting publishes go first.

* fix(ebs): close a superseded unpublish obligation past a newer publish here

After a force detach and an attach again on the same node, the reaper
returned on the newer publish before it read the volume, so the old
obligation stayed open until the new attachment ended.

* fix(vm): recover a wedged terminate without waiting on its stalled unmount

The unmount records every volume as owed before it starts, so recovery
keeps that record rather than wait for the request lock the stalled
seal holds.
2026-11-04 15:59:41 +10:00
Julian Sommer e73eb1ce16 fix(ecs): refuse CreateCapacityProvider autoScalingGroupProvider enum and range breaks with AWS's ClientException (#1579) 2026-11-04 16:52:10 +11:00
Julian Sommer 700a72c4af fix(ecs): refuse ListServices, ListTaskDefinitions and ListTasks enum values outside AWS's sets with InvalidParameterException (#1577) 2026-11-04 16:40:18 +11:00
Julian Sommer 6a7c4cdb02 chore: update README 2026-11-04 15:32:49 +10:00
Julian Sommer 06f521969a fix(ecs): refuse Describe* include values outside the model's enum with AWS's error (#1576) 2026-11-04 16:24:05 +11:00
Julian Sommer fc57de83b4 fix(ecr): refuse imageIds, imageTag, acceptedMediaTypes and repositoryNames outside the model's bounds (#1575) 2026-11-04 16:12:08 +11:00
Julian Sommer 2f98f53ad1 fix: report AWS's per-protocol target group attributes and return the full set from ModifyTargetGroupAttributes (#1574) 2026-11-04 16:11:43 +11:00
Julian Sommer 2caebe352e fix: match ICMP type and code in security group ACLs (#1573) 2026-11-04 16:11:05 +11:00
Julian Sommer 5401921e3e fix(iam): authorize UpdateUser on the new user name as well as the existing user (#1572) 2026-11-04 16:10:48 +11:00
Josh Siviour ee6a661eeb ci: keep private design prompts out of the public PR template (#1571) 2026-11-04 14:21:27 +10:00
Julian Sommer 171e004c14 fix(ui): bump seroval to 1.7.6 and minimatch to 11.0.3 (#1570)
Resolves GHSA-7rv5-mrxv-xcr9, GHSA-wp97-x4hv-qvhp, GHSA-97r2-qh9m-474h,
GHSA-9wx9-5pv9-3349, GHSA-f86p-h4j2-7qgj (seroval) and GHSA-g3hv-75wg-m249
(minimatch, dev-only via eslint, forced with a pnpm override).
2026-11-04 14:47:34 +11:00
Julian Sommer 8c7c4bb772 test: run the viperblock provider's self-contained tests in parallel (#1569)
The conformance and NBD client suites, the fence, lease, dirty-marker,
abandon, takeover and stale-mount tests each stand up their own embedded
JetStream and Config, so they now overlap instead of running back to back.
Tests that touch package or process-wide state stay serial.
2026-11-04 14:45:31 +11:00
Julian Sommer ed3f6a3185 test: overlap the admin package's timer-bound waits with its serial tests (#1567)
The six account-teardown tests and the unanswered network-test case each
wait about 5s on a production timer. Calling t.Parallel() first queued all
seven through the parallel slots; starting the teardown or network test
before t.Parallel() lets the wait run during the serial phase instead.

Also generate 1024-bit RSA keys in TestMain and shorten the test-only
context in TestQueryMetaNodeStatus_Unreachable.

go test -coverprofile at GOMAXPROCS=4: 16.9s -> 7.1-8.0s.
2026-11-04 14:45:15 +11:00
Julian Sommer b333775a62 test: start accountlifecycle's silent-node fan-out tests first (#1568)
The four tests that wait out the fixed 3s describe fan-out deadline for a
silent node (9s, 6s, 6s, 6s) were queued behind the package's short
parallel tests, two of them as subtests of a table test. Hoist the two
slow table cases into top-level tests and move all four into a file that
sorts first, so they take parallel slots before the short tests do. The
table body becomes a shared helper; no case or assertion changes.
2026-11-04 14:44:53 +11:00
Josh Siviour 16a4e0d96b feat(ec2): report instances of an account that are not fully torn down (#1565)
* feat(ec2): report instances of an account that are not fully torn down

* fix(ec2): report a terminated instance held only in the primary store
2026-11-04 13:36:20 +10:00
Julian Sommer 535d9d6751 chore: run sqs jetstream message store tests in parallel (#1566)
Each test stands up its own embedded JetStream server, so top-level tests
and independent subtests now call t.Parallel. Tests that redeclare the
process-wide cluster size or capture the default logger stay sequential.
Package wall time drops from ~23.5s to ~4.4s locally.
2026-11-04 14:32:56 +11:00
Julian Sommer d18c08df93 chore: move mulga-design-check to bash 2026-11-04 13:07:35 +10:00
Julian Sommer 53eb4131bf chore: del CODEOWNERS 2026-11-04 13:02:12 +10:00
Julian Sommer 121aa5a17b chore: move sibling ref resolver scripts into .github/scripts 2026-11-04 13:01:47 +10:00
Julian Sommer 78d27af118 test: run the admin package's timeout-bound tests in parallel (#1564)
Six account-delete tests each wait one 5s teardown drain poll, and the
network-test and storage-status tests wait out fixed 5s and 2s timeouts.
Each uses its own embedded NATS, so they run concurrently: the package
drops from about 37s to 8s.
2026-11-04 12:48:38 +11:00
Julian Sommer 3f2fff3542 chore: suppress preflight build script output 2026-11-04 11:41:09 +10:00
Julian Sommer 769bb8014a test: stub the /dev/kvm probe so QEMU argv tests run on hosts without KVM 2026-11-04 11:37:01 +10:00
Josh Siviour 4d23698d28 feat(ec2): complete-or-error account inventories for instances, spot requests and volumes (#1544)
* test: pin instance inventory gaps G1-G3 as current behaviour

* feat(ec2): strict account instance inventory from the record store

* feat(ec2): strict spot-request and volume inventories

* fix(ec2): strict instance inventory fails on node-only instances

* fix(ec2): strict instance inventory sees error and late strays and rejects malformed records
2026-11-04 11:19:28 +10:00
Julian Sommer 5ae6ae7234 fix: refuse ELBv2 health check, IpAddressType, pool and SSL policy type values outside the model with ValidationError (#1562) 2026-11-04 11:54:35 +11:00
Julian Sommer 2b51f83c26 refactor: sign ECS agent gateway calls with gwsign and drop the bespoke credential types (#1563)
The ecs-agent now holds an aws.CredentialsProvider (a CredentialsCache over
imdscreds.NewProvider) and ecsgw.New takes a *gwsign.Signer, matching eksgw,
rdsgw and elbv2. imdscreds.Fetch and stsauth.AssumeRole return aws.Credentials,
so agents/ecs/credentials and the field-copy adapters are removed.
2026-11-04 11:50:49 +11:00
Julian Sommer 845bbb89c0 refactor(admin): delete ParsePredastoreHostIDFromConfig, which only its own tests called (#1561) 2026-11-04 11:49:28 +11:00
Julian Sommer 57a55caf26 refactor(installer): render sizes and validate subnet masks in the UI through install.HumanSize and install.ValidDottedMask (#1560) 2026-11-04 11:49:17 +11:00
Julian Sommer e04d710d0d refactor(eks): open the per-account bucket through one accountBucket helper (#1559) 2026-11-04 11:49:03 +11:00
Julian Sommer 9c4b7755b0 refactor(eks): run the access entry, add-on and nodegroup tag CAS updates through kvutil.Update (#1558) 2026-11-04 11:42:01 +11:00
Julian Sommer 2cdf71f2ce refactor(qmp): delete the unused command response map and query-block reply envelope (#1557) 2026-11-04 11:41:47 +11:00
Julian Sommer 83b6cfd3ea refactor(vm): cache the host virtualization stack with sync.OnceValue (#1556) 2026-11-04 11:38:35 +11:00
Julian Sommer 92b2d7d925 refactor(eks): mint every EKS agent certificate through one konnectivitycert.Mint (#1555) 2026-11-04 11:36:58 +11:00
Julian Sommer 81b76172ff refactor: delete the ECS agent's unused ECS_ACCESS_KEY/ECS_SECRET_KEY config (#1554) 2026-11-04 11:36:45 +11:00
Julian Sommer 35ae09dd6c refactor(ec2): build describe ID lookup sets with one identifiers.Set helper (#1553) 2026-11-04 11:36:31 +11:00
Julian Sommer bfc4737bf2 refactor(vpc): delete unused Purpose constants, the legacy external-type map and the external IPAM aliases (#1552) 2026-11-04 11:34:26 +11:00
Julian Sommer 6e51d46bb4 refactor(imds): back the IMDSv1 allow cache with the generic TTL cache, which gains sweep (#1551) 2026-11-04 11:34:15 +11:00
Julian Sommer 701686704f refactor(natgw): delete unused PublishAddEvent and test-only GetNatGateway (#1550) 2026-11-04 11:34:03 +11:00
Julian Sommer 25c7567d79 refactor: share one kvutil.IsRevisionConflict instead of five private conflict checks (#1549) 2026-11-04 11:30:57 +11:00
Julian Sommer ba2a418b3d refactor(iam): share policy attach, inline policy and tag helpers across users, groups, roles and other taggable records (#1548) 2026-11-04 11:29:14 +11:00
Julian Sommer 08b52169c3 refactor(awserrors): delete the unwired WithFields error fields and the test-only ValidErrorCode and HasErrorCode (#1547) 2026-11-04 11:28:57 +11:00
Julian Sommer ad60a7260f refactor: share AWS error assertions and SeedKV through internal/testkit instead of per-package copies (#1546) 2026-11-04 11:28:43 +11:00
Julian Sommer 295a4c0040 refactor: collect sorted map keys with slices.Sorted(maps.Keys) in dispatch, SQS, Route 53 and cloud fakes (#1545) 2026-11-04 11:28:25 +11:00
Julian Sommer 893ac710d6 refactor: share the assumed-role principal type and the caller projection through ingress dispatch (#1543) 2026-11-04 11:28:12 +11:00
Julian Sommer 9a9ffeccc9 refactor: route RunInstances ClientToken through the shared EC2 idempotency wrapper (#1542) 2026-11-04 11:27:55 +11:00
Julian Sommer 66fcda9b49 refactor: evaluate EC2 tag-key and tag-value filters only through MatchesTags (#1541) 2026-11-04 11:27:41 +11:00
Julian Sommer 60eb047721 refactor: send ECR, ACM, EKS, ECS, bedrock and tagging results through one AWS JSON 1.1 writer (#1540) 2026-11-04 11:19:36 +11:00
Julian Sommer 934110e828 refactor: read the kvstore last sequence through the shared leader message get, honouring the JetStream API prefix and domain (#1539) 2026-11-04 11:17:54 +11:00
Julian Sommer 8db1b7f63e refactor: page EKS and RDS listings through foundation/aws/paging and reject foreign EKS tokens (#1538) 2026-11-04 11:17:39 +11:00
Julian Sommer b255007fb2 fix: page GetSecurityGroupsForVpc with resume tokens so a deleted group does not shift the next page (#1537) 2026-11-04 11:15:50 +11:00
Bryn Mailer 52423d3573 feat(uefi): delete a terminated instance's variable store and report unowned stores (#1502)
* feat(uefi): delete a terminated instance's variable store through its store record and report unowned stores

Terminate, on both the running and stopped paths, moves the store record
to deleting and stamps a uefi teardown entry, then returns. The
cluster-wide uefi-store reaper deletes the objects under the instance
prefix and then the record, so the deletion converges even after the
terminated record's TTL has expired. CompareAndSet refuses to return a
deleting record to live.

spx admin volumes orphans now also lists instance prefixes with no
owner, image prefixes with no AMI document, and store records with no
instance record. It deletes nothing.

* refactor(uefi): read instance state for the store reaper and orphan report through daemon capabilities

The reaper and the orphan report named the raw instance-state records,
buckets and key prefixes, which ADR-0007:S6 forbids a new consumer to do.
They now ask TerminateDecided, OpenTeardowns, MarkTeardownDone and
KnownInstanceIDs, each stating its absent-record and failure behaviour.

* fix(uefi): keep a stopped instance's store when its terminate left the stopped record

TerminateDecided read the terminated record first, so a stopped terminate
that wrote it but failed to remove the stopped record let the reaper delete
the store of an instance that can still start. The live record now decides,
and the reaper no longer marks a live store deleting while one exists.
2026-11-04 10:14:27 +10:00
Bryn Mailer d6a9bc00ef feat(uefi): launch a UEFI instance from its variable store pointer (#1500)
* feat(uefi): launch a UEFI instance from its variable store pointer

A launch whose record points at a variable store keeps the live copy at
<DataDir>/uefi/<instance-id>/vars.fd and opens it as pflash unit 1 with
format=raw. It reuses the copy when seeded.json beside it names the
pointer, and otherwise gets the object, decompresses it, checks it is
exactly the node's VARS size and writes vars.fd then seeded.json by
temporary file and rename. A failed get is retryable like a mount; a
missing or malformed object is not.

A store whose firmware arch or VARS size differs from the node's fails
the launch before any volume is mounted, with a Server.InternalError
state reason and a log naming both firmwares. A different CODE hash
boots, and is logged and counted.

A UEFI instance with no pointer and no EFI volume, or with both, is
refused with InternalError before its record is changed, in launch,
Start and the claimed-instance start. An instance whose variables are on
an EFI volume still launches through it.

* test(uefi): separate the embedded store from the injected error in the failing-get fake

* test(uefi): call startQEMU with its one argument after the dev merge
2026-11-04 10:13:55 +10:00
Bryn Mailer 020319c44a fix(ec2): classify a volume held elsewhere by the provider code, not lease text (#1496)
* fix(ec2): classify a volume held elsewhere by the provider code, not lease text

The instance service and the EKS cluster reconciler recognised a start
refused because another node holds a volume only by viperblockd's lease
text, so a provider that words the refusal differently was reported as
ServerInternal and counted against the reconciler's restart budget.

The instance service now matches the EBS provider's volume_held_elsewhere
code, on both the stopped-instance claim and the live-owner start. The
owning node marks such a refusal with an X-Volume-Held-Elsewhere reply
header, the ec2.start forwarder relays it, and StartInstances wraps
ec2v1.ErrVolumeHeldElsewhere around the rebuilt error, which the
reconciler matches with errors.Is. The AWS code stays IncorrectState.

The lease text stays as a fallback for replies that carry no code: the
legacy ebs.mount reply, a publish below schema version 3 (where
volume_in_use also means a read-only mismatch), and an owner node that
predates the header.

* fix(daemon): relay every header of the owner's start reply on both forwarder paths

The ec2.start forwarder copied only the held-elsewhere header onto a
relayed reply, and its unsealed-writes branch answered with the body
alone, so any other header the owning node set was lost in the relay.

Both relay branches now go through relayStartReply, which forwards the
owner's reply body and every header unchanged.

* test(daemon): pin that only a held volume gets the held-elsewhere header

The owner's reply header is the one signal that a start was refused
because another node holds a volume, and every other start refusal in
the same IncorrectState code must reach the caller without it.

* refactor(ec2): classify a volume held elsewhere only by the provider code

Every node sends provider requests at the one contract version, at which
every provider refuses a volume held by another node with the
volume_held_elsewhere code. The lease-text fallbacks in the instance
service and the EKS cluster reconciler matched replies that no longer
exist, so they are removed: a start is a held-volume refusal only when
the mount error carries the provider code, or when the start error wraps
ec2v1.ErrVolumeHeldElsewhere, which StartInstances rebuilds from the
owner's header.

The ec2instance.ErrVolumeHeldElsewhere alias is removed, so the sentinel
has one name, ec2v1.ErrVolumeHeldElsewhere. The reconciler's budget test
now proves an IncorrectState without the sentinel still counts, whether
its message is unrelated or carries the lease text.

* fix(ec2): log a held-volume start refusal at Warn with the instance ID

A start refused because another node holds a volume is returned as
IncorrectState, a client error, yet was logged at Error without the
instance it concerned. It is now logged at Warn, and every vmMgr.Start
failure log names the instance.
2026-11-04 10:13:23 +10:00
Julian Sommer 9c73e2c523 fix: fail instance-role signing when an expired IMDS key cannot be refreshed (#1536) 2026-11-04 11:12:59 +11:00
Julian Sommer 3e588fdcb6 test: delete the never-called AssertPredastoreObjectLifecycle e2e helper (#1535) 2026-11-04 11:12:40 +11:00
Julian Sommer 29b8788fec refactor: delete the unused vpc.ValidatePoolConfig and its IP comparison helper (#1534) 2026-11-04 11:12:26 +11:00
Julian Sommer acaa22fd25 refactor: delete the unwired gpu Manager.MarkMIGFailed and its tests (#1533) 2026-11-04 11:12:12 +11:00
Julian Sommer 1bda358f0b fix: accept the private-ip-address filters on DescribeInstances (#1529) 2026-11-04 11:11:56 +11:00
Julian Sommer 637f68c62b refactor: drop redundant helpers in DHCP leases, OVN router lookups and Route 53 tag sets (#1532) 2026-11-04 11:11:22 +11:00
Julian Sommer e2bd1f523a fix: name the caller, action and repository in ECR registry policy denials (#1531) 2026-11-04 11:11:10 +11:00
Julian Sommer 4e8c182e2f fix: name the resource, not the cluster, in the default EKS ResourceNotFoundException message (#1530) 2026-11-04 11:06:04 +11:00
Julian Sommer 4f5049d6da fix: refuse DetachInternetGateway with DependencyViolation while an elastic IP is mapped in the VPC (#1523) 2026-11-04 11:05:27 +11:00
Julian Sommer 98bde73228 fix: accept the tag-key and tag-value filters in DescribeVolumes and DescribeSnapshots (#1528) 2026-11-04 11:00:26 +11:00
Julian Sommer 5645b49a61 fix: return LoadBalancerNotFound from DescribeLoadBalancers for unknown load balancer ARNs (#1527) 2026-11-04 10:59:58 +11:00
Julian Sommer 4851a9f8e4 fix: report Attachment.InstanceOwnerId on described network interfaces, as amazon-elb for system VMs (#1526) 2026-11-04 10:57:38 +11:00
Julian Sommer 7af40f3411 fix: refuse RunInstances when the AMI architecture differs from the instance type (#1525) 2026-11-04 10:57:22 +11:00
Julian Sommer 56658c1647 fix: return counts, statistics and empty lists on every ECS cluster response and honour DescribeClusters include (#1518) 2026-11-04 10:56:04 +11:00
Julian Sommer 795cf8de13 fix: return HTTP 500 for InternalFailure so SDKs treat it as a server fault (#1524) 2026-11-04 10:54:05 +11:00
Julian Sommer b9c412f385 fix: reject RegisterTargets for an instance that does not exist with ValidationError (#1520) 2026-11-04 10:47:31 +11:00
Julian Sommer e7c1ccf5ad fix: name the missing instance ids in InvalidInstanceID.NotFound from CreateTags and DescribeInstances (#1522) 2026-11-04 10:44:31 +11:00
Julian Sommer f53b7dab03 fix: refuse reserved aws: tag keys in TagSpecifications on EC2 create calls (#1521) 2026-11-04 10:41:13 +11:00
Julian Sommer 23aed4a08d fix(elbv2): validate tags on AddTags and the four create calls instead of silently dropping them (#1519)
Tags are checked against the ELBv2 model's key and value length and
pattern (ValidationError), repeated keys are refused with DuplicateTagKeys,
and more than 50 tags on a resource with TooManyTags, counting a resource's
existing tags on AddTags. A tag with no Value is stored as an empty value
and a tag with no Key is refused rather than dropped.
2026-11-04 10:40:46 +11:00
Julian Sommer 21500f18c3 refactor(cli): delete the unread admin init --host and global --host, --nats-token and --nats-subject flags (#1516) 2026-11-04 10:36:16 +11:00
Julian Sommer a94038b358 refactor: share the stream invocation record, route action list and e2e key-pair helpers (#1515)
Ochre's two streaming pumps now record through one streamRecordCtx.record and
frame upstream faults through one frameWriter.writeUpstreamFault. The EKS and
Lambda route tables list their actions through rest.Router.Actions,
toDetails converts toSummary's identically-shaped struct, Lambda's
environment response uses aws.StringMap, and the e2e suites call
harness.DiscoverNanoInstanceType and harness.EnsureKeyPair directly.
2026-11-04 10:23:10 +11:00
Julian Sommer 1f2dc13c5e refactor(installer): delete unused ImportablePools, Config.PlaneBridge, RolesForDisks and Disk.Stable (#1514) 2026-11-04 10:22:22 +11:00
dependabot[bot] f5e09923c1 chore: bump the npm_and_yarn group across 1 directory with 2 updates (#1510)
Bumps the npm_and_yarn group with 2 updates in the /spinifex/runtime/roles/spinifexui/frontend directory: [brace-expansion](https://github.com/juliangruber/brace-expansion) and [source-map-js](https://github.com/7rulnik/source-map-js).


Updates `brace-expansion` from 5.0.9 to 5.0.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.9...v5.0.12)

Updates `source-map-js` from 1.2.1 to 1.2.2
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 10:13:53 +11:00
Ben Duncan fc8bf59fad chore: merge main into dev to carry the Codex review caller 2026-11-04 05:02:30 +10:00
Ben Duncan f805e947e9 ci: call the shared Codex PR review on PRs into dev 2026-11-04 04:58:32 +10:00
Ben Duncan 173ac7a936 test(e2e): attach and detach a volume on a stopped instance, as AWS allows (#1508) 2026-11-03 10:14:02 -06:00
Josh Siviour 1c39e29367 fix: stop on guest poweroff and keep stopped instances' volumes attached (#1507)
* test: pin guest poweroff handling and stopped-instance volume state

* test: gofmt the poweroff and stopped-volume tests

* test: pin start after detach, one-poll poweroff, reattach of released volumes and the delete refusal

* test: separate the embedded mounter from the recorder fields

* fix: end a guest poweroff as a stop and keep stopped instances' data volumes attached

A clean QEMU exit after the heartbeat saw the guest power off now stops the
instance instead of waiting for crash detection. Unmount no longer releases
data volumes. DetachVolume on a stopped instance edits the record, then
releases the volume. Starting an instance stopped on an older build drops data
volumes that are attached elsewhere or deleted and fails on a missing boot
volume.

* fix: keep the stopped-instance detach inside the instance service file and format start reconcile

* docs: describe guest poweroff, stopped-instance volumes and detach on a stopped instance

* fix: allow detaching the root volume of a stopped instance and refuse a start with no root volume as AWS does

* feat: attach volumes to a stopped instance and fix root-detach start fixtures

* docs: describe attach and root detach on a stopped instance

* test: Unmount seals only and leaves every attached volume in-use

Changed with the person's explicit approval, relayed by dev-design on 11-03.
EBS root and data volumes persist across stop on AWS, so stop and crash
unmount no longer release any attached volume.

* docs: note that the EFI variable store cannot be detached from a stopped instance

* test: unmount contract tests expect no volume state write on stop

* test: order imports in stopped-volume daemon tests

* refactor: drop the unused volume state updater from the volume mounter
2026-11-03 23:51:39 +10:00
Josh Siviour 19e3141c28 fix: cap instance console files and retire them when the instance ends (#1506)
* test: console log trim, removal by instance state and append-mode chardevs

* fix: cap and reap instance console logs, always append

* test: expect append mode on microvm file chardevs

* fix: retire console logs on terminate instead of reading instance state

* docs: describe console log location, caps and retirement on terminate

* test: drop removed append argument from startQEMU calls

* docs: state that GetConsoleOutput returns NotFound once an instance has been torn down

* docs: drop the unverified AWS retention claim for console output

* feat: remove idle console logs that no QEMU on the node holds open

* fix: keep idle console logs when a QEMU pidfile names a pid the scan cannot see
2026-11-03 23:51:13 +10:00
Josh Siviour ad8e4ea2d7 feat: forward filtered k3s-agent logs from EKS workers to the console (#1505)
* test: eks node log forwarder filter, redaction, caps and role wiring

* feat(eks-node): forward k3s agent warnings and errors to the serial console

A redacting, rate-capped forwarder tails the agent log and writes warning,
error and node-status lines to the console so they reach the sink. Secrets are
redacted before the 512-byte cut.
2026-11-03 23:51:00 +10:00
Bryn Mailer 4765e5e3ea feat(uefi): add the instance UEFI variable store pointer, store records and their bucket (#1499)
InstanceStatus and VM carry an optional UEFIVarStoreRef naming the store
object and the firmware it was written under. The new uefistore package
holds FirmwareIdentity, UEFIStoreRecord and UEFIWriteBack, and a Store over
the spinifex-uefi-stores bucket (history 1, no TTL, schema 1) whose writes
are all revision-guarded and validated. The daemon creates the bucket at
startup. Nothing reads or writes either yet.
2026-11-03 17:28:40 +10:00
Bryn Mailer 56e2d700e4 feat(ebs): carry the fence notice over the provider contract subject (#1491)
* feat(ebs): carry the fence notice over the provider contract subject

viperblockd now sends the contract FenceNotice on
ebs.provider.v1.<node>.fenced when it revokes a live export, and the daemon
subscribes to that subject whatever the EBS provider, stopping the guest
using the fenced volume as before. The legacy ebs.<node>.fenced emission,
subscription and their viperblockd legacy contract types are removed.

NotifyFenced is the shared single-attempt sender; viperblockd keeps its
retry-until-answered loop around it. MemoryProvider can revoke a
publication and announce it, and the conformance suite gains a
RevokeExport case that viperblockd and MemoryProvider both run.

* fix(ebs): send the fence notice through the contract request path

NotifyFenced now shares requestReply with NATSProvider.request, so a
notice gets the connected check, the X-Account-ID header and the
account-attributed client span the other contract requests do. A
client span also fails for a reply it cannot read.

A notice that can never be sent (no volume, an unaddressable node, no
connection) is ErrInvalidArgument and terminal, and FenceNoticeRetryable
names the errors worth sending again: unheard, or refused as
unavailable. MemoryProvider.RevokePublication retries until heard or
its context ends, and shares unpublish with UnpublishVolume.

* fix(viperblock): refuse an unaddressable node and retry an unavailable fence notice

viperblockd refuses to start, and RegisterProviderSubjects to register,
when the node name is not one contract subject token: there would be no
fence-notice subject, so a fenced guest would keep running. Nothing is
subscribed before the refusal.

publishVolumeFenced keeps sending a notice the daemon refused as
unavailable, stops on one that can never be sent, and no longer checks
the subject itself. The late fence completion is shown to send its
notice.

* fix(daemon): refuse an unaddressable node and trace fence notices as the contract does

NewDaemon refuses a cluster config node that cannot address the fence
notice subject, instead of starting with no fence-notice subscription.

handleFenceNotice opens the EBS provider server span, so the notice is
attributed to the sender's account, and every refusal goes through one
path that records its code on the span and answers in the notice's
version. An unsupported version names the version it got.

Tests hold the guest's stop open to show no ack leaves before it ends,
and drive the notice through subscribeAll's real subscription table.
2026-11-03 17:24:19 +10:00
Josh Siviour 700e98c421 fix: drain IAM group memberships and group policies in account teardown (#1501)
The user reaper removes each group membership before DeleteUser and the group reaper detaches attached and deletes inline policies before DeleteGroup, so a user in a group or a group carrying policies no longer strands the identity stage. DeleteUser now ignores a group name with no group record, as it already does for an access key ID with no key entry.
2026-11-03 17:03:52 +10:00
Bryn Mailer 5120aceaf6 feat(ebs): drain the volume inside each provider's CreateSnapshot (#1481)
* feat(ebs): drain the volume inside each provider's CreateSnapshot

viperblockd now drains a mounted volume's export over snapshot.sock in the
background snapshot work, before reading the live checkpoint. A refused or
stale socket fails the snapshot; no socket with the volume lock free means no
client is connected and nothing is buffered; no socket with the lock held is a
client between connections and is reported as retry-later.

Write-through providers drain nothing: the control-plane DrainVolume and the
daemon's drain handler skip the snapshot.sock drain when the node's EBS
provider is not viperblockd, so a snapshot or CreateImage of an attached
volume on qemunbd no longer fails. The control-plane drain stays for
viperblockd nodes that predate the provider drain.

The conformance suite gains a case that CreateSnapshot answered on the queue
subject by a node other than the volume's holder is refused, while the
holder's own snapshot succeeds, run against viperblockd through a third node
reachable only on the queue subjects.

* fix(ebs): refuse a snapshot whose last export client left unsealed WAL

The plugin releases its volume lock even when the seal on disconnect fails,
so a free lock alone did not show the last client's writes reached the
backend. With no drain socket and the lock free, the snapshot now also
requires that the volume holds no local WAL, or that a seal receipt attests
to it.

A socket path of 108 bytes or more cannot be listened on, so a held lock
there is reported as a failed drain rather than as retry-later, and the path
bound is now inclusive. The drain logs with the request context.

* fix(qemunbdd): snapshot a published volume through its export

qemu-nbd holds a published volume's qcow2 open with the write lock, so
qemu-img convert of the file failed with "Failed to get shared write lock",
and reading it with -U would race the metadata qemu-nbd caches. A published
volume is now copied from the NBD export, which serves exactly what the
attached client wrote; an unpublished volume keeps copying the file.

The reader needs a second connection beside the attached client. Any
--shared limit above 1 makes qemu-nbd advertise multi-conn, after which a
client opening several connections blocks on those over the limit, so the
export now accepts any number of clients.

* fix(ebs): require retry-later from the queue-subject snapshot refusal

The conformance case now requires the refusal to carry ErrUnavailable rather
than any error. The viperblockd harness returns a struct instead of five
positional values and stands its queue-only node up only for the case that
asks for it.

* docs(ebs): say only the snapshot accept reply is freed of the drain

* fix(ec2): warn when a drain is skipped for a provider not known to write through

Only viperblockd serves the drain socket, so the skip itself is unchanged, but
it is logged at Info only for qemunbd and at Warn for any other provider. The
daemon logs the host-side skip under its own message.

* fix(ec2): log a provider snapshot refusal and surface retry-later as ServiceUnavailable

CreateSnapshot flattened every provider failure into an unlogged
ServerInternal. It now logs the provider error with the volume and snapshot
ids, and maps a provider ErrUnavailable to ServiceUnavailable as the delete
path already does. CreateImage's snapshot step shares the mapping.

* fix(qemunbdd): refuse a snapshot of a published volume

Reading a published volume through its export needed qemu-nbd to accept more
than one client, which drops the guard against a second writer, and the copy
it made of a live export was not point-in-time. CreateSnapshot now refuses a
published volume with volume_in_use before running qemu-img, and the export
is back to a single client. An unpublished volume still snapshots from its
file.

* refactor(ec2): drop the control-plane snapshot drain

Every viperblockd in a cluster drains the volume inside its own
CreateSnapshot, so CreateSnapshot and CreateImage no longer dial
snapshot.sock or route a DrainVolume command to the instance's host.

Removed: DrainVolume, DrainVolumeSocket, DrainsThroughSocket and the
skipped-drain logging in the snapshot domain; the state.json read and
the volumestate package only it used; the daemon's DrainVolume handler
and the DrainVolume command, payload and response in ec2v1. CreateImage
now snapshots the root volume through one provider path whether or not
the instance is running.

Tests assert that both EC2 paths snapshot an attached volume through the
provider without opening the drain socket or messaging the host, and
that viperblockd drains a mounted volume exactly once.

* fix(viperblock): prove no local writer before an unmounted snapshot

The unmounted branch of CreateSnapshot opened its own engine once the lease
was held, but the lease is reentrant on the volume's own host, so an nbdkit
left running unregistered after a failed recovery rebuild kept taking writes
the snapshot never saw. The branch now takes the volume lock before the open
and holds it until the snapshot is read; a held lock is drained through the
export's socket, or refused as retry-later when it serves none.

A seal receipt now approves local WAL only if it is newer than every change
under the WAL, so a receipt from an earlier connection no longer covers WAL a
later one failed to seal. A refusal for a held lock with no drain socket is
logged at WARN with the socket path.

* fix(ec2): map a provider volume-in-use snapshot refusal to IncorrectState

A provider that cannot snapshot an attached volume, as qemunbdd refuses a
published one, surfaced as ServerInternal. CreateSnapshot and the CreateImage
snapshot step now return IncorrectState telling the caller to detach the
volume first.

* docs(e2e): describe the stopped CreateImage path as the single snapshotVolume path

* chore(viperblock): order the io/fs import as gofmt does
2026-11-03 16:38:47 +10:00
Bryn Mailer 0524bbe916 feat(daemon): mount, unmount and abandon over the contract at version 3 (#1470)
* feat(daemon): mount and unmount through PublishVolume behind the local provider's version 3

The mounter asks its own node's provider for capabilities on the per-node
subject and, when version 3 is listed, mounts and unmounts every volume
through PublishVolume and UnpublishVolume at version 1, recording the
published export on the request. Otherwise it keeps the legacy
ebs.<node>.mount and unmount subjects.

A retry-later or not_found refusal and a publish with no responder back off
as ErrMountRetryable, so restore keeps the legacy retry. No responder on
any mount or unmount drops the cached route so the next call re-reads
capabilities. An unpublish takes the provider's data-movement budget.

* fix(daemon): treat a publish reply with no usable export as an ambiguous mount

NATSProvider.PublishVolume now wraps ErrNoPublishedVolume and
ErrInvalidExport, keeping its messages. The mounter turns either, and an
export it cannot apply, into vm.ErrMountAmbiguous, so hot attach runs its
defensive unmount and Mount's rollback unpublishes the failing volume as
well as those already published.

* fix(daemon): give the contract unpublish its own seal budget

DetachVolume bounds its context for the unplug and then seals, so an
unpublish on that context got whatever the unplug left. The mounter now
detaches the unpublish from the caller's cancellation, keeping the trace,
so NATSProvider's data-movement timeout governs the seal as
unmountSealTimeout does on the legacy path.

* fix(ebs): let NATSProvider requests buffer through a reconnect

request refused whenever the connection was not connected, which includes
reconnecting, while the legacy subjects' plain nats request buffers and
sends once the connection is back. Only a nil or closed connection now
refuses.

* fix(daemon): roll back a mount over the route the mount took

Mount's rollback re-read the route, so after a failure that dropped the
cached one a contract-published volume could be unmounted over legacy
ebs.unmount. The rollback now takes the route the mounts took. Unmount,
unmountOne and the rollback share one seal path that is given the route,
replacing the per-route helpers and their error out-parameter.

* test(daemon): cover a not_found unpublish and a legacy no-responder

An unpublish answered not_found counts as sealed for Unmount and
UnmountOne, and a mount that finds no responder on the legacy subject
re-reads capabilities, so a provider restarted at version 3 takes the next
mount over the contract.

* refactor(daemon): reuse endOpSpan in the mounter and correct its comments

The contract spans end through the existing endOpSpan, the adapter's doc
comment fits three lines, and configureEBSProvider no longer claims its
provider is the only one wired, since the mounter builds its own.

* refactor(daemon): mount, unmount and abandon over the contract at version 3

A cluster runs one EBS contract version and every provider in it serves
version 3, so the volume mounter no longer reads its node's capabilities
to choose a route. Mount, MountOne, Unmount and UnmountOne always send
PublishVolume and UnpublishVolume at version 3, and Abandon is an
UnpublishVolume with NoSeal whose torn-down field replaces the legacy
abandon reply. The mounter sends nothing on a legacy ebs.<node> subject.

Export mapping, the retry-later and ambiguous-mount mappings, the seal
budget, the detached rollback context and the provider error chain are
unchanged. Tests that drove the legacy route or route selection are
removed or moved onto the contract subjects.

* refactor(ebs): remove the per-node capabilities subject

The volume mounter was the only consumer of ebs.provider.v1.<node>.capabilities,
and it no longer reads capabilities to choose a route. NodeCapabilitiesSubject,
its wildcard and verb, NATSProvider.GetNodeCapabilities and the natsserve and
viperblockd subscriptions serving it go. The cluster-wide GetCapabilities and
its contract version list stay.

* fix(ebs): refuse requests on a disconnected connection and carry the account

NATSProvider refuses a request again whenever its connection is not
connected. A request buffered through a reconnect is flushed after its
caller may have given up, so the provider would act with nobody waiting,
such as creating a volume the caller reported as failed.

A reply that cannot be decoded, or that carries a schema version this
build does not serve, now wraps ErrUnreadableReply, so a caller can tell
a request the provider received from one it never saw.

WithAccountID puts an account on a context. Every request made under it
carries the X-Account-ID header and names the account on its client
span, and the server span reads it back from the header.

* fix(daemon): retry a refused publish, tear down an unknown one, attribute every call

A publish refused because the connection is down was never sent, so it is
now vm.ErrMountRetryable and restore backs off. A publish that timed out
or whose reply could not be read may have started an export, so it is
vm.ErrMountAmbiguous and Mount and AttachVolume unpublish it.

Publish, unpublish and abandon put the instance's account on the context,
so the provider request carries it on the wire and on its span. The
mounter's own per-call span is gone, leaving one client span per call.
Abandon opens one span naming the instance, account and reason, and a
failed volume's error names the reason. Its bound is a field so a test
can check that an unanswered abandon gives up within it.

The mounter takes the daemon's provider rather than building a second
one, sends at NewLatestVersioned, calls unpublish from UnmountOne and
leaves export validation to the provider client.

* refactor: describe the single-version cluster and the publish path

NewVersioned and NewLatestVersioned no longer describe an N/N-1 rollout
or a capability exchange, since a cluster runs one contract version. The
volume path names publish and unpublish rather than the legacy ebs.mount
and ebs.unmount subjects, and ErrMountAmbiguous describes a publish reply
with no usable export.

* test(viperblock): pin the held-elsewhere text on a version 3 refusal

Callers still recognise a volume held by another node by its message, so
the held_elsewhere refusal must keep ec2v1.VolumeHeldElsewhereText.

* fix(daemon): tear down a publish whose caller cancelled after sending

A publish the provider received and the caller then stopped waiting for has
an unknown outcome, like a timeout: the provider may have started the export.
Classify context.Canceled from a sent publish as ErrMountAmbiguous so Mount
unpublishes it and hot attach rolls it back, both under a context the caller's
cancellation does not reach.

A caller already gone before the publish starts sends nothing, so publish
returns its context error without calling the provider and without the
ambiguous classification; a disconnected connection stays retryable.
2026-11-03 16:38:19 +10:00
Julian Sommer 274e8daa22 refactor(elbv2): resolve DescribeTags ARNs through resolveTaggable instead of a duplicate per-type switch (#1498) 2026-11-03 16:58:17 +11:00
Julian Sommer e7296fc866 refactor(cli): send storage status, spinifex actions and admin calls through one signed POST helper (#1497) 2026-11-03 16:57:13 +11:00
Julian Sommer 4860a89e38 refactor: delete unused ECR key helpers, ECS lease key, Issuer.ActiveKid, CheckMessageSize and feed.isClosed (#1495) 2026-11-03 16:41:21 +11:00
Julian Sommer 4a093f7eab refactor(ecs): keep one exported copy of the KV JSON helpers in taskdefinition and delete the ecs duplicates (#1494) 2026-11-03 16:39:36 +11:00
Julian Sommer b750dc5f67 refactor(elbv2): delete unused Store.GetRule (#1493) 2026-11-03 16:38:43 +11:00
Julian Sommer 0a077b671b refactor(ochre): extract ConverseStream guardrail input text through converseStreamToConverseInput (#1492) 2026-11-03 16:36:56 +11:00
Julian Sommer 5ef84cfb84 refactor(e2e): share one nano-instance-type and AMI picker between the reboot and lb suites (#1490) 2026-11-03 15:44:38 +11:00
Ben Duncan 18430ab909 fix(ci): keep the bare-metal job's root steps out of the workspace and stop them when it ends (#1488)
The PXE build rebuilt spinifex as root inside the workspace, and a cancelled
job's root make kept running for an hour, so the next job on the runner could
not write bin/spx. The microVM image is now built as the runner user, the PXE
build skips its spinifex rebuild, and both root steps run in named systemd
scopes that are stopped when the job ends and before the next one starts.
2026-11-03 15:21:39 +11:00
Julian Sommer 7ae7d53559 refactor(ochre): parse provisioned-model and guardrail ARNs with one parser on arn.Split (#1489) 2026-11-03 15:20:33 +11:00
Bryn Mailer 6b1b26e1e3 feat(compute): boot the root volume from a -blockdev node that takes NBD or host_device (#1427)
* feat(compute): boot the root volume from a -blockdev node that takes NBD or host_device

The boot volume moves from -drive if=none to a -blockdev node named os, so
a block export reaches QEMU as host_device as data volumes already do. The
NBD boot shape keeps its cache, reconnect, iothread and bootindex settings;
werror and rerror move onto the virtio-blk device. A block EFI volume is
still refused because pflash has no host_device form.

* test(viperblock): pin the boot -blockdev shape in the nbdkit orphan reaper

The boot volume's NBD server options now carry a file. prefix, which the
referencer scan matches only because it compares by substring. Cover the
unix and TCP boot strings so a stricter match cannot reap a live root
export, and stop describing the joined host:port form as a boot drive.

* fix(compute): refuse a disk-boot launch with no boot volume

Data-volume -blockdev nodes satisfy Execute's drive check on their own, so
a disk-boot request list without a boot volume would start a QEMU with
nothing to boot. The disk-boot path now refuses it before launch.

* refactor(compute): share the NBD node options and export dispatch

The data and boot -blockdev builders share one helper for the nbd server,
export and reconnect-delay options, buildDrives dispatches both branches
through one export helper, and hot attach parses its URI with the shared
parser. Emitted arguments are unchanged. The BlkDeviceMapped doc now states
when it falls back to the scalar form.

* test(compute): cover an unnamed boot device with no medium in the device map
2026-11-03 14:04:13 +10:00
Josh Siviour 2e9e324c2a feat(accountlifecycle): cut ordinary deletes over to EC2-owned cleanup for key pairs, placement groups and launch templates (#1486)
* feat(ec2): delete live launch templates in platform cleanup when no consumer names them

A cleanup request may ask for live templates to be deleted. It then reads which
templates records outside EC2 (nodegroups) name, deletes the rest first, and
checks key pair and placement group references against what remains. A failed,
partial or absent consumer read deletes nothing and blocks every live template.
Requests that do not ask behave as before.

* feat(accountlifecycle): clean L09 kinds through the EC2 platform cleanup on ordinary delete

Ordinary delete evaluates quiescence again under the same fence before the
platform stage, cleans only the kinds that record as quiesced (live launch
templates only when EKS names none), and re-reads the owner inventory before
the fenced DeleteAccount. Any remaining L09 record or incomplete re-read keeps
the account TERMINATING; Force is not forwarded. The key-pair, placement-group
and launch-template reapers and the dry-run shadow comparison are removed.

* test(accountlifecycle): Force never bypasses a reference on an ordinary delete
2026-11-03 14:01:15 +10:00
Julian Sommer add9e2fe02 refactor(ochre): serve Converse, ConverseStream, InvokeModel and InvokeModelWithResponseStream from one Router (#1487) 2026-11-03 15:00:13 +11:00
Julian Sommer d2ce439562 refactor(network): share one jitter and context-aware sleep between the dhcp and ocinet allocators (#1485) 2026-11-03 14:40:17 +11:00
Julian Sommer a8c9c91005 refactor(policy): delete unused SecurityGroupManager.DeleteSG and route vpc.update-sg through EnsureSG (#1482) 2026-11-03 14:38:38 +11:00
Julian Sommer fd8d48e8c6 refactor: delete route53 MarkChangesInSync and DHCP RequestRelease, which only tests called (#1484) 2026-11-03 14:38:27 +11:00
Julian Sommer 37f3b02fb7 refactor(network): delete unused legacy per-VPC external switch names and ocinet.NewKVStoreOver (#1483) 2026-11-03 14:38:18 +11:00
Julian Sommer 418adc5cbc refactor(ingress): share one bounded body reader and action resolver across AWS registrations (#1476) 2026-11-03 14:01:19 +11:00
Julian Sommer 9a32eccfbb refactor(runtime): delete IsComplete, IsSocketURI and IsMIGModeEnabled, which only their own tests called (#1475) 2026-11-03 13:58:31 +11:00
Julian Sommer 7589c36a0e refactor(ovn): share one router-scoped NAT delete and have DeleteStaticRoute reuse FindStaticRoute (#1477) 2026-11-03 13:58:17 +11:00
Julian Sommer 2cf4586415 refactor(network): delete host IMDS and NAT egress teardown functions that only tests called (#1478) 2026-11-03 13:58:07 +11:00
Julian Sommer e92f400e09 refactor(vpc): select external IP pools with the tested external.FindPool (#1480) 2026-11-03 13:57:55 +11:00
Julian Sommer 3cbd05e7f4 refactor(dhcp): decode lease addresses with one helper shared by the wire and KV formats (#1479) 2026-11-03 13:57:49 +11:00
Ben Duncan d1085daf08 docs(vultr): set up the cluster from node 1 (#1472)
* docs(vultr): say where to run the cluster setup commands on Vultr

* docs(vultr): set up the cluster from node 1
2026-11-02 19:47:17 -06:00
Josh Siviour 7e2e67dda7 feat(lambda): add ListVersionsByFunction listing only $LATEST (#1471)
Until versions can be published, the only version is $LATEST: the list holds
its configuration with the FunctionArn qualified as :$LATEST and no
NextMarker. MaxItems outside 1 to 10000 is refused, and Marker is refused
outright rather than guessing AWS's paging behaviour. It is authorized as
lambda:ListVersionsByFunction on the function ARN and does not need the code
store.
2026-11-03 11:43:51 +10:00
Josh Siviour 362eceeb15 fix(eks): delete node groups asynchronously and remove their own instance profile (#1466)
* test(eks): pin async nodegroup delete and per-nodegroup instance profile

* feat(eks): delete nodegroups asynchronously and own one instance profile per nodegroup

* fix: confirm nodegroup workers are gone before deleting their profile, and kick the delete after the request

* fix: end a stuck nodegroup delete as DELETE_FAILED, and cover nodegroup delete in the EKS e2e

* docs: describe asynchronous nodegroup delete and the per-group instance profile

* test: keep the EKS e2e bystander instance profile until the parent test ends

* test: cover nodegroup delete failure paths, profile ownership refusals and worker absence checks
2026-11-03 11:43:03 +10:00
Julian Sommer 8404ed0123 refactor(ecr): delete the in-memory MetaStore and run its tests against JetStream KV (#1474) 2026-11-03 12:32:44 +11:00
Julian Sommer 428573c829 refactor(e2e): delete harness helpers that no test calls (#1473) 2026-11-03 12:32:36 +11:00
Julian Sommer 37c86bfb8a refactor(ec2): delete the unused NATSInstanceService, which only its own test called (#1469) 2026-11-03 11:53:58 +11:00
Josh Siviour cd2618bebf feat(admin): non-finalizing DeleteAccount exercises the quiesced EC2 platform kinds (#1458)
* feat(admin): non-finalizing DeleteAccount exercises the quiesced EC2 platform kinds

A non-finalizing DeleteAccount quiesces the account, records the S9 evaluation, cleans only the key-pair, placement-group and launch-template kinds it recorded as quiesced, then re-reads the owner inventory and accounts for what remains. The job ends EXERCISED with the account still TERMINATING; a later ordinary delete takes the same operation over.

Only an exercise records a quiesced scope, so ordinary deletes still record none. The cleanup capability gains a kinds selector, awsgw binds the in-process owners, and the instance reference reader fails closed.

* refactor(admin): name the takeover of a finished exercise exercise_finished
2026-11-03 10:50:28 +10:00
Josh Siviour 4507b0cb60 feat(lambda): add GetFunctionCodeSigningConfig for unsigned functions (#1467)
Code signing is unsupported, so the read only proves the function exists and
returns AWS's body for a function with no code signing config: both members
present and null. A missing function is ResourceNotFoundException. It is
authorized as lambda:GetFunctionCodeSigningConfig on the function ARN and
needs neither the code store nor environment decryption.
2026-11-03 10:49:39 +10:00
Josh Siviour 1ca56642be feat(lambda): add GetFunctionConfiguration for $LATEST (#1465)
Serves GET /2015-03-31/functions/{FunctionName}/configuration with the
same name, qualifier, ownership and environment handling as GetFunction,
authorized as lambda:GetFunctionConfiguration on the function ARN. It is
a metadata read, so it neither opens nor presigns the code store.
2026-11-03 10:49:39 +10:00
Josh Siviour 7692627708 feat(lambda): run a ZIP package in the test-link guest (#1459)
Dev-only code delivery behind the lambdatestlink tag: the host attaches the
ZIP as a read-only disk and passes its exact size and SHA-256 through
fw_cfg. The agent reads those values before sealing fw_cfg, and installs
the package only after authenticating and receiving the host's first
frame: it checks size and hash, validates every entry before writing any,
extracts through os.Root into a nosuid,nodev tmpfs at /var/task, and makes
it read-only. Any failure exits without starting the bootstrap.

Entry names that could escape the task root, entries other than regular
files and directories, and sizes over the dev bounds are refused; declared
sizes cannot wrap the running total, and actual bytes are counted whatever
the header claims. The bounds are dev limits, not AWS limits.

StartBootstrap now starts the bootstrap in its own directory, so packaged
functions can open their files by relative path.
2026-11-03 10:49:39 +10:00
Bryn Mailer 89c7fac5e3 feat(daemon): lock a cluster to one EBS provider (#1428)
* feat(config): refuse a spinifex.toml whose nodes resolve to different EBS providers

* feat(daemon): lock a cluster to one EBS provider in spinifex-cluster-config

The first daemon records its provider at ebs-provider with Create, which
also backfills an existing cluster. A daemon whose provider differs from
the recorded one refuses to start, naming both providers and the recovery.

* fix(config): compare only explicit EBS providers across nodes

A generated spinifex.toml writes no ebs stanza for peers, so treating an
absent peer provider as viperblockd refused every non-default cluster.

* fix(daemon): exit on an EBS provider lock refusal and refuse a malformed lock

A mismatch now exits 1 so the unit shows failed. A recorded value that
does not decode or names no supported provider is a distinct permanent
refusal quoting the raw value. Permanence is a sentinel matched with
errors.Is, and the cluster-config bucket is declared beside the others.

* style(daemon): align JetStreamManager fields
2026-11-03 09:41:38 +10:00
Bryn Mailer 54ecdde17e feat(ebs): contract schema version 3 for the provider data path (#1436)
* feat(ebs): contract schema version 3 for the provider data path

Servers accept schema versions 1 to 3 and answer every request in the
version it was sent at, while every client still sends version 1.

Version 3 adds no_seal to UnpublishVolumeRequest, refused below 3, and
torn_down to UnpublishVolumeResponse. UnpublishVolume now returns an
UnpublishedVolume so the torn-down result reaches callers.

A publish refused because another node holds the volume returns
volume_held_elsewhere at version 3 and volume_in_use below it, which is
what v1.23.0 returns. A read-only mismatch on republish stays
volume_in_use at every version.

GetCapabilities lists the contract versions served and is also answered
on a per-node subject. The unsealed-writes subject is part of
EBSProvider, and write-through providers answer it with nothing held. A
fence-notice event subject and message are defined. Unpublish gets the
data-movement timeout.

The memory provider, natsserve and qemunbdd implement all of this. A
qemunbdd republish whose qemu-nbd has died re-exports the volume instead
of returning the dead socket.

* feat(viperblock): serve contract schema version 3 from viperblockd

Every provider subject replies in its request's version, and
GetCapabilities lists the contract versions and is also served on the
node's own capabilities subject.

PublishVolume no longer returns an existing entry early. It goes through
mountVolume, which hands back a live export and drops and remounts one
whose nbdkit has died. Mount failures map to not_found, held_elsewhere
(version 3 only, volume_in_use below it), volume_in_use for a mode
mismatch, unavailable for a backing or lease store that is not ready,
and internal otherwise.

UnpublishVolume with no_seal abandons the export without sealing it,
and reports whether an export was torn down.

* test(ebs): conformance cases for contract schema version 3

Every provider run through the suite now has to:
- list ascending contract versions including the legacy one, answer each
  in that version, and refuse an unlisted one without changing state;
- refuse a publish elsewhere with volume_held_elsewhere only at version 3
  and volume_in_use at versions 1 and 2;
- refuse a republish in the other access mode as volume_in_use, never
  held elsewhere;
- report torn_down truthfully, refuse no_seal below version 3, and either
  honour no_seal at version 3 or refuse it as unsupported;
- answer the unsealed-writes query with a subset of what was asked.

SuiteConfig.KillExport lets a provider whose export is a process prove
that a republish after the export died returns a serving export.
qemunbdd and viperblockd supply it.

* fix(ebs): stop a running export before remounting over its lost socket

An export whose socket is gone was treated as dead even while its
process ran, and a republish started a second export beside it. A
client connected before the socket went keeps writing through the old
process, so that made two writers on one volume.

qemunbdd now SIGTERMs that qemu-nbd and waits for it to exit before
exporting again. viperblockd stops the nbdkit through unmountVolume, so
the old copy is sealed as a normal unmount would seal it. If the process
will not stop, the publish is refused as unavailable and the entry is
kept for the next attempt.

* test(viperblock): give the no-seal tests a seal they could reach

The no-seal unpublish and abandon tests left BaseDir empty, so
volumeNeedsSeal was false and no path could seal whatever the code did.
Both now create local state under BaseDir first, and check that a seal
is reachable before asserting none ran.

* refactor(ebs): leave version rules to the provider and trim the v3 surface

The provider decides what a request's version allows, so natsserve no
longer gates TornDown or held-elsewhere on it: classifyError takes no
version, and the reply carries what the provider returned. viperblockd
applies the TornDown rule in unpublishVolume, its provider half.

The unpublish handlers check the version once, through
CheckUnpublishVersion, and classify its refusal. Each handler binds its
reply version once.

UnpublishedVolume is reduced to TornDown. movesData classifies a subject
by its parsed verb, which replaces isNodeSubject and the owner-verb
list. nodeSubjectWildcard is unexported. The conformance suite reads
capabilities once.

* test(ebs): check v3 success replies, timeouts and refusals end to end

- Unpublish is sized as data movement, and publish, unsealed writes and
  per-node capabilities keep the caller's timeout.
- A mount whose lease store or backing store is not ready reaches the
  wire as unavailable.
- A successful publish replies in the request's version at every served
  version, in natsserve and in viperblockd. Every natsserve case now gets
  its own seeded provider, so none relies on an earlier case's state.
  The conformance republish runs at every listed version.
- viperblockd's per-node capabilities list the versions it serves.
- A no_seal unpublish above the served version is refused, and the
  export is neither torn down nor sealed.
- The dead-entry republish test asserts unconditionally. The snapshot
  lease test runs against real volume state and asserts nothing was
  written. The conformance torn-down case skips a provider that does
  not list version 3.

* fix(ebs): wrap the stop failure behind an unavailable publish

The refusal now wraps the stop error as well as ErrUnavailable, so a
caller can still inspect why the old export did not stop. Also fixes
import order and spacing that the linter flagged in the new tests.
2026-11-03 09:41:19 +10:00
1153 changed files with 51946 additions and 12991 deletions
-15
View File
@@ -1,15 +0,0 @@
# CODEOWNERS — GitHub PR review enforcement
#
# Format: <path pattern> <owner> [<owner> …]
# Order matters — last matching pattern wins.
#
# Set the matching branch protection rule on `dev` and `main` to
# "Require review from Code Owners" or this file is advisory only.
# Network layer is governed by its invariant tests.
# Changes here must respect those clauses
# see spinifex/domains/network/invariants/
# mulga docs/development/proposals/region-local-architecture/0006-spinifex-network-layer-contract.md (proposal, not accepted)
/spinifex/domains/network/ @joshsiv-mulga
/spinifex/runtime/roles/vpcd/ @joshsiv-mulga
/spinifex/domains/network/invariants/ @joshsiv-mulga
-14
View File
@@ -6,20 +6,6 @@
<!-- The notable changes, as bullets. -->
## Account-scoped lifecycle impact
<!-- Complete this section only when the PR changes account-scoped resource lifecycle, account termination, resource cleanup, or a lifecycle boundary. Otherwise write: N/A. -->
- Lifecycle owner and resource family:
- Owner-local ordering, recovery, retained-state, or force impact:
- Cross-domain dependency or ownership impact:
- Evidence added or rechecked:
- ADR-0008 lifecycle register / ADR-0001 migration-record / bead updates:
## Mulga design
<!-- One line, so a reviewer knows whether the design records in the private mulga repository need to change. Write either "Updated: <mulga PR or bead id>" or "Not needed: <reason>". This is a reminder, not a gate. -->
## Community Contribution License
- [ ] All community contributions in this pull request are licensed to Mulga under the terms of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). By creating this pull request I represent that I have the right to license these contributions to Mulga under the Apache License 2.0.
@@ -1,87 +0,0 @@
#!/bin/sh
# hypervisor-disk-preflight_test.sh — drives hypervisor-disk-preflight.sh against
# a stub ssh that answers for each hypervisor from a canned file, so every
# verdict is checked without a real host.
set -eu
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
PREFLIGHT="$SCRIPT_DIR/hypervisor-disk-preflight.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
# The stub answers "<root-kb> <pool-kb> <path>" from $FAKE/<host>, or exits 255
# when that host has no file, the way ssh does for a host it cannot reach.
mkdir -p "$TMP/bin" "$TMP/hosts"
cat > "$TMP/bin/ssh" <<'STUB'
#!/bin/sh
for a in "$@"; do case $a in *@*) host=${a#*@} ;; esac; done
cat >/dev/null
if [ -f "$FAKE/$host" ]; then cat "$FAKE/$host"; exit 0; fi
echo "ssh: Could not resolve hostname $host" >&2
exit 255
STUB
chmod +x "$TMP/bin/ssh"
PATH="$TMP/bin:$PATH"
FAKE="$TMP/hosts"
export PATH FAKE
cat > "$TMP/env.tfvars" <<'TFVARS'
nodes = [
{ name = "wattle", wan_ip = "192.0.2.1", storage_pool = "drive1-pool", base_image = "/x.qcow2" },
# { name = "retired", wan_ip = "192.0.2.9", storage_pool = "drive1-pool", base_image = "/x.qcow2" },
{ name = "banksia", wan_ip = "192.0.2.2", storage_pool = "nvme1-pool", base_image = "/x.qcow2" },
]
TFVARS
GiB=1048576
plenty="$((50 * GiB)) $((200 * GiB)) /mnt/drive1/pool"
# run <label> <want-rc> <want-text>
run() {
rc=0
out=$(bash "$PREFLIGHT" "$TMP/env.tfvars" 2>&1) || rc=$?
if [ "$rc" -ne "$2" ]; then
fail "$1: exit $rc, want $2"
echo "$out" | sed 's/^/ /'
return
fi
case $out in
*"$3"*) pass "$1" ;;
*) fail "$1: output lacks '$3'"; echo "$out" | sed 's/^/ /' ;;
esac
}
echo "-- hypervisor-disk-preflight.sh"
echo "$plenty" > "$FAKE/wattle"
echo "$plenty" > "$FAKE/banksia"
run "every host has room" 0 "banksia: root fs 51200 MiB free"
echo "0 $((200 * GiB)) /mnt/drive1/pool" > "$FAKE/wattle"
run "a full root filesystem fails fast" 1 "::error::hypervisor wattle has 0 MiB free on the filesystem holding /etc/libvirt/qemu"
echo "$plenty" > "$FAKE/wattle"
echo "$((50 * GiB)) $((3 * GiB)) /mnt/nvme1/pool" > "$FAKE/banksia"
run "a nearly full pool fails fast" 1 "::error::hypervisor banksia has 3 GiB free in storage pool nvme1-pool"
echo "$plenty" > "$FAKE/banksia"
rm -f "$FAKE/wattle"
run "an unreachable host is reported, not failed" 0 "::warning::hypervisor disk preflight: cannot reach wattle"
echo "$plenty" > "$FAKE/wattle"
echo "$((50 * GiB)) - -" > "$FAKE/banksia"
run "a pool libvirt does not know is reported, not failed" 0 "pool nvme1-pool (-) not found"
echo "0 0 /x" > "$FAKE/retired"
echo "$plenty" > "$FAKE/banksia"
run "a commented-out node is never checked" 0 "wattle: root fs"
if [ "$fails" -ne 0 ]; then
echo "$fails failure(s)"
exit 1
fi
echo " hypervisor-disk-preflight ok"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Warn when a PR body has no filled "Mulga design" line. Reads PR_BODY and
# never fails the job: the check is a prompt, not a gate.
set -euo pipefail
shopt -s nocasematch
section_re='^#{1,6}[[:space:]]*Mulga design[[:space:]]*$'
line_re='^[[:space:]]*(Updated|Not needed)[[:space:]]*:[[:space:]]*(.*)$'
placeholder_re='<[^<>]*>'
filled() {
local body=$1 line value kept inside=0 pre rest
body=${body//$'\r'/}
while [[ $body == *'<!--'*'-->'* ]]; do
pre=${body%%'<!--'*}
rest=${body#*'<!--'}
body=$pre${rest#*'-->'}
done
while IFS= read -r line; do
if [[ $line =~ $section_re ]]; then
inside=1
elif ((inside)) && [[ ${line#"${line%%[![:space:]]*}"} == '#'* ]]; then
return 1
elif ((inside)) && [[ $line =~ $line_re ]]; then
value=${BASH_REMATCH[2]} kept=''
while [[ $value =~ $placeholder_re ]]; do
kept+=${value%%"${BASH_REMATCH[0]}"*}
value=${value#*"${BASH_REMATCH[0]}"}
done
[[ $kept$value == *[![:space:]]* ]] && return 0
fi
done <<<"$body"
return 1
}
if filled "${PR_BODY:-}"; then
echo "The Mulga design line is filled in."
else
echo '::warning title=Mulga design::Add a "## Mulga design" section with one line: "Updated: <mulga PR>" or "Not needed: <reason>".'
fi
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env python3
"""Warn when a PR body has no filled "Mulga design" line. Reads PR_BODY; never fails the job."""
import os
import re
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
SECTION_RE = re.compile(r"^#{1,6}\s*Mulga design\s*$", re.I)
LINE_RE = re.compile(r"^\s*(Updated|Not needed)\s*:\s*(.*?)\s*$", re.I)
PLACEHOLDER_RE = re.compile(r"<[^<>]*>")
def filled(body):
inside = False
for line in COMMENT_RE.sub("", body or "").splitlines():
if SECTION_RE.match(line):
inside = True
elif inside and line.lstrip().startswith("#"):
return False
elif inside:
m = LINE_RE.match(line)
if m and PLACEHOLDER_RE.sub("", m.group(2)).strip():
return True
return False
def main():
if filled(os.environ.get("PR_BODY", "")):
print("The Mulga design line is filled in.")
else:
print('::warning title=Mulga design::Add a line under "## Mulga design": "Updated: <mulga PR or bead id>" or "Not needed: <reason>".')
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,23 +0,0 @@
import unittest
from mulga_design_check import filled
class FilledTest(unittest.TestCase):
def test_accepts_either_form(self):
self.assertTrue(filled("## Mulga design\nUpdated: mulga#275"))
self.assertTrue(filled("## Mulga design\n\nnot needed: internal refactor\n## Next"))
def test_rejects_unanswered(self):
self.assertFalse(filled(""))
self.assertFalse(filled("## Mulga design\n<!-- Updated: x -->"))
self.assertFalse(filled("## Mulga design\nUpdated: <mulga PR or bead id>"))
self.assertFalse(filled("## Mulga design\nUpdated:"))
def test_scoped_to_the_section(self):
self.assertFalse(filled("Updated: mulga#1"))
self.assertFalse(filled("## Mulga design\n## Other\nUpdated: mulga#1"))
if __name__ == "__main__":
unittest.main()
-47
View File
@@ -1,47 +0,0 @@
#!/bin/sh
# wait-for-memory_test.sh — drives wait-for-memory.sh against a fake meminfo
# that a background writer changes mid-wait, so each verdict is checked without
# a loaded host.
set -eu
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
WAIT="$SCRIPT_DIR/wait-for-memory.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
meminfo() { printf 'MemTotal: 65536000 kB\nMemAvailable: %s kB\n' "$(( $1 * 1024 ))" > "$TMP/meminfo"; }
MEMINFO="$TMP/meminfo"
MEM_WAIT_POLL_S=1
export MEMINFO MEM_WAIT_POLL_S
meminfo 30000
if MEM_WAIT_TIMEOUT_S=5 bash "$WAIT" 26624 > "$TMP/out" 2>&1; then
pass "enough memory returns at once"
else
fail "enough memory returns at once: $(cat "$TMP/out")"
fi
meminfo 21577
( sleep 2; meminfo 30000 ) &
if MEM_WAIT_TIMEOUT_S=20 bash "$WAIT" 26624 > "$TMP/out" 2>&1 && grep -q waiting "$TMP/out"; then
pass "memory freed mid-wait is admitted"
else
fail "memory freed mid-wait is admitted: $(cat "$TMP/out")"
fi
wait
meminfo 21577
if MEM_WAIT_TIMEOUT_S=2 bash "$WAIT" 26624 > "$TMP/out" 2>&1; then
fail "memory that never frees fails: exited 0"
elif grep -q '::error::need 26624 MiB available, have 21577 MiB' "$TMP/out" && grep -q 'RSS' "$TMP/out"; then
pass "memory that never frees fails and names the biggest processes"
else
fail "memory that never frees fails and names the biggest processes: $(cat "$TMP/out")"
fi
[ "$fails" -eq 0 ] || { echo "wait-for-memory: $fails failed"; exit 1; }
echo "wait-for-memory: all passed"
+21
View File
@@ -0,0 +1,21 @@
# Codex review gate: calls bluebottle's reusable workflows from main, so a PR cannot change its own review.
name: codex-review
on:
pull_request_target:
branches: [dev]
types: [opened, synchronize, reopened, ready_for_review, edited]
issue_comment:
types: [created]
permissions: {}
jobs:
review:
if: github.event_name == 'pull_request_target'
uses: mulgadc/bluebottle/.github/workflows/codex-pr-review.yml@main
secrets: inherit
waive:
if: github.event_name == 'issue_comment' && github.event.issue.pull_request
uses: mulgadc/bluebottle/.github/workflows/codex-waive.yml@main
secrets: inherit
+9 -5
View File
@@ -30,6 +30,10 @@ concurrency:
jobs:
prerelease:
runs-on: ubuntu-26.04
env:
# Dev prereleases, newest published first: GitHub lists releases by tag name
# as text, so list order is not build order. The install Worker uses this rule.
DEV_RELEASES_JQ: '[.[] | select(.draft == false and .prerelease and .published_at != null and (.tag_name | test("-dev(\\.|$)")))] | sort_by(.published_at) | reverse'
steps:
# Full history and tags, because the tag name is git describe and a shallow
# clone has no tag to describe against.
@@ -164,12 +168,12 @@ jobs:
exit 1
fi
echo "latest is still $latest"
dev="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=20" --jq 'map(select(.prerelease))[0].tag_name')"
dev="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" --jq "$DEV_RELEASES_JQ | .[0].tag_name")"
[ "$dev" = "$TAG" ] || { echo "::error::the dev channel resolves to $dev, not $TAG"; exit 1; }
# Kept rather than unbounded: the Worker reads the first page of releases to
# resolve the channel, so enough prereleases would eventually push the real
# ones off it. Five leaves a few recent builds to roll back to.
# Kept rather than unbounded: the Worker reads one page of releases to resolve
# the channel, so enough prereleases would eventually push the newest off it.
# Five leaves a few recent builds to roll back to.
- name: Prune old dev prereleases
if: steps.version.outputs.exists != 'true'
env:
@@ -177,7 +181,7 @@ jobs:
run: |
set -euo pipefail
gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
--jq 'map(select(.prerelease and (.tag_name | endswith("-dev")))) | .[5:] | .[].tag_name' \
--jq "$DEV_RELEASES_JQ | .[5:] | .[].tag_name" \
> stale.txt
while read -r tag; do
[ -n "$tag" ] || continue
+23 -2
View File
@@ -71,9 +71,16 @@ jobs:
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-${{ inputs.cell || '20' }}-${{ github.run_id }}
SPINIFEX_OTEL_ATTRS: ${{ inputs.otel-attrs || format('ci.run_id={0},ci.workflow={1},ci.cell={2},ci.matrix=baremetal/pxe/real-hw', github.run_id, github.workflow, inputs.cell || '20') }}
steps:
# A cancelled job's root steps outlive it, since the runner cannot reach
# them once it kills sudo, and would keep writing here and to the cache.
# A rootfs killed before bin/bash exists is one the build refuses to reuse.
- name: Clean Workspace
run: |
sudo systemctl stop spinifex-bm-pxe.scope spinifex-bm-e2e.scope 2>/dev/null || true
sudo umount -R -l "${BM_ISO_CACHE}/rootfs" 2>/dev/null || true
if [ -d "${BM_ISO_CACHE}/rootfs" ] && [ ! -x "${BM_ISO_CACHE}/rootfs/bin/bash" ]; then
sudo rm -rf --one-file-system "${BM_ISO_CACHE}/rootfs"
fi
if [ ! -c /dev/null ]; then sudo rm -f /dev/null; sudo mknod -m 666 /dev/null c 1 3; fi
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
@@ -113,10 +120,15 @@ jobs:
"$GITHUB_WORKSPACE" \
"${RUNNER_TEMP}/spinifex-tests.tar" \
"${RUNNER_TEMP}/bm-bins"
# Built here, as the runner user, so the PXE build finds it current.
make -C "$GITHUB_WORKSPACE" build-microvm-image
- name: Build PXE Artifacts
run: |
sudo make -C mulga/scripts/iso-builder \
# -o build-spinifex: the binaries come from Build Artifacts, and a
# rebuild here would write root-owned files into the workspace.
sudo systemd-run --scope --collect --quiet --unit=spinifex-bm-pxe -- \
make -C mulga/scripts/iso-builder -o build-spinifex \
build-rootfs build-squashfs build-initramfs \
CACHE="${BM_ISO_CACHE}" \
SPINIFEX_BIN="${RUNNER_TEMP}/bm-bins/spx" \
@@ -124,6 +136,10 @@ jobs:
SPINIFEX_SRC="${GITHUB_WORKSPACE}" \
VIPERBLOCK_SRC="${RUNNER_TEMP}/bm-bins/viperblock" \
VERSION="$(cd "$GITHUB_WORKSPACE" && git describe --tags --always --exclude '*-dev' 2>/dev/null || echo dev)"
if root_owned="$(find "$GITHUB_WORKSPACE" -user root -print -quit)" && [ -n "$root_owned" ]; then
echo "::error::the PXE build wrote root-owned files into the workspace, e.g. $root_owned"
exit 1
fi
- name: Write ePDU credentials
run: |
@@ -195,13 +211,18 @@ jobs:
done
export BM_TEST_TIMEOUT
echo "suites=${BM_SUITES} test-timeout=${BM_TEST_TIMEOUT}"
sudo -E mulga/scripts/baremetal/run-baremetal-e2e.sh \
sudo -E systemd-run --scope --collect --quiet --unit=spinifex-bm-e2e -- \
mulga/scripts/baremetal/run-baremetal-e2e.sh \
--vmlinuz "${BM_ISO_CACHE}/vmlinuz" \
--initrd "${BM_ISO_CACHE}/initrd.img" \
--squashfs "${BM_ISO_CACHE}/spinifex.squashfs" \
--tests-tar "${RUNNER_TEMP}/spinifex-tests.tar" \
2>&1 | tee "${ARTIFACT_DIR}/test-baremetal.log"
- name: Stop this job's root steps
if: always()
run: sudo systemctl stop spinifex-bm-pxe.scope spinifex-bm-e2e.scope 2>/dev/null || true
- name: Generate JUnit report
if: always()
run: |
+4 -4
View File
@@ -1017,7 +1017,7 @@ jobs:
id: siblings
env:
BRANCH: ${{ env.GIT_BRANCH }}
run: bash .github/resolve-sibling-refs.sh
run: bash .github/scripts/resolve-sibling-refs.sh
working-directory: mulga/spinifex
- uses: actions/checkout@v7.0.1
@@ -1275,7 +1275,7 @@ jobs:
id: siblings
env:
BRANCH: ${{ env.GIT_BRANCH }}
run: bash .github/resolve-sibling-refs.sh
run: bash .github/scripts/resolve-sibling-refs.sh
working-directory: mulga/spinifex
- uses: actions/checkout@v7.0.1
@@ -1423,7 +1423,7 @@ jobs:
id: siblings
env:
BRANCH: ${{ github.ref_name }}
run: bash .github/resolve-sibling-refs.sh
run: bash .github/scripts/resolve-sibling-refs.sh
working-directory: spinifex
- name: Checkout Predastore
@@ -1547,7 +1547,7 @@ jobs:
id: siblings
env:
BRANCH: ${{ github.ref_name }}
run: bash .github/resolve-sibling-refs.sh
run: bash .github/scripts/resolve-sibling-refs.sh
working-directory: spinifex
- name: Checkout Predastore
+4 -5
View File
@@ -2,7 +2,8 @@ name: Mulga design
# Reminds the author to say whether the Mulga design records need to change.
# It reads only the pull request body, uses no secrets, and warns without ever
# failing: the check is a prompt, not a gate.
# failing: the check is a prompt, not a gate. Fork PRs are skipped because their
# authors cannot see the design records.
on:
pull_request:
@@ -16,7 +17,7 @@ permissions:
jobs:
mulga_design:
name: Mulga design line
if: ${{ !endsWith(github.actor, '[bot]') }}
if: ${{ !endsWith(github.actor, '[bot]') && github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-26.04
steps:
- name: Checkout Code
@@ -28,6 +29,4 @@ jobs:
env:
PR_BODY: ${{ github.event.pull_request.body }}
working-directory: .github/scripts
run: |
python3 -m unittest -q test_mulga_design_check
python3 mulga_design_check.py
run: bash mulga-design-check.sh
+1 -1
View File
@@ -113,7 +113,7 @@ jobs:
id: siblings
env:
BRANCH: ${{ env.GIT_BRANCH }}
run: bash .github/resolve-sibling-refs.sh
run: bash .github/scripts/resolve-sibling-refs.sh
working-directory: spinifex
- uses: actions/checkout@v7.0.1
+1 -1
View File
@@ -84,7 +84,7 @@ jobs:
- name: Resolve sibling release tags from go.mod
id: siblings
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
run: bash spinifex/.github/scripts/resolve-release-sibling-refs.sh spinifex/go.mod
- name: Checkout Viperblock
uses: actions/checkout@v7.0.1
+3 -3
View File
@@ -18,7 +18,7 @@ jobs:
- name: Resolve sibling release tags from go.mod
id: siblings
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
run: bash spinifex/.github/scripts/resolve-release-sibling-refs.sh spinifex/go.mod
- name: Checkout Predastore
uses: actions/checkout@v7.0.1
@@ -85,7 +85,7 @@ jobs:
- name: Resolve sibling release tags from go.mod
id: siblings
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
run: bash spinifex/.github/scripts/resolve-release-sibling-refs.sh spinifex/go.mod
- name: Checkout Predastore
uses: actions/checkout@v7.0.1
@@ -335,7 +335,7 @@ jobs:
- name: Resolve sibling release tags from go.mod
id: siblings
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
run: bash spinifex/.github/scripts/resolve-release-sibling-refs.sh spinifex/go.mod
- name: Checkout Predastore
uses: actions/checkout@v7.0.1
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
id: siblings
env:
BRANCH: ${{ github.head_ref || github.ref_name }}
run: bash .github/resolve-sibling-refs.sh
run: bash .github/scripts/resolve-sibling-refs.sh
working-directory: spinifex
- name: Checkout Viperblock
+9 -3
View File
@@ -20,6 +20,12 @@ on:
permissions:
contents: read
# A newer push to the same pull request supersedes its run. Pushes and dispatches
# get a group of their own, so every commit on dev and main keeps its result.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }}
cancel-in-progress: true
jobs:
build_and_test:
name: Build and Test
@@ -36,7 +42,7 @@ jobs:
id: siblings
env:
BRANCH: ${{ github.head_ref || github.ref_name }}
run: bash .github/resolve-sibling-refs.sh
run: bash .github/scripts/resolve-sibling-refs.sh
working-directory: spinifex
- name: Checkout Viperblock
@@ -166,7 +172,7 @@ jobs:
id: siblings
env:
BRANCH: ${{ github.head_ref || github.ref_name }}
run: bash .github/resolve-sibling-refs.sh
run: bash .github/scripts/resolve-sibling-refs.sh
working-directory: spinifex
- name: Checkout Viperblock
@@ -268,7 +274,7 @@ jobs:
id: siblings
env:
BRANCH: ${{ github.head_ref || github.ref_name }}
run: bash .github/resolve-sibling-refs.sh
run: bash .github/scripts/resolve-sibling-refs.sh
working-directory: spinifex
- name: Checkout Viperblock
+6
View File
@@ -30,6 +30,12 @@ on:
permissions:
contents: read
# A newer push to the same pull request supersedes its run. Pushes and dispatches
# get a group of their own, so every commit on dev and main keeps its result.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }}
cancel-in-progress: true
jobs:
ui_build_and_test:
name: UI Build and Test
+1
View File
@@ -27,6 +27,7 @@ build/microvm/sqs-worker/.stage.*/
build/microvm/lambda-runtime/vmlinuz
build/microvm/lambda-runtime/initramfs.cpio.gz
build/microvm/lambda-runtime/.stage.*/
build/microvm/lambda-testlink-runtime/
build/qboot/
!spinifex/runtime/roles/spinifexui/frontend/dist/
tmp/
+76 -20
View File
@@ -42,11 +42,15 @@ ifdef QUIET
_COVQ = 2>&1 | { grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' | grep -v 'coverage: 0\.0%' || true; }; exit $${PIPESTATUS[0]}
_RACEQ = 2>&1 | { grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' || true; }; exit $${PIPESTATUS[0]}
_SECQ = >
_HDR = :
_RUNSH = sh "$$t" >"$$log" 2>&1 || { echo "-- $$t"; cat "$$log"; exit 1; }
else
_Q =
_COVQ =
_RACEQ =
_SECQ = 2>&1 | tee
_HDR = echo -e
_RUNSH = echo "-- $$t"; sh "$$t" || exit 1
endif
build: go_build build-installer build-lb-agent generate-aws-model-coverage
@@ -78,6 +82,35 @@ go_build:
@echo -e "\n....Building $(GO_PROJECT_NAME)"
go build $(GO_BUILD_MOD) -ldflags "$(LDFLAGS)" -o ./bin/$(GO_PROJECT_NAME) cmd/spinifex/main.go
# Controlled-test only: builds the tagged daemon and its hard-kill initializer
# together, pinning the initializer's digest into that daemon build. The daemon
# keeps its symbols so the pin can be read back from the binary.
# LAMBDA_IMAGE_DIGEST is the test-link runtime image digest that
# build-lambda-testlink-image prints; it is read raw and validated before use.
LAMBDA_TESTLINK_DIR := ./bin/lambda-testlink
LAMBDA_TESTLINK_PKG := github.com/mulgadc/spinifex/spinifex/runtime/daemon
LAMBDA_TESTLINK_PIN := $(LAMBDA_TESTLINK_PKG).lambdaHardKillSHA256
LAMBDA_DIGEST_RE := ^[0-9a-f]{64}$$
build-lambda-testlink: override export LAMBDA_IMAGE_DIGEST := $(value LAMBDA_IMAGE_DIGEST)
build-lambda-testlink:
@[[ "$$LAMBDA_IMAGE_DIGEST" =~ $(LAMBDA_DIGEST_RE) ]] || { echo "LAMBDA_IMAGE_DIGEST must be 64 lowercase hex digits"; exit 1; }
@host=$$(uname -m); goos=$$(go env GOOS); goarch=$$(go env GOARCH); \
test "$$host" = x86_64 && test "$$goos/$$goarch" = linux/amd64 || { echo "the test link supports only an x86_64 host building linux/amd64, not $$host building $$goos/$$goarch"; exit 1; }
@rm -f $(LAMBDA_TESTLINK_DIR)/hardkill.so $(LAMBDA_TESTLINK_DIR)/$(GO_PROJECT_NAME)
@mkdir -p $(LAMBDA_TESTLINK_DIR).tmp
@rm -f $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so $(LAMBDA_TESTLINK_DIR).tmp/$(GO_PROJECT_NAME)
cc -std=c17 -O2 -Wall -Wextra -Werror -shared -fPIC -o $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so spinifex/runtime/compute/lambda/testlink/hardkill/hardkill.c -lrt
scripts/check-glibc-ceiling.sh 2.34 $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so
@digest=$$(sha256sum $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so | cut -d' ' -f1); \
test $${#digest} -eq 64 || { echo "initializer digest missing"; exit 1; }; \
echo "initializer sha256 $$digest"; \
pin="-X $(LAMBDA_TESTLINK_PIN)=$$digest -X $(LAMBDA_TESTLINK_PKG).lambdaImageDigest=$$LAMBDA_IMAGE_DIGEST"; \
LAMBDA_TESTLINK_INITIALIZER=$(abspath $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so) go test -count=1 -tags lambdatestlink -ldflags "$$pin" -run '^TestLambda(HardKillPin|ImageDigestPin)$$' ./spinifex/runtime/daemon/ && \
go build $(GO_BUILD_MOD) -tags lambdatestlink -ldflags "$(subst -s -w ,,$(LDFLAGS)) $$pin" -o $(LAMBDA_TESTLINK_DIR).tmp/$(GO_PROJECT_NAME) cmd/spinifex/main.go
@mkdir -p $(LAMBDA_TESTLINK_DIR)
mv $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so $(LAMBDA_TESTLINK_DIR).tmp/$(GO_PROJECT_NAME) $(LAMBDA_TESTLINK_DIR)/
@rmdir $(LAMBDA_TESTLINK_DIR).tmp
build-installer:
@echo -e "\n....Building spinifex-installer"
go build -ldflags "-s -w" -o ./bin/spinifex-installer cmd/installer/main.go
@@ -193,6 +226,24 @@ bin/lambda-agent: go.mod go.sum $$(call go_local_srcs,./cmd/lambda-agent)
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ ./cmd/lambda-agent
build-lambda-runtime-microvm-image: $(LAMBDA_RUNTIME_MICROVM_ARTIFACTS) ## Build the Lambda runtime microVM kernel and initramfs (not shipped)
GOWORK=off go run ./cmd/lambda-image-agent-tag $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/initramfs.cpio.gz absent
# Controlled-test only: a separately built guest whose agent has the
# lambdatestlink tag, in its own outputs, never touching bin/lambda-agent or the
# default image. It prints the digest to pass as LAMBDA_IMAGE_DIGEST.
LAMBDA_TESTLINK_IMAGE_DIR := $(MICROVM_OUT_DIR)/lambda-testlink-runtime
build-lambda-testlink-image: ## Build the test-link Lambda guest image and print its digest (not shipped)
@host=$$(uname -m); goos=$$(go env GOOS); goarch=$$(go env GOARCH); \
test "$$host" = x86_64 && test "$$goos/$$goarch" = linux/amd64 || { echo "the test link supports only an x86_64 host building linux/amd64, not $$host building $$goos/$$goarch"; exit 1; }
$(MAKE) --no-print-directory $(MICROVM_OUT_DIR)/qboot.rom
@mkdir -p bin $(LAMBDA_TESTLINK_IMAGE_DIR)
GOWORK=off GOFLAGS= CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -tags lambdatestlink -ldflags '-s -w' -o bin/lambda-agent-testlink ./cmd/lambda-agent
./scripts/build-lambda-runtime-microvm-image.sh bin/lambda-agent-testlink $(LAMBDA_TESTLINK_IMAGE_DIR)
GOWORK=off go run ./cmd/lambda-image-agent-tag $(LAMBDA_TESTLINK_IMAGE_DIR)/initramfs.cpio.gz present
install -m 0644 $(MICROVM_OUT_DIR)/qboot.rom $(LAMBDA_TESTLINK_IMAGE_DIR)/qboot.rom
@digest=$$(GOWORK=off go run -tags lambdatestlink ./cmd/lambda-image-digest $(LAMBDA_TESTLINK_IMAGE_DIR)) && \
[[ "$$digest" =~ $(LAMBDA_DIGEST_RE) ]] || { echo "image digest not computed"; exit 1; }; \
echo "LAMBDA_IMAGE_DIGEST=$$digest"
# qboot is the microvm firmware. The source tarball ships with each release
# because qboot is GPL-2.0.
@@ -240,27 +291,29 @@ preflight:
NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh scripts/lib/distro.sh scripts/lib/stage-sqs-store-microvm.sh
test-build-scripts:
@echo -e "\n....Running build/scripts/**/*_test.sh...."
@for t in $$(find build/scripts -name '*_test.sh' | sort); do \
echo "-- $$t"; \
sh "$$t" || exit 1; \
@$(_HDR) "\n....Running build/scripts/**/*_test.sh...."
@log=$$(mktemp); trap 'rm -f "$$log"' EXIT; for t in $$(find build/scripts -name '*_test.sh' | sort); do \
$(_RUNSH); \
done
@echo -e "\n....Running scripts/*_test.sh, scripts/lib/*_test.sh and scripts/appliance/*_test.sh...."
@for t in $$( (find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib scripts/appliance -name '*_test.sh') | sort); do \
echo "-- $$t"; \
sh "$$t" || exit 1; \
@$(_HDR) "\n....Running scripts/*_test.sh, scripts/lib/*_test.sh and scripts/appliance/*_test.sh...."
@log=$$(mktemp); trap 'rm -f "$$log"' EXIT; for t in $$( (find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib scripts/appliance -name '*_test.sh') | sort); do \
$(_RUNSH); \
done
@echo -e "\n....Running .github/scripts/*_test.sh...."
@for t in $$(find .github/scripts -maxdepth 1 -name '*_test.sh' | sort); do \
echo "-- $$t"; \
sh "$$t" || exit 1; \
@$(_HDR) "\n....Running scripts/terraform/latitude-spx/*_test.sh...."
@log=$$(mktemp); trap 'rm -f "$$log"' EXIT; for t in $$(find scripts/terraform/latitude-spx -maxdepth 1 -name '*_test.sh' | sort); do \
$(_RUNSH); \
done
shellcheck -S warning .github/scripts/hypervisor-disk-preflight.sh .github/scripts/hypervisor-disk-preflight_test.sh \
.github/scripts/wait-for-memory.sh .github/scripts/wait-for-memory_test.sh
@echo -e "\n....Running shellcheck over build/scripts/**/*.sh + the node scripts...."
shellcheck -S warning $$(find build/scripts -name '*.sh' | sort) $(NODE_SCRIPTS)
shellcheck -S warning -x scripts/build-oracle-appliance.sh $$(find scripts/appliance -name '*.sh' | sort)
@echo " test-build-scripts ok"
@$(_HDR) "\n....Running .github/scripts/*_test.sh...."
@log=$$(mktemp); trap 'rm -f "$$log"' EXIT; for t in $$(find .github/scripts -maxdepth 1 -name '*_test.sh' | sort); do \
$(_RUNSH); \
done
$(_Q)shellcheck -S warning .github/scripts/hypervisor-disk-preflight.sh \
.github/scripts/wait-for-memory.sh \
.github/scripts/mulga-design-check.sh
@$(_HDR) "\n....Running shellcheck over build/scripts/**/*.sh + the node scripts...."
$(_Q)shellcheck -S warning $$(find build/scripts -name '*.sh' | sort) $(NODE_SCRIPTS)
$(_Q)shellcheck -S warning -x scripts/build-oracle-appliance.sh $$(find scripts/appliance -name '*.sh' | sort)
@$(_HDR) " test-build-scripts ok"
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
# default `go test ./spinifex/...`. Runs with mocked AWS clients — no
@@ -327,7 +380,10 @@ test-cover:
test-race:
@echo -e "\n....Running tests with race detector for $(GO_PROJECT_NAME)...."
$(_Q)LOG_IGNORE=1 SPINIFEX_SQS_R3=1 go test $(GOTESTFLAGS) -race -timeout 300s ./spinifex/... ./cmd/... ./internal/... ./contracts/... ./architecture/... $(_RACEQ)
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -tags=lambdatestlink -timeout 120s ./cmd/lambda-agent/ ./spinifex/agents/lambda/hostlink/ $(_RACEQ)
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -tags=lambdatestlink -timeout 120s ./cmd/lambda-agent/ ./spinifex/agents/lambda/hostlink/ ./spinifex/runtime/compute/lambda/testlink/ ./spinifex/domains/network/host/ $(_RACEQ)
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -tags=lambdatestlink -timeout 120s -run '^TestLambdaTestLink(Executor|Reply)_' ./spinifex/runtime/roles/awsgw/ $(_RACEQ)
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -tags=lambdatestlink,lambdatestlinkseam -timeout 120s -run '^TestLambdaTestLinkSeam_' ./spinifex/runtime/roles/awsgw/ $(_RACEQ)
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -tags=lambdatestlink -timeout 120s -run '^TestLambdaTestLink' ./spinifex/runtime/daemon/ $(_RACEQ)
# Unit tests for in-repo GitHub Actions (e.g. .github/actions/e2e-analyze).
# Kept out of `test-cover` so coverage % isn't diluted by CI-only tooling.
@@ -538,7 +594,7 @@ distro-arm64:
distro-clean:
rm -rf dist/
.PHONY: test-package-check build build-ui test-ui-e2e ui-e2e-container ui-baselines build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image build-sqs-worker-microvm-image build-lambda-runtime-microvm-image install-microvm install-sqs-worker-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
.PHONY: test-package-check build build-lambda-testlink build-lambda-testlink-image build-ui test-ui-e2e ui-e2e-container ui-baselines build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image build-sqs-worker-microvm-image build-lambda-runtime-microvm-image install-microvm install-sqs-worker-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck nilaway \
+2 -2
View File
@@ -70,8 +70,8 @@ The platform serves **405 AWS API operations** across the services below. See ex
| **RDS** | Databases | PostgreSQL and MariaDB, with automated backups | Available |
| **Route 53** | DNS | [Northstar](https://github.com/mulgadc/northstar): authoritative DNS with hosted zones | Available |
| **Bedrock** | AI deployment | vLLM for text generation and Hugging Face TEI for embeddings and reranking | Q4 2026 |
| **Lambda** | Serverless functions | | Q1 2027 |
| **SQS** | Message queues | | Q1 2027 |
| **Lambda** | Serverless functions | QEMU `microvm` execution environments | Q1 2027 |
| **SQS** | Message queues | A dedicated NATS JetStream store in a per-Region QEMU `microvm` guest | Q1 2027 |
Roadmap services ship under the same AWS API surface, so code written for AWS today works the moment they land. [Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
-3
View File
@@ -15,9 +15,6 @@ var allowlist = []allowance{
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/igw", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/image", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/instance", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/key", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/launchtemplate", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/placementgroup", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/routetable", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/snapshot", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/volume", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
+6 -5
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
)
@@ -29,20 +30,20 @@ func defaultStartup() startup {
// connect loads the identity, seals fw_cfg whether or not the load succeeded,
// then dials the host. Under TLS 1.3 a host refusal is seen only on first read.
func (s startup) connect(ctx context.Context) (*tls.Conn, int, error) {
func (s startup) connect(ctx context.Context) (*tls.Conn, lambdav1.Scope, int, error) {
id, loadErr := hostlink.LoadIdentity(s.fwCfgDir)
if err := hostlink.SealFwCfg(s.fwCfgRoot, s.unload); err != nil {
return nil, exitRefused, errors.Join(loadErr, err)
return nil, "", exitRefused, errors.Join(loadErr, err)
}
if loadErr != nil {
return nil, exitRefused, loadErr
return nil, "", exitRefused, loadErr
}
conn, err := hostlink.Dial(ctx, id)
if err != nil {
if ctx.Err() != nil {
err = fmt.Errorf("%w: %w", err, context.Cause(ctx))
}
return nil, exitLink, err
return nil, "", exitLink, err
}
return conn, 0, nil
return conn, id.Scope, 0, nil
}
+66
View File
@@ -0,0 +1,66 @@
//go:build lambdatestlink
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
)
const entryGrant = "test-grant"
// testGrant is the grant the host launched this VM under.
type testGrant struct {
grantID, attemptID, environmentID string
generation uint64
}
// readGrant reads the test-grant fw_cfg entry: grant ID, attempt ID,
// environment ID and generation, one per line.
func readGrant(fwCfgDir string) (testGrant, error) {
b, err := os.ReadFile(filepath.Join(fwCfgDir, entryGrant, "raw"))
if err != nil {
return testGrant{}, fmt.Errorf("read %s: %w", entryGrant, err)
}
lines := strings.Split(strings.TrimSuffix(string(b), "\n"), "\n")
if len(lines) != 4 {
return testGrant{}, errors.New(entryGrant + " entry malformed")
}
g := testGrant{grantID: lines[0], attemptID: lines[1], environmentID: lines[2]}
g.generation, err = strconv.ParseUint(lines[3], 10, 64)
if err != nil || g.generation == 0 || g.grantID == "" || g.attemptID == "" || g.environmentID == "" {
return testGrant{}, errors.New(entryGrant + " entry malformed")
}
return g, nil
}
// checkBinding refuses a run unless the grant names the authenticated environment
// generation, the request carries that grant and its deadline is still ahead. The
// clock check is a local guard only: it cannot detect skew or a superseded grant.
func checkBinding(scope lambdav1.Scope, g testGrant, req hostlink.Request, now time.Time) error {
parts := strings.Split(string(scope), "/")
if len(parts) != 5 {
return errors.New("authenticated scope malformed")
}
if parts[3] != g.environmentID || parts[4] != strconv.FormatUint(g.generation, 10) {
return errors.New("grant is not for the authenticated environment generation")
}
if req.GrantID != g.grantID || req.AttemptID != g.attemptID {
return errors.New("request does not carry this VM's grant")
}
if req.DeadlineMs <= 0 {
return errors.New("request has no deadline")
}
if !now.Before(time.UnixMilli(req.DeadlineMs)) {
return errors.New("request deadline already passed")
}
return nil
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"github.com/stretchr/testify/require"
)
const scope lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/gen-1"
const scope lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/1"
// guest is a fake fw_cfg tree; unload removes it as unloading the module would.
type guest struct {
+286
View File
@@ -0,0 +1,286 @@
//go:build lambdatestlink
package main
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"unicode"
"golang.org/x/sys/unix"
)
// Dev-only code delivery for the test path: the host attaches the ZIP as a
// read-only disk and passes its exact size and SHA-256 through fw_cfg. This
// is not the production delivery, and these bounds are not AWS limits.
const (
entryPackageSize = "test-package-size"
entryPackageSHA256 = "test-package-sha256"
entryHandler = "test-handler"
devMaxZipBytes = 16 << 20
devMaxUnzipBytes = 64 << 20
devMaxEntries = 1024
packageDevice = "/dev/vda"
taskTmpfsSizeOpts = "mode=0755,size=72m"
)
type packageSpec struct {
size int64
sha256 [sha256.Size]byte
handler string
}
type unpackLimits struct {
entries int
bytes int64
}
var devLimits = unpackLimits{entries: devMaxEntries, bytes: devMaxUnzipBytes}
// readPackageSpec reads the expected size and SHA-256 from fw_cfg; it must
// run before fw_cfg is sealed.
func readPackageSpec(fwCfgDir string) (packageSpec, error) {
var spec packageSpec
read := func(name string) (string, error) {
b, err := os.ReadFile(filepath.Join(fwCfgDir, name, "raw"))
if err != nil {
return "", fmt.Errorf("read %s: %w", name, err)
}
return strings.TrimSpace(string(b)), nil
}
s, err := read(entryPackageSize)
if err != nil {
return spec, err
}
spec.size, err = strconv.ParseInt(s, 10, 64)
if err != nil || spec.size <= 0 || spec.size > devMaxZipBytes {
return spec, fmt.Errorf("package size %q outside 1..%d bytes", s, devMaxZipBytes)
}
h, err := read(entryPackageSHA256)
if err != nil {
return spec, err
}
b, err := hex.DecodeString(h)
if err != nil || len(b) != sha256.Size {
return spec, errors.New("package sha256 is not 64 hex digits")
}
copy(spec.sha256[:], b)
if spec.handler, err = read(entryHandler); err != nil {
return spec, err
}
if spec.handler == "" || len(spec.handler) > 128 || strings.IndexFunc(spec.handler, unicode.IsSpace) >= 0 ||
strings.ContainsRune(spec.handler, 0) {
return spec, errors.New("handler must be 1..128 characters with no whitespace")
}
return spec, nil
}
// taskPackage puts the package under dir: a fresh nosuid,nodev tmpfs, made
// read-only once the files are in place.
type taskPackage struct {
device string
dir string
loadDriver func() error
mountDir func(dir string) error
sealDir func(dir string) error
}
func devTaskPackage() taskPackage {
return taskPackage{device: packageDevice, dir: taskRoot, loadDriver: loadBlockDriver, mountDir: mountTaskDir, sealDir: sealTaskDir}
}
func (p taskPackage) install(spec packageSpec) error {
if err := p.loadDriver(); err != nil {
return fmt.Errorf("load block driver: %w", err)
}
dev, err := os.Open(p.device)
if err != nil {
return fmt.Errorf("open package disk: %w", err)
}
defer dev.Close()
if err := p.mountDir(p.dir); err != nil {
return fmt.Errorf("mount %s: %w", p.dir, err)
}
if err := unpack(dev, spec, p.dir, devLimits); err != nil {
return err
}
if err := p.sealDir(p.dir); err != nil {
return fmt.Errorf("make %s read-only: %w", p.dir, err)
}
return nil
}
// unpack reads exactly spec.size bytes once, checks their SHA-256, then
// validates every entry before writing any of them under dir.
func unpack(r io.ReaderAt, spec packageSpec, dir string, lim unpackLimits) error {
buf := make([]byte, spec.size)
if _, err := io.ReadFull(io.NewSectionReader(r, 0, spec.size), buf); err != nil {
return fmt.Errorf("read package: %w", err)
}
if sha256.Sum256(buf) != spec.sha256 {
return errors.New("package sha256 mismatch")
}
zr, err := zip.NewReader(bytes.NewReader(buf), spec.size)
if err != nil {
return fmt.Errorf("open package: %w", err)
}
if len(zr.File) > lim.entries {
return fmt.Errorf("package has %d entries, limit %d", len(zr.File), lim.entries)
}
names := make([]string, len(zr.File))
var declared int64
for i, f := range zr.File {
name, err := entryName(f.Name)
if err != nil {
return err
}
names[i] = name
switch mode := f.Mode(); {
case mode.IsDir():
case mode.IsRegular():
// Compared against what remains, so a huge size cannot wrap the sum.
if f.UncompressedSize64 > uint64(lim.bytes-declared) {
return fmt.Errorf("%q: unpacked size over the %d-byte limit", f.Name, lim.bytes)
}
declared += int64(f.UncompressedSize64)
default:
return fmt.Errorf("%q: %s entries are refused", f.Name, mode.Type())
}
}
root, err := os.OpenRoot(dir)
if err != nil {
return err
}
defer root.Close()
var written int64
for i, f := range zr.File {
n, err := extract(root, names[i], f, lim.bytes-written)
written += n
if err != nil {
return fmt.Errorf("%q: %w", f.Name, err)
}
}
return nil
}
// extract writes one entry, refusing it if its actual bytes exceed budget
// whatever its header claims.
func extract(root *os.Root, name string, f *zip.File, budget int64) (int64, error) {
if f.Mode().IsDir() {
return 0, mkdirs(root, name)
}
if d := path.Dir(name); d != "." {
if err := mkdirs(root, d); err != nil {
return 0, err
}
}
perm := os.FileMode(0o644)
if f.Mode()&0o111 != 0 {
perm = 0o755
}
in, err := f.Open()
if err != nil {
return 0, err
}
defer in.Close()
out, err := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm)
if err != nil {
return 0, err
}
// The umask applies at create; fchmod does not, so the mode is exact.
if err := out.Chmod(perm); err != nil {
out.Close()
return 0, err
}
n, err := io.Copy(out, io.LimitReader(in, budget+1))
if cerr := out.Close(); err == nil {
err = cerr
}
if err == nil && n > budget {
err = errors.New("unpacked bytes over the limit")
}
return n, err
}
// mkdirs creates each component of dir as mode 0755 whatever the umask. An
// existing component must be a real directory, never a symlink.
func mkdirs(root *os.Root, dir string) error {
cur := ""
for _, part := range strings.Split(dir, "/") {
cur = path.Join(cur, part)
if err := root.Mkdir(cur, 0o755); err != nil {
if !errors.Is(err, fs.ErrExist) {
return err
}
fi, err := root.Lstat(cur)
if err != nil {
return err
}
if !fi.IsDir() {
return fmt.Errorf("%q exists and is not a directory", cur)
}
}
if err := root.Chmod(cur, 0o755); err != nil {
return err
}
}
return nil
}
// entryName returns a clean relative path, refusing anything that could name
// a location outside the task root.
func entryName(name string) (string, error) {
if name == "" || strings.ContainsAny(name, "\\\x00") || path.IsAbs(name) {
return "", fmt.Errorf("%q: entry name refused", name)
}
c := path.Clean(name)
if c == "." || c == ".." || strings.HasPrefix(c, "../") {
return "", fmt.Errorf("%q: entry name refused", name)
}
return c, nil
}
// loadBlockDriver loads virtio_blk, which the image ships but init does not
// load, then waits briefly for the package disk to appear.
func loadBlockDriver() error {
if err := exec.Command("/sbin/modprobe", "virtio_blk").Run(); err != nil {
var uts unix.Utsname
if uerr := unix.Uname(&uts); uerr != nil {
return uerr
}
ko := "/lib/modules/" + unix.ByteSliceToString(uts.Release[:]) + "/kernel/drivers/block/virtio_blk.ko"
if err := exec.Command("/sbin/insmod", ko).Run(); err != nil {
return fmt.Errorf("modprobe and insmod virtio_blk failed: %w", err)
}
}
for range 50 {
if _, err := os.Stat(packageDevice); err == nil {
return nil
}
time.Sleep(20 * time.Millisecond)
}
return fmt.Errorf("%s did not appear", packageDevice)
}
func mountTaskDir(dir string) error {
return unix.Mount("tmpfs", dir, "tmpfs", unix.MS_NOSUID|unix.MS_NODEV, taskTmpfsSizeOpts)
}
func sealTaskDir(dir string) error {
return unix.Mount("", dir, "", unix.MS_REMOUNT|unix.MS_RDONLY|unix.MS_NOSUID|unix.MS_NODEV, "")
}
+215
View File
@@ -0,0 +1,215 @@
//go:build lambdatestlink
package main
import (
"archive/zip"
"bytes"
"crypto/sha256"
"hash/crc32"
"io/fs"
"math"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testZip builds a ZIP; an entry named bootstrap is marked executable.
func testZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
names := make([]string, 0, len(files))
for n := range files {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
h := &zip.FileHeader{Name: n, Method: zip.Deflate}
h.SetMode(0o644)
if n == "bootstrap" {
h.SetMode(0o755)
}
w, err := zw.CreateHeader(h)
require.NoError(t, err)
_, err = w.Write([]byte(files[n]))
require.NoError(t, err)
}
require.NoError(t, zw.Close())
return buf.Bytes()
}
// rawZip writes entries with headers set by the caller, so a test can claim
// sizes or modes the data does not have.
func rawZip(t *testing.T, entries ...*zip.FileHeader) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, h := range entries {
w, err := zw.CreateRaw(h)
require.NoError(t, err)
_, err = w.Write([]byte("x"))
require.NoError(t, err)
}
require.NoError(t, zw.Close())
return buf.Bytes()
}
func specFor(b []byte) packageSpec {
return packageSpec{size: int64(len(b)), sha256: sha256.Sum256(b)}
}
// Not parallel: the umask is process-wide, so the test sets a restrictive one
// and restores it.
func TestUnpackInstallsFilesWithFixedModes(t *testing.T) {
old := syscall.Umask(0o077)
t.Cleanup(func() { syscall.Umask(old) })
b := testZip(t, map[string]string{"bootstrap": "#!/bin/sh\n", "lib/data.txt": "data"})
dir := t.TempDir()
require.NoError(t, unpack(bytes.NewReader(b), specFor(b), dir, devLimits))
pfi, err := os.Stat(filepath.Join(dir, "lib"))
require.NoError(t, err)
assert.Equal(t, fs.FileMode(0o755), pfi.Mode().Perm(), "implicit parent dir")
fi, err := os.Stat(filepath.Join(dir, "bootstrap"))
require.NoError(t, err)
assert.Equal(t, fs.FileMode(0o755), fi.Mode().Perm(), "executable")
fi, err = os.Stat(filepath.Join(dir, "lib", "data.txt"))
require.NoError(t, err)
assert.Equal(t, fs.FileMode(0o644), fi.Mode().Perm())
got, err := os.ReadFile(filepath.Join(dir, "lib", "data.txt"))
require.NoError(t, err)
assert.Equal(t, "data", string(got))
}
func TestUnpackRefusesBeforeWritingAnything(t *testing.T) {
symlink := &zip.FileHeader{Name: "link", Method: zip.Store, CRC32: crc32.ChecksumIEEE([]byte("x")), CompressedSize64: 1, UncompressedSize64: 1}
symlink.SetMode(fs.ModeSymlink | 0o777)
fifo := &zip.FileHeader{Name: "pipe", Method: zip.Store, CRC32: crc32.ChecksumIEEE([]byte("x")), CompressedSize64: 1, UncompressedSize64: 1}
fifo.SetMode(fs.ModeNamedPipe | 0o644)
// The second size would wrap a plain running sum back under the limit.
big := func(name string, n uint64) *zip.FileHeader {
return &zip.FileHeader{Name: name, Method: zip.Store, CRC32: crc32.ChecksumIEEE([]byte("x")), CompressedSize64: 1, UncompressedSize64: n}
}
cases := map[string]struct {
zip []byte
want string
}{
"parent traversal": {testZip(t, map[string]string{"a": "ok", "../escape": "x"}), "entry name refused"},
"nested traversal": {testZip(t, map[string]string{"a": "ok", "lib/../../escape": "x"}), "entry name refused"},
"absolute path": {testZip(t, map[string]string{"a": "ok", "/etc/escape": "x"}), "entry name refused"},
"backslash": {testZip(t, map[string]string{"a": "ok", `..\escape`: "x"}), "entry name refused"},
"symlink": {rawZip(t, big("a", 1), symlink), "entries are refused"},
"named pipe": {rawZip(t, big("a", 1), fifo), "entries are refused"},
"declared size": {rawZip(t, big("a", devMaxUnzipBytes+1)), "over the"},
"wrapping sizes": {rawZip(t, big("a", devMaxUnzipBytes), big("b", math.MaxUint64)), "over the"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
err := unpack(bytes.NewReader(tc.zip), specFor(tc.zip), dir, devLimits)
assert.ErrorContains(t, err, tc.want)
left, rerr := os.ReadDir(dir)
require.NoError(t, rerr)
assert.Empty(t, left, "files written before the refusal")
})
}
}
func TestUnpackRefusesTooManyEntries(t *testing.T) {
b := testZip(t, map[string]string{"a": "1", "b": "2", "c": "3"})
err := unpack(bytes.NewReader(b), specFor(b), t.TempDir(), unpackLimits{entries: 2, bytes: devMaxUnzipBytes})
assert.ErrorContains(t, err, "limit 2")
}
// The header claims a size within the limit, but the data is longer: the
// actual bytes are refused, whatever the header says.
func TestUnpackRefusesBytesBeyondTheLimit(t *testing.T) {
data := bytes.Repeat([]byte("a"), 64)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
h := &zip.FileHeader{Name: "a", Method: zip.Store, CRC32: crc32.ChecksumIEEE(data), CompressedSize64: uint64(len(data)), UncompressedSize64: 8}
w, err := zw.CreateRaw(h)
require.NoError(t, err)
_, err = w.Write(data)
require.NoError(t, err)
require.NoError(t, zw.Close())
b := buf.Bytes()
dir := t.TempDir()
err = unpack(bytes.NewReader(b), specFor(b), dir, unpackLimits{entries: 4, bytes: 16})
require.Error(t, err)
fi, serr := os.Stat(filepath.Join(dir, "a"))
if serr == nil {
assert.LessOrEqual(t, fi.Size(), int64(17))
}
}
func TestUnpackChecksSizeAndHash(t *testing.T) {
b := testZip(t, map[string]string{"bootstrap": "x"})
wrongHash := specFor(b)
wrongHash.sha256[0] ^= 1
assert.ErrorContains(t, unpack(bytes.NewReader(b), wrongHash, t.TempDir(), devLimits), "sha256 mismatch")
longer := specFor(b)
longer.size++
assert.ErrorContains(t, unpack(bytes.NewReader(b), longer, t.TempDir(), devLimits), "read package")
}
func TestReadPackageSpecBounds(t *testing.T) {
sum := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
cases := map[string]struct {
size, hash, handler string
want string
}{
"valid": {"10", sum, "bootstrap.h", ""},
"zero": {"0", sum, "bootstrap.h", "outside"},
"too large": {"16777217", sum, "bootstrap.h", "outside"},
"not hex": {"10", "zz", "bootstrap.h", "64 hex digits"},
"short": {"10", sum[:62], "bootstrap.h", "64 hex digits"},
"empty handler": {"10", sum, "", "handler"},
"handler space": {"10", sum, "a b", "handler"},
"128-char handler": {"10", sum, strings.Repeat("h", 128), ""},
"129-char handler": {"10", sum, strings.Repeat("h", 129), "handler"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
for n, v := range map[string]string{entryPackageSize: tc.size, entryPackageSHA256: tc.hash, entryHandler: tc.handler} {
require.NoError(t, os.MkdirAll(filepath.Join(dir, n), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(dir, n, "raw"), []byte(v), 0o600))
}
spec, err := readPackageSpec(dir)
if tc.want == "" {
require.NoError(t, err)
assert.Equal(t, int64(10), spec.size)
assert.Equal(t, tc.handler, spec.handler)
return
}
assert.ErrorContains(t, err, tc.want)
})
}
}
// A pre-existing symlink where a directory belongs is refused, not followed
// or chmodded.
func TestMkdirsRefusesSymlinkComponent(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
require.NoError(t, os.Chmod(outside, 0o700))
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "lib")))
root, err := os.OpenRoot(dir)
require.NoError(t, err)
defer root.Close()
assert.ErrorContains(t, mkdirs(root, "lib/sub"), "not a directory")
fi, err := os.Stat(outside)
require.NoError(t, err)
assert.Equal(t, fs.FileMode(0o700), fi.Mode().Perm(), "symlink target was chmodded")
}
+1 -1
View File
@@ -25,7 +25,7 @@ func start(ctx context.Context) int {
// run authenticates to the host and holds the connection until it closes.
func (s startup) run(ctx context.Context) (int, error) {
conn, code, err := s.connect(ctx)
conn, _, code, err := s.connect(ctx)
if err != nil {
return code, err
}
+64 -14
View File
@@ -5,8 +5,8 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net"
"net/http"
@@ -24,12 +24,11 @@ const (
taskRoot = "/var/task"
)
var handler = flag.String("handler", os.Getenv("_HANDLER"), "function handler passed to the bootstrap")
// testlink relays one invocation over the test carrier on the authenticated
// connection; the carrier is not the guest protocol.
type testlink struct {
startup
pkg taskPackage
runtimeAddr string
startBootstrap func(ctx context.Context, env []string) (*exec.Cmd, error)
}
@@ -37,6 +36,7 @@ type testlink struct {
func start(ctx context.Context) int {
t := testlink{
startup: defaultStartup(),
pkg: devTaskPackage(),
runtimeAddr: runtimeAddr,
startBootstrap: func(ctx context.Context, env []string) (*exec.Cmd, error) {
return agent.StartBootstrap(ctx, taskRoot+"/bootstrap", env, os.Stdout, os.Stderr)
@@ -50,11 +50,17 @@ func start(ctx context.Context) int {
}
func (t testlink) run(parent context.Context) (int, error) {
conn, code, err := t.connect(parent)
// The package values are fw_cfg entries, so read them before connect seals it.
spec, specErr := readPackageSpec(t.fwCfgDir)
grant, grantErr := readGrant(t.fwCfgDir)
conn, scope, code, err := t.connect(parent)
if err != nil {
return code, err
return code, errors.Join(err, specErr, grantErr)
}
defer conn.Close()
if err := errors.Join(specErr, grantErr); err != nil {
return exitRefused, err
}
link := hostlink.NewTestStream(conn)
// The handshake can return before the host refuses the guest, so nothing
@@ -69,6 +75,13 @@ func (t testlink) run(parent context.Context) (int, error) {
if parent.Err() != nil {
return exitLink, context.Cause(parent)
}
// Nothing of the tenant's is installed or run for an unbound request.
if err := checkBinding(scope, grant, first, time.Now()); err != nil {
return exitRefused, err
}
if err := t.pkg.install(spec); err != nil {
return exitRefused, fmt.Errorf("install package: %w", err)
}
srv := runtimeapi.New()
ln, err := net.Listen("tcp", t.runtimeAddr)
@@ -83,7 +96,7 @@ func (t testlink) run(parent context.Context) (int, error) {
defer cancel(nil)
cmd, err := t.startBootstrap(ctx, []string{
"AWS_LAMBDA_RUNTIME_API=" + ln.Addr().String(),
"_HANDLER=" + *handler,
"_HANDLER=" + spec.handler,
"LAMBDA_TASK_ROOT=" + taskRoot,
"PATH=/bin:/sbin:/usr/bin",
})
@@ -94,7 +107,11 @@ func (t testlink) run(parent context.Context) (int, error) {
go func() {
defer close(exited)
err := cmd.Wait()
cancel(fmt.Errorf("bootstrap exited: %w", err))
// Once a response is being received the result stands, so only an
// exit before that cancels the invocation.
if srv.BootstrapExited() {
cancel(fmt.Errorf("bootstrap exited: %w", err))
}
}()
err = agent.Run(ctx, &oneInvocation{Link: link, first: &first}, srv)
@@ -106,11 +123,18 @@ func (t testlink) run(parent context.Context) (int, error) {
return exitClosed, nil
}
// shutdownGrace is how long past the invocation deadline the agent waits for
// the host's shutdown frame once its result is written.
const shutdownGrace = 2 * time.Second
// oneInvocation serves the host's first request and then stops the agent:
// each test VM runs a single invocation.
// each test VM runs a single invocation. After the result is written it waits
// for the host's shutdown frame or EOF, so the result is not cut off by the
// poweroff that follows the agent's exit.
type oneInvocation struct {
hostlink.Link
first *hostlink.Request
first *hostlink.Request
deadlineMs int64
}
var _ hostlink.Link = (*oneInvocation)(nil)
@@ -119,10 +143,36 @@ func (o *oneInvocation) Next(ctx context.Context) (hostlink.Request, error) {
if err := ctx.Err(); err != nil {
return hostlink.Request{}, err
}
if o.first == nil {
return hostlink.Request{}, hostlink.ErrShutdown
if o.first != nil {
req := *o.first
o.first = nil
o.deadlineMs = req.DeadlineMs
return req, nil
}
req := *o.first
o.first = nil
return req, nil
return hostlink.Request{}, o.awaitShutdown(ctx)
}
// awaitShutdown reads the host's next frame and always ends the agent: with
// ErrShutdown for a shutdown frame, EOF, a stray frame or an expired wait.
func (o *oneInvocation) awaitShutdown(ctx context.Context) error {
end := time.UnixMilli(o.deadlineMs)
if now := time.Now(); end.Before(now) {
end = now
}
wctx, cancel := context.WithDeadline(ctx, end.Add(shutdownGrace))
defer cancel()
_, err := o.Link.Next(wctx)
switch {
case errors.Is(err, hostlink.ErrShutdown), errors.Is(err, io.EOF):
return hostlink.ErrShutdown
case err == nil:
slog.Warn("lambda-agent: frame after the invocation was not dispatched")
return hostlink.ErrShutdown
case ctx.Err() != nil:
return err
case wctx.Err() != nil:
slog.Warn("lambda-agent: no shutdown from the host; exiting")
return hostlink.ErrShutdown
}
return err
}
+281 -7
View File
@@ -5,10 +5,14 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"testing"
"time"
@@ -21,12 +25,18 @@ import (
const helperEnv = "LAMBDA_AGENT_TEST_BOOTSTRAP"
// helperHandlerOut names the file the helper bootstrap writes its _HANDLER to.
const helperHandlerOut = "LAMBDA_AGENT_TEST_HANDLER_OUT"
// TestHelperBootstrap is the fake function bootstrap, run as a subprocess: it
// echoes one invocation through the Runtime API, then waits for the next.
func TestHelperBootstrap(t *testing.T) {
if os.Getenv(helperEnv) != "1" {
return
}
if out := os.Getenv(helperHandlerOut); out != "" {
require.NoError(t, os.WriteFile(out, []byte(os.Getenv("_HANDLER")), 0o600))
}
base := "http://" + os.Getenv("AWS_LAMBDA_RUNTIME_API") + "/2018-06-01/runtime/invocation/"
resp, err := http.Get(base + "next")
require.NoError(t, err)
@@ -37,24 +47,64 @@ func TestHelperBootstrap(t *testing.T) {
post, err := http.Post(base+id+"/response", "application/json", bytes.NewReader(body))
require.NoError(t, err)
post.Body.Close()
if os.Getenv(helperExitAfterPost) == "1" {
return
}
_, _ = http.Get(base + "next")
}
// helperExitAfterPost makes the helper bootstrap exit once its response is posted.
const helperExitAfterPost = "LAMBDA_AGENT_TEST_EXIT_AFTER_POST"
// testGrantEntry grants g-1 for attempt a-1 on the fixture scope's env-1/1.
const testGrantEntry = "g-1\na-1\nenv-1\n1\n"
// testHandler is the handler the fixture's host configures.
const testHandler = "bootstrap.fixture"
type bootstrapStarts struct {
n int
started chan struct{}
task string
sealed bool
// handlerOut receives the _HANDLER the bootstrap process itself saw.
handlerOut string
}
func (b *bootstrapStarts) testlink(g *guest) testlink {
// testlink builds a guest whose fw_cfg also carries a controlled package,
// installed into a temp dir without mounting anything.
func (b *bootstrapStarts) testlink(t *testing.T, entries map[string][]byte) testlink {
t.Helper()
zipBytes := testZip(t, map[string]string{"bootstrap": "#!/bin/sh\n", "lib/data.txt": "data"})
disk := filepath.Join(t.TempDir(), "disk")
require.NoError(t, os.WriteFile(disk, zipBytes, 0o600))
sum := sha256.Sum256(zipBytes)
entries[entryPackageSize] = []byte(strconv.Itoa(len(zipBytes)))
entries[entryPackageSHA256] = []byte(hex.EncodeToString(sum[:]))
if _, ok := entries[entryHandler]; !ok {
entries[entryHandler] = []byte(testHandler)
}
if _, ok := entries[entryGrant]; !ok {
entries[entryGrant] = []byte(testGrantEntry)
}
g := newGuest(t, entries)
b.started = make(chan struct{}, 1)
b.task = t.TempDir()
b.handlerOut = filepath.Join(t.TempDir(), "handler")
return testlink{
startup: g.startup(),
startup: g.startup(),
pkg: taskPackage{
device: disk, dir: b.task,
loadDriver: func() error { return nil },
mountDir: func(string) error { return nil },
sealDir: func(string) error { b.sealed = true; return nil },
},
runtimeAddr: "127.0.0.1:0",
startBootstrap: func(ctx context.Context, env []string) (*exec.Cmd, error) {
b.n++
b.started <- struct{}{}
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestHelperBootstrap$")
cmd.Env = append(env, helperEnv+"=1")
cmd.Env = append(env, helperEnv+"=1", helperHandlerOut+"="+b.handlerOut)
return cmd, cmd.Start()
},
}
@@ -80,7 +130,7 @@ func TestTestlinkServesOneInvocationThenStops(t *testing.T) {
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
done := runTestlinkAsync(ctx, b.testlink(newGuest(t, h.entries())))
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
conn := h.guest(t)
defer conn.Close()
@@ -109,15 +159,112 @@ func TestTestlinkServesOneInvocationThenStops(t *testing.T) {
assert.Error(t, err, "agent answered a second invocation")
}
assert.Equal(t, 1, b.n)
seen, err := os.ReadFile(b.handlerOut)
require.NoError(t, err, "bootstrap process did not report its handler")
assert.Equal(t, testHandler, string(seen), "bootstrap process not given the configured handler")
assert.True(t, b.sealed, "task dir not made read-only")
assert.FileExists(t, filepath.Join(b.task, "lib", "data.txt"))
}
// A bootstrap that posts its response and exits at once does not cost the
// host the result; the agent then waits for the host's shutdown.
func TestTestlinkBootstrapExitingAfterResponseStillDelivers(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
tl := b.testlink(t, h.entries())
start := tl.startBootstrap
tl.startBootstrap = func(ctx context.Context, env []string) (*exec.Cmd, error) {
return start(ctx, append(env, helperExitAfterPost+"=1"))
}
done := runTestlinkAsync(ctx, tl)
conn := h.guest(t)
defer conn.Close()
hs := hostlink.NewTestStream(conn)
payload := bytes.Repeat([]byte("0123456789"), 300_000)
req := hostlink.Request{GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: payload}
require.NoError(t, hs.HostSend(req))
got, err := hs.HostResult(ctx)
require.NoError(t, err)
assert.Equal(t, hostlink.Response{GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", Payload: payload}, got)
require.NoError(t, hs.HostShutdown())
res := wait(t, done, "agent to stop after the host's shutdown")
assert.Equal(t, exitClosed, res.code)
assert.NoError(t, res.err)
}
// The agent stays up for the host's shutdown rather than exiting as soon as
// its result is written, and never dispatches a stray frame.
func TestOneInvocationAfterResult(t *testing.T) {
first := hostlink.Request{RequestID: "r-1", DeadlineMs: time.Now().Add(time.Minute).UnixMilli()}
cases := map[string]struct {
next func(context.Context) (hostlink.Request, error)
want error
}{
"shutdown frame": {func(context.Context) (hostlink.Request, error) { return hostlink.Request{}, hostlink.ErrShutdown }, hostlink.ErrShutdown},
"eof": {func(context.Context) (hostlink.Request, error) { return hostlink.Request{}, io.EOF }, hostlink.ErrShutdown},
"a second request is not dispatched": {func(context.Context) (hostlink.Request, error) {
return hostlink.Request{RequestID: "r-2"}, nil
}, hostlink.ErrShutdown},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
o := &oneInvocation{Link: stubLink{tc.next}, first: &first}
_, err := o.Next(context.Background())
require.NoError(t, err)
req, err := o.Next(context.Background())
require.ErrorIs(t, err, tc.want)
assert.Empty(t, req.RequestID)
})
}
t.Run("host never sends shutdown", func(t *testing.T) {
past := first
past.DeadlineMs = time.Now().Add(-time.Second).UnixMilli()
o := &oneInvocation{Link: stubLink{func(ctx context.Context) (hostlink.Request, error) {
<-ctx.Done()
return hostlink.Request{}, ctx.Err()
}}, first: &past}
_, err := o.Next(context.Background())
require.NoError(t, err)
start := time.Now()
_, err = o.Next(context.Background())
require.ErrorIs(t, err, hostlink.ErrShutdown)
assert.GreaterOrEqual(t, time.Since(start), shutdownGrace-100*time.Millisecond, "waited the grace")
})
t.Run("parent cancel is not a shutdown", func(t *testing.T) {
o := &oneInvocation{Link: stubLink{func(ctx context.Context) (hostlink.Request, error) {
<-ctx.Done()
return hostlink.Request{}, ctx.Err()
}}, first: &first}
_, _ = o.Next(context.Background())
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := o.Next(ctx)
require.ErrorIs(t, err, context.Canceled)
})
}
type stubLink struct {
next func(context.Context) (hostlink.Request, error)
}
var _ hostlink.Link = stubLink{}
func (l stubLink) Next(ctx context.Context) (hostlink.Request, error) { return l.next(ctx) }
func (stubLink) Complete(context.Context, hostlink.Response) error { return nil }
func TestTestlinkShutdownBeforeInvocationNeverStartsBootstrap(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
done := runTestlinkAsync(ctx, b.testlink(newGuest(t, h.entries())))
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
conn := h.guest(t)
defer conn.Close()
@@ -142,7 +289,7 @@ func TestTestlinkHostRefusalNeverStartsBootstrap(t *testing.T) {
entries[lambdav1.EntryGuestKey], entries[lambdav1.EntryGuestCert] = wrong[lambdav1.EntryGuestKey], wrong[lambdav1.EntryGuestCert]
var b bootstrapStarts
res := wait(t, runTestlinkAsync(ctx, b.testlink(newGuest(t, entries))), "agent to stop")
res := wait(t, runTestlinkAsync(ctx, b.testlink(t, entries)), "agent to stop")
assert.Equal(t, exitLink, res.code)
require.Error(t, res.err)
assert.Contains(t, res.err.Error(), "bad certificate")
@@ -155,7 +302,7 @@ func TestTestlinkHostCloseBeforeFrameNeverStartsBootstrap(t *testing.T) {
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
done := runTestlinkAsync(ctx, b.testlink(newGuest(t, h.entries())))
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
require.NoError(t, h.guest(t).Close())
res := wait(t, done, "agent to stop")
@@ -177,3 +324,130 @@ func TestOneInvocationHonoursCancelledContext(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "r-1", req.RequestID)
}
// A package that fails its check is refused after authentication, and the
// bootstrap never starts.
func TestTestlinkBadPackageNeverStartsBootstrap(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
tl := b.testlink(t, h.entries())
tl.pkg.device = filepath.Join(t.TempDir(), "other")
require.NoError(t, os.WriteFile(tl.pkg.device, testZip(t, map[string]string{"bootstrap": "x"}), 0o600))
done := runTestlinkAsync(ctx, tl)
conn := h.guest(t)
defer conn.Close()
require.NoError(t, hostlink.NewTestStream(conn).HostSend(hostlink.Request{GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1",
DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli()}))
res := wait(t, done, "agent to stop")
assert.Equal(t, exitRefused, res.code)
assert.ErrorContains(t, res.err, "install package")
assert.Zero(t, b.n)
assert.False(t, b.sealed)
}
// Missing package values still seal fw_cfg, and the agent refuses before
// waiting for any frame.
func TestTestlinkMissingPackageSpecRefuses(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
tl := b.testlink(t, h.entries())
require.NoError(t, os.RemoveAll(filepath.Join(tl.fwCfgDir, entryPackageSHA256)))
res := wait(t, runTestlinkAsync(ctx, tl), "agent to stop")
assert.Equal(t, exitRefused, res.code)
assert.ErrorContains(t, res.err, "read "+entryPackageSHA256)
assert.Zero(t, b.n)
_, err := os.Stat(tl.fwCfgRoot)
assert.ErrorIs(t, err, os.ErrNotExist, "fw_cfg not sealed")
}
// A request that does not carry this VM's grant, or a grant for another
// environment generation than the one authenticated, installs and runs
// nothing of the tenant's.
func TestTestlinkUnboundRequestNeverStartsBootstrap(t *testing.T) {
cases := map[string]struct {
grant string
grantID, attempt string
deadlineMs int64
want string
}{
"frame with no deadline": {testGrantEntry, "g-1", "a-1", -1, "no deadline"},
"frame with expired deadline": {testGrantEntry, "g-1", "a-1", time.Now().Add(-time.Second).UnixMilli(), "deadline already passed"},
"frame for another grant": {testGrantEntry, "g-2", "a-1", 0, "does not carry this VM's grant"},
"frame for another attempt": {testGrantEntry, "g-1", "a-2", 0, "does not carry this VM's grant"},
"frame with no grant": {testGrantEntry, "", "", 0, "does not carry this VM's grant"},
"grant for another env": {"g-1\na-1\nenv-2\n1\n", "g-1", "a-1", 0, "not for the authenticated environment"},
"grant for another gen": {"g-1\na-1\nenv-1\n2\n", "g-1", "a-1", 0, "not for the authenticated environment"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
entries := h.entries()
entries[entryGrant] = []byte(tc.grant)
done := runTestlinkAsync(ctx, b.testlink(t, entries))
conn := h.guest(t)
defer conn.Close()
deadline := time.Now().Add(5 * time.Second).UnixMilli()
switch {
case tc.deadlineMs < 0:
deadline = 0
case tc.deadlineMs > 0:
deadline = tc.deadlineMs
}
req := hostlink.Request{GrantID: tc.grantID, AttemptID: tc.attempt, RequestID: "r-1",
DeadlineMs: deadline, Payload: []byte(`{}`)}
require.NoError(t, hostlink.NewTestStream(conn).HostSend(req))
res := wait(t, done, "agent to stop")
assert.Equal(t, exitRefused, res.code)
assert.ErrorContains(t, res.err, tc.want)
assert.Zero(t, b.n)
assert.False(t, b.sealed)
assert.NoFileExists(t, filepath.Join(b.task, "bootstrap"), "package installed")
})
}
}
// A missing or malformed grant entry refuses before any frame is awaited.
func TestTestlinkBadGrantEntryRefuses(t *testing.T) {
cases := map[string]*string{
"missing": nil,
"too few lines": ptr("g-1\na-1\nenv-1\n"),
"generation 0": ptr("g-1\na-1\nenv-1\n0\n"),
"empty grant": ptr("\na-1\nenv-1\n1\n"),
}
for name, grant := range cases {
t.Run(name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
entries := h.entries()
if grant != nil {
entries[entryGrant] = []byte(*grant)
}
tl := b.testlink(t, entries)
if grant == nil {
require.NoError(t, os.RemoveAll(filepath.Join(tl.fwCfgDir, entryGrant)))
}
res := wait(t, runTestlinkAsync(ctx, tl), "agent to stop")
assert.Equal(t, exitRefused, res.code)
assert.ErrorContains(t, res.err, entryGrant)
assert.Zero(t, b.n)
})
}
}
func ptr(s string) *string { return &s }
+221
View File
@@ -0,0 +1,221 @@
// Command lambda-image-agent-tag checks whether the lambda-agent archived in a
// built Lambda guest initramfs carries the lambdatestlink build tag. Build
// information is recorded by the build; it is not proof of how the agent behaves.
package main
import (
"bufio"
"bytes"
"compress/gzip"
"debug/buildinfo"
"errors"
"fmt"
"io"
"os"
"slices"
"strconv"
"strings"
)
const (
agentPath = "usr/local/bin/lambda-agent"
maxArchive = 1 << 30
maxAgent = 256 << 20
maxName = 4096
newcHeader = 110
trailerName = "TRAILER!!!"
modeTypeMask = 0o170000
modeDir = 0o040000
modeRegular = 0o100000
)
var agentParents = []string{"usr", "usr/local", "usr/local/bin"}
func main() {
if len(os.Args) != 3 || (os.Args[2] != "present" && os.Args[2] != "absent") {
fmt.Fprintln(os.Stderr, "usage: lambda-image-agent-tag <initramfs.cpio.gz> present|absent")
os.Exit(2)
}
if err := run(os.Args[1], os.Args[2] == "present"); err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", os.Args[1], err)
os.Exit(1)
}
}
func run(path string, want bool) error {
fi, err := os.Lstat(path)
if err != nil {
return err
}
if !fi.Mode().IsRegular() {
return errors.New("not a regular file")
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
agent, err := archivedAgent(f)
if err != nil {
return err
}
return checkTag(agent, want)
}
// archivedAgent returns the single agent entry after checking listed names,
// entry types, the agent's link count and zero-only padding after the trailer.
// It is not a full kernel-equivalent parser; 070702 CRCs, for one, are unchecked.
func archivedAgent(r io.Reader) ([]byte, error) {
zr, err := gzip.NewReader(bufio.NewReader(r))
if err != nil {
return nil, fmt.Errorf("not a readable gzip archive: %w", err)
}
raw, err := io.ReadAll(io.LimitReader(zr, maxArchive+1))
if err != nil {
return nil, fmt.Errorf("not a readable gzip archive: %w", err)
}
if len(raw) > maxArchive {
return nil, fmt.Errorf("archive larger than %d bytes", maxArchive)
}
seen := map[string]bool{}
var agent []byte
off := 0
for {
e, next, err := parseEntry(raw, off)
if err != nil {
return nil, fmt.Errorf("entry at offset %d: %w", off, err)
}
off = next
if e.name == trailerName {
break
}
if !canonical(e.name) {
return nil, fmt.Errorf("entry name %q is not canonical", e.name)
}
if seen[e.name] {
return nil, fmt.Errorf("entry %q appears more than once", e.name)
}
seen[e.name] = true
for _, p := range agentParents {
if e.name == p && e.mode&modeTypeMask != modeDir {
return nil, fmt.Errorf("%s is not a directory", p)
}
}
if e.name == agentPath {
for _, p := range agentParents {
if !seen[p] {
return nil, fmt.Errorf("%s has no %s directory entry before it", agentPath, p)
}
}
if e.mode&modeTypeMask != modeRegular {
return nil, fmt.Errorf("%s is not a regular file", agentPath)
}
if e.nlink != 1 {
return nil, fmt.Errorf("%s has %d links, want 1", agentPath, e.nlink)
}
if len(e.data) > maxAgent {
return nil, fmt.Errorf("%s is larger than %d bytes", agentPath, maxAgent)
}
agent = e.data
}
}
if i := bytes.IndexFunc(raw[off:], func(r rune) bool { return r != 0 }); i >= 0 {
return nil, fmt.Errorf("data after the archive trailer at offset %d", off+i)
}
if agent == nil {
return nil, fmt.Errorf("no %s entry", agentPath)
}
return agent, nil
}
type entry struct {
name string
mode uint64
nlink uint64
data []byte
}
func parseEntry(raw []byte, off int) (entry, int, error) {
if len(raw)-off < newcHeader {
return entry{}, 0, errors.New("truncated header")
}
h := raw[off : off+newcHeader]
if m := string(h[:6]); m != "070701" && m != "070702" {
return entry{}, 0, fmt.Errorf("magic %q is not newc", m)
}
field := func(i int) (uint64, error) {
return strconv.ParseUint(string(h[6+8*i:14+8*i]), 16, 32)
}
mode, err1 := field(1)
nlink, err2 := field(4)
size, err3 := field(6)
nameSize, err4 := field(11)
if err := errors.Join(err1, err2, err3, err4); err != nil {
return entry{}, 0, fmt.Errorf("bad header field: %w", err)
}
if nameSize < 2 || nameSize > maxName {
return entry{}, 0, fmt.Errorf("name size %d out of range", nameSize)
}
nameEnd := off + newcHeader + int(nameSize)
dataOff := align4(nameEnd)
dataEnd := dataOff + int(size)
if dataEnd > len(raw) || align4(dataEnd) > len(raw) {
return entry{}, 0, errors.New("truncated entry")
}
name := raw[off+newcHeader : nameEnd]
if name[len(name)-1] != 0 || bytes.IndexByte(name[:len(name)-1], 0) >= 0 {
return entry{}, 0, errors.New("name is not one NUL-terminated string")
}
return entry{name: string(name[:len(name)-1]), mode: mode, nlink: nlink, data: raw[dataOff:dataEnd]}, align4(dataEnd), nil
}
func align4(n int) int { return (n + 3) &^ 3 }
// canonical accepts "." and relative names with no empty, "." or ".." component.
func canonical(name string) bool {
if name == "." {
return true
}
if name == "" || strings.HasPrefix(name, "/") {
return false
}
for c := range strings.SplitSeq(name, "/") {
if c == "" || c == "." || c == ".." {
return false
}
}
return true
}
func checkTag(agent []byte, want bool) error {
info, err := buildinfo.Read(bytes.NewReader(agent))
if err != nil {
return fmt.Errorf("no Go build information in %s: %w", agentPath, err)
}
return decide(info, want)
}
// decide refuses build information with no recorded settings, since their
// absence does not show the tag was absent.
func decide(info *buildinfo.BuildInfo, want bool) error {
if len(info.Settings) == 0 {
return fmt.Errorf("%s records no build settings", agentPath)
}
if hasTag(info, "lambdatestlink") != want {
return fmt.Errorf("%s: lambdatestlink build tag present=%v, want %v", agentPath, !want, want)
}
return nil
}
func hasTag(info *buildinfo.BuildInfo, tag string) bool {
for _, s := range info.Settings {
if s.Key != "-tags" {
continue
}
if slices.Contains(strings.Split(s.Value, ","), tag) {
return true
}
}
return false
}
+154
View File
@@ -0,0 +1,154 @@
package main
import (
"bytes"
"compress/gzip"
"debug/buildinfo"
"fmt"
"os"
"path/filepath"
"runtime/debug"
"testing"
"github.com/stretchr/testify/require"
)
type fileEntry struct {
name string
mode uint32
nlink uint32
data string
}
func dir(name string) fileEntry { return fileEntry{name: name, mode: modeDir | 0o755, nlink: 2} }
func file(name, data string) fileEntry {
return fileEntry{name: name, mode: modeRegular | 0o755, nlink: 1, data: data}
}
func symlink(name, target string) fileEntry {
return fileEntry{name: name, mode: 0o120777, nlink: 1, data: target}
}
// newc writes entries and a trailer in the format the kernel unpacks.
func newc(entries ...fileEntry) []byte {
var b bytes.Buffer
for i, e := range append(entries, fileEntry{name: trailerName, nlink: 1}) {
fmt.Fprintf(&b, "070701%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X",
i+1, e.mode, 0, 0, e.nlink, 0, len(e.data), 0, 0, 0, 0, len(e.name)+1, 0)
b.WriteString(e.name)
b.WriteByte(0)
pad(&b)
b.WriteString(e.data)
pad(&b)
}
return b.Bytes()
}
func pad(b *bytes.Buffer) {
for b.Len()%4 != 0 {
b.WriteByte(0)
}
}
func gz(parts ...[]byte) []byte {
var b bytes.Buffer
for _, p := range parts {
w := gzip.NewWriter(&b)
_, _ = w.Write(p)
_ = w.Close()
}
return b.Bytes()
}
func withAgent(agent fileEntry) []byte {
return newc(dir("."), dir("usr"), dir("usr/local"), dir("usr/local/bin"), agent, file("init", "#!/bin/sh"))
}
func TestArchivedAgent_ReturnsTheOneArchivedAgent(t *testing.T) {
got, err := archivedAgent(bytes.NewReader(gz(withAgent(file(agentPath, "agent-bytes")))))
require.NoError(t, err)
require.Equal(t, "agent-bytes", string(got))
padded := append(withAgent(file(agentPath, "agent-bytes")), make([]byte, 1024)...)
got, err = archivedAgent(bytes.NewReader(gz(padded)))
require.NoError(t, err, "zero padding after the trailer")
require.Equal(t, "agent-bytes", string(got))
}
func TestArchivedAgent_Refuses(t *testing.T) {
parents := []fileEntry{dir("."), dir("usr"), dir("usr/local"), dir("usr/local/bin")}
agent := file(agentPath, "untagged")
small := newc(append(parents, agent)...)
second := newc(append(parents[1:], file(agentPath, "tagged"))...)
// The second archive starts inside what would be the first one's final block.
sameBlock := append(append([]byte{}, small...), second...)
for len(sameBlock)%512 != 0 {
sameBlock = append(sameBlock, 0)
}
for name, archive := range map[string][]byte{
"aliased duplicate": gz(newc(append(parents, agent, file("././"+agentPath, "tagged"))...)),
"leading slash alias": gz(newc(append(parents, agent, file("/"+agentPath, "tagged"))...)),
"dot-dot component": gz(newc(append(parents, agent, file("usr/local/../local/bin/lambda-agent", "x"))...)),
"empty component": gz(newc(append(parents, agent, file("usr//local/bin/lambda-agent", "x"))...)),
"exact duplicate": gz(newc(append(parents, agent, file(agentPath, "tagged"))...)),
"duplicate directory": gz(newc(dir("usr"), dir("usr"), dir("usr/local"), dir("usr/local/bin"), agent)),
"missing directory entry": gz(newc(dir("usr"), dir("usr/local/bin"), agent)),
"directory after the agent": gz(newc(dir("usr"), dir("usr/local"), agent, dir("usr/local/bin"))),
"parent is a symlink": gz(newc(dir("usr"), dir("usr/local"), symlink("usr/local/bin", "elsewhere"), agent)),
"agent is a symlink": gz(newc(append(parents, symlink(agentPath, "/bin/sh"))...)),
"hard-linked agent": gz(newc(append(parents, fileEntry{name: agentPath, mode: modeRegular | 0o755, nlink: 2, data: "x"})...)),
"no agent": gz(newc(parents...)),
"second archive appended": gz(small, second),
"second archive in same block": gz(sameBlock),
"non-zero byte after trailer": gz(append(append([]byte{}, small...), 0, 0, 1)),
"truncated archive": gz(small[:len(small)-20]),
"not newc": gz([]byte("not cpio at all, but long enough to fill one newc header: " + string(make([]byte, 120)))),
"not gzip": small,
} {
t.Run(name, func(t *testing.T) {
_, err := archivedAgent(bytes.NewReader(archive))
require.Error(t, err)
})
}
}
func TestRun_RefusesASymlinkedImagePath(t *testing.T) {
d := t.TempDir()
img := filepath.Join(d, "initramfs.cpio.gz")
require.NoError(t, os.WriteFile(img, gz(withAgent(file(agentPath, "x"))), 0o644))
require.NoError(t, os.Symlink(img, filepath.Join(d, "link")))
require.ErrorContains(t, run(filepath.Join(d, "link"), false), "not a regular file")
}
func TestHasTag_MatchesWholeTagsOnly(t *testing.T) {
info := func(tags string) *buildinfo.BuildInfo {
return &buildinfo.BuildInfo{Settings: []debug.BuildSetting{{Key: "-buildmode", Value: "exe"}, {Key: "-tags", Value: tags}}}
}
require.True(t, hasTag(info("lambdatestlink,fips140v1.0"), "lambdatestlink"))
require.False(t, hasTag(info("fips140v1.0"), "lambdatestlink"))
require.False(t, hasTag(info("lambdatestlinkx,xlambdatestlink"), "lambdatestlink"))
require.False(t, hasTag(&buildinfo.BuildInfo{}, "lambdatestlink"))
}
// The test binary carries real build information, so checkTag reads it and
// refuses the opposite answer; bytes with no build information are refused.
func TestCheckTag_ReadsRealBuildInformation(t *testing.T) {
exe, err := os.Executable()
require.NoError(t, err)
raw, err := os.ReadFile(exe)
require.NoError(t, err)
info, err := buildinfo.Read(bytes.NewReader(raw))
require.NoError(t, err)
tagged := hasTag(info, "lambdatestlink")
require.NoError(t, checkTag(raw, tagged))
require.Error(t, checkTag(raw, !tagged))
require.Error(t, checkTag([]byte("not a Go binary"), false))
}
func TestDecide_RefusesMissingBuildSettings(t *testing.T) {
require.ErrorContains(t, decide(&buildinfo.BuildInfo{}, false), "no build settings")
require.ErrorContains(t, decide(&buildinfo.BuildInfo{}, true), "no build settings")
untagged := &buildinfo.BuildInfo{Settings: []debug.BuildSetting{{Key: "-buildmode", Value: "exe"}}}
require.NoError(t, decide(untagged, false))
require.Error(t, decide(untagged, true))
}
+26
View File
@@ -0,0 +1,26 @@
//go:build lambdatestlink
// Command lambda-image-digest prints the test-link runtime image digest of one
// directory, selecting the boot files the same way the daemon does. The digest
// identifies the bytes; it does not authenticate where they came from.
package main
import (
"fmt"
"os"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/testlink"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: lambda-image-digest <image-dir>")
os.Exit(2)
}
digest, err := testlink.DirDigest(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(digest)
}
+38
View File
@@ -56,6 +56,8 @@ type NodeFacts struct {
Memory MemoryFacts `json:"memory"`
NICs []NICFacts `json:"nics"`
Planes []PlaneFacts `json:"planes"`
Disks []DiskFacts `json:"disks,omitempty"`
Filesystems []FilesystemFacts `json:"filesystems,omitempty"`
Sources map[string]string `json:"sources"`
}
@@ -134,6 +136,42 @@ type MemoryGroup struct {
Serials []string `json:"serials,omitempty"`
}
// DiskFacts is one physical drive as sysfs reports it. Transport is nvme, sata,
// usb or virtio, and empty when the device path names none, as a RAID
// controller's virtual disk does.
type DiskFacts struct {
Name string `json:"name"`
Path string `json:"path"`
SizeBytes uint64 `json:"size_bytes"`
Model string `json:"model,omitempty"`
Transport string `json:"transport,omitempty"`
Rotational bool `json:"rotational,omitempty"`
Removable bool `json:"removable,omitempty"`
}
// FilesystemFacts is one mounted block-device or ZFS filesystem. BindOf names
// the directory a bind mount shows; Disk names the drives under it. Error says
// why Usage is missing or stale.
type FilesystemFacts struct {
Mount string `json:"mount"`
Source string `json:"source"`
Disk string `json:"disk,omitempty"`
Type string `json:"type"`
BindOf string `json:"bind_of,omitempty"`
Usage *FilesystemUsage `json:"usage,omitempty"`
Error string `json:"error,omitempty"`
// ID identifies the mount on its node, so a filesystem replaced at the
// same path is read afresh. Empty for a mount the daemon cannot see.
ID string `json:"-"`
}
// FilesystemUsage is a filesystem's last statfs reading, as df counts it.
type FilesystemUsage struct {
SizeBytes uint64 `json:"size_bytes"`
UsedBytes uint64 `json:"used_bytes"`
AvailBytes uint64 `json:"avail_bytes"`
}
// NICFacts is one physical network interface and, where LLDP reports it, the
// switch port it is cabled to.
type NICFacts struct {
+8 -1
View File
@@ -23,7 +23,7 @@ subject shape are wire compatibility. Callers and receivers must use this
package rather than rebuild those details. `commands_test.go` preserves the
version-1 JSON shape and subject form.
This package owns shared command payloads and the drain-volume response. It
This package owns shared command payloads. It
does not define every action's reply body, authorize the request, store EC2
state or implement NATS transport. Those remain responsibilities of the EC2
domain and its runtime composition.
@@ -37,6 +37,13 @@ contract or an approved compatibility plan; it is not a package refactor.
Only the subject is owned here today.
The request (`account_id`) and reply (`vpc_id`, `error`) bodies are still declared as anonymous structs on each side; moving them into this package is a contract change that needs its own characterization first.
## Volume held elsewhere
A start refused because another node holds one of the instance's volumes is an `IncorrectState` error payload on the `ec2.start`, `ec2.start.<node>` and `ec2.cmd.<instance-id>` replies, and the AWS body cannot say why.
The owning node adds the `X-Volume-Held-Elsewhere: true` header to that reply, the `ec2.start` forwarder relays it, and `StartInstances` wraps `ErrVolumeHeldElsewhere` around the error it rebuilds, so a caller matches it with `errors.Is` rather than reading the message.
The owning node sets the header only from the EBS provider's `volume_held_elsewhere` code, which every provider returns for this refusal; no reader matches the message text.
`VolumeHeldElsewhereText` is the wording viperblockd puts in that refusal's message, not a signal.
## Platform inventory
`PlatformInventorySubject` (`ec2.v1.PlatformInventory`) is a request/reply route on which a daemon in the `spinifex-workers` queue group reports one account's key pairs, placement groups, launch templates and their `ec2-tags` entries from the EC2 owner stores.
-29
View File
@@ -29,7 +29,6 @@ type EC2InstanceCommand struct {
Attributes EC2CommandAttributes `json:"attributes"`
AttachVolumeData *AttachVolumeData `json:"attach_volume_data,omitempty"`
DetachVolumeData *DetachVolumeData `json:"detach_volume_data,omitempty"`
DrainVolumeData *DrainVolumeData `json:"drain_volume_data,omitempty"`
AttachENIData *AttachENIData `json:"attach_eni_data,omitempty"`
DetachENIData *DetachENIData `json:"detach_eni_data,omitempty"`
IamProfileAssociationData *IamProfileAssociationData `json:"iam_profile_association_data,omitempty"`
@@ -45,7 +44,6 @@ type EC2CommandAttributes struct {
StartInstance bool `json:"start_instance"`
AttachVolume bool `json:"attach_volume"`
DetachVolume bool `json:"detach_volume"`
DrainVolume bool `json:"drain_volume,omitempty"`
RebootInstance bool `json:"reboot_instance"`
AttachENI bool `json:"attach_eni"`
DetachENI bool `json:"detach_eni"`
@@ -69,33 +67,6 @@ type DetachVolumeData struct {
Force bool `json:"force,omitempty"`
}
// DrainVolumeData carries parameters for a drain-volume command, which flushes
// the volume's in-flight writes to S3 before a snapshot reads them. The command
// is addressed to the instance the volume is attached to because the drain
// socket only exists on the node hosting it.
type DrainVolumeData struct {
VolumeID string `json:"volume_id"`
}
const (
// DrainVolumeStatusDrained is the ack a node returns once the volume's
// in-flight writes have reached S3.
DrainVolumeStatusDrained = "drained"
// DrainVolumeStatusNotRunning is the ack a node returns when it still holds
// the instance but the VM is not running, so no process is writing to the
// volume and its sealed checkpoint is already current. An attachment record
// outlives the writer — stop deliberately leaves boot volumes attached — so
// the caller cannot tell this from the record alone.
DrainVolumeStatusNotRunning = "not-running"
)
// DrainVolumeResponse is the reply to a drain-volume command.
type DrainVolumeResponse struct {
VolumeID string `json:"volume_id"`
Status string `json:"status"`
}
// AttachENIData carries parameters for an attach-network-interface command.
type AttachENIData struct {
NetworkInterfaceID string `json:"network_interface_id"`
-14
View File
@@ -16,7 +16,6 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
StartInstance: true,
AttachVolume: true,
DetachVolume: true,
DrainVolume: true,
RebootInstance: true,
AttachENI: true,
DetachENI: true,
@@ -28,7 +27,6 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
},
AttachVolumeData: &AttachVolumeData{VolumeID: "vol-1", Device: "/dev/sdf"},
DetachVolumeData: &DetachVolumeData{VolumeID: "vol-2", Device: "/dev/sdg", Force: true},
DrainVolumeData: &DrainVolumeData{VolumeID: "vol-3"},
AttachENIData: &AttachENIData{NetworkInterfaceID: "eni-1", DeviceIndex: 2},
DetachENIData: &DetachENIData{AttachmentID: "eni-attach-1", Force: true},
IamProfileAssociationData: &IamProfileAssociationData{InstanceProfileArn: "arn:aws:iam::123456789012:instance-profile/example"},
@@ -47,7 +45,6 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
"start_instance": true,
"attach_volume": true,
"detach_volume": true,
"drain_volume": true,
"reboot_instance": true,
"attach_eni": true,
"detach_eni": true,
@@ -59,7 +56,6 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
},
"attach_volume_data": {"volume_id": "vol-1", "device": "/dev/sdf"},
"detach_volume_data": {"volume_id": "vol-2", "device": "/dev/sdg", "force": true},
"drain_volume_data": {"volume_id": "vol-3"},
"attach_eni_data": {"network_interface_id": "eni-1", "device_index": 2},
"detach_eni_data": {"attachment_id": "eni-attach-1", "force": true},
"iam_profile_association_data": {"instance_profile_arn": "arn:aws:iam::123456789012:instance-profile/example"},
@@ -69,16 +65,6 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
}`, string(data))
}
func TestDrainVolumeResponseJSONContract(t *testing.T) {
data, err := json.Marshal(DrainVolumeResponse{
VolumeID: "vol-1",
Status: DrainVolumeStatusDrained,
})
require.NoError(t, err)
require.JSONEq(t, `{"volume_id":"vol-1","status":"drained"}`, string(data))
require.Equal(t, "not-running", DrainVolumeStatusNotRunning)
}
func TestInstanceCommandSubjects(t *testing.T) {
require.Equal(t, "ec2.cmd.i-0123456789abcdef0", InstanceCommandSubject("i-0123456789abcdef0"))
require.Equal(t, "ec2.cmd.*", InstanceCommandSubjectWildcard)
+10 -4
View File
@@ -1,7 +1,13 @@
package ec2v1
// VolumeHeldElsewhereText is the text of a start refused because another node
// holds one of the instance's volumes. It crosses NATS as message text, so
// readers match on it; the provider that refuses and every reader share this
// string so they cannot drift.
import "errors"
// VolumeHeldElsewhereText is the message viperblockd gives a lease refusal. It
// is wording for the operator; callers classify the refusal by its code.
const VolumeHeldElsewhereText = "volume is leased by another owner"
// ErrVolumeHeldElsewhere marks a start refused because another node still
// holds one of the instance's volumes. The owning node sets it from the EBS
// provider's held-elsewhere code, and a start reply carries it across NATS as
// the X-Volume-Held-Elsewhere header rather than as message text.
var ErrVolumeHeldElsewhere = errors.New("a volume is leased by another node")
+74 -2
View File
@@ -25,6 +25,10 @@ const (
DeletionStateRunning = "RUNNING"
DeletionStateCompleted = "COMPLETED"
DeletionStateFailed = "FAILED"
// DeletionStateExercised ends a non-finalizing run: the account is left
// TERMINATING and a later DeleteAccount takes the same operation over.
DeletionStateExercised = "EXERCISED"
)
// CreateAccountRequest is the /admin/CreateAccount request body.
@@ -52,6 +56,10 @@ type DeleteAccountRequest struct {
ClientToken string `json:"clientToken"`
Force bool `json:"force,omitempty"`
DryRun bool `json:"dryRun,omitempty"`
// NonFinalizing quiesces the account, cleans the quiesced EC2 platform
// kinds and stops. No legacy stage runs and the account record stays.
NonFinalizing bool `json:"nonFinalizing,omitempty"`
}
// DescribeAccountDeletionRequest is the /admin/DescribeAccountDeletion body.
@@ -76,8 +84,8 @@ type ListAccountsResponse struct {
}
// IAMQuiescence is the coordinator's recorded view of IAM quiescence for one
// fence. Recording only: it admits nothing, and QuiescedScope stays empty until
// a final inventory re-read exists to catch a late write.
// fence. Recording only: it admits nothing. QuiescedScope is filled only by a run
// that guarantees a final inventory re-read, and holds as of EvaluatedAt.
type IAMQuiescence struct {
OperationID string `json:"operationId"`
Fence uint64 `json:"fence"`
@@ -136,3 +144,67 @@ const (
ReasonOwnerInventoryIncomplete = "owner-inventory-incomplete"
ReasonFinalInventoryRereadNotWired = "final-inventory-reread-not-wired"
)
// ExerciseResult is what a non-finalizing run did after IAM quiescence.
type ExerciseResult struct {
// Skipped is the fixed reason no cleanup ran; Cleanup is then absent.
Skipped string `json:"skipped,omitempty"`
Cleanup *PlatformCleanup `json:"cleanup,omitempty"`
Accounting []KindAccounting `json:"accounting,omitempty"`
}
// PlatformCleanup is the EC2 platform cleanup result. It carries resource IDs,
// fixed reasons and counts only, never names, tags, key material or data.
type PlatformCleanup struct {
Kinds []CleanupKind `json:"kinds"`
Items []CleanupItem `json:"items,omitempty"`
Counts []CleanupCount `json:"counts,omitempty"`
}
// CleanupKind is one kind's scan status after the cleanup's own re-scan.
type CleanupKind struct {
Kind string `json:"kind"`
Status string `json:"status"`
IncompleteReasons []string `json:"incompleteReasons,omitempty"`
}
// CleanupItem is one resource by its ID with what the cleanup did to it.
type CleanupItem struct {
ResourceID string `json:"resourceId"`
Kind string `json:"kind"`
Disposition string `json:"disposition"`
Reason string `json:"reason,omitempty"`
MemberCount int `json:"memberCount,omitempty"`
}
// CleanupCount is the number of records of one class that have no public ID.
type CleanupCount struct {
Kind string `json:"kind"`
Class string `json:"class"`
Disposition string `json:"disposition"`
Reason string `json:"reason,omitempty"`
Count int `json:"count"`
}
// KindAccounting is the final inventory re-read held against the cleanup result
// for one kind. Quiescence is not rewritten from it.
type KindAccounting struct {
Kind string `json:"kind"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Remaining int `json:"remaining"`
}
// Accounting statuses and fixed reasons of a KindAccounting.
const (
AccountingAccounted = "accounted"
AccountingUnaccounted = "unaccounted"
AccountingReasonRereadIncomplete = "reread-incomplete"
AccountingReasonUnnamedRemainder = "unnamed-remainder"
)
// Fixed reasons an exercise ran no cleanup.
const (
ExerciseSkippedNoQuiescedKind = "no-quiesced-kind"
)
+21
View File
@@ -43,6 +43,27 @@ func TestAdminWire_DeletionStates(t *testing.T) {
require.Equal(t, "RUNNING", DeletionStateRunning)
require.Equal(t, "COMPLETED", DeletionStateCompleted)
require.Equal(t, "FAILED", DeletionStateFailed)
require.Equal(t, "EXERCISED", DeletionStateExercised)
}
func TestAdminWire_NonFinalizingDeleteRequest(t *testing.T) {
goldenJSON(t, DeleteAccountRequest{AccountID: "000000000042", AccountName: "acme", ClientToken: "tok-3", NonFinalizing: true},
`{"accountId":"000000000042","accountName":"acme","clientToken":"tok-3","nonFinalizing":true}`)
}
func TestAdminWire_ExerciseResult(t *testing.T) {
goldenJSON(t, ExerciseResult{
Cleanup: &PlatformCleanup{
Kinds: []CleanupKind{{Kind: "key-pair", Status: "observed-clean-at-scan-time"}},
Items: []CleanupItem{{ResourceID: "pg-1", Kind: "placement-group", Disposition: "blocked", Reason: "instance-members", MemberCount: 1}},
Counts: []CleanupCount{{Kind: "key-pair", Class: "public-key-object-without-metadata", Disposition: "deleted", Count: 2}},
},
Accounting: []KindAccounting{{Kind: "placement-group", Status: AccountingAccounted, Remaining: 1}},
}, `{"cleanup":{"kinds":[{"kind":"key-pair","status":"observed-clean-at-scan-time"}],
"items":[{"resourceId":"pg-1","kind":"placement-group","disposition":"blocked","reason":"instance-members","memberCount":1}],
"counts":[{"kind":"key-pair","class":"public-key-object-without-metadata","disposition":"deleted","count":2}]},
"accounting":[{"kind":"placement-group","status":"accounted","remaining":1}]}`)
goldenJSON(t, ExerciseResult{Skipped: ExerciseSkippedNoQuiescedKind}, `{"skipped":"no-quiesced-kind"}`)
}
func TestAdminWire_ListAccountsResponse(t *testing.T) {
-13
View File
@@ -51,19 +51,6 @@ request/reply replies.
removed its local state and export. These routes and JSON shapes are deployed
legacy compatibility behaviour, not a generic EBS-provider interface.
## Fence route
`VolumeFencedSubject(node)` is `ebs.<node>.fenced`, or `ebs.fenced` with no
node for a single-node daemon. The Viperblock adapter publishes
`VolumeFencedEvent` on it after tearing down an export whose volume lease
moved to another node; the local daemon subscribes to stop the guest that
was using it. Node-addressed, like mount and unmount: the guest is local, and
no other node's daemon has anything to stop.
The event is sent as a request and repeated until the daemon replies with `VolumeFencedAck`, because the fence often runs while the node's NATS server is restarting and a single publish can reach no subscriber.
The daemon replies only when the event carries a reply subject, so a plain publish is still accepted.
Repeats are harmless, since fencing a guest that is already fenced changes nothing.
## Abandon route
`VolumeAbandonSubject(node)` is `ebs.<node>.abandon`. The daemon uses
@@ -9,9 +9,8 @@ import (
"github.com/stretchr/testify/require"
)
// TestVolumeAbandonSubject_BothForms pins the abandon route the same way the
// fenced route is pinned: node-addressed, with an empty-node fallback for a
// single-node daemon.
// TestVolumeAbandonSubject_BothForms pins the abandon route: node-addressed,
// with an empty-node fallback for a single-node daemon.
func TestVolumeAbandonSubject_BothForms(t *testing.T) {
assert.Equal(t, "ebs.node-a.abandon", viperblocklegacyv1.VolumeAbandonSubject("node-a"))
assert.Equal(t, "ebs.abandon", viperblocklegacyv1.VolumeAbandonSubject(""))
-29
View File
@@ -1,29 +0,0 @@
package viperblocklegacyv1
// VolumeFencedSubject is where a fenced volume is announced so the daemon on
// this node can stop the guest that was using it. Node-addressed: the guest is
// local, and every other node's daemon has nothing to do about it.
func VolumeFencedSubject(node string) string {
if node == "" {
return "ebs.fenced"
}
return "ebs." + node + ".fenced"
}
// VolumeFencedEvent announces that a node tore down a volume's export because
// the lease moved. Winner is who holds it now, for the operator reading the
// guest's stop reason.
type VolumeFencedEvent struct {
Volume string `json:"volume"`
Node string `json:"node"`
Winner string `json:"winner"`
Reason string `json:"reason"`
}
// VolumeFencedAck is the daemon's answer to a VolumeFencedEvent. Any answer
// ends the sender's retries; Instance names the guest stopped, if one was, and
// Error says why the daemon could not act on the event.
type VolumeFencedAck struct {
Instance string `json:"instance,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -1,54 +0,0 @@
package viperblocklegacyv1_test
import (
"encoding/json"
"testing"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestVolumeFencedSubject_IsNodeAddressed pins the routing. A fenced guest is on
// the node that lost the volume, so a subject without the node in it would reach
// daemons with nothing to stop and, on a queue group, miss the one that has.
func TestVolumeFencedSubject_IsNodeAddressed(t *testing.T) {
assert.Equal(t, "ebs.node-a.fenced", viperblocklegacyv1.VolumeFencedSubject("node-a"))
assert.Equal(t, "ebs.fenced", viperblocklegacyv1.VolumeFencedSubject(""),
"a single-node daemon has no node name, and still has to hear its own fences")
}
// TestVolumeFencedEvent_JSON pins the exact field spelling of the fenced
// event, published by viperblockd and consumed by the daemon.
func TestVolumeFencedEvent_JSON(t *testing.T) {
golden := `{"volume":"vol-1","node":"node-a","winner":"node-b","reason":"volume lease moved to node-b while this node had the volume open"}`
event := viperblocklegacyv1.VolumeFencedEvent{
Volume: "vol-1",
Node: "node-a",
Winner: "node-b",
Reason: "volume lease moved to node-b while this node had the volume open",
}
marshaled, err := json.Marshal(event)
require.NoError(t, err)
assert.JSONEq(t, golden, string(marshaled))
var decoded viperblocklegacyv1.VolumeFencedEvent
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
assert.Equal(t, event, decoded)
}
// TestVolumeFencedAck_JSON pins the daemon's answer, which is what ends
// viperblockd's retries of a fence notice.
func TestVolumeFencedAck_JSON(t *testing.T) {
golden := `{"instance":"i-1","error":"bad event"}`
ack := viperblocklegacyv1.VolumeFencedAck{Instance: "i-1", Error: "bad event"}
marshaled, err := json.Marshal(ack)
require.NoError(t, err)
assert.JSONEq(t, golden, string(marshaled))
empty, err := json.Marshal(viperblocklegacyv1.VolumeFencedAck{})
require.NoError(t, err)
assert.JSONEq(t, `{}`, string(empty), "an ack for a volume with no local guest carries nothing")
}
+2 -2
View File
@@ -95,7 +95,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--advertise` (external IP off-host clients dial; auto-detected from the default route), `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--predastore-compaction-interval` (seconds; test clusters set a short one), `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--discard-jetstream` (with `--nodes` >= 2, remove this node's existing JetStream streams once every node has joined; without it a multi-node init over a store that holds streams is refused, and it never proceeds while NATS is running), `--skip-host-dns`, `--email` (operator address for update and security notices), `--no-telemetry`, `--gpu-passthrough`, `--ipsec` (default true; encrypt intra-AZ Geneve via OVN native IPsec cluster-wide — disable only for a trusted single-rack lab; on a host whose IPsec stack is libreswan it defaults to false and `--ipsec=true` is refused), `--host` (leader to join; falls back to multicast discovery), `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode), `--external-iface` (WAN NIC for br-external, auto-detected), `--external-source` (`dhcp` \| `static`), `--external-pool` (`start-end`), `--external-prefix-len` (default 24), `--external-gateway` (auto-detected), `--external-dns` (comma-separated IPv4 addresses, at most 3; the DNS servers written to the external pool's `dns_servers` and handed to guests, verbatim; when omitted they are detected from the host resolver at init time, so set it to give every node of a cluster the same value), `--external-bind-bridge` (default `br-wan` when `--external-source=dhcp`), `--gateway-ip` (OVN gateway router's external IP for SNAT; defaults to the pool's range start, required for `nat` mode without DHCP) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` → points the host resolver at this node's local northstar so the Spinifex zones (ELB/EKS names) resolve from the node itself (opt out with `--skip-host-dns`). `--force` on an already-initialized node is idempotent for crypto: it preserves the existing keys (master.key, predastore/viperblock encryption keys), system + admin credentials, and CA, and refreshes only the config files and the CA-signed server certificate (so a changed bind IP / SANs is picked up without breaking already-joined nodes or CA-baked AMIs). A genuine clean slate requires removing the data dirs first. |
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--advertise` (external IP off-host clients dial; auto-detected from the default route), `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--predastore-compaction-interval` (seconds; test clusters set a short one), `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--discard-jetstream` (with `--nodes` >= 2, remove this node's existing JetStream streams once every node has joined; without it a multi-node init over a store that holds streams is refused, and it never proceeds while NATS is running), `--skip-host-dns`, `--email` (operator address for update and security notices), `--no-telemetry`, `--gpu-passthrough`, `--ipsec` (default true; encrypt intra-AZ Geneve via OVN native IPsec cluster-wide — disable only for a trusted single-rack lab; on a host whose IPsec stack is libreswan it defaults to false and `--ipsec=true` is refused), `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode), `--external-iface` (WAN NIC for br-external, auto-detected), `--external-source` (`dhcp` \| `static`), `--external-pool` (`start-end`), `--external-prefix-len` (default 24), `--external-gateway` (auto-detected), `--external-dns` (comma-separated IPv4 addresses, at most 3; the DNS servers written to the external pool's `dns_servers` and handed to guests, verbatim; when omitted they are detected from the host resolver at init time, so set it to give every node of a cluster the same value), `--external-bind-bridge` (default `br-wan` when `--external-source=dhcp`), `--gateway-ip` (OVN gateway router's external IP for SNAT; defaults to the pool's range start, required for `nat` mode without DHCP) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` → points the host resolver at this node's local northstar so the Spinifex zones (ELB/EKS names) resolve from the node itself (opt out with `--skip-host-dns`). `--force` on an already-initialized node is idempotent for crypto: it preserves the existing keys (master.key, predastore/viperblock encryption keys), system + admin credentials, and CA, and refreshes only the config files and the CA-signed server certificate (so a changed bind IP / SANs is picked up without breaking already-joined nodes or CA-baked AMIs). A genuine clean slate requires removing the data dirs first. |
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--advertise` (defaults to `--bind`, or the auto-detected WAN IP when `--bind` is 0.0.0.0), `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services`, `--skip-host-dns`, `--email`, `--no-telemetry`, `--predastore-compaction-interval`, `--join-timeout` (how long to keep retrying while the formation server is unreachable, default 20m), `--force` (join even though this node is already initialized or holds JetStream streams, **discarding its own CA, master key and JetStream store**; the store is removed only after formation succeeds, and the join is refused while NATS is running even with `--force`) | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations → points the host resolver at this node's local northstar so the Spinifex zones resolve from the node itself (opt out with `--skip-host-dns`) |
### Version
@@ -135,7 +135,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin volumes orphans` | — | Lists volumes the storage provider holds that the control plane has no record of. Their blocks consume space but they have no API handle, so they can be neither described nor deleted through the EC2 API. **Reports only, never deletes**: the evidence that an orphan is still wanted is the record that went missing, so removal is an operator decision taken per volume. |
| `spx admin volumes orphans` | — | Lists volumes the storage provider holds that the control plane has no record of. Their blocks consume space but they have no API handle, so they can be neither described nor deleted through the EC2 API. **Reports only, never deletes**: the evidence that an orphan is still wanted is the record that went missing, so removal is an operator decision taken per volume. It also lists UEFI variable stores nothing owns: a `uefi/i-*/` prefix with no instance record and no store record, a `uefi/ami-*/` prefix with no AMI document, and a store record that is not `deleting` and has no instance record. These are reported and never deleted, because an absent instance record is not proof of a terminate. |
| `spx admin volumes unsealed` | — | Lists volumes a node has open, or had open, without a confirmed seal to the backend — the node named holds writes predastore may not have. Starting such a volume elsewhere is refused while that node is alive (its daemon heartbeat is under 60 seconds old, or cannot be read) and is otherwise allowed, in which case it opens from the last checkpoint that did reach the backend. This is the list of volumes where that would cost something. |
| `spx admin storage status` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Sends the signed `GetStorageStatus` action to the AWS gateway with credentials from the standard AWS chain (e.g. `AWS_PROFILE=spinifex`) and prints what the console's Service Metrics page shows: predastore encoding, meta and blob node counts, cluster health, a meta node table (health, raft state, leader, term, commit and applied index) and the blob nodes. Cluster health is `Degraded` when any meta node is unreachable or the nodes reporting raft state do not agree on one leader. A meta node read only through its host's `/readyz` shows healthy with blank raft fields. |
+7 -6
View File
@@ -65,9 +65,10 @@ The EC2 registration (`spinifex/domains/ec2/awsapi`, `NewRegistration`) parses t
The gateway communicates with daemons via NATS request/response. Most calls go through `natsmsg.NATSRequest`, which marshals the input, attaches the account ID as a NATS header, and unmarshals the typed response:
```go
func (s *NATSInstanceService) RunInstances(ctx context.Context, input *ec2.RunInstancesInput, accountID string) (*ec2.Reservation, error) {
topic := fmt.Sprintf("ec2.RunInstances.%s", aws.StringValue(input.InstanceType))
return natsmsg.NATSRequest[ec2.Reservation](ctx, s.natsConn, topic, input, 5*time.Minute, accountID)
func runIntoReservation(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, accountID, crID string) (*ec2.Reservation, error) {
subject := "ec2.RunInstances.cr." + crID
reservation, err := natsmsg.NATSRequest[ec2.Reservation](ctx, natsConn, subject, input, 5*time.Minute, accountID)
// ...
}
```
@@ -111,14 +112,14 @@ When a daemon handles `RunInstances`:
1. **Resource Check**: Validates CPU/memory availability via `ResourceManager`
2. **Volume Generation**: Creates boot and EFI volumes via Viperblock
3. **Volume Mount**: Sends a mount request to the colocated Viperblock worker on `ebs.{node}.mount`, receives NBD URI
3. **Volume Mount**: Publishes each volume through the EBS provider contract (`PublishVolume` on `ebs.provider.v1.{node}.mount`), receives its export
4. **QEMU Launch**: Builds and executes QEMU command with NBD-backed disks
5. **QMP Monitoring**: Establishes QEMU Machine Protocol connection for VM management
6. **Command subscription**: Subscribes to `ec2.cmd.{instanceID}` for subsequent commands on this VM
```go
// Volume mounting via NATS (runtime/daemon/vm_adapters.go)
reply, err := ebsRequestWithTrace(ctx, a.nc, instance.AccountID, viperblocklegacyv1.MountSubject(a.node), ebsMountRequest, 30*time.Second)
// Volume mounting through the EBS provider contract (runtime/daemon/vm_adapters.go)
published, err := a.provider.PublishVolume(ebsprovider.WithAccountID(ctx, accountID), ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewLatestVersioned(), VolumeID: req.Name, NodeID: a.node})
// QEMU launch with NBD-backed drives in args (runtime/compute/vm/vm.go)
cmd := exec.Command(qemuArchitecture, args...) // qemu-system-x86_64 or qemu-system-aarch64
@@ -23,6 +23,7 @@ resources:
- [Overview](#overview)
- [Launch](#launch)
- [Manage](#manage)
- [Stopping and Data Volumes](#stopping-and-data-volumes)
- [Modify Instance Attributes](#modify-instance-attributes)
- [Instance Metadata Options](#instance-metadata-options)
- [Spot Instances](#spot-instances)
@@ -73,6 +74,31 @@ aws ec2 terminate-instances --instance-ids $INSTANCE_ID
aws ec2 reboot-instances --instance-ids $INSTANCE_ID
```
## Stopping and Data Volumes
A guest that powers itself off (`poweroff`, `shutdown -h`) goes `stopping` then `stopped`, the same as `stop-instances`.
It is not restarted automatically.
`InstanceInitiatedShutdownBehavior=terminate` is not honoured yet: a guest shutdown always stops the instance.
Data volumes stay `in-use` and attached to a stopped instance, and `start-instances` mounts them again.
While the instance is stopped, `attach-volume` of one of its data volumes to another instance returns `VolumeInUse`, and `delete-volume` is refused.
`detach-volume` works on a stopped instance, with or without `--instance-id`.
The volume becomes `available` and the next start does not attach it.
The root volume of a stopped instance can be detached too, as on AWS.
The internal EFI variable store is not a customer volume: it never appears in `describe-volumes` or `BlockDeviceMappings`, and detaching it is refused with `OperationNotPermitted`.
The instance then cannot start: `start-instances` returns `InvalidParameterValue` with "Instance does not have a volume attached at root (/dev/sda1)".
`attach-volume` works on a stopped instance too.
The volume must be `available` and in the instance's availability zone, otherwise the call returns `VolumeInUse` or `InvalidVolume.ZoneMismatch`.
The attachment is recorded on the stopped instance and the volume becomes `in-use` straight away.
Attaching at the root device name (for example `/dev/sda1`) makes the volume the root again, and the next `start-instances` boots from it.
An instance stopped by an earlier build had its data volumes released to `available` while its record still listed them.
On start, a listed data volume that was deleted or is now attached to another instance is dropped with a warning in the daemon log, and the start continues.
A listed volume that is still `available` and unattached is attached again.
A missing root volume fails the start with the same `InvalidParameterValue` answer.
## Modify Instance Attributes
Change instance type, user data, or termination protection. Instance type and user data require the instance to be **stopped** first.
@@ -135,6 +161,30 @@ aws ec2 get-console-output --instance-id $INSTANCE_ID \
--query 'Output' --output text | base64 -d
```
Like AWS, the call returns the most recent 64 KiB of the console.
While an instance is shutting down after a terminate request, the call reads the renamed file described below.
Once the instance has left the node's running set, which includes a stopped or terminated instance, the node no longer answers and the call returns `InvalidInstanceID.NotFound`.
AWS still lists a terminated instance for a while after termination, so a caller may expect this call to work then; it does not here, a known gap.
Whether the API should answer for a stopped instance is not settled here; only the file on the host is kept.
### Where console logs live and how big they get
The serial console of each instance is written on its host to `/run/spinifex/console-<instance-id>.log`.
`/run` is memory-backed, so these files are cleared when the host reboots, including any left by older releases.
The daemon keeps them bounded.
A file over 4 MiB is cut back in place to its newest 1 MiB, and the guest keeps writing while it happens.
The console is opened in append mode, so a reboot or auto-restart of the guest adds to the same file instead of starting over.
A live `console-<instance-id>.log` is deleted only when it has not changed for an hour and no QEMU process on the host has it open, so a stopped instance keeps its console for an hour and a quiet running guest keeps it indefinitely.
If the daemon cannot read the open files of every QEMU process on the host, it deletes nothing on that pass and logs a warning.
This also clears the file of an instance that was stopped and then terminated, or that was recovered on another node, within about an hour of its last write.
When an instance is terminated, including a failed first launch and the system instances behind load balancers, queues and EKS control planes, its file is renamed to `console-<instance-id>.log.terminated`.
The daemon removes a `.terminated` file an hour after its last write.
Known limits:
- The log shipper follows only `console-*.log`, so it stops following a file when it is renamed on terminate, and the last few seconds of output that it had not yet read can be lost.
- The instance password retrieval reads only the live file name, so it does not find a console renamed after terminate.
## Instance Types
List instance types available on the current host. The catalog is generated from the host CPU (Intel, AMD, or ARM) and includes burstable (t-family), general purpose (m-family), compute optimised (c-family), and memory optimised (r-family) types.
+12
View File
@@ -59,6 +59,9 @@ Authentication uses **EKS access entries** (the API authentication mode) — IAM
- **Access entries are the only auth path.** `authentication_mode` accepts `API` (the default when unset) and `API_AND_CONFIG_MAP`; bare `CONFIG_MAP` is rejected. Spinifex has no `aws-auth` ConfigMap path, so `API_AND_CONFIG_MAP` behaves as `API`: grant access with access entries.
- **The cluster security group is auto-managed.** As on EKS, Spinifex creates one `eks-cluster-sg-<name>-<id>` in your VPC, tagged `kubernetes.io/cluster/<name>=owned` and `aws:eks:cluster-name`, allowing all traffic from itself. It is attached to the workers and the private-endpoint network interface, and `DescribeCluster` reports it as `clusterSecurityGroupId`. A launch template that names security groups replaces it on the workers, as on AWS. Clusters created before this change keep their `-control-plane-sg` and `-nodegroup-sg` until you recreate them. Deleting a cluster deletes the cluster security group and removes only the rules on your other security groups that reference it. `vpc_config.security_group_ids` is ignored.
- **Node group deletes are asynchronous.** `DeleteNodegroup` returns at once with the group `DELETING`, and `DescribeNodegroup` answers `ResourceNotFoundException` once the workers and the group's instance profile are gone. Deleting a cluster or creating a group of the same name while a group is `DELETING` returns `ResourceInUseException`, as does a second delete of a `DELETING` group.
- **If a node group delete cannot finish,** the group moves to `DELETE_FAILED` after 30 minutes, with a `NODE_TERMINATION_FAILURE` health issue naming the workers that are still running. Run `DeleteNodegroup` again to resume it.
- **Each node group owns an EKS-managed instance profile.** Spinifex creates it, tags it `spinifex:eks-cluster`, `spinifex:eks-nodegroup` and `spinifex:eks-profile-id`, and puts the node role in it. It is removed with the group, so you can delete the node role afterwards with no extra steps. Spinifex never deletes an instance profile it did not create, or one whose tags, name or role no longer match what it recorded; it leaves the profile and logs a warning with its name.
- **The worker image is fixed.** Node groups always boot Spinifex's `eks-node` image. `ami_type` is recorded but does not select the image, and the image must be registered on the cluster before you create a node group.
- **Workers launch with IMDSv2 required and an IMDS hop limit of 2.**
This matches AWS managed node groups and lets a pod without IRSA reach the node role through the SDK default credential chain.
@@ -377,6 +380,15 @@ Control plane readiness and kubectl authentication above lists which clusters re
**`create-cluster` rejected with an authentication-mode error.** `CONFIG_MAP` is the one mode Spinifex rejects, because it has no `aws-auth` ConfigMap path. Set `authentication_mode` to `API` or `API_AND_CONFIG_MAP`, or leave it unset, and grant access with access entries.
**`delete-role` fails with `DeleteConflict` after deleting a node group.** Node groups created before per-group instance profiles left an instance profile named after the role, which Spinifex no longer removes because it cannot prove it created it. Remove it yourself:
```bash
aws iam remove-role-from-instance-profile --instance-profile-name <role-name> --role-name <role-name>
aws iam delete-instance-profile --instance-profile-name <role-name>
```
**A node group is `DELETE_FAILED`.** `aws eks describe-nodegroup` lists the workers that did not stop under `health.issues`. Once they are gone, or after you fix the cause, run `aws eks delete-nodegroup` again.
**Addon install fails.** Only addons bundled into the registered `eks-node` image install successfully. If you pin an addon version in Terraform, use the AWS form from `DescribeAddonVersions` (see Add-on versions above), or leave `addon_version` unset so Spinifex selects the catalog default.
## Reference Architectures
@@ -34,7 +34,7 @@ Node group workers and CSI-provisioned EBS volumes are never removed by a cluste
## Gaps
1. Four `eks.*` handlers still hold their request for work that grows with the resource, and the subject table names them as not conformant: `CreateCluster` (reclaiming a `FAILED` cluster of the same name tears its infra down inline), `DeleteNodegroup` (one awaited terminate per worker), `UpdateNodegroupConfig` (one awaited launch or terminate per worker added or removed) and `RestoreSnapshot` (launches a control-plane VM and fences the old one inline).
1. Three `eks.*` handlers still hold their request for work that grows with the resource, and the subject table names them as not conformant: `CreateCluster` (reclaiming a `FAILED` cluster of the same name tears its infra down inline), `UpdateNodegroupConfig` (one awaited launch or terminate per worker added or removed) and `RestoreSnapshot` (launches a control-plane VM and fences the old one inline).
2. AWS's answer to a repeat delete on a `DELETING` cluster has not been observed; `DELETING` from the record is the chosen behaviour.
3. Control-plane, load balancer and VPC resources in the system account are keyed by the bare cluster name, so a teardown by name can reach another tenant's same-named cluster.
4. The teardown deletes load balancers the in-cluster controller created in the customer account, which AWS leaves behind.
+1 -1
View File
@@ -110,7 +110,7 @@ Three entry points, in the order they run:
### `iam` — everything IAM and STS
Policy evaluation against live requests rather than unit-level: a deny applying to the next request without a new credential, resource-scoped grants naming one key pair, `aws:SourceIp` seeing the real client address, assumed-role sessions carrying role policies, and revocation stopping one key alone.
Policy evaluation against live requests rather than unit-level: a deny applying to the next request without a new credential, resource-scoped grants naming one key pair, a user rename needing `iam:UpdateUser` on both the old and the new name, `aws:SourceIp` seeing the real client address, assumed-role sessions carrying role policies, and revocation stopping one key alone.
### `cert` — the cluster CA and TLS identity
+6 -1
View File
@@ -265,7 +265,12 @@ If this returns a list of available instance types, your installation is working
Spinifex is running, but it holds nothing yet: no machine images, no networks, no instances.
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create an SSH key pair, create a VPC with a public subnet, and launch your first instance.
Log in to node 1, and follow [Setting Up Your Cluster](/docs/setting-up-your-cluster) there to import an AMI, create an SSH key pair, create a VPC with a public subnet, and launch your first instance:
```bash
./deploy-vultr.sh ssh
```
An instance you launch into a public subnet gets a Vultr reserved IP, which you can see in the Vultr portal under **Network → Reserved IPs**.
## 8. Test resiliency
+1
View File
@@ -92,6 +92,7 @@ type Shape struct {
Min *float64
Max *float64
Pattern string
Documentation string
LocationName string
TimestampFormat string
XMLNamespace XMLNamespace
+3
View File
@@ -443,6 +443,9 @@ func (t *smithyTranslator) applyShapeTraits(shape *Shape, traits smithyTraits) e
if shape.Pattern, err = traits.string("smithy.api#pattern"); err != nil {
return err
}
if shape.Documentation, err = traits.string("smithy.api#documentation"); err != nil {
return err
}
if shape.LocationName, err = traits.string("smithy.api#xmlName"); err != nil {
return err
}
+12 -22
View File
@@ -7,9 +7,10 @@ package ecsgw
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt"
"io"
"net/http"
@@ -17,10 +18,8 @@ import (
"strings"
"time"
"github.com/aws/aws-sdk-go/aws/credentials"
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
"github.com/mulgadc/bluebottle/pkg/tlsconfig"
"github.com/mulgadc/spinifex/internal/gwsign"
)
// targetPrefix is the X-Amz-Target service prefix the gateway's ECS dispatch
@@ -28,31 +27,26 @@ import (
// suffix); this one mirrors the AWS JSON 1.1 convention.
const targetPrefix = "AmazonEC2ContainerServiceV20141113."
// CredentialsFunc yields the current SigV4 credentials. It is called once per
// request so a provider backed by IMDS can rotate instance-role creds (and their
// session token) transparently. Must be safe for concurrent use.
type CredentialsFunc func(ctx context.Context) (accessKey, secretKey, sessionToken string, err error)
// Client posts SigV4-signed AWS JSON 1.1 requests to the gateway for service
// "ecs". One client is reused for register/heartbeat/state/poll.
type Client struct {
baseURL string
creds CredentialsFunc
signer *gwsign.Signer
region string
httpClient *http.Client
}
// New builds a client. caPath optionally pins the gateway TLS CA; empty relies on
// the system trust store. creds supplies the per-call instance-role credentials.
// the system trust store. signer supplies the per-call instance-role credentials.
// region defaults to us-east-1 when empty (SigV4 requires a non-empty region).
// timeout bounds a single call — callers needing a long-poll pass a larger value
// than the default register/heartbeat timeout.
func New(baseURL, caPath string, creds CredentialsFunc, region string, timeout time.Duration) (*Client, error) {
func New(baseURL, caPath string, signer *gwsign.Signer, region string, timeout time.Duration) (*Client, error) {
if baseURL == "" {
return nil, fmt.Errorf("ecsgw: baseURL is required")
}
if creds == nil {
return nil, fmt.Errorf("ecsgw: credentials func is required")
if signer == nil {
return nil, fmt.Errorf("ecsgw: signer is required")
}
if region == "" {
region = "us-east-1"
@@ -79,7 +73,7 @@ func New(baseURL, caPath string, creds CredentialsFunc, region string, timeout t
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
creds: creds,
signer: signer,
region: region,
httpClient: &http.Client{
Timeout: timeout,
@@ -90,7 +84,7 @@ func New(baseURL, caPath string, creds CredentialsFunc, region string, timeout t
// Call SigV4-signs and POSTs body under X-Amz-Target "<prefix><action>" to the
// gateway root, returning the 2xx response body. Credentials are fetched per call
// and the v4 signer emits X-Amz-Security-Token for instance-role (temporary)
// and the signer emits X-Amz-Security-Token for instance-role (temporary)
// creds, so the gateway's ASIA path verifies them. No retry; callers wrap.
func (c *Client) Call(action string, body []byte) ([]byte, error) {
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/", bytes.NewReader(body))
@@ -100,12 +94,8 @@ func (c *Client) Call(action string, body []byte) ([]byte, error) {
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
req.Header.Set("X-Amz-Target", targetPrefix+action)
akid, secret, token, err := c.creds(req.Context())
if err != nil {
return nil, fmt.Errorf("retrieve credentials: %w", err)
}
signer := v4.NewSigner(credentials.NewStaticCredentials(akid, secret, token))
if _, err := signer.Sign(req, bytes.NewReader(body), "ecs", c.region, time.Now()); err != nil {
sum := sha256.Sum256(body)
if err := c.signer.Sign(req, hex.EncodeToString(sum[:]), "ecs", c.region); err != nil {
return nil, fmt.Errorf("sign request: %w", err)
}
+23 -8
View File
@@ -1,24 +1,29 @@
package ecsgw
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/internal/gwsign"
)
// testCreds returns a fixed temporary credential set (with a session token, as
// IMDS instance-role creds carry).
func testCreds(context.Context) (string, string, string, error) {
return "ASIATEST", "secret", "session-token-xyz", nil
const testSecret = "secret"
// testSigner signs with a fixed temporary credential set (with a session token,
// as IMDS instance-role creds carry).
func testSigner() *gwsign.Signer {
return gwsign.New(credentials.NewStaticCredentialsProvider("ASIATEST", testSecret, "session-token-xyz"))
}
func newTestClient(t *testing.T, srv *httptest.Server) *Client {
t.Helper()
c, err := New(srv.URL, "", testCreds, "ap-southeast-2", time.Second)
c, err := New(srv.URL, "", testSigner(), "ap-southeast-2", time.Second)
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -30,7 +35,14 @@ func newTestClient(t *testing.T, srv *httptest.Server) *Client {
// 2xx body.
func TestClient_CallSignsAndTargets(t *testing.T) {
var gotTarget, gotAuth, gotBody, gotToken string
var verifyErr error
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// sigv4.Parse reads and hashes the body itself, then restores it.
sr, err := sigv4.Parse(r)
if err == nil {
_, err = sr.Verify(testSecret, "ap-southeast-2", "ecs")
}
verifyErr = err
gotTarget = r.Header.Get("X-Amz-Target")
gotAuth = r.Header.Get("Authorization")
gotToken = r.Header.Get("X-Amz-Security-Token")
@@ -45,6 +57,9 @@ func TestClient_CallSignsAndTargets(t *testing.T) {
if err != nil {
t.Fatalf("Call: %v", err)
}
if verifyErr != nil {
t.Fatalf("gateway-side SigV4 verify failed: %v", verifyErr)
}
if string(out) != `{"ok":true}` {
t.Errorf("body = %q", out)
}
@@ -78,10 +93,10 @@ func TestClient_CallErrorStatus(t *testing.T) {
}
func TestNew_Validation(t *testing.T) {
if _, err := New("", "", testCreds, "r", 0); err == nil {
if _, err := New("", "", testSigner(), "r", 0); err == nil {
t.Error("want error for empty baseURL")
}
if _, err := New("https://gw:9999", "", nil, "r", 0); err == nil {
t.Error("want error for nil credentials func")
t.Error("want error for nil signer")
}
}
+12 -5
View File
@@ -15,8 +15,7 @@ import (
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds"
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
"github.com/mulgadc/spinifex/internal/imdscreds"
)
// Signer holds a credential provider and signs requests in place.
@@ -24,6 +23,12 @@ type Signer struct {
provider aws.CredentialsProvider
}
// New returns a Signer over provider. Wrap a provider that fetches remotely in
// aws.NewCredentialsCache, since Sign retrieves on every call.
func New(provider aws.CredentialsProvider) *Signer {
return &Signer{provider: provider}
}
// NewStatic returns a Signer using fixed long-lived keys (no session token).
func NewStatic(accessKey, secretKey string) *Signer {
return &Signer{provider: credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")}
@@ -46,12 +51,14 @@ func NewIMDS(ctx context.Context, region string) (*Signer, error) {
// NewInstanceRole returns a Signer whose credentials come only from the
// instance metadata service: no environment, profile or static-key fallback.
// A failed refresh of expired credentials is an error, not the stale key.
func NewInstanceRole() *Signer {
return newInstanceRole(imds.New(imds.Options{}))
return newInstanceRole("")
}
func newInstanceRole(client *imds.Client) *Signer {
return &Signer{provider: aws.NewCredentialsCache(ec2rolecreds.New(func(o *ec2rolecreds.Options) { o.Client = client }))}
// newInstanceRole reads from the IMDS root base; "" means the default endpoint.
func newInstanceRole(base string) *Signer {
return &Signer{provider: aws.NewCredentialsCache(imdscreds.NewProvider(nil, base))}
}
// EnsureCredentials retrieves once from the provider, surfacing the error so a
+34 -3
View File
@@ -12,7 +12,6 @@ import (
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
"github.com/mulgadc/bluebottle/pkg/sigv4"
)
@@ -145,7 +144,7 @@ func TestNewInstanceRole_UsesOnlyIMDS(t *testing.T) {
}))
t.Cleanup(srv.Close)
s := newInstanceRole(imds.New(imds.Options{Endpoint: srv.URL}))
s := newInstanceRole(srv.URL)
req := httptest.NewRequest(http.MethodPost, "https://gw.example/", nil)
if err := s.Sign(req, emptySHA, testService, testRegion); err != nil {
t.Fatal(err)
@@ -155,10 +154,42 @@ func TestNewInstanceRole_UsesOnlyIMDS(t *testing.T) {
}
srv.Close()
cold := newInstanceRole(imds.New(imds.Options{Endpoint: srv.URL}))
cold := newInstanceRole(srv.URL)
if err := cold.EnsureCredentials(context.Background()); err == nil {
t.Fatal("environment keys were used when IMDS was unreachable")
}
}
// Once the cached instance-role key has expired, a refresh that cannot reach
// IMDS must fail the sign rather than reuse the expired key with a new expiry.
func TestNewInstanceRole_FailedRefreshErrorsInsteadOfStaleKey(t *testing.T) {
const staleKey = "ASIASTALEEXAMPLE0001"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/latest/api/token":
_, _ = w.Write([]byte("token"))
case "/latest/meta-data/iam/security-credentials/":
_, _ = w.Write([]byte("sqsWorkerRole"))
case "/latest/meta-data/iam/security-credentials/sqsWorkerRole":
_, _ = w.Write([]byte(`{"Code":"Success","AccessKeyId":"` + staleKey + `","SecretAccessKey":"s","Token":"t","Expiration":"2000-01-01T00:00:00Z"}`))
default:
http.NotFound(w, r)
}
}))
s := newInstanceRole(srv.URL)
if err := s.EnsureCredentials(t.Context()); err != nil {
t.Fatalf("first retrieve: %v", err)
}
srv.Close()
req := httptest.NewRequest(http.MethodPost, "https://gw.example/", nil)
err := s.Sign(req, emptySHA, testService, testRegion)
if err == nil {
t.Fatalf("Sign after failed refresh = nil, signed with %q", req.Header.Get("Authorization"))
}
if auth := req.Header.Get("Authorization"); auth != "" {
t.Fatalf("request carries Authorization %q after a failed refresh", auth)
}
}
const emptySHA = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+4 -17
View File
@@ -21,31 +21,18 @@ import (
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
)
// Credentials is a resolved instance-role credential set with parsed expiry.
type Credentials struct {
AccessKeyID string
SecretAccessKey string
SessionToken string
Expiration time.Time
}
// Fetch resolves the instance role then its credentials via the SDK's IMDSv2
// client and the ec2rolecreds provider. base is the IMDS root, e.g.
// http://169.254.169.254/latest.
func Fetch(client *http.Client, base string) (Credentials, error) {
func Fetch(client *http.Client, base string) (aws.Credentials, error) {
creds, err := NewProvider(client, base).Retrieve(context.Background())
if err != nil {
return Credentials{}, fmt.Errorf("fetch IMDS credentials: %w", err)
return aws.Credentials{}, fmt.Errorf("fetch IMDS credentials: %w", err)
}
if creds.AccessKeyID == "" || creds.SecretAccessKey == "" {
return Credentials{}, fmt.Errorf("IMDS credentials missing access/secret key")
return aws.Credentials{}, fmt.Errorf("IMDS credentials missing access/secret key")
}
return Credentials{
AccessKeyID: creds.AccessKeyID,
SecretAccessKey: creds.SecretAccessKey,
SessionToken: creds.SessionToken,
Expiration: creds.Expires,
}, nil
return creds, nil
}
// NewProvider builds an ec2rolecreds provider against base, an IMDS root URL
+47 -2
View File
@@ -1,9 +1,11 @@
package imdscreds
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
@@ -60,8 +62,8 @@ func TestFetch_OK(t *testing.T) {
if got.AccessKeyID != "AKIATEST" || got.SecretAccessKey != "secrettest" || got.SessionToken != "sessiontoken" {
t.Errorf("creds mismatch: %+v", got)
}
if !got.Expiration.Equal(exp) {
t.Errorf("Expiration = %v, want %v", got.Expiration, exp)
if !got.Expires.Equal(exp) {
t.Errorf("Expires = %v, want %v", got.Expires, exp)
}
}
@@ -132,3 +134,46 @@ func TestNewProvider_WithholdsExtendOnFailure(t *testing.T) {
t.Error("provider dropped AdjustExpiresBy, which a caller using an ExpiryWindow relies on")
}
}
// Behind aws.CredentialsCache, as the ecs-agent uses it, a refresh that fails
// against an expired credential must surface as an error rather than hand back
// the dead key with a pushed-out expiry.
func TestNewProvider_CachedFailedRefreshErrors(t *testing.T) {
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/latest/api/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
_, _ = w.Write([]byte("v2-token"))
})
mux.HandleFunc("/latest/meta-data/iam/security-credentials/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/latest/meta-data/iam/security-credentials/" {
_, _ = w.Write([]byte("node-role"))
return
}
if hits.Add(1) > 1 {
w.WriteHeader(http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{
"Code": "Success",
"AccessKeyId": "AKIA",
"SecretAccessKey": "secret",
"Expiration": time.Now().Add(-time.Minute).UTC().Format(time.RFC3339),
})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
cache := aws.NewCredentialsCache(NewProvider(srv.Client(), srv.URL+"/latest"))
first, err := cache.Retrieve(context.Background())
if err != nil {
t.Fatalf("first Retrieve: %v", err)
}
got, err := cache.Retrieve(context.Background())
if err == nil {
t.Fatalf("refresh failure was reported as success: %+v", got)
}
if got.Expires.After(first.Expires) {
t.Errorf("expiry extended on refresh failure: %v -> %v", first.Expires, got.Expires)
}
}
+9 -17
View File
@@ -8,8 +8,8 @@ package stsauth
import (
"fmt"
"net/http"
"time"
awsv2 "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go/aws"
awscreds "github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
@@ -20,24 +20,15 @@ import (
// scope and the gateway does not enforce a specific value.
const defaultRegion = "us-east-1"
// Credentials is the decoded result of AssumeRole: a SigV4 credential set bound
// to the assumed role, with its expiry.
type Credentials struct {
AccessKeyID string
SecretAccessKey string
SessionToken string
Expiration time.Time
}
// AssumeRole builds an aws-sdk STS client pointed at the gateway and calls
// AssumeRole with the supplied static (instance-role) credentials, returning the
// assumed-role credential set. httpClient must trust the gateway CA.
func AssumeRole(region, gatewayURL string, httpClient *http.Client, akid, secret, sessionToken, roleARN, sessionName string) (Credentials, error) {
func AssumeRole(region, gatewayURL string, httpClient *http.Client, akid, secret, sessionToken, roleARN, sessionName string) (awsv2.Credentials, error) {
if region == "" {
region = defaultRegion
}
if roleARN == "" || sessionName == "" {
return Credentials{}, fmt.Errorf("stsauth: roleARN and sessionName are required")
return awsv2.Credentials{}, fmt.Errorf("stsauth: roleARN and sessionName are required")
}
sess, err := session.NewSession(&aws.Config{
Region: aws.String(region),
@@ -47,7 +38,7 @@ func AssumeRole(region, gatewayURL string, httpClient *http.Client, akid, secret
DisableSSL: aws.Bool(false),
})
if err != nil {
return Credentials{}, fmt.Errorf("new session: %w", err)
return awsv2.Credentials{}, fmt.Errorf("new session: %w", err)
}
out, err := sts.New(sess).AssumeRole(&sts.AssumeRoleInput{
@@ -55,16 +46,17 @@ func AssumeRole(region, gatewayURL string, httpClient *http.Client, akid, secret
RoleSessionName: aws.String(sessionName),
})
if err != nil {
return Credentials{}, fmt.Errorf("AssumeRole: %w", err)
return awsv2.Credentials{}, fmt.Errorf("AssumeRole: %w", err)
}
if out.Credentials == nil {
return Credentials{}, fmt.Errorf("AssumeRole returned no credentials")
return awsv2.Credentials{}, fmt.Errorf("AssumeRole returned no credentials")
}
c := out.Credentials
return Credentials{
return awsv2.Credentials{
AccessKeyID: aws.StringValue(c.AccessKeyId),
SecretAccessKey: aws.StringValue(c.SecretAccessKey),
SessionToken: aws.StringValue(c.SessionToken),
Expiration: aws.TimeValue(c.Expiration),
CanExpire: c.Expiration != nil,
Expires: aws.TimeValue(c.Expiration),
}, nil
}
+2 -2
View File
@@ -41,8 +41,8 @@ func TestAssumeRole_DecodesCredentials(t *testing.T) {
t.Errorf("bad creds: %+v", creds)
}
want, _ := time.Parse(time.RFC3339, "2031-01-02T03:04:05Z")
if !creds.Expiration.Equal(want) {
t.Errorf("Expiration = %v, want %v", creds.Expiration, want)
if !creds.Expires.Equal(want) {
t.Errorf("Expires = %v, want %v", creds.Expires, want)
}
// The request must carry the AssumeRole action and the role ARN we asked for.
if !strings.Contains(gotBody, "Action=AssumeRole") {
+29
View File
@@ -0,0 +1,29 @@
package testutil
import (
"testing"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// RequireAWSError asserts err carries the AWS code and exact client-facing
// message; an empty message asserts the bare code with no message attached.
func RequireAWSError(t *testing.T, err error, code, message string) {
t.Helper()
require.Error(t, err)
got, msg, ok := awserrors.ResolveErrorDetail(err)
require.True(t, ok, "error %v carries no AWS code", err)
assert.Equal(t, code, got)
assert.Equal(t, message, msg)
}
// RequireAWSCode asserts err carries the AWS code, whatever its message.
func RequireAWSCode(t *testing.T, err error, code string) {
t.Helper()
require.Error(t, err)
got, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok, "error %v carries no AWS code", err)
assert.Equal(t, code, got)
}
+12 -6
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
@@ -28,10 +29,11 @@ func SocketTempDir(t *testing.T) string {
// StartDrainSocket stands in for the NBD plugin's drain socket, at the path the
// plugin serves it on the node hosting volumeID ({dataDir}/viperblock/{volumeID}
// /snapshot.sock). Every connection is answered with ack ("OK\n" for a drain
// that reached S3, "ERR\n" for one that did not) and closed.
func StartDrainSocket(t *testing.T, dataDir, volumeID, ack string) {
// that reached S3, "ERR\n" for one that did not) and closed. connections
// reports how many drains have been asked for so far.
func StartDrainSocket(t *testing.T, dataDir, volumeID, ack string) (connections func() int) {
t.Helper()
serveDrainSocket(t, dataDir, volumeID, ack, nil, nil)
return serveDrainSocket(t, dataDir, volumeID, ack, nil, nil)
}
// StartBlockingDrainSocket stands in for a plugin whose flush is still running:
@@ -43,7 +45,7 @@ func StartBlockingDrainSocket(t *testing.T, dataDir, volumeID, ack string) (acce
acceptedCh := make(chan struct{})
releaseCh := make(chan struct{})
serveDrainSocket(t, dataDir, volumeID, ack, acceptedCh, releaseCh)
_ = serveDrainSocket(t, dataDir, volumeID, ack, acceptedCh, releaseCh)
var once sync.Once
t.Cleanup(func() { once.Do(func() { close(releaseCh) }) })
@@ -52,8 +54,9 @@ func StartBlockingDrainSocket(t *testing.T, dataDir, volumeID, ack string) (acce
}
// serveDrainSocket answers every connection with ack, optionally closing
// accepted on the first one and waiting for release before it replies.
func serveDrainSocket(t *testing.T, dataDir, volumeID, ack string, accepted chan struct{}, release <-chan struct{}) {
// accepted on the first one and waiting for release before it replies. The
// returned func counts the connections accepted so far.
func serveDrainSocket(t *testing.T, dataDir, volumeID, ack string, accepted chan struct{}, release <-chan struct{}) func() int {
t.Helper()
dir := filepath.Join(dataDir, "viperblock", volumeID)
@@ -63,6 +66,7 @@ func serveDrainSocket(t *testing.T, dataDir, volumeID, ack string, accepted chan
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
var count atomic.Int64
go func() {
var once sync.Once
for {
@@ -70,6 +74,7 @@ func serveDrainSocket(t *testing.T, dataDir, volumeID, ack string, accepted chan
if err != nil {
return // listener closed by the cleanup above
}
count.Add(1)
if accepted != nil {
once.Do(func() { close(accepted) })
}
@@ -80,4 +85,5 @@ func serveDrainSocket(t *testing.T, dataDir, volumeID, ack string, accepted chan
_ = conn.Close()
}
}()
return func() int { return int(count.Load()) }
}
+2 -2
View File
@@ -167,11 +167,11 @@ func registerVpcdStub(t *testing.T, nc *nats.Conn, defaultPayload []byte) {
})
}
// SeedKV creates a KV bucket and populates it with the given entries.
// SeedKV creates or updates a KV bucket and populates it with the given entries.
// Returns the KV handle for further use.
func SeedKV(t *testing.T, js jetstream.JetStream, bucket string, entries map[string][]byte) jetstream.KeyValue {
t.Helper()
kv, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: bucket, History: 1})
kv, err := js.CreateOrUpdateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: bucket, History: 1})
require.NoError(t, err)
for k, v := range entries {
_, err := kv.Put(t.Context(), k, v)
+19
View File
@@ -0,0 +1,19 @@
package testutil
import (
"testing"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/require"
)
func TestSeedKV_ExistingBucket(t *testing.T) {
_, _, js := StartTestJetStream(t)
_, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: "seeded", History: 5})
require.NoError(t, err)
kv := SeedKV(t, js, "seeded", map[string][]byte{"k": []byte("v")})
entry, err := kv.Get(t.Context(), "k")
require.NoError(t, err)
require.Equal(t, []byte("v"), entry.Value())
}
+18 -10
View File
@@ -3,24 +3,28 @@
# + initramfs: a guest running the Lambda guest agent, with virtio-net for its
# host control connection. It is not shipped.
#
# Usage: build-lambda-runtime-microvm-image.sh [agent-binary [output-dir]]
#
# Inputs:
# $REPO_ROOT/build/microvm/lambda-runtime/{init.sh,inittab,run-agent}
# $REPO_ROOT/bin/lambda-agent (make bin/lambda-agent)
# agent binary, default $REPO_ROOT/bin/lambda-agent (make bin/lambda-agent)
#
# Outputs:
# $REPO_ROOT/build/microvm/lambda-runtime/vmlinuz
# $REPO_ROOT/build/microvm/lambda-runtime/initramfs.cpio.gz
# Outputs, in output-dir (default $REPO_ROOT/build/microvm/lambda-runtime):
# vmlinuz
# initramfs.cpio.gz
#
# Rootfs strategy and module stripping: scripts/lib/microvm-image.sh.
# shellcheck disable=SC2034 # variables are consumed by the sourced library
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$REPO_ROOT/build/microvm/lambda-runtime"
INIT_SH="$BUILD_DIR/init.sh"
INITTAB="$BUILD_DIR/inittab"
RUN_AGENT="$BUILD_DIR/run-agent"
LAMBDA_AGENT_BIN="$REPO_ROOT/bin/lambda-agent"
INPUT_DIR="$REPO_ROOT/build/microvm/lambda-runtime"
INIT_SH="$INPUT_DIR/init.sh"
INITTAB="$INPUT_DIR/inittab"
RUN_AGENT="$INPUT_DIR/run-agent"
LAMBDA_AGENT_BIN="${1:-$REPO_ROOT/bin/lambda-agent}"
# The library stages beside BUILD_DIR, so it names the output, not the inputs.
BUILD_DIR="${2:-$INPUT_DIR}"
TAG="[build-lambda-runtime-microvm-image]"
ALPINE_VERSION="${ALPINE_VERSION:-3.24}"
@@ -38,7 +42,8 @@ MICROVM_GATE_MODULES="qemu_fw_cfg virtio_net"
source "$REPO_ROOT/scripts/lib/microvm-image.sh"
echo "$TAG repo root: $REPO_ROOT"
echo "$TAG build dir: $BUILD_DIR"
echo "$TAG input dir: $INPUT_DIR"
echo "$TAG output dir: $BUILD_DIR"
microvm_check_tools
@@ -57,6 +62,9 @@ if ! file -b "$LAMBDA_AGENT_BIN" | grep -q "statically linked"; then
exit 1
fi
# Absolute, because packing the initramfs runs from inside the chroot.
mkdir -p "$BUILD_DIR"
BUILD_DIR=$(cd "$BUILD_DIR" && pwd)
microvm_init_dirs
microvm_make_rootfs
microvm_empty_dev
@@ -0,0 +1,81 @@
#!/bin/sh
# build-lambda-runtime-microvm-image_test.sh — a build into a chosen output
# directory stages and publishes there, and leaves the default input directory
# untouched.
#
# The script runs in a fixture repo against the real library, with only the
# rootfs, kernel and packing steps replaced so no image is downloaded.
set -eu
SCRIPTS_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- build-lambda-runtime-microvm-image.sh output directory"
REPO="$TMP/repo"
IN="$REPO/build/microvm/lambda-runtime"
mkdir -p "$REPO/scripts/lib" "$IN" "$REPO/bin" "$TMP/bin"
cp "$SCRIPTS_DIR/build-lambda-runtime-microvm-image.sh" "$REPO/scripts/"
for f in init.sh inittab run-agent; do printf '%s\n' "$f" > "$IN/$f"; done
printf 'default image\n' > "$IN/vmlinuz"
printf '#!/bin/sh\n' > "$REPO/bin/agent-testlink"
chmod +x "$REPO/bin/agent-testlink"
printf '#!/bin/sh\necho "ELF 64-bit, statically linked"\n' > "$TMP/bin/file"
chmod +x "$TMP/bin/file"
BEFORE=$(cd "$IN" && ls -A | cksum)
# The real library, with the steps that need a rootfs or kernel replaced.
# Packing runs from inside the chroot as the real step does, and records where
# the stage is while it still exists.
cat > "$REPO/scripts/lib/microvm-image.sh" <<EOF
. "$SCRIPTS_DIR/lib/microvm-image.sh"
microvm_check_tools() { :; }
microvm_make_rootfs() { mkdir -p "\$CHROOT_DIR/etc"; }
microvm_empty_dev() { :; }
microvm_locate_kernel() { :; }
microvm_strip_modules() { :; }
microvm_strip_rootfs() { :; }
microvm_stage_kernel() { printf 'kernel\n' > "\$STAGE_DIR/vmlinuz"; }
microvm_pack_initramfs() {
(cd "\$CHROOT_DIR" && printf 'initramfs\n' | gzip > "\$STAGE_DIR/initramfs.cpio.gz")
dirname "\$STAGE_DIR" > "$TMP/stage-parent"
ls -A "$IN" > "$TMP/input-during"
}
EOF
# The output is given relative to the repo root, as make passes it.
OUT="$REPO/build/microvm/lambda-testlink-runtime"
if ! (cd "$REPO" && PATH="$TMP/bin:$PATH" bash scripts/build-lambda-runtime-microvm-image.sh bin/agent-testlink build/microvm/lambda-testlink-runtime) > "$TMP/log" 2>&1; then
fail "build into output dir: $(tr '\n' ' ' < "$TMP/log")"
else
if [ "$(cat "$TMP/stage-parent")" = "$OUT" ]; then
pass "stage created in the output directory"
else
fail "stage created in $(cat "$TMP/stage-parent"), want $OUT"
fi
if grep -q '^\.stage\.' "$TMP/input-during"; then
fail "stage present in the default input directory during the build"
else
pass "no stage in the default input directory during the build"
fi
if [ "$(cd "$IN" && ls -A | cksum)" = "$BEFORE" ] && grep -qx 'default image' "$IN/vmlinuz"; then
pass "default input directory unchanged"
else
fail "default input directory changed: $(cd "$IN" && ls -A | tr '\n' ' ')"
fi
if grep -qx kernel "$OUT/vmlinuz" && gzip -t "$OUT/initramfs.cpio.gz" && [ "$(ls -A "$OUT" | wc -l)" -eq 2 ]; then
pass "output directory holds exactly the published pair"
else
fail "output directory: $(ls -A "$OUT" | tr '\n' ' ')"
fi
fi
if [ "$fails" -ne 0 ]; then
echo "$fails failure(s)"
exit 1
fi
@@ -9,8 +9,7 @@ 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
# Static config (gateway URL, CA, region, cluster, 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
+2 -3
View File
@@ -3,8 +3,7 @@
# IMDSv2, registers + heartbeats + polls task assignments through the AWS gateway
# over TLS+SigV4 (no NATS), and pulls task images through containerd.
#
# 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.
# Static config (gateway URL, CA, region, cluster, containerd socket) comes from /etc/spinifex-ecs/agent.env, dropped by cloud-init at launch.
description="Spinifex ECS container-instance agent"
command="/usr/local/bin/ecs-agent"
@@ -28,7 +27,7 @@ start_pre() {
# shellcheck disable=SC1091
. /etc/spinifex-ecs/agent.env
export ECS_GATEWAY_URL ECS_GATEWAY_CA ECS_REGION ECS_CLUSTER \
ECS_ACCESS_KEY ECS_SECRET_KEY ECS_IMDS_BASE ECS_CONTAINERD_SOCKET \
ECS_IMDS_BASE ECS_CONTAINERD_SOCKET \
ECS_HEARTBEAT_INTERVAL ECS_POLL_INTERVAL
fi
}
+3
View File
@@ -159,6 +159,7 @@ case "${ROLE}" in
svc_start mulga-eks-state-report
svc_start mulga-eks-addon-sync
svc_start mulga-eks-node-gc
svc_optional mulga-eks-log-forward
svc_optional eks-pod-identity-webhook
;;
server-join)
@@ -178,12 +179,14 @@ case "${ROLE}" in
svc_start konnectivity-server
svc_start mulga-eks-state-report
svc_start mulga-eks-node-gc
svc_optional mulga-eks-log-forward
svc_optional eks-pod-identity-webhook
;;
agent)
log "configuring agent role"
svc_enable k3s-agent
svc_start k3s-agent
svc_optional mulga-eks-log-forward
;;
*)
die "SPINIFEX_K3S_ROLE missing or invalid: '${ROLE}'"
@@ -146,6 +146,15 @@ for INIT in openrc systemd; do
)"
check "server(${INIT}): enables node-gc" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-node-gc)")"
check "server(${INIT}): starts node-gc" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-node-gc)")"
check "server(${INIT}): enables log-forward" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-log-forward)")"
check "server(${INIT}): starts log-forward" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-log-forward)")"
check "server(${INIT}): starts log-forward before the webhook" "yes" "$(
awk -v f="$(start_cmd "${INIT}" mulga-eks-log-forward)" -v w="$(start_cmd "${INIT}" eks-pod-identity-webhook)" '
index($0, f) == 1 { fn = NR }
index($0, w) == 1 { wn = NR }
END { print (fn > 0 && wn > fn) ? "yes" : "no" }
' "${CALLS}"
)"
check "server(${INIT}): no k3s-agent" "no" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "server(${INIT}): self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
check "server(${INIT}): pod-identity-webhook is the last call" "$(start_cmd "${INIT}" eks-pod-identity-webhook)" "$(grep -v "$(disable_cmd "${INIT}" eks-node-role)" "${CALLS}" | tail -n 1)"
@@ -196,6 +205,15 @@ for INIT in openrc systemd; do
# published the cluster-identical artifacts, and a join re-publish would
# only race the bootstrap bus. This is deliberate, not an oversight.
check "server-join(${INIT}): pod-identity-webhook is the last call" "$(start_cmd "${INIT}" eks-pod-identity-webhook)" "$(grep -v "$(disable_cmd "${INIT}" eks-node-role)" "${CALLS}" | tail -n 1)"
check "server-join(${INIT}): enables log-forward" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-log-forward)")"
check "server-join(${INIT}): starts log-forward" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-log-forward)")"
check "server-join(${INIT}): starts log-forward before the webhook" "yes" "$(
awk -v f="$(start_cmd "${INIT}" mulga-eks-log-forward)" -v w="$(start_cmd "${INIT}" eks-pod-identity-webhook)" '
index($0, f) == 1 { fn = NR }
index($0, w) == 1 { wn = NR }
END { print (fn > 0 && wn > fn) ? "yes" : "no" }
' "${CALLS}"
)"
check "server-join(${INIT}): no k3s-first-boot" "yes" "$(absent_all "${CALLS}" "k3s-first-boot")"
check "server-join(${INIT}): no k3s-agent" "yes" "$(absent_all "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "server-join(${INIT}): self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
@@ -207,8 +225,39 @@ for INIT in openrc systemd; do
check "agent(${INIT}): exit 0" 0 "${rc}"
check "agent(${INIT}): role file" "agent" "$(cat "${ROLE_FILE}")"
check "agent(${INIT}): enables k3s-agent" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "agent(${INIT}): enables log-forward" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-log-forward)")"
check "agent(${INIT}): starts log-forward" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-log-forward)")"
check "agent(${INIT}): log-forward is enabled after k3s-agent" "yes" "$(
awk -v k="$(enable_cmd "${INIT}" k3s-agent)" -v f="$(enable_cmd "${INIT}" mulga-eks-log-forward)" '
index($0, k) == 1 { kn = NR }
index($0, f) == 1 { fn = NR }
END { print (kn > 0 && fn > kn) ? "yes" : "no" }
' "${CALLS}"
)"
check "agent(${INIT}): no server services" "yes" "$(absent_all "${CALLS}" "eks-token-webhook" "eks-pod-identity-webhook" "k3s-first-boot" "mulga-eks-node-gc" "$(enable_cmd "${INIT}" k3s)")"
# A broken log forwarder must never stop a node joining.
for FAILING in enable start; do
run_case "${INIT}" agent_logfwd_${FAILING}_fail
printf 'SPINIFEX_K3S_ROLE=agent\n' > "${EKS_NODE_ENVFILE}"
if [ "${FAILING}" = enable ]; then
STUB_FAIL="$(enable_cmd "${INIT}" mulga-eks-log-forward)"
export STUB_FAIL
STUB_FAIL="${STUB_FAIL#rc-update add }"; STUB_FAIL="${STUB_FAIL#systemctl enable }"
else
STUB_FAIL="$(start_cmd "${INIT}" mulga-eks-log-forward)"
export STUB_FAIL
STUB_FAIL="${STUB_FAIL#rc-service }"; STUB_FAIL="${STUB_FAIL#systemctl start --no-block }"
fi
invoke
unset STUB_FAIL
check "agent(${INIT}): log-forward ${FAILING} failure still exits 0" 0 "${rc}"
check "agent(${INIT}): log-forward ${FAILING} failure still enables k3s-agent" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "agent(${INIT}): log-forward ${FAILING} failure still starts k3s-agent" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" k3s-agent)")"
check "agent(${INIT}): log-forward ${FAILING} failure still self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
done
# --- agent (inferred from agent.env, no explicit role) ---
run_case "${INIT}" agent_inferred
printf 'K3S_URL=https://nlb:443\nK3S_TOKEN=abc\n' > "${EKS_NODE_AGENT_ENVFILE}"
+2
View File
@@ -45,6 +45,8 @@ scripts/images/eks-node/mulga-eks-addon-sync.initd:/etc/init.d/mulga-eks-addon-s
scripts/images/eks-node/mulga-eks-addon-sync.sh:/usr/local/sbin/mulga-eks-addon-sync \
scripts/images/eks-node/mulga-eks-node-gc.initd:/etc/init.d/mulga-eks-node-gc \
scripts/images/eks-node/mulga-eks-node-gc.sh:/usr/local/sbin/mulga-eks-node-gc \
scripts/images/eks-node/mulga-eks-log-forward.initd:/etc/init.d/mulga-eks-log-forward \
scripts/images/eks-node/mulga-eks-log-forward.sh:/usr/local/sbin/mulga-eks-log-forward \
scripts/images/eks-node/konnectivity-server.initd:/etc/init.d/konnectivity-server \
scripts/images/eks-node/konnectivity-server-prestart.sh:/usr/local/sbin/konnectivity-server-prestart \
scripts/images/eks-node/konnectivity/konnectivity-agent.yaml:/usr/share/spinifex-eks/konnectivity/konnectivity-agent.yaml \
+30
View File
@@ -0,0 +1,30 @@
#!/sbin/openrc-run
# mulga-eks-log-forward — copies k3s warnings and errors to the serial console
# so they reach the Elasticsearch sink. Best effort: nothing depends on it and
# a failure here never affects the node.
description="Forward k3s warnings and errors to the serial console"
command="/bin/sh"
command_background="yes"
supervisor="supervise-daemon"
respawn_delay=5
pidfile="/run/mulga-eks-log-forward.pid"
output_log="/dev/null"
error_log="/dev/null"
depend() {
after k3s k3s-agent
}
start_pre() {
role=$(cat /etc/spinifex-eks/role 2>/dev/null || true)
if [ "${role}" = "agent" ]; then
log=/var/log/k3s-agent.log
FORWARD_PREFIX="[k3s-agent]"
else
log=/var/log/k3s.log
FORWARD_PREFIX="[k3s]"
fi
export FORWARD_PREFIX
command_args="-c 'tail -n +1 -F ${log} 2>/dev/null | /usr/local/sbin/mulga-eks-log-forward'"
}
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
# mulga-eks-log-forward — copy the warnings and errors of a k3s log to the
# serial console, where the host picks them up for the Elasticsearch sink.
#
# Reads the tailed log on stdin. Lines that matter are redacted first, then
# cut to FORWARD_MAX_BYTES, then rate limited: at most FORWARD_RATE_PER_MIN a
# minute reach the console and the rest are reported as one "N lines dropped"
# line. Every console write has a timeout, so a stuck console loses lines
# instead of stalling the tail or k3s.
#
# Env: FORWARD_CONSOLE (default /dev/console), FORWARD_PREFIX, FORWARD_MAX_BYTES
# (512), FORWARD_RATE_PER_MIN (60), FORWARD_WRITE_TIMEOUT seconds (2) and
# FORWARD_CLOCK_CMD (default: seconds of uptime, read without forking).
CONSOLE=${FORWARD_CONSOLE:-/dev/console}
PREFIX=${FORWARD_PREFIX:-[k3s]}
MAX_BYTES=${FORWARD_MAX_BYTES:-512}
RATE=${FORWARD_RATE_PER_MIN:-60}
WRITE_TIMEOUT=${FORWARD_WRITE_TIMEOUT:-2}
CLOCK_CMD=${FORWARD_CLOCK_CMD:-}
now() {
if [ -n "${CLOCK_CMD}" ]; then
sh -c "${CLOCK_CMD}"
else
read -r up _ < /proc/uptime && echo "${up%%.*}"
fi
}
# emit TEXT: one console line, abandoned after WRITE_TIMEOUT. Returns non-zero
# when the console did not take it.
emit() {
timeout "${WRITE_TIMEOUT}" sh -c 'printf "%s\n" "$1" >> "$2"' sh "$1" "${CONSOLE}" 2>/dev/null
}
# Filter, redact and truncate in one awk pass so lines that do not matter cost
# no fork. Redaction runs before the cut so a secret straddling the limit
# cannot leave a readable prefix behind.
select_lines() {
LC_ALL=C awk -v max="${MAX_BYTES}" '
{
line = $0
gsub(/\r/, "", line)
keep = 0
if (line ~ /level=(warning|error)/) keep = 1
else if (line ~ /^[WE][0-9][0-9][0-9][0-9] /) keep = 1
else if (line ~ /NodeNotReady|PLEG|[Ll]ease|[Nn]ode status/) keep = 1
if (!keep) next
gsub(/[Bb]earer +[A-Za-z0-9._~+\/=-]+/, "Bearer [REDACTED]", line)
gsub(/K10[0-9a-f]+(::[A-Za-z0-9-]+:[^ "\047]+)?/, "[REDACTED]", line)
gsub(/eyJ[A-Za-z0-9_.-]+/, "[REDACTED]", line)
gsub(/(AKIA|ASIA)[A-Z0-9]+/, "[REDACTED]", line)
gsub(/X-Amz-Signature=[^& "\047]*/, "X-Amz-Signature=[REDACTED]", line)
gsub(/X-Amz-Credential=[^& "\047]*/, "X-Amz-Credential=[REDACTED]", line)
gsub(/X-Amz-Security-Token=[^& "\047]*/, "X-Amz-Security-Token=[REDACTED]", line)
gsub(/[^[:print:]\t]/, " ", line)
print substr(line, 1, max)
}'
}
win_start=0
sent=0
dropped=0
flush_dropped() {
[ "${dropped}" -gt 0 ] || return 0
emit "${PREFIX} ${dropped} lines dropped"
dropped=0
}
select_lines | {
while IFS= read -r line; do
t=$(now) || t=0
if [ "${win_start}" -eq 0 ] || [ $((t - win_start)) -ge 60 ]; then
flush_dropped
win_start=${t}
sent=0
[ "${win_start}" -eq 0 ] && win_start=1
fi
if [ "${sent}" -ge "${RATE}" ]; then
dropped=$((dropped + 1))
continue
fi
sent=$((sent + 1))
emit "${PREFIX} ${line}" || dropped=$((dropped + 1))
done
flush_dropped
}
exit 0
@@ -0,0 +1,190 @@
#!/bin/sh
# Self-contained POSIX test for mulga-eks-log-forward.sh: the line filter,
# redaction, truncation, rate cap and blocked-console behaviour. The script
# reads the tailed log on stdin and writes to FORWARD_CONSOLE, which the test
# points at a temp file (or a reader-less FIFO), so no root or tty is needed.
#
# Run: sh scripts/images/eks-node/mulga-eks-log-forward_test.sh
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SCRIPT="${SCRIPT_DIR}/mulga-eks-log-forward.sh"
WORK=$(mktemp -d)
trap 'rm -rf "${WORK}"' EXIT
PASS=0
FAIL=0
check() { # check <desc> <expected> <actual>
if [ "$2" = "$3" ]; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
echo "FAIL: $1"
echo " expected: [$2]"
echo " actual: [$3]"
fi
}
# forward <name>: feeds stdin to the script with a per-case console file and a
# fixed clock, leaving the forwarded text in ${OUT}.
forward() {
CASE="${WORK}/$1"
mkdir -p "${CASE}"
OUT="${CASE}/console"
: > "${OUT}"
FORWARD_CONSOLE="${OUT}" FORWARD_PREFIX="[k3s-agent]" \
FORWARD_CLOCK_CMD="echo 1000" sh "${SCRIPT}" 2>/dev/null || true
}
forwarded() { grep -c . "${OUT}" || true; }
# --- filter: lines that must pass ---
forward filter_pass <<'EOF'
time="2026-11-03T03:00:00Z" level=warning msg="slow response from apiserver"
time="2026-11-03T03:00:01Z" level=error msg="failed to renew"
W1103 03:00:02.123456 1234 reflector.go:561] watch of *v1.Pod ended
E1103 03:00:03.123456 1234 kubelet.go:2400] node not found
I1103 03:00:04.123456 1234 kubelet_node_status.go:73] Node status update failed
I1103 03:00:05.123456 1234 controller.go:193] Failed to update lease
I1103 03:00:06.123456 1234 kubelet.go:2200] PLEG is not healthy
time="2026-11-03T03:00:07Z" level=info msg="Event: NodeNotReady kubelet"
EOF
check "filter: all eight interesting lines pass" "8" "$(forwarded)"
check "filter: lines carry the prefix" "8" "$(grep -c '^\[k3s-agent\] ' "${OUT}" || true)"
# --- filter: lines that must be dropped ---
forward filter_drop <<'EOF'
time="2026-11-03T03:00:00Z" level=info msg="Starting k3s agent"
time="2026-11-03T03:00:01Z" level=debug msg="tick"
I1103 03:00:02.123456 1234 server.go:100] Version: v1.32.5
I1103 03:00:03.123456 1234 kubelet.go:1000] SyncLoop (UPDATE, "api")
EOF
check "filter: info and debug lines are dropped" "0" "$(forwarded)"
# --- redaction: one case per secret shape ---
redact() { # redact <name> <line> <secret-that-must-not-appear>
forward "redact_$1" <<EOF
time="2026-11-03T03:00:00Z" level=error msg="$2"
EOF
check "redact(${1}): line still forwarded" "1" "$(forwarded)"
if grep -qF "$3" "${OUT}"; then
check "redact(${1}): secret removed" "absent" "present"
else
check "redact(${1}): secret removed" "absent" "absent"
fi
check "redact(${1}): marker present" "1" "$(grep -c 'REDACTED' "${OUT}" || true)"
}
redact bearer "request failed Authorization: Bearer abc123SECRETvalue456" "abc123SECRETvalue456"
redact k3s_token "join failed token K1009f3c2d4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2::server:s3cr3tPassw0rd" "s3cr3tPassw0rd"
redact jwt "bad token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXNlY3JldA" "eyJhbGciOiJIUzI1NiJ9"
redact akia "credentials AKIAIOSFODNN7EXAMPLE rejected" "AKIAIOSFODNN7EXAMPLE"
redact asia "credentials ASIAIOSFODNN7EXAMPLE rejected" "ASIAIOSFODNN7EXAMPLE"
redact amz_signature "GET /obj?X-Amz-Signature=0123456789abcdef0123456789abcdef&X-Amz-Expires=60 failed" "0123456789abcdef0123456789abcdef"
redact amz_credential "GET /obj?X-Amz-Credential=ZZZsecretscope%2F20261103%2Fap%2Fs3%2Faws4_request failed" "ZZZsecretscope"
# --- truncation: text over 512 bytes is cut ---
LONG=$(awk 'BEGIN { for (i = 0; i < 2000; i++) printf "a"; }')
forward truncate <<EOF
time="2026-11-03T03:00:00Z" level=error msg="${LONG}"
EOF
check "truncate: still forwarded" "1" "$(forwarded)"
BODY=$(sed 's/^\[k3s-agent\] //' "${OUT}" | head -n 1)
check "truncate: body is exactly 512 bytes" "512" "$(printf '%s' "${BODY}" | wc -c | tr -d ' ')"
check "truncate: keeps the start of the line" "time=\"2026-11-03T03:00:00Z\"" "$(printf '%s' "${BODY}" | cut -c1-27)"
# --- redaction runs before the cut: a secret over the limit leaves no prefix ---
# The pad puts the 512-byte cut right after the bare marker (AKIA, eyJ, K10).
# A cut fragment that long matches no redaction pattern, so redacting after the
# cut would leave it in the output.
HEAD='time="2026-11-03T03:00:00Z" level=error msg="'
straddle() { # straddle <name> <secret> <bytes of the secret kept by the cut>
PAD=$(awk -v n=$((512 - ${#HEAD} - 1 - $3)) 'BEGIN { for (i = 0; i < n; i++) printf "x"; }')
forward "straddle_$1" <<EOF
${HEAD}${PAD} $2 tail"
EOF
check "straddle(${1}): line forwarded" "1" "$(forwarded)"
check "straddle(${1}): cut keeps the marker length" "$3" "$(sed 's/^\[k3s-agent\] //' "${OUT}" | head -n 1 | awk -v p="${PAD} " '{ print length($0) - index($0, p) - length(p) + 1 }')"
if grep -qF "$(printf '%s' "$2" | cut -c1-"$3")" "${OUT}"; then
check "straddle(${1}): no fragment survives the cut" "absent" "present"
else
check "straddle(${1}): no fragment survives the cut" "absent" "absent"
fi
}
straddle jwt "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.c2ln" 3
straddle akia "AKIAIOSFODNN7EXAMPLE" 4
straddle k3s_token "K1009f3c2d4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2::server:s3cr3tPassw0rd" 3
# --- rate cap: 60 a minute, then one summary of what was dropped ---
gen() { # gen <count>
awk -v n="$1" 'BEGIN { for (i = 0; i < n; i++) printf "time=\"t\" level=error msg=\"loop %d\"\n", i }'
}
gen 100 > "${WORK}/burst"
forward rate_cap < "${WORK}/burst"
check "rate cap: 60 lines forwarded plus one summary" "61" "$(forwarded)"
check "rate cap: one dropped summary" "1" "$(grep -c '40 lines dropped' "${OUT}" || true)"
check "rate cap: first line kept" "1" "$(grep -c 'loop 0"' "${OUT}" || true)"
check "rate cap: line past the cap is not forwarded" "0" "$(grep -c 'loop 60"' "${OUT}" || true)"
# --- rate cap: a new minute starts a new window and reports the old drops ---
# The clock command advances 60 s every 70 calls, so lines 0-69 share a window
# (10 dropped) and lines 70-74 land in the next one.
CASE="${WORK}/rate_window"
mkdir -p "${CASE}"
OUT="${CASE}/console"
: > "${OUT}"
echo 0 > "${CASE}/calls"
cat > "${CASE}/clock" <<EOF
#!/bin/sh
n=\$(cat "${CASE}/calls")
echo \$((n + 1)) > "${CASE}/calls"
echo \$((1000 + n / 70 * 60))
EOF
chmod +x "${CASE}/clock"
gen 75 | FORWARD_CONSOLE="${OUT}" FORWARD_PREFIX="[k3s-agent]" FORWARD_CLOCK_CMD="${CASE}/clock" sh "${SCRIPT}" 2>/dev/null || true
check "window: 60 + 5 forwarded plus one summary" "66" "$(forwarded)"
check "window: summary names the 10 dropped" "1" "$(grep -c '10 lines dropped' "${OUT}" || true)"
check "window: new window forwards its lines" "1" "$(grep -c 'loop 74"' "${OUT}" || true)"
# --- blocked console: lines are dropped, the forwarder neither stalls nor fails ---
CASE="${WORK}/blocked"
mkdir -p "${CASE}"
mkfifo "${CASE}/console"
gen 3 > "${CASE}/in"
(
FORWARD_CONSOLE="${CASE}/console" FORWARD_PREFIX="[k3s-agent]" FORWARD_CLOCK_CMD="echo 1000" \
FORWARD_WRITE_TIMEOUT=1 sh "${SCRIPT}" < "${CASE}/in"
) &
PID=$!
i=0
while kill -0 "${PID}" 2>/dev/null && [ "${i}" -lt 20 ]; do
sleep 1
i=$((i + 1))
done
if kill -0 "${PID}" 2>/dev/null; then
kill "${PID}" 2>/dev/null || true
check "blocked console: forwarder finished" "finished" "stalled"
else
check "blocked console: forwarder finished" "finished" "finished"
fi
rc=0
wait "${PID}" || rc=$?
check "blocked console: exit 0" "0" "${rc}"
# --- the service is wired in, and nothing depends on it ---
INITD="${SCRIPT_DIR}/mulga-eks-log-forward.initd"
check "initd ships" "yes" "$([ -f "${INITD}" ] && echo yes || echo no)"
check "manifest installs the initd" "1" "$(grep -c 'mulga-eks-log-forward.initd:/etc/init.d/mulga-eks-log-forward' "${SCRIPT_DIR}/manifest.conf" || true)"
check "manifest installs the script" "1" "$(grep -c 'mulga-eks-log-forward.sh:/usr/local/sbin/mulga-eks-log-forward' "${SCRIPT_DIR}/manifest.conf" || true)"
DEPS=0
for f in "${SCRIPT_DIR}"/*.initd; do
[ "${f}" = "${INITD}" ] && continue
if grep -E '^[[:space:]]*(need|use|want)[[:space:]].*mulga-eks-log-forward' "${f}" >/dev/null 2>&1; then
DEPS=$((DEPS + 1))
fi
done
check "nothing depends on the forwarder" "0" "${DEPS}"
if [ -f "${INITD}" ]; then
check "initd does not need anything it could block boot on" "0" "$(grep -cE '^[[:space:]]*need[[:space:]]' "${INITD}" || true)"
fi
echo "mulga-eks-log-forward_test: ${PASS} passed, ${FAIL} failed"
[ "${FAIL}" -eq 0 ]
+2 -2
View File
@@ -45,10 +45,10 @@ ln -sf /usr/local/bin/k3s /usr/local/bin/ctr
chmod 0755 /etc/init.d/eks-node-role /etc/init.d/k3s /etc/init.d/k3s-agent \
/etc/init.d/eks-token-webhook /etc/init.d/k3s-first-boot /etc/init.d/mulga-eks-state-report \
/etc/init.d/mulga-eks-addon-sync /etc/init.d/mulga-eks-node-gc /etc/init.d/konnectivity-server \
/etc/init.d/eks-pod-identity-webhook
/etc/init.d/eks-pod-identity-webhook /etc/init.d/mulga-eks-log-forward
chmod 0755 /usr/local/sbin/eks-node-role /usr/local/sbin/k3s-first-boot \
/usr/local/sbin/mulga-eks-state-report /usr/local/sbin/mulga-eks-addon-sync \
/usr/local/sbin/mulga-eks-node-gc
/usr/local/sbin/mulga-eks-node-gc /usr/local/sbin/mulga-eks-log-forward
# start_pre helpers shared with the systemd wrappers that will eventually
# replace these runscripts.
chmod 0755 /usr/local/sbin/k3s-prestart /usr/local/sbin/k3s-agent-prestart \
+19 -9
View File
@@ -260,6 +260,14 @@ out() {
ssh "${SSH_OPTS[@]}" "$SSH_USER@$host" "$@" 2>/dev/null || true
}
# remote_join_cmd prints the command that joins one node. The token is not in it: it arrives on
# stdin and is read after sudo has logged its command, so sudo's log never holds it. The other
# values are positional arguments of a literal script, never spliced into it.
remote_join_cmd() {
local script='exec spx admin join --force --node "$1" --bind "$2" --cluster-bind "$2" --advertise "$3" --host "$4" --token "$(cat)" --region "$5" --az "$6"'
printf 'sudo bash -c %q _ %q %q %q %q %q %q' "$script" "$@"
}
SETUP_OVN="/usr/local/share/spinifex/setup-ovn.sh"
# --- Preflight -------------------------------------------------------------
@@ -652,14 +660,18 @@ fi
[ -n "$EXT_IFACE" ] && init_args+=(--external-iface="$EXT_IFACE")
[ -n "$EMAIL" ] && init_args+=(--email="$EMAIL")
INIT_LOG=/tmp/spx-init.log
# init prints the join token, so its log is root-only and outside the world-writable /tmp.
INIT_LOG=/var/log/spx-init.log
# redact_tokens hides join tokens in text about to be shown to the operator.
redact_tokens() { sed -E 's/spx_join_[A-Za-z0-9_-]+/spx_join_[redacted]/g'; }
log "initializing ${HOSTS[0]} as ${NODE_NAMES[0]} (waiting for $((N - 1)) node(s) to join)"
# init blocks in a formation server until every node has joined, so it runs
# detached and the token is read back out of its output.
on "${HOSTS[0]}" "sudo rm -f $INIT_LOG; \
sudo setsid sh -c 'spx admin init ${init_args[*]} >$INIT_LOG 2>&1' </dev/null >/dev/null 2>&1 & \
sudo setsid sh -c 'umask 077; spx admin init ${init_args[*]} >$INIT_LOG 2>&1' </dev/null >/dev/null 2>&1 & \
exit 0" || fail "${HOSTS[0]}: could not start spx admin init"
TOKEN=""
@@ -675,7 +687,7 @@ else
elapsed=$((elapsed + 5))
done
if [ -z "$TOKEN" ]; then
out "${HOSTS[0]}" "sudo tail -40 $INIT_LOG" | sed 's/^/ | /' >&2
out "${HOSTS[0]}" "sudo tail -40 $INIT_LOG" | redact_tokens | sed 's/^/ | /' >&2
fail "${HOSTS[0]}: spx admin init produced no join token within ${TOKEN_TIMEOUT}s"
fi
log " join token issued"
@@ -691,11 +703,9 @@ fi
# waiting on a shell that node 2 is still holding.
JOIN_PIDS=()
for i in $(seq 1 $((N - 1))); do
on "${HOSTS[$i]}" "sudo spx admin join --force \
--node ${NODE_NAMES[$i]} \
--bind ${LAN_IPS[$i]} --cluster-bind ${LAN_IPS[$i]} --advertise ${WAN_IPS[$i]} \
--host ${LAN_IPS[0]}:$PORT --token $TOKEN \
--region $REGION --az $AZ" &
# The token goes to the node on stdin, so it is in no ssh argument and not in sudo's log.
# spx admin join takes it only as --token, so it is still in that process's arguments.
on "${HOSTS[$i]}" "$(remote_join_cmd "${NODE_NAMES[$i]}" "${LAN_IPS[$i]}" "${WAN_IPS[$i]}" "${LAN_IPS[0]}:$PORT" "$REGION" "$AZ")" <<<"$TOKEN" &
JOIN_PIDS+=("$!")
done
@@ -724,7 +734,7 @@ if ! $DRY_RUN; then
elapsed=$((elapsed + 5))
done
if [ "$elapsed" -ge "$FORMATION_TIMEOUT" ]; then
out "${HOSTS[0]}" "sudo tail -40 $INIT_LOG" | sed 's/^/ | /' >&2
out "${HOSTS[0]}" "sudo tail -40 $INIT_LOG" | redact_tokens | sed 's/^/ | /' >&2
fail "${HOSTS[0]}: formation did not complete within ${FORMATION_TIMEOUT}s"
fi
log " formation complete"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Tests remote_join_cmd from install-node.sh: the join token must reach spx only on stdin, so
# sudo's log (which holds the command it was given) never contains it, and the other values must
# arrive as plain arguments even when they hold shell syntax.
# preflight runs this with sh
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
mkdir "$work/bin"
cd "$work"
cat > "$work/bin/sudo" <<'SH'
#!/bin/sh
echo "$*" >> "$FAKE_SUDO_LOG"
exec "$@"
SH
cat > "$work/bin/spx" <<'SH'
#!/bin/sh
for a in "$@"; do printf '<%s>' "$a"; done > "$FAKE_SPX_ARGV"
SH
chmod +x "$work/bin/sudo" "$work/bin/spx"
export FAKE_SUDO_LOG="$work/sudo.log" FAKE_SPX_ARGV="$work/spx.argv" PATH="$work/bin:$PATH"
eval "$(sed -n '/^remote_join_cmd()/,/^}/p' "$here/install-node.sh")"
fails=0
check() { if [ "$2" = "$3" ]; then echo "ok $1"; else echo "FAIL $1: want '$3' got '$2'"; fails=$((fails + 1)); fi; }
TOKEN=spx_join_FAKETOKEN-abc_1
cmd="$(remote_join_cmd node2 10.0.0.2 203.0.113.2 '10.0.0.1:4432;touch PWNED' us-east-1 'az $(touch PWNED2)')"
bash -c "$cmd" <<<"$TOKEN"
check "the token is not in the command" "$(printf '%s' "$cmd" | grep -c FAKETOKEN || true)" 0
check "the token is not in sudo's log" "$(grep -c FAKETOKEN "$FAKE_SUDO_LOG" || true)" 0
check "spx got the token" "$(grep -o '<--token><[^>]*>' "$FAKE_SPX_ARGV")" "<--token><$TOKEN>"
check "spx got the other values as given" "$(cat "$FAKE_SPX_ARGV")" "<admin><join><--force><--node><node2><--bind><10.0.0.2><--cluster-bind><10.0.0.2><--advertise><203.0.113.2><--host><10.0.0.1:4432;touch PWNED><--token><$TOKEN><--region><us-east-1><--az><az \$(touch PWNED2)>"
check "no value was run as a command" "$(find "$work" -maxdepth 1 -name 'PWNED*' | wc -l | tr -d ' ')" 0
[ "$fails" -eq 0 ]
+111
View File
@@ -0,0 +1,111 @@
#!/bin/sh
# lambda-testlink-build_test.sh — the test-link make targets refuse a malformed
# image digest and an unsupported host or GOARCH before touching any output.
#
# Every case is refused on the recipe's first lines, so nothing is compiled.
set -eu
LIB_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(dirname -- "$(dirname -- "$LIB_DIR")")
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- make build-lambda-testlink / build-lambda-testlink-image guards"
PWN="$TMP/pwn"
OUT="$TMP/out"
HEX=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
mkdir -p "$OUT"
printf 'old daemon\n' > "$OUT/spx"
printf 'old initializer\n' > "$OUT/hardkill.so"
PAIR=$(cat "$OUT/spx" "$OUT/hardkill.so" | cksum)
# unchanged <name> — the existing pair is intact, no scratch was made and no
# injected command ran.
unchanged() {
if [ -e "$PWN" ]; then
fail "$1: injected command ran"
rm -f "$PWN"
elif [ -e "$OUT.tmp" ]; then
fail "$1: scratch directory created"
elif [ "$(cat "$OUT/spx" "$OUT/hardkill.so" | cksum)" != "$PAIR" ]; then
fail "$1: existing pair replaced"
else
pass "$1"
fi
}
# refused <name> <want> <log> <status> — make failed with the expected message.
refused() {
if [ "$4" -eq 0 ]; then
fail "$1: make succeeded"
elif ! grep -q "$2" "$3"; then
fail "$1: wrong refusal: $(tr '\n' ' ' < "$3")"
else
unchanged "$1"
fi
}
testlink() {
make -s -C "$REPO_ROOT" --no-print-directory build-lambda-testlink LAMBDA_TESTLINK_DIR="$OUT" "$@" > "$TMP/log" 2>&1
}
DIGEST_MSG="LAMBDA_IMAGE_DIGEST must be 64 lowercase hex digits"
NL='
'
for v in '' "\$(shell touch $PWN)" "\$\$(touch $PWN)" "\`touch $PWN\`" "$HEX; touch $PWN" \
"$HEX\" -X main.x=\"y" "$HEX -X main.x=y" "$HEX${NL}touch $PWN" \
0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF "${HEX%?}" "${HEX}0"; do
# GNU make itself expands a command-line $(shell ...) while parsing, for
# any target, so that one literal is tested only from the environment.
case "$v" in
"\$(shell"*) ;;
*)
status=0
testlink LAMBDA_IMAGE_DIGEST="$v" || status=$?
refused "command-line digest [$v]" "$DIGEST_MSG" "$TMP/log" "$status"
;;
esac
status=0
LAMBDA_IMAGE_DIGEST="$v" testlink || status=$?
refused "environment digest [$v]" "$DIGEST_MSG" "$TMP/log" "$status"
done
status=0
env -u LAMBDA_IMAGE_DIGEST make -s -C "$REPO_ROOT" --no-print-directory build-lambda-testlink LAMBDA_TESTLINK_DIR="$OUT" > "$TMP/log" 2>&1 || status=$?
refused "digest unset" "$DIGEST_MSG" "$TMP/log" "$status"
ARCH_MSG="supports only an x86_64 host building linux/amd64"
SHIM="$TMP/shim"
mkdir -p "$SHIM"
printf '#!/bin/sh\necho aarch64\n' > "$SHIM/uname"
chmod +x "$SHIM/uname"
status=0
GOARCH=arm64 testlink LAMBDA_IMAGE_DIGEST="$HEX" || status=$?
refused "GOARCH=arm64" "$ARCH_MSG" "$TMP/log" "$status"
status=0
PATH="$SHIM:$PATH" testlink LAMBDA_IMAGE_DIGEST="$HEX" || status=$?
refused "aarch64 host" "$ARCH_MSG" "$TMP/log" "$status"
# The image target refuses before building qboot or any agent binary.
for c in "GOARCH=arm64" "PATH=$SHIM:$PATH"; do
status=0
env "$c" make -s -C "$REPO_ROOT" --no-print-directory build-lambda-testlink-image > "$TMP/log" 2>&1 || status=$?
if [ "$status" -eq 0 ]; then
fail "image target [${c%%=*}]: make succeeded"
elif ! grep -q "$ARCH_MSG" "$TMP/log" || grep -q "qboot\|go build" "$TMP/log"; then
fail "image target [${c%%=*}]: did not refuse first: $(tr '\n' ' ' < "$TMP/log")"
else
pass "image target [${c%%=*}] refused first"
fi
done
if [ "$fails" -ne 0 ]; then
echo "$fails failure(s)"
exit 1
fi
+10
View File
@@ -0,0 +1,10 @@
.terraform/
terraform.tfstate
terraform.tfstate.*
*.tfplan
*.auto.tfvars
crash.log
terraform.tfvars
*.pem
*.log
.terraform.lock.hcl
+137
View File
@@ -0,0 +1,137 @@
# latitude-spx
Builds a Spinifex deployment on latitude.sh bare metal from your laptop with `deploy-latitude.sh`.
The commands, flags, step markers and state layout follow `vultr-spx`.
**Status: not yet run end to end.**
The script was written from the plan doc findings (`docs/development/feature/latitude-bm.md`, L1 to L26) and from the provider schema.
No latitude.sh API call or host run was made while building it, so treat the first `up` as the test.
One node has been the only shape run live, and only in part (see Unverified). The three-node flow, including the floating pool, has not been run: Ben takes that over.
## What it creates
| Resource | Notes |
|---|---|
| Project | `spinifex-<name>`, unless `--project` names an existing one (then it is never deleted) |
| SSH key | `spinifex-<name>`, account scoped |
| User data | `spinifex-<name>`, host preparation only (no `{{ }}`, latitude.sh substitutes them) |
| Servers | `m4-metal-medium`, site `DAL`, Ubuntu 26.04, hourly billing, hostnames `spx-<name>-nodeN` |
| Elastic IP | one by default (`elastic_ip_count` in `variables.tf`, 1 to 5; set `TF_VAR_elastic_ip_count` to change it). With one node it is a static pool address and the count must stay 1, because a second pool is never tried when the first is full; with three nodes the addresses form the `latitude` floating pool, which Spinifex moves between servers. `nginx-webserver` needs one address and runs on one node; `nginx-alb` needs two public addresses and is a three-node workbook |
| Virtual network | three nodes only: `spinifex-<name>`, one VLAN assignment per server |
## Requirements
OpenTofu (or Terraform) 1.5 or later, `aws` CLI, `python3`, `curl`, `ssh`, an SSH key pair.
| Variable | When | Meaning |
|---|---|---|
| `LATITUDESH_AUTH_TOKEN` | always | the account API token; used by the provider and this script only, never sent to a node |
| `LATITUDESH_NODE_TOKEN` | `--nodes 3` | a second, different, writable API key, copied to each node (`/etc/spinifex/latitude/api-token`, owned by `spinifex-daemon` with mode 0400, in a root-owned 0711 directory; the other services share the `spinifex` group, so no group may read it; ownership and mode are set on every `up`; a symlink or any other owner there is refused) so the daemon can move Elastic IPs |
The script refuses a node key it can see is read-only (it lists `/auth/api_keys` and matches the key's last five characters). If the key is not among the account's keys, write authority is first proven by the first Elastic IP move.
Exposure: a latitude.sh API key is team-wide. It is not scoped to Elastic IPs, so anyone who reads it from a node can do whatever the key's owner can in the team (the API exposes `read_only` and `allowed_ips` on a key, and no narrower scope). Create the node key with `allowed_ips` set to the nodes' public addresses, and revoke it after `destroy`.
Nodes are reached as `ubuntu` with the deployment's SSH key, and remote commands run through passwordless `sudo`: latitude.sh puts the key on the image's login user and root login is not enabled.
## Usage
```
export LATITUDESH_AUTH_TOKEN=... # from the latitude.sh dashboard
./deploy-latitude.sh up --name demo # one node
export LATITUDESH_NODE_TOKEN=... # a second, writable key
./deploy-latitude.sh up --name demo3 --nodes 3
./deploy-latitude.sh status --name demo
./deploy-latitude.sh ssh --name demo node1 'systemctl status spinifex.target'
./deploy-latitude.sh credentials --name demo
./deploy-latitude.sh stop node1 --name demo
./deploy-latitude.sh start node1 --name demo
./deploy-latitude.sh destroy --name demo
```
Options: `--name`, `--site` (alias `--region`), `--plan`, `--os`, `--project ID|SLUG`, `--distro TARBALL`, `--channel`, `--spx-region`, `--spx-az`, `--ssh-key`, `--image`, `--data-disk-id`, `--vpc-subnet` (the VLAN's /24, three nodes), `--nodes 1|3`, `--yes`.
`./deploy-latitude.sh --help` lists them with defaults.
Three nodes need `--distro` with a tarball built from this branch (`make distro-amd64`) until a release carries the latitude provider.
A released spx rejects `source="latitude"` and its daemon restarts in a loop, so `up --nodes 3` without `--distro` stops in preflight.
Before the pool is written, each node's `/usr/local/bin/spx` is checked for the `latitude_elastic_ip_ids` key name; spx has no validate subcommand, so this is a string search of the binary, not a run of its config check.
## What `up` does
`terraform` then, per node: `host` (wait for SSH as `ubuntu` and cloud-init, `/dev/kvm`), `disk`, `vlan` (three nodes), `install`, `form`, `start`; then `pool` (one node) or `cluster` (three nodes: `install-node.sh` forms the cluster); three nodes then install the node token on every `up` and add the `latitude` pool (`pool`); then `restart` (three nodes wait for the current daemon to log "floating pool ready" for the pool), `public`, `image` and the AWS profile.
Each step writes `step.<name>[.nodeN]` in the state directory, and a rerun resumes at the first step without one.
- **Data disk.** `host-data-disk.sh` picks the blank NVMe by content and by-id EUI, never by `nvmeXn1` (L1, L11).
A candidate is at least 100 GB with no signature, partitions, holders or mounts, and not in the root filesystem's ancestry; with none it stops and lists the disks.
With several (LON2 has two 1.7 TB and one spare 447 GB blank NVMe next to the root disk) it takes the largest, the lowest by-id path among equals, prints what it chose and the blank disks it left, and never touches the others (`--data-disk-id` chooses another).
It mounts by UUID with `nofail` and the script records the UUID in `data_uuid.<node>`.
- **Mount check.** Before anything writes under `/var/lib/spinifex`, and on every run, the mounted filesystem UUID must equal the recorded one.
The units that write there also get a `RequiresMountsFor=/var/lib/spinifex` drop-in.
The mount is by UUID in fstab, so renamed disks do not change it, and there is no check at service start.
- **Firewall.** `setup.sh --firewall on` runs before Spinifex first starts.
- **One node.** The Elastic IP is a static pool (`gateway_ip = "192.0.2.1"`, the node's next hop as `gateway`, no `prefix_len`) with the IMDS addresses moved off the ones latitude.sh routes.
- **Three nodes.** The `vlan` step makes `eno2.<vid>` with `ip link` and persists it as systemd-networkd files for that link plus a drop-in on the `eno2` unit.
It never runs `netplan apply`, refuses a subnet that overlaps the SSH peer or a route, and removes the VLAN again if the public interface's address, default route or the route to the SSH peer differ afterwards.
`form_cluster` then puts the cluster and Geneve planes on that VLAN with `install-node.sh` (`--user ubuntu`); its output is not kept, as it can hold the join token.
- **Floating pool (three nodes).** The `latitude` pool names the Elastic IP IDs and the project ID from the Terraform outputs, and each node's `provider_host_id` is its server ID. `spinifex.toml` is edited idempotently and read back. Failover moves an address with one `PATCH /elastic_ips/{id}` and waits for it to be active; latitude.sh has no unassign, so a node that loses an address is not told to release it.
- **State** is in `~/.spinifex/latitude/<name>/`: Terraform state, `ids` (every `latitudesh_*` object in the state, merged on each apply and before each destroy), `step.*` markers, logs and the data disk UUIDs.
- **AWS profile** `spinifex-latitude-<name>` is replaced on each `up`, with the CA at `~/.spinifex/latitude-<name>-ca.pem`.
The secret key is written to `~/.aws/credentials` (0600, other profiles kept, the old file saved beside it) and never appears in a process argument.
## Token handling
`LATITUDESH_AUTH_TOKEN` is read from the environment by the provider and by the script's API calls.
It is not a Terraform variable, is on no command line (curl reads it from stdin), is not sent to a node, and is written to no file.
`LATITUDESH_NODE_TOKEN` is checked with curl reading it from stdin and reaches each node on stdin of the install script, so it is in no argument on either side.
`TF_LOG` is unset by the script.
The join token that `install-node.sh` creates is shared with Vultr and OCI: its init log is root-only, and `spx admin join` still takes it as `--token` on the joining node (tracked separately).
## Recovery
If SSH is lost once the firewall is armed, use the serial console.
`up` enables the server's out-of-band connection with the deployment's SSH key and prints the `ssh` command it returns before it arms the firewall, falling back to the Out-of-Band tab in the dashboard.
Logging in there needs an OS user password, which you set yourself while SSH still works.
A fresh deployment that cannot be recovered is cheaper to `destroy` and `up` again.
The VLAN step prints the console command first.
It refuses a subnet that holds the SSH peer or overlaps an existing route, and removes the VLAN again if the route to the SSH peer or the public interface changes.
## destroy
`destroy` asks you to type the deployment name (`--yes` skips only that), then records every ID in the Terraform state and stops without destroying anything if it cannot.
It runs `terraform destroy`, fetches every recorded ID by itself (2xx present, 404 gone, anything else fails and keeps the records), and deletes what remains of those.
Servers, keys, user data, virtual networks and projects whose names match the deployment but that are not recorded are listed by ID and deleted only after you type `delete-untracked` for exactly those IDs, which `--yes` never supplies; an ID that shows up later needs its own confirmation, and without a terminal they are kept and `destroy` exits 1.
Elastic IPs are touched only by recorded ID.
Elastic IP release is asynchronous and only works once the address is active or in error, so the sweep retries.
Because the Elastic IP list ignores pagination, `destroy` ends by asking you to check the dashboard for leftovers and type `checked`.
An `up` that failed while creating can leave an object nothing recorded, such as an Elastic IP; that dashboard check is the only thing that finds it.
## Unverified
Nothing in the three-node flow has run against real servers. For one node, only the parts the person ran have been exercised, and the script has changed since.
- The OS slug `ubuntu_26_04_x64_lts` (the `latitudesh_operating_system` data source fails the plan if it is wrong).
- That the deploy key is on `ubuntu` and `sudo -n` works for it, and that `sudo` resets nothing the script needs (the SSH peer address is read before `sudo` for that reason).
- The power action request (`POST /servers/{id}/actions` with `power_off` and `power_on`).
- Resolving `--project` as a slug through `GET /projects/{slug}`, and the `in_stock` shape of `GET /plans` used by the stock check, which only warns when it cannot read it.
- The `GET` and `DELETE` paths for `/user_data/{id}` and `/virtual_networks/{id}`.
- That the Spinifex API certificate names the node's public address, which the profile check needs.
- That the private NIC is `eno2`, its tagging and MTU (the script uses 1500), and that the networkd files persist the VLAN across a reboot.
- `GET /auth/api_keys` and its `token_last_slice` and `read_only` fields, which the node key check relies on; and that a key with `allowed_ips` set to the nodes' addresses still works from them.
- The Elastic IP move itself: `PATCH /elastic_ips/{id}` with `server_id`, its 202 then `active`, the error codes, and how long a move takes; the client waits up to its move timeout and makes one write per call.
- That the daemon reads the node token and logs "floating pool ready", then fails over when a node stops (`stop node1`) and the address lands on another node.
- `install-node.sh` as `--user ubuntu` on latitude.sh servers.
## Not exercised
Any real API call from the three-node flow, `destroy`, `host-data-disk.sh` on a host and the VLAN step on a server.
Guest launch, public reachability of the Elastic IP, the Terraform workbooks and failover are not part of `up`; prove them by hand.
`e2e-latitude.sh` and removing the old `install-node.sh` in this directory are not done.
## Tests
`sh node-scripts_test.sh` checks the node token install (install, unchanged, rotation, modes, an older install converging, symlinks and owners refused; run it as root too, with `sudo sh node-scripts_test.sh`, to check that only the daemon's user can read the file) and the readiness check (old success then current failure) with stand-in tools.
`bash deploy-argv_test.sh` checks that neither API token, nor the five characters used to find the node key, is an argument of any external command in the node token paths, and that the SSH peer address comes from the same connection after sudo's environment reset.
`scripts/install-node_test.sh` (part of `make preflight`) checks that the join token reaches `spx admin join` only on stdin, so sudo's log never holds it.
The first two are not part of `make preflight`.
The Go side: `cd spinifex && GOWORK=off go test ./providers/cloud/latitude/ ./domains/network/external/floating/ ./bootstrap/config/ ./runtime/daemon/`.
@@ -0,0 +1,23 @@
#cloud-config
# Host preparation only. Spinifex is installed and formed over SSH by deploy-latitude.sh.
# latitude.sh substitutes double-brace variables in user data, so this file holds none.
write_files:
- path: /etc/cloud/cloud.cfg.d/99-spinifex-network.cfg
content: |
network: {config: disabled}
# The image's netplan lists eno2 with no address and without optional, so boot waits two minutes
# for it. --any returns once one link, eno1, is online; it changes the wait, not the network.
# UNVERIFIED: the binary path is the Ubuntu one and the drop-in first applies on the next boot.
- path: /etc/systemd/system/systemd-networkd-wait-online.service.d/10-spinifex-any.conf
content: |
[Service]
ExecStart=
ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --any
# runcmd carries on past a failing command, so each line stands alone.
runcmd:
- [sh, -c, "command -v ufw >/dev/null && ufw --force disable || true"]
- [sh, -c, "systemctl disable --now unattended-upgrades.service apt-daily-upgrade.timer apt-daily.timer || true"]
# The image ships empty apt lists and no git, which the workbooks need.
- [sh, -c, "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git || true"]
- [sh, -c, "systemctl daemon-reload || true"]
- [sh, -c, "touch /var/lib/cloud/spinifex-host-ready"]
+70
View File
@@ -0,0 +1,70 @@
locals {
hostname_prefix = "spx-${var.deployment_name}-node"
project = var.project != "" ? var.project : latitudesh_project.deploy[0].slug
project_id = var.project != "" ? var.project_id : latitudesh_project.deploy[0].id
}
# Both lookups fail the plan when a slug does not exist, which is the only check of the OS slug.
data "latitudesh_operating_system" "os" {
slug = var.operating_system
}
data "latitudesh_plan" "plan" {
slug = var.plan
}
resource "latitudesh_project" "deploy" {
count = var.project == "" ? 1 : 0
name = "spinifex-${var.deployment_name}"
description = "Spinifex deployment ${var.deployment_name}"
environment = "Development"
provisioning_type = "on_demand"
}
# The key is team scoped. The server login user is ubuntu, not root.
resource "latitudesh_ssh_key" "deploy" {
name = "spinifex-${var.deployment_name}"
public_key = trimspace(file(pathexpand(var.ssh_public_key_path)))
}
# Host preparation only. Latitude substitutes double-brace variables inside user data, so the file
# must hold none. Spinifex is installed over SSH by deploy-latitude.sh.
resource "latitudesh_user_data" "host" {
description = "spinifex-${var.deployment_name}"
content = filebase64("${path.module}/cloud-init/user-data.yaml")
}
# billing must be hourly: the default is monthly, which bills upfront with no refund.
resource "latitudesh_server" "node" {
count = var.node_count
project = local.project
hostname = "${local.hostname_prefix}${count.index + 1}"
site = var.site
plan = data.latitudesh_plan.plan.slug
operating_system = data.latitudesh_operating_system.os.slug
billing = "hourly"
ssh_keys = [latitudesh_ssh_key.deploy.id]
user_data = latitudesh_user_data.host.id
timeouts = {
create = "60m"
}
}
# Elastic IPs are bought here and never created or deleted by Spinifex. Each starts on a server, spread
# over the nodes, and server_id is ignored afterwards so a move by Spinifex is not undone by a later apply.
resource "latitudesh_elastic_ip" "pool" {
count = var.elastic_ip_count
# The provider documents ID or slug but posts the value as project_id unresolved: a slug gave 404.
project = local.project_id
server_id = latitudesh_server.node[count.index % var.node_count].id
lifecycle {
ignore_changes = [server_id]
}
}
moved {
from = latitudesh_elastic_ip.node1
to = latitudesh_elastic_ip.pool[0]
}
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
# Checks that neither API token is ever an argument of an external command in the node token
# paths of deploy-latitude.sh. External commands are stand-ins that record their arguments and
# their standard input; the test fails if a token is in the first and proves it is in the second.
# preflight runs this with sh
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
set -euo pipefail
exec </dev/null
here="$(cd "$(dirname "$0")" && pwd)"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
mkdir "$work/bin" "$work/home"
real_python="$(command -v python3)"
AUTH=FAKEAUTHTOKEN9
NODE=FAKENODETOKEN7
# Every command the script runs outside bash records "name args" and, for the first line of
# standard input, "name stdin: ...".
for c in curl ssh scp aws tofu ssh-keygen; do
cat > "$work/bin/$c" <<'SH'
#!/bin/sh
echo "$(basename "$0") $*" >> "$FAKE_ARGV"
if [ ! -t 0 ]; then
in="$(cat)"
[ -z "$in" ] || echo "$(basename "$0") stdin: $in" >> "$FAKE_STDIN"
fi
case "$(basename "$0")" in
curl) printf '{"data":[{"attributes":{"token_last_slice":"%s","read_only":false}}]}\n200' "$(printf '%s' "$FAKE_NODE" | tail -c 5)" ;;
ssh)
if [ -n "${FAKE_SSH_EVAL:-}" ]; then
# Behave like a login on a node: skip the options and host, run the rest with the
# connection variable of this connection, and let sudo reset the environment.
while [ "$#" -gt 0 ]; do case "$1" in ubuntu@*) shift; break ;; *) shift ;; esac; done
SSH_CONNECTION="$FAKE_SSH_CONNECTION" bash -c "$*"
else
echo installed
fi ;;
esac
SH
chmod +x "$work/bin/$c"
done
cat > "$work/bin/sudo" <<'SH'
#!/bin/sh
while [ "$#" -gt 0 ]; do case "$1" in -*) shift ;; *) break ;; esac; done
exec env -i PATH="$PATH" "$@"
SH
chmod +x "$work/bin/sudo"
cat > "$work/bin/python3" <<SH
#!/bin/sh
echo "python3 \$*" >> "\$FAKE_ARGV"
exec "$real_python" "\$@"
SH
chmod +x "$work/bin/python3"
export FAKE_ARGV="$work/argv" FAKE_STDIN="$work/stdin" FAKE_NODE="$NODE" PATH="$work/bin:$PATH" HOME="$work/home"
: > "$FAKE_ARGV"
: > "$FAKE_STDIN"
export LATITUDESH_AUTH_TOKEN="$AUTH" LATITUDESH_NODE_TOKEN="$NODE"
# shellcheck source=deploy-latitude.sh
source "$here/deploy-latitude.sh"
# shellcheck disable=SC2034 # read by the sourced functions
NODES=3 IP=192.0.2.10 NODE_NAME=node1
check_node_token >/dev/null
install_key >/dev/null 2>&1
fails=0
for tok in "$AUTH" "$NODE" "${NODE: -5}"; do
if grep -q "$tok" "$FAKE_ARGV"; then
echo "FAIL token $tok is in a command's arguments:"
grep "$tok" "$FAKE_ARGV"
fails=$((fails + 1))
else
echo "ok $tok is in no argument"
fi
done
for what in "curl stdin:.*Bearer $NODE" "ssh stdin: $NODE"; do
if grep -q "$what" "$FAKE_STDIN"; then
echo "ok the check can see the token where it is sent ($what)"
else
echo "FAIL the token never reached stdin ($what): the test would pass without checking anything"
fails=$((fails + 1))
fi
done
# The peer address comes from the same connection that runs the command, whichever source
# address that connection has, and survives sudo's environment reset.
export FAKE_SSH_EVAL=1
for src in 198.51.100.7 203.0.113.99; do
export FAKE_SSH_CONNECTION="$src 51000 192.0.2.10 22"
got="$(on 'printf %s "$SSH_PEER"')"
if [ "$got" = "$src" ]; then echo "ok peer $src reaches the command"; else echo "FAIL peer for $src was '$got'"; fails=$((fails + 1)); fi
done
# A three-node run needs a build that knows the latitude source: a release would be refused by its
# own config check. The guard needs --distro, and the node is checked before it gets the pool.
check_result() { if [ "$2" = "$3" ]; then echo "ok $1"; else echo "FAIL $1: want '$3' got '$2'"; fails=$((fails + 1)); fi; }
check_result "three nodes without --distro stop" "$( (NODES=3 DISTRO="" require_branch_build) >/dev/null 2>&1 && echo ran || echo stopped)" stopped
check_result "three nodes with --distro go on" "$( (NODES=3 DISTRO=/tmp/x.tar.gz require_branch_build) >/dev/null 2>&1 && echo ran || echo stopped)" ran
check_result "one node without --distro goes on" "$( (NODES=1 DISTRO="" require_branch_build) >/dev/null 2>&1 && echo ran || echo stopped)" ran
check_result "one node with one address goes on" "$( (NODES=1 TF_VAR_elastic_ip_count=1 require_one_address) >/dev/null 2>&1 && echo ran || echo stopped)" ran
check_result "one node with no setting goes on" "$( (NODES=1 TF_VAR_elastic_ip_count="" require_one_address) >/dev/null 2>&1 && echo ran || echo stopped)" ran
check_result "one node with two addresses stops" "$( (NODES=1 TF_VAR_elastic_ip_count=2 require_one_address) >/dev/null 2>&1 && echo ran || echo stopped)" stopped
check_result "three nodes with three addresses go on" "$( (NODES=3 TF_VAR_elastic_ip_count=3 require_one_address) >/dev/null 2>&1 && echo ran || echo stopped)" ran
printf 'binary with latitude_elastic_ip_ids inside\n' > "$work/spx-new"
printf 'binary of a release\n' > "$work/spx-old"
SPX_BIN="$work/spx-new"
check_result "a build that knows latitude is accepted" "$( (node_knows_latitude) >/dev/null 2>&1 && echo ran || echo stopped)" ran
# shellcheck disable=SC2034 # read by the sourced functions
SPX_BIN="$work/spx-old"
check_result "a release is refused" "$( (node_knows_latitude) >/dev/null 2>&1 && echo ran || echo stopped)" stopped
# The profile spx writes under sudo is in the ubuntu user's home, so node-side reads name that
# directory instead of relying on root's HOME.
mkdir "$work/awsdir"
printf '[other]\naws_access_key_id = X\n[spinifex]\naws_access_key_id = AK1\naws_secret_access_key = SK1\n[z]\nq = 1\n' > "$work/awsdir/credentials"
node_ip() { printf 192.0.2.10; }
# shellcheck disable=SC2034 # read by the sourced functions
NODE_AWS_DIR="$work/awsdir"
check_result "credentials come from the node's AWS dir" "$(cmd_credentials | tr '\n' ' ')" "[spinifex] aws_access_key_id = AK1 aws_secret_access_key = SK1 "
# A failed API call logs its status and a documented code, never the response text, which can
# echo the request's headers.
mkdir "$work/bin2"
cat > "$work/bin2/curl" <<'SH'
#!/bin/sh
cat > /dev/null
printf '{"errors":[{"code":"%s","detail":"Authorization: Bearer %s"}]}\n401' "$FAKE_CODE" "$FAKE_LEAK"
SH
chmod +x "$work/bin2/curl"
logged="$(FAKE_CODE=UNAUTHORIZED FAKE_LEAK="$AUTH" PATH="$work/bin2:$PATH" latitude GET /servers 2>&1 || true)"
check_result "a failed call names its status and code" "$(grep -c 'returned 401 (UNAUTHORIZED)' <<<"$logged" || true)" 1
check_result "a failed call never logs the token" "$(grep -c "$AUTH" <<<"$logged" || true)" 0
logged="$(FAKE_CODE="$AUTH" FAKE_LEAK="$AUTH" PATH="$work/bin2:$PATH" latitude GET /servers 2>&1 || true)"
check_result "an undocumented code is not logged" "$(grep -c "$AUTH" <<<"$logged" || true)" 0
# A recorded ID that now names another deployment's object is not ours to delete.
# shellcheck disable=SC2034 # read by the sourced functions
NAME=demo
latitude() { printf '%s' "$FAKE_LAT"; }
orig_expected_project_id="$(declare -f expected_project_id)"
expected_project_id() { echo proj_1; }
srv() {
local frag=',"project":{"id":"proj_1"}'
[ "$#" -lt 2 ] || frag="$2"
printf '{"data":{"id":"sv_1","attributes":{"hostname":"%s"%s}}}' "$1" "$frag"
}
FAKE_LAT="$(srv spx-demo-node2)"
check_result "a recorded server with our hostname is ours" "$(recorded_identity server sv_1 && echo ours || echo "rc=$?")" ours
FAKE_LAT="$(srv spx-other-node1)"
check_result "a recorded server with another hostname is not" "$(recorded_identity server sv_1 && echo ours || echo "rc=$?")" rc=1
FAKE_LAT="$(srv spx-demo2-node1)"
check_result "a hostname that only starts like ours is not" "$(recorded_identity server sv_1 && echo ours || echo "rc=$?")" rc=1
FAKE_LAT="$(srv spx-demo-node1 ',"project":{"id":"proj_other"}')"
check_result "our hostname in another project is not ours" "$(recorded_identity server sv_1 && echo ours || echo "rc=$?")" rc=1
FAKE_LAT="$(srv spx-demo-node1 '')"
check_result "a server with no project is unknown" "$(recorded_identity server sv_1 && echo ours || echo "rc=$?")" rc=2
FAKE_LAT="$(srv spx-demo-node1 ',"project":{}')"
check_result "a project with no id is unknown" "$(recorded_identity server sv_1 && echo ours || echo "rc=$?")" rc=2
FAKE_LAT="$(srv spx-demo-node1 ',"project":null')"
check_result "a null project is unknown" "$(recorded_identity server sv_1 && echo ours || echo "rc=$?")" rc=2
expected_project_id() { return 1; }
FAKE_LAT="$(srv spx-demo-node1)"
check_result "no known project id is unknown" "$(recorded_identity server sv_1 && echo ours || echo "rc=$?")" rc=2
expected_project_id() { echo proj_1; }
FAKE_LAT='not json'
check_result "an unreadable object is unknown" "$(recorded_identity server sv_1 && echo ours || echo "rc=$?")" rc=2
KNOWN_IDS="$work/ids"
printf 'server sv_1\n' > "$KNOWN_IDS"
lat_exists() { return 0; }
FAKE_LAT="$(srv spx-other-node1)"
check_result "destroy stops before Terraform when a recorded ID was reused" "$( (check_recorded_identity) >/dev/null 2>&1 && echo went || echo stopped)" stopped
FAKE_LAT="$(srv spx-demo-node1 ',"project":{"id":"proj_other"}')"
check_result "destroy stops when the same hostname is in another project" "$( (check_recorded_identity) >/dev/null 2>&1 && echo went || echo stopped)" stopped
FAKE_LAT="$(srv spx-demo-node1 '')"
check_result "destroy stops when the project cannot be read" "$( (check_recorded_identity) >/dev/null 2>&1 && echo went || echo stopped)" stopped
FAKE_LAT="$(srv spx-demo-node1)"
check_result "destroy goes on when the recorded server is ours" "$( (check_recorded_identity) >/dev/null 2>&1 && echo went || echo stopped)" went
leftovers_out="$( (lat_match() { return 0; }; FAKE_LAT="$(srv spx-other-node1)" leftovers) 2>/dev/null || true)"
check_result "a reused ID is not listed as a leftover to delete" "$(grep -c sv_1 <<<"$leftovers_out" || true)" 0
# After terraform destroy the state's project output is gone; the remembered ID still lets the
# sweep delete a matching leftover and keep a foreign one.
eval "$orig_expected_project_id"
TF_BIN="$work/tofu"
printf '#!/bin/sh\nif [ -f "%s/gone" ]; then exit 1; fi\nprintf proj_1\n' "$work" > "$TF_BIN"
chmod +x "$TF_BIN"
EXPECTED_PROJECT=""
remember_project_id
check_result "the project ID is remembered while the state holds it" "$EXPECTED_PROJECT" proj_1
: > "$work/gone"
check_result "the state no longer answers after destroy" "$("$TF_BIN" output >/dev/null 2>&1 && echo up || echo gone)" gone
printf 'server sv_1\nserver sv_2\n' > "$KNOWN_IDS"
lat_match() { return 1; }
latitude() { if [ "$2" = /servers/sv_1 ]; then srv spx-demo-node1; else srv spx-other-node1 ',"project":{"id":"proj_other"}'; fi; }
swept="$(leftovers 2>/dev/null || true)"
check_result "after destroy a matching leftover is still listed" "$(grep -c 'server sv_1 ' <<<"$swept" || true)" 1
check_result "after destroy a foreign one is kept out" "$(grep -c 'server sv_2 ' <<<"$swept" || true)" 0
EXPECTED_PROJECT=""
swept="$(leftovers 2>/dev/null || true)"
check_result "without the remembered ID a leftover is unknown, not matched" "$(grep -c 'server sv_1 sv_1 (unknown)' <<<"$swept" || true)" 1
expected_project_id() { echo proj_1; }
# Cleanup never deletes an object whose ownership could not be read, recorded or not.
deleted="$work/deleted"
: > "$deleted"
latitude() { printf '%s %s\n' "$1" "$2" >> "$deleted"; }
printf 'server sv_1\nserver sv_2\n' > "$KNOWN_IDS"
delete_left $'server sv_1 sv_1 (unknown)\nserver sv_2 spx-demo-node2\n' "" >/dev/null 2>&1
check_result "an unreadable recorded object is not deleted" "$(grep -c sv_1 "$deleted" || true)" 0
check_result "a verified recorded object is deleted" "$(grep -c 'DELETE .*sv_2' "$deleted" || true)" 1
# Both deployment sizes write the metadata remap.
cfg="$work/spinifex.toml"
extract() { sed -n "/^$1()/,/^}/p" "$here/deploy-latitude.sh" | sed -n "/<<'PY'/,/^PY\$/p" | sed '1d;$d' | sed "s|/etc/spinifex/spinifex.toml|$cfg|"; }
printf '[network]\nexternal_mode = "nat"\n\n[bootstrap]\ny = 1\n' > "$cfg"
extract configure_pool | python3 -I - 203.0.113.1 203.0.113.254
check_result "one node gets the metadata remap" "$(grep -c 'imds_host_meta_ip = "169.254.42.254"' "$cfg")" 1
printf '[network]\nexternal_mode = "nat"\n\n[nodes.node1]\nx = 1\n\n[nodes.node2]\nx = 1\n\n[nodes.node3]\nx = 1\n\n[bootstrap]\ny = 1\n' > "$cfg"
extract configure_latitude_pool | python3 -I - proj_1 eip_1,eip_2 node1=sv_1,node2=sv_2,node3=sv_3
check_result "three nodes get the metadata remap" "$(grep -c 'imds_host_dns_ip = "169.254.42.253"' "$cfg")" 1
check_result "three nodes get the latitude pool" "$(grep -c 'source = "latitude"' "$cfg")" 1
extract configure_latitude_pool | python3 -I - proj_1 eip_1,eip_2 node1=sv_1,node2=sv_2,node3=sv_3
check_result "writing the config again changes nothing" "$(grep -c 'imds_host_meta_ip' "$cfg")" 1
unset FAKE_SSH_EVAL
[ "$fails" -eq 0 ]
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# host-data-disk.sh — format and mount the blank data disk at /var/lib/spinifex. Run as root ON the host.
#
# host-data-disk.sh [/dev/disk/by-id/nvme-eui.<id>]
#
# The disk is chosen by content, never by its nvmeXn1 name, which swaps between boots. A candidate is a
# whole-disk by-id EUI link whose device is not in the root filesystem's ancestry, has no partitions,
# holders, swap or mounts, no filesystem signature and no partition table. With none the script stops
# and lists the disks. With several it takes the largest, and the lowest by-id path among equals, and
# prints what it chose and the blank disks it left alone; pass the by-id path to choose another.
# It never reformats: a rerun on a host already done prints the UUID and exits. The last line of stdout
# is DATA_UUID=<uuid>, which the later install phases need.
set -euo pipefail
MP=/var/lib/spinifex
LABEL=spinifex-data
FSTAB_BAK=/etc/fstab.pre-spinifex
MIN_BYTES="${MIN_DATA_BYTES:-100000000000}"
WANT="${1:-}"
die() { echo "STOP: $*" >&2; exit 1; }
[ "$(id -u)" = 0 ] || die "run as root"
# Resume: the mount is already there and is ours.
if mp_uuid="$(findmnt --mountpoint "$MP" -no UUID 2>/dev/null)" && [ -n "$mp_uuid" ]; then
src="$(findmnt --mountpoint "$MP" -no SOURCE)"
[ "$(blkid -s LABEL -o value "$src")" = "$LABEL" ] || die "$MP is mounted from $src, which is not labelled $LABEL; not touching it"
grep -q "UUID=${mp_uuid}[[:space:]]" /etc/fstab || die "$MP is mounted but /etc/fstab has no UUID=$mp_uuid entry"
echo "already done: $MP is mounted from $src (UUID $mp_uuid)"
echo "DATA_UUID=$mp_uuid"
exit 0
fi
if grep -q "$MP" /etc/fstab; then die "/etc/fstab mentions $MP but nothing is mounted there; fix that by hand first"; fi
[ ! -e "$MP" ] || [ -z "$(ls -A "$MP")" ] || die "$MP exists and is not empty"
root_src="$(findmnt -no SOURCE /)" || die "cannot find the root source"
root_anc="$(lsblk -s -nro NAME "$root_src")" || die "cannot read the root ancestry"
[ -n "$root_anc" ] || die "empty root ancestry"
# blank_reason ID prints why the disk is not a candidate and returns 1, or returns 0.
blank_reason() {
local id="$1" dev name size kids hold sw mnt rc w
dev="$(readlink -e "$id")" || { echo "link does not resolve"; return 1; }
[ -b "$dev" ] || { echo "not a block device"; return 1; }
name="$(basename "$dev")"
if grep -qx "$name" <<<"$root_anc"; then echo "in the root ancestry"; return 1; fi
size="$(blockdev --getsize64 "$id")" || { echo "size unreadable"; return 1; }
[ "$size" -ge "$MIN_BYTES" ] || { echo "only $size bytes"; return 1; }
kids="$(lsblk -nro NAME "$id")" || { echo "children unreadable"; return 1; }
[ "$kids" = "$name" ] || { echo "has children"; return 1; }
hold="$(ls -A "/sys/block/$name/holders/")" || { echo "holders unreadable"; return 1; }
[ -z "$hold" ] || { echo "has holders"; return 1; }
sw="$(swapon --show=NAME --noheadings)" || { echo "swapon failed"; return 1; }
if grep -q "$name" <<<"$sw"; then echo "is swap"; return 1; fi
mnt="$(lsblk -nro MOUNTPOINTS "$id")" || { echo "mounts unreadable"; return 1; }
[ -z "${mnt//[[:space:]]/}" ] || { echo "is mounted"; return 1; }
set +e; blkid -p "$id" >/dev/null 2>&1; rc=$?; set -e
[ "$rc" -eq 2 ] || { echo "blkid -p says it has a signature (rc $rc)"; return 1; }
w="$(wipefs -n "$id")" || { echo "wipefs failed"; return 1; }
[ -z "$w" ] || { echo "wipefs finds signatures"; return 1; }
return 0
}
# choose_data_disk ID... prints the largest disk's ID, the lowest path among equal sizes, and says on
# stderr which it left. The choice depends only on size and path, so every node of one plan agrees.
choose_data_disk() {
local chosen rest
chosen="$(for id in "$@"; do printf '%s %s\n' "$(blockdev --getsize64 "$id")" "$id"; done |
sort -k1,1nr -k2,2 | head -n1)"
chosen="${chosen#* }"
if [ "$#" -gt 1 ]; then
rest="$(printf '%s\n' "$@" | grep -vxF "$chosen" | sort | tr '\n' ' ')"
echo "chose $chosen ($(blockdev --getsize64 "$chosen") bytes), the largest of $# blank disks; left untouched: ${rest% }" >&2
fi
printf '%s\n' "$chosen"
}
candidates=()
shopt -s nullglob
for id in /dev/disk/by-id/nvme-eui.*; do
case "$id" in *-part*) continue ;; esac
if [ -n "$WANT" ] && [ "$id" != "$WANT" ]; then continue; fi
if why="$(blank_reason "$id")"; then
candidates+=("$id")
else
echo "skip $id: $why"
fi
done
shopt -u nullglob
if [ "${#candidates[@]}" -eq 0 ]; then
lsblk -o NAME,SIZE,TYPE,MODEL,SERIAL,FSTYPE,MOUNTPOINTS >&2 || true
die "no blank data disk among the by-id EUI disks"
fi
ID="$(choose_data_disk "${candidates[@]}")"
DEV="$(readlink -e "$ID")"
echo "CHECKS PASSED: $ID -> $DEV"
mkdir -p "$MP"
mkfs.ext4 -q -L "$LABEL" "$ID"
U="$(blkid -p -o value -s UUID "$ID")" || die "blkid UUID failed"
[ -n "$U" ] || die "no UUID after mkfs"
[ "$(blkid -p -o value -s TYPE "$ID")" = ext4 ] || die "not ext4 after mkfs"
NEW="$(mktemp /etc/fstab.spinifex.XXXXXX)"
cp /etc/fstab "$NEW"
echo "UUID=$U $MP ext4 defaults,nofail,x-systemd.device-timeout=30s 0 2" >> "$NEW"
findmnt --verify --tab-file "$NEW" || { rm -f "$NEW"; die "staged fstab failed verify, /etc/fstab untouched"; }
BAK="$FSTAB_BAK.$(date +%s)"
cp -a /etc/fstab "$BAK"
trap 'cp -a "$BAK" /etc/fstab; systemctl daemon-reload; echo "ROLLED BACK /etc/fstab from $BAK" >&2' EXIT
chmod 644 "$NEW"
mv "$NEW" /etc/fstab
systemctl daemon-reload
mount "$MP"
[ "$(findmnt --mountpoint "$MP" -no UUID)" = "$U" ] || die "wrong UUID at $MP"
[ "$(findmnt --mountpoint "$MP" -no FSTYPE)" = ext4 ] || die "wrong fstype at $MP"
[ "$(findmnt --mountpoint "$MP" -no MAJ:MIN | tr -d ' ')" = "$(lsblk -dno MAJ:MIN "$ID" | tr -d ' ')" ] || die "wrong device at $MP"
trap - EXIT
findmnt --mountpoint "$MP" -o TARGET,SOURCE,MAJ:MIN,UUID,FSTYPE,OPTIONS
echo "STORAGE OK"
echo "DATA_UUID=$U"
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
# A private network needs two or more servers, so a single node has none. The VLAN carries the cluster
# and Geneve traffic; its address is set over SSH, since the VID is only known after apply.
resource "latitudesh_virtual_network" "lan" {
count = var.node_count > 1 ? 1 : 0
project = local.project_id
site = var.site
description = "spinifex-${var.deployment_name}"
}
resource "latitudesh_vlan_assignment" "node" {
count = var.node_count > 1 ? var.node_count : 0
server_id = latitudesh_server.node[count.index].id
virtual_network_id = latitudesh_virtual_network.lan[0].id
}
@@ -0,0 +1,48 @@
#!/bin/sh
# Runs as root on a node. Reads the API token on stdin and installs it for the daemon.
# The directory belongs to root and is not writable by anyone else, so no service can create or
# swap an entry in it. The file belongs to the daemon's user alone (mode 0400): the other
# services share the spinifex group, so group access would hand them the team-wide token. The
# temporary file has an unpredictable name and is created exclusively. A symlink, or an owner
# other than root or the daemon's user, where the directory or the file should be is refused.
# Ownership and mode are set on every run, so an older install converges. The file's content is
# replaced only when the token differs. Prints installed or unchanged.
set -eu
dir="${API_TOKEN_DIR:-/etc/spinifex/latitude}"
root="${API_TOKEN_ROOT-root}"
rootgroup="${API_TOKEN_ROOT_GROUP-root}"
user="${API_TOKEN_USER-spinifex-daemon}"
umask 077
fail() { echo "$*" >&2; exit 1; }
owner_of() { stat -c %U "$1"; }
[ ! -L "$dir" ] || fail "$dir is a symlink"
if [ ! -e "$dir" ]; then mkdir -p "$dir"; fi
[ -d "$dir" ] || fail "$dir is not a directory"
[ "$(owner_of "$dir")" = "$root" ] || fail "$dir is not owned by $root"
chown "$root:$rootgroup" "$dir"
chmod 0711 "$dir"
target="$dir/api-token"
[ ! -L "$target" ] || fail "$target is a symlink"
if [ -e "$target" ]; then
[ -f "$target" ] || fail "$target is not a regular file"
holder="$(owner_of "$target")"
[ "$holder" = "$root" ] || [ "$holder" = "$user" ] || fail "$target is owned by $holder"
fi
tmp="$(mktemp "$dir/.api-token.XXXXXX")"
trap 'rm -f "$tmp"' EXIT
cat > "$tmp"
[ -s "$tmp" ] || fail "empty token on stdin"
if [ -f "$target" ] && cmp -s "$tmp" "$target"; then
chown "$user" "$target"
chmod 0400 "$target"
echo unchanged
exit 0
fi
chown "$user" "$tmp"
chmod 0400 "$tmp"
mv -f "$tmp" "$target"
echo installed
@@ -0,0 +1,17 @@
#!/bin/sh
# Runs as root on a node with the pool name as $1. Reads only the journal of the daemon's
# current invocation, so a success logged before a restart cannot hide a failure after it.
# Prints ready, refused or waiting.
set -eu
pool="${1:?pool name}"
id="$(systemctl show -p InvocationID --value spinifex-daemon)"
[ -n "$id" ] || { echo waiting; exit 0; }
log="$(journalctl "_SYSTEMD_INVOCATION_ID=$id" -u spinifex-daemon --no-pager -o cat 2>/dev/null || true)"
named() { grep -F -e "pool=$pool" -e "\"pool\":\"$pool\""; }
if printf '%s\n' "$log" | grep -F "refused the pool's first call" | named >/dev/null; then
echo refused
elif printf '%s\n' "$log" | grep -F "floating pool ready" | named >/dev/null; then
echo ready
else
echo waiting
fi
@@ -0,0 +1,119 @@
#!/bin/sh
# Tests node-install-api-token.sh and node-pool-state.sh with stand-in tools.
set -eu
here="$(cd "$(dirname "$0")" && pwd)"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
fails=0
check() { if [ "$2" = "$3" ]; then echo "ok $1"; else echo "FAIL $1: want '$3' got '$2'"; fails=$((fails + 1)); fi; }
# token install: A, A again, then B across a rotation; the file ends as B, owned by the daemon
# user alone (0400) in a 0711 directory that only root may change
me="$(id -un)"
export API_TOKEN_DIR="$work/tok" API_TOKEN_ROOT="$me" API_TOKEN_ROOT_GROUP="$(id -gn)" API_TOKEN_USER="$me"
install_token() { sh "$here/node-install-api-token.sh"; }
check "first install" "$(printf 'TOKENA' | install_token)" installed
check "same token" "$(printf 'TOKENA' | install_token)" unchanged
check "rotation" "$(printf 'TOKENB' | install_token)" installed
check "file holds B" "$(cat "$work/tok/api-token")" TOKENB
check "file mode and owner" "$(stat -c '%U %a' "$work/tok/api-token")" "$me 400"
check "dir mode and owner" "$(stat -c '%U %a' "$work/tok")" "$me 711"
check "no temp left after install" "$(ls -A "$work/tok")" api-token
check "empty refused" "$(printf '' | install_token 2>/dev/null || echo failed)" failed
check "file kept after refusal" "$(cat "$work/tok/api-token")" TOKENB
# an older install converges on the unchanged path too
chmod 0640 "$work/tok/api-token"
chmod 0755 "$work/tok"
check "unchanged path repairs" "$(printf 'TOKENB' | install_token)" unchanged
check "mode repaired on unchanged" "$(stat -c %a "$work/tok/api-token")" 400
check "dir mode repaired on unchanged" "$(stat -c %a "$work/tok")" 711
# who can read it: only the daemon's identity. This needs two real identities, so it runs as root only.
if [ "$(id -u)" = 0 ]; then
chmod 0711 "$work"
export API_TOKEN_ROOT=root API_TOKEN_ROOT_GROUP=root API_TOKEN_USER=daemon
for mode in installed unchanged; do
[ "$mode" = installed ] && rm -f "$work/tok/api-token"
check "$mode path" "$(printf 'TOKENB' | install_token)" "$mode"
check "daemon reads ($mode)" "$(setpriv --reuid=daemon --regid=daemon --clear-groups cat "$work/tok/api-token")" TOKENB
check "another identity in the shared group cannot read ($mode)" \
"$(setpriv --reuid=nobody --regid=daemon --clear-groups cat "$work/tok/api-token" 2>/dev/null || echo denied)" denied
done
export API_TOKEN_ROOT="$me" API_TOKEN_ROOT_GROUP="$(id -gn)" API_TOKEN_USER="$me"
else
echo "skip reading as the daemon and as another service: needs root"
fi
# a link planted where the file or the directory belongs is refused, and nothing it points at is touched
echo SENTINEL > "$work/sentinel"
rm -f "$work/tok/api-token"
ln -s "$work/sentinel" "$work/tok/api-token"
check "symlinked file refused" "$(printf 'TOKENC' | sh "$here/node-install-api-token.sh" 2>/dev/null || echo refused)" refused
check "symlink target untouched" "$(cat "$work/sentinel")" SENTINEL
ln -s "$work/tok" "$work/tok-link"
check "symlinked dir refused" "$(printf 'TOKENC' | API_TOKEN_DIR="$work/tok-link" sh "$here/node-install-api-token.sh" 2>/dev/null || echo refused)" refused
check "dir owned by someone else refused" "$(printf 'TOKENC' | API_TOKEN_ROOT=nobody-such install_token 2>/dev/null || echo refused)" refused
rm -f "$work/tok/api-token"
printf 'TOKENB' | install_token >/dev/null
check "file owned by a stranger refused" "$(printf 'TOKENC' | API_TOKEN_ROOT=$me API_TOKEN_USER=nobody-such install_token 2>/dev/null || echo refused)" refused
check "no temp left" "$(ls -A "$work/tok")" api-token
# data disk choice: the largest blank disk, the lowest path among equals, the rest left alone
choose_dir="$work/choose"
mkdir -p "$choose_dir/bin"
cat > "$choose_dir/bin/blockdev" <<'SH'
#!/bin/sh
# blockdev --getsize64 <id>: the fixture's size for that disk
cat "$CHOOSE_DIR/size.$(basename "$2")"
SH
chmod +x "$choose_dir/bin/blockdev"
eval "$(sed -n '/^choose_data_disk()/,/^}/p' "$here/host-data-disk.sh")"
size_of() { printf '%s' "$2" > "$choose_dir/size.$1"; }
pick() { CHOOSE_DIR="$choose_dir" PATH="$choose_dir/bin:$PATH" choose_data_disk "$@" 2>"$choose_dir/err"; }
size_of nvme-eui.0b 1700000000000
size_of nvme-eui.0a 1700000000000
size_of nvme-eui.09 447000000000
size_of nvme-eui.0c 1900000000000
check "largest of three wins" "$(pick /dev/disk/by-id/nvme-eui.09 /dev/disk/by-id/nvme-eui.0b /dev/disk/by-id/nvme-eui.0c)" /dev/disk/by-id/nvme-eui.0c
check "equal sizes: the lowest path" "$(pick /dev/disk/by-id/nvme-eui.09 /dev/disk/by-id/nvme-eui.0b /dev/disk/by-id/nvme-eui.0a)" /dev/disk/by-id/nvme-eui.0a
check "order of the list does not matter" "$(pick /dev/disk/by-id/nvme-eui.0b /dev/disk/by-id/nvme-eui.0a /dev/disk/by-id/nvme-eui.09)" /dev/disk/by-id/nvme-eui.0a
check "what was left is said" "$(grep -c 'left untouched: /dev/disk/by-id/nvme-eui.09 /dev/disk/by-id/nvme-eui.0b' "$choose_dir/err")" 1
check "one disk is just chosen" "$(pick /dev/disk/by-id/nvme-eui.09)" /dev/disk/by-id/nvme-eui.09
check "one disk says nothing" "$(wc -c < "$choose_dir/err" | tr -d ' ')" 0
# pool state: the stand-in journal serves only the lines of the invocation asked for
mkdir "$work/bin"
cat > "$work/bin/systemctl" <<'SH'
#!/bin/sh
cat "$FAKE_DIR/invocation"
SH
cat > "$work/bin/journalctl" <<'SH'
#!/bin/sh
for a in "$@"; do
case "$a" in _SYSTEMD_INVOCATION_ID=*) id="${a#_SYSTEMD_INVOCATION_ID=}" ;; esac
done
cat "$FAKE_DIR/journal.$id" 2>/dev/null
SH
chmod +x "$work/bin/systemctl" "$work/bin/journalctl"
export FAKE_DIR="$work" PATH="$work/bin:$PATH"
state() { sh "$here/node-pool-state.sh" latitude; }
echo old > "$work/invocation"
echo 'INFO floating pool ready pool=latitude source=latitude' > "$work/journal.old"
check "current success" "$(state)" ready
echo new > "$work/invocation"
echo 'ERROR floating: the provider refused the pool'"'"'s first call; public addresses will fail until fixed pool=latitude' > "$work/journal.new"
check "old success then current failure" "$(state)" refused
echo 'INFO starting' > "$work/journal.new"
check "current invocation not yet ready" "$(state)" waiting
echo 'INFO floating pool ready pool=other source=latitude' > "$work/journal.new"
check "another pool does not count" "$(state)" waiting
echo 'INFO floating pool ready pool=latitude source=latitude' > "$work/journal.new"
check "current success" "$(state)" ready
[ "$fails" -eq 0 ]
+56
View File
@@ -0,0 +1,56 @@
output "deployment_name" {
value = var.deployment_name
}
output "site" {
value = var.site
}
output "project" {
value = local.project
}
# provider_host_id is the server ID. public_ip is the server's own /31 address, which SSH and the
# Spinifex API use; the Elastic IP is separate.
output "nodes" {
value = [for n in latitudesh_server.node : {
name = trimprefix(n.hostname, "spx-${var.deployment_name}-")
provider_host_id = n.id
public_ip = n.primary_ipv4
}]
}
# The first address, for a single node's static pool.
output "elastic_ip" {
value = latitudesh_elastic_ip.pool[0].address
}
# IDs as well as addresses: destroy deletes only these IDs, and the floating pool lists them.
output "elastic_ips" {
value = [for e in latitudesh_elastic_ip.pool : { id = e.id, address = e.address }]
}
# The VLAN ID of the cluster network; null for one node, which has no private network.
output "vid" {
value = length(latitudesh_virtual_network.lan) == 0 ? null : latitudesh_virtual_network.lan[0].vid
}
output "ssh_key_id" {
value = latitudesh_ssh_key.deploy.id
}
output "user_data_id" {
value = latitudesh_user_data.host.id
}
output "project_id" {
value = local.project_id
}
output "project_created" {
value = length(latitudesh_project.deploy) > 0
}
output "virtual_network_id" {
value = length(latitudesh_virtual_network.lan) == 0 ? null : latitudesh_virtual_network.lan[0].id
}
@@ -0,0 +1,4 @@
# The token comes from LATITUDESH_AUTH_TOKEN in the environment, which the provider reads
# itself (its auth_token argument documents that variable). It is never a variable here, so it
# is not written to the state or to a tfvars file. The default project is set per resource.
provider "latitudesh" {}
@@ -0,0 +1,5 @@
deployment_name = "l1"
site = "DAL"
plan = "m4-metal-medium"
node_count = 1
ssh_public_key_path = "~/.ssh/id_ed25519.pub"
@@ -0,0 +1,63 @@
variable "deployment_name" {
type = string
description = "Names every resource and the server hostnames, so two deployments in one account never collide."
validation {
# A hostname is at most 32 characters: "spx-" + name + "-node1".
condition = can(regex("^[a-z0-9][a-z0-9-]{0,15}$", var.deployment_name))
error_message = "deployment_name must be 1-16 lowercase letters, digits or hyphens."
}
}
variable "project" {
type = string
default = ""
description = "ID or slug of an existing latitude.sh project to deploy into. Empty creates a project spinifex-<deployment_name> and removes it on destroy."
}
variable "project_id" {
type = string
default = ""
description = "ID of the project named by project. The Elastic IP create sends it as project_id without resolving a slug (provider 4.8.0), so it must be an ID. Unused when project is empty."
}
variable "site" {
type = string
default = "DAL"
description = "latitude.sh site slug. Pick one with stock for the plan and Elastic IP support; not SAO."
}
variable "plan" {
type = string
default = "m4-metal-medium"
description = "Server plan slug (the pricing page calls it m4.metal.medium)."
}
variable "operating_system" {
type = string
default = "ubuntu_26_04_x64_lts"
description = "OS slug. UNVERIFIED: the 26.04 slug follows the 24.04 pattern (ubuntu_24_04_x64_lts) and is checked by a data source at plan time."
}
variable "node_count" {
type = number
default = 1
validation {
condition = contains([1, 3], var.node_count)
error_message = "node_count must be 1 or 3."
}
}
variable "elastic_ip_count" {
type = number
default = 1
description = "Elastic IPs to buy, each 4 USD a month. Every one starts on a server and keeps it unless Spinifex moves it. latitude.sh allows 5 per team."
validation {
condition = var.elastic_ip_count >= 1 && var.elastic_ip_count <= 5 && floor(var.elastic_ip_count) == var.elastic_ip_count
error_message = "elastic_ip_count must be a whole number from 1 to 5."
}
}
variable "ssh_public_key_path" {
type = string
default = "~/.ssh/id_ed25519.pub"
}
@@ -0,0 +1,7 @@
terraform {
required_version = ">= 1.5.0"
required_providers {
# Pinned exactly: 4.8.0 is the version whose schema these files were written against.
latitudesh = { source = "latitudesh/latitudesh", version = "= 4.8.0" }
}
}
+5 -2
View File
@@ -12,8 +12,9 @@ import (
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
"github.com/mulgadc/spinifex/spinifex/agents/ecs/credentials"
"github.com/mulgadc/spinifex/internal/imdscreds"
ctrruntime "github.com/mulgadc/spinifex/spinifex/agents/ecs/runtime"
)
@@ -102,7 +103,9 @@ func New(cfg Config) (*Agent, error) {
AgentVersion: cfg.AgentVersion,
}
creds := credentials.NewIMDSProvider(imdsClient, cfg.IMDSBase)
// The cache refetches only once the real expiry passes, and a failed
// refresh is an error rather than an extended expiry.
creds := aws.NewCredentialsCache(imdscreds.NewProvider(imdsClient, cfg.IMDSBase))
resolver := newLazyECRResolver(creds, cfg.Region, cfg.GatewayURL, cfg.GatewayCA)
// A dial that fails here is not fatal and not final: the node starts the
+3 -8
View File
@@ -21,17 +21,14 @@ const (
// Config holds the static settings the agent reads at boot. Cluster identity
// (account ID, instance ID) is discovered at runtime from IMDS, not configured;
// only the cluster *name* the instance was launched into is static. AccessKey /
// SecretKey are legacy seeded creds, optional now that the agent SigV4-signs
// gateway calls with instance-role credentials from IMDS; the agent never holds
// a NATS token.
// only the cluster *name* the instance was launched into is static. The agent
// SigV4-signs gateway calls with instance-role credentials from IMDS and never
// holds a NATS token.
type Config struct {
GatewayURL string
GatewayCA string
Region string
ClusterName string
AccessKey string
SecretKey string
IMDSBase string
ContainerdSocket string
Heartbeat time.Duration
@@ -53,8 +50,6 @@ func LoadConfig(envFile string) Config {
GatewayCA: get("ECS_GATEWAY_CA"),
Region: get("ECS_REGION"),
ClusterName: get("ECS_CLUSTER"),
AccessKey: get("ECS_ACCESS_KEY"),
SecretKey: get("ECS_SECRET_KEY"),
IMDSBase: get("ECS_IMDS_BASE"),
ContainerdSocket: get("ECS_CONTAINERD_SOCKET"),
}

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