Compare commits

..

91 Commits

Author SHA1 Message Date
brynmailer-mdc ca4aa9d730 fix: check the provider only on node tables and capture iSCSI state when the EBS gate fails 2026-11-11 14:35:11 +10:00
brynmailer-mdc f0ef931894 fix: resume the nightly's formation only from a config init wrote
An ocibsd API-key run seeds spinifex.toml with the credential before init,
so a non-empty file no longer shows that formation ran, and a checkpoint
left by an earlier run skipped init on fresh nodes.
2026-11-11 14:35:11 +10:00
brynmailer-mdc 38c2a6aeeb fix: parse the nightly's node table and cluster_id as spx now writes them
spx get nodes draws its columns with box characters, so the Ready count
split on '|' never found a node. A multi-node leader's config lists the
other nodes without EBS tables, so read the cluster_id only from tables
that carry one.
2026-11-11 14:35:11 +10:00
brynmailer-mdc 610c2ede7c test: read the OCI retry token header by its canonical name 2026-11-11 14:35:11 +10:00
brynmailer-mdc 65049cb9e3 test: table the OCI not-found and limit classification cases 2026-11-11 14:35:11 +10:00
brynmailer-mdc 575a43a722 test: read the live run's exit status through the initiator's exitCode 2026-11-11 14:35:11 +10:00
brynmailer-mdc 435a0e3f2d test: build the test block client through NewBlockClientWithProvider 2026-11-11 14:35:10 +10:00
brynmailer-mdc c8b6d45edd refactor: share the OCI fakes' staged failures and the OCI IQN prefix 2026-11-11 14:35:10 +10:00
brynmailer-mdc 85d54b72c7 test: build ocibsd test nodes through one newNode helper 2026-11-11 14:35:10 +10:00
brynmailer-mdc 46e0db9d4f refactor: replace unreachable negative-size guards with gosec notes 2026-11-11 14:35:10 +10:00
brynmailer-mdc 798af3bbd0 refactor: check a masked unit through systemd.IsMasked 2026-11-11 14:35:10 +10:00
brynmailer-mdc 83bb47d821 refactor: name the OCI auth selections once in bootstrap/config 2026-11-11 14:35:10 +10:00
brynmailer-mdc 7eef0699d9 refactor: wait for OCI work and the iSCSI device through one poll loop 2026-11-11 14:35:10 +10:00
brynmailer-mdc ff48f0fed9 refactor: refuse a volume held by another instance in one heldElsewhere check 2026-11-11 14:35:10 +10:00
brynmailer-mdc d46ba1f82a refactor: sort listed IDs with slices.Sorted 2026-11-11 14:35:10 +10:00
brynmailer-mdc 2480052a4f refactor: check a capacity range through CapacityRange.Valid 2026-11-11 14:35:10 +10:00
brynmailer-mdc 94dd7076c8 refactor: share one exported EBS schema version check 2026-11-11 14:35:10 +10:00
brynmailer-mdc 0acb44f9f9 refactor: drop the EBS fields applyNetworkConfig set before the node's EBS choice 2026-11-11 14:35:10 +10:00
brynmailer-mdc 3669f00f74 refactor: page OCI list calls through one listAll helper 2026-11-11 14:35:10 +10:00
brynmailer-mdc 8f4266087d refactor: find volumes and backups through one tagged lookup 2026-11-11 14:35:10 +10:00
brynmailer-mdc cf9f92d30d refactor: map OCI conflicts to sentinels through ociError 2026-11-11 14:35:10 +10:00
brynmailer-mdc 15ca6c63bc fix: keep a timed-out publish classified as unavailable 2026-11-11 14:35:10 +10:00
brynmailer-mdc c9f99bf556 fix: grant OCI volume rights only to ocibsd deployments
The node policy and the API-key policy statements granted manage
volume-family and use instances to every deployment, although only
ocibsd touches OCI volumes. They now follow ebs_provider, and
setup-identity.sh takes --ebs-provider so the shared policy can carry
them; validate-topology.sh passes the topology's provider.
2026-11-11 14:35:10 +10:00
brynmailer-mdc 92caea1e9b fix: give volume publish its own request budget
A publish on a remote block backend waits for the attachment and the
device it yields, which can take minutes, so the 30s request floor turned
a slow but successful attach into an ambiguous mount failure. Publish now
gets PublishTimeout (5m) on the NATS client, and ocibsd bounds a whole
publish 30s short of it so it answers retry-later before the client gives up.
2026-11-11 14:35:10 +10:00
brynmailer-mdc 22ca9c2c1d docs: describe EBS on OCI Block Volumes for operators 2026-11-11 14:35:10 +10:00
brynmailer-mdc 3e792efe31 docs: specify the snapshot copy capability and the version-2 block export 2026-11-11 14:35:10 +10:00
brynmailer-mdc 9b350a7e61 fix: return the volume parameter encoding error instead of panicking 2026-11-11 14:35:10 +10:00
brynmailer-mdc 6ab3ec385f chore: fix lint findings on the ocibsd branch 2026-11-11 14:35:10 +10:00
brynmailer-mdc 7b0d57d889 fix: leave a masked unit masked during the systemd reconcile 2026-11-11 14:35:10 +10:00
brynmailer-mdc 2743bfa491 feat: let the OCI nightly's ocibsd topologies rely on init's detection
The ocibsd topologies set no EBS provider and check that init detected it on
the OCI VM, with the placement the state reports; the other topologies set
SPINIFEX_EBS_PROVIDER=viperblockd. The ocibsd unit install and the post-init
auth write are gone; an API-key run installs the credential and seeds each
node's config before init.
2026-11-11 14:35:10 +10:00
brynmailer-mdc c4885b57e8 feat: carry SPINIFEX_EBS_PROVIDER to init in place of install-node.sh's EBS flags
install-node.sh drops --ebs-provider, --oci-compartment-id and --oci-availability-domain, which spx admin init no longer takes. A SPINIFEX_EBS_PROVIDER in its environment is passed to init on the first host through env, after sudo, and joining nodes take the provider from formation. The value is joined into a remote sh -c, so anything but lower-case letters is refused.
2026-11-11 14:35:10 +10:00
brynmailer-mdc a50ff3ac7d feat: install the iSCSI initiator and stage the ocibsd files from setup.sh on an OCI VM
When the OCI instance metadata service answers within a second with a shape that is not bare metal, the check spx admin init detects ocibsd by, setup.sh installs open-iscsi (apt) or iscsi-initiator-utils (dnf), enables iscsid.socket, and stages the tarball's ocibsd/ under /usr/local/share/spinifex/ocibsd for init or join to install.

On a node whose existing config already names ocibsd, the new ocibsd stage also reinstalls the unit, daemon drop-in and udev rule from the new stage, masks the viperblock unit install_systemd has just rewritten, and reloads systemd and udev. Any other node gets none of them.
2026-11-11 14:35:10 +10:00
brynmailer-mdc b4e793babd feat: select the EBS provider at init by existing config, SPINIFEX_EBS_PROVIDER or OCI detection
spx admin init loses --ebs-provider, --oci-compartment-id and --oci-availability-domain. It resolves the provider once: the one an existing node config resolves to, an omitted provider counting as viperblockd; then SPINIFEX_EBS_PROVIDER; then detection, which picks ocibsd when the OCI instance metadata service answers within one second with a shape that is not BM.*, and viperblockd otherwise. Init prints the provider and where it came from.

For ocibsd the compartment and availability domain come from the metadata and auth defaults to instance_principal, with an existing config's [nodes.<name>.ebs.oci] values winning; the cluster ID is generated once as before. Before any config is written, init makes the read ocibsd makes at start with the node's credential and stops, naming the manage volume-family and use instances statements, when it is refused. spx admin join takes the provider from formation and checks its own credential the same way.

When the provider is ocibsd, init and join install the unit, udev rule and daemon drop-in staged under /usr/local/share/spinifex/ocibsd, mask spinifex-viperblock.service by a link to /dev/null, enable spinifex-ocibsd.service, and reload systemd and udev. A viperblock node is untouched.
2026-11-11 14:35:10 +10:00
brynmailer-mdc 2ac9d00335 feat: cover the ocibsd EBS provider in the OCI cloud-vendor nightly
Adds the vm-single-ocibsd and vm-multi-ocibsd topologies. They build nodes with the Block Volume Management plugin disabled, grant the node principal manage volume-family and use instances, install the ocibsd units, and init with --ebs-provider ocibsd and the state's compartment and availability domain.

An ebs provider gate checks the units and masks on every node, boots from an AMI imported onto OCI, and drives stop and start, attach and detach, snapshot and restore with a guest-written marker, a cross-node start on multi-node, and a terminate that leaves no tagged OCI volume or backup.

Each run records the cluster_id init generated, and e2e-oci.sh deletes every volume and backup tagged with it after the destroy and in --sweep-only, failing the run if any remain.
2026-11-11 14:35:10 +10:00
brynmailer-mdc a2b8c5ec4f feat: pass an EBS provider and its OCI placement through install-node.sh
install-node.sh takes --ebs-provider, --oci-compartment-id and --oci-availability-domain and adds them to spx admin init. The values are joined into a remote sh -c, so each is checked against the characters an OCID or availability domain can hold.
2026-11-11 14:35:10 +10:00
brynmailer-mdc 33798c007c fix: give a pending snapshot five minutes to settle in conformance
An OCI backup takes 40-49 s, past the 30 s ceiling. The poll now backs off from 10 ms to 2 s so fast providers still settle at once and a slow backend is not read in a tight loop.
2026-11-11 14:35:10 +10:00
brynmailer-mdc fc60d434df fix: list each OCI volume and backup once
In a live run OCI's ListVolumeBackups returned one new backup twice as it
left REQUEST_RECEIVED, and ocibsd refused the snapshot read because two
backups appeared to carry its snapshot ID. The SDK client's ListVolumes and
ListVolumeBackups now keep the first entry for each OCID.
2026-11-11 14:35:10 +10:00
brynmailer-mdc 77d8595bb3 test: keep the live ocibsd NATS server serving through the suite's teardown
t.Context is cancelled before cleanups run, so the suite's teardown, sent over NATS, reached the provider with a cancelled context and left every volume, backup and attachment behind.
2026-11-11 14:35:10 +10:00
brynmailer-mdc 7b233087d0 test: run the ocibsd conformance suite live against OCI
The live test runs the unmodified EBS provider conformance suite, in process
and over NATS, against the real SDK block client and one OCI instance, with
iscsiadm and the device check run on the instance over SSH. It also checks
that OCI's GB is 2^30 bytes on the attached device, and that a volume
recreated under a deleted volume's ID is never answered with the deleted one.
It skips unless SPINIFEX_OCIBSD_LIVE is set; the README lists its inputs.
2026-11-11 14:35:10 +10:00
brynmailer-mdc 95c73b57c3 fix: refuse to start viperblock on a node configured for another EBS provider
spx service viperblock start exits with an error when the node's [ebs] provider resolves to anything but viperblockd, so a viperblock unit reinstalled over its mask on an ocibsd or qemunbd node does not answer the provider subjects; an unset provider still resolves to viperblockd and passes. ocibsd start shares the same check.
2026-11-11 14:35:10 +10:00
brynmailer-mdc c70b6b2212 fix: leave the ebs object out of a viperblock cluster's formation payload
formation.NetworkConfig carries the EBS provider as a nil pointer with omitempty, set only when the provider is not viperblockd, so a viperblock cluster's payload is the same JSON as before the provider choice existed. A joining node that receives no ebs object writes the default provider; one that receives ocibsd still gets the provider, OCI fields and cluster_id.
2026-11-11 14:35:10 +10:00
brynmailer-mdc 72a0826a31 feat: ship the ocibsd unit, daemon device drop-in and udev rule in the tarball
build/ocibsd holds spinifex-ocibsd.service, which runs spx service ocibsd start as root with no capabilities once NATS is up, a spinifex-daemon.service drop-in allowing block-sd so QEMU opens the iSCSI disk, and a udev rule giving group spinifex read-write on disks whose by-path names an OCI attachment IQN. The release image and dev-install.sh package the directory as ocibsd/, which setup.sh never installs; the install role copies it onto ocibsd nodes only. uninstall-spx.sh removes the drop-in, which its unit globs miss.
2026-11-11 14:35:10 +10:00
brynmailer-mdc 1ec9ee2059 feat: generate the ocibsd cluster ID at init and carry it to joining nodes
spx admin init with --ebs-provider ocibsd generates a random 128-bit cluster ID once, writes it as [nodes.<name>.ebs.oci] cluster_id, keeps an existing one across a --force re-init, and formation carries it to every joining node with the provider choice. Any other provider writes no cluster ID and renders the same spinifex.toml as before. ocibsd tags every OCI resource with it in place of a value derived from the cluster CA, which would change if the CA were reissued, and refuses to start without it; config validation refuses nodes that carry different IDs.
2026-11-11 14:35:10 +10:00
brynmailer-mdc 8edd34ed71 feat: add the ocibsd service role, its config and the spx admin init flags
runtime/roles/ocibsd reads the node's OCI instance, refuses a node outside the cluster's availability domain, starts the provider and serves it with this node's per-node subjects; the cluster tag is derived from the cluster CA. service.New and spx service gain an ocibsd arm. [nodes.<name>.ebs.oci] carries compartment_id, availability_domain and the credential keys, validated with the external pool's OCI auth rules and required to agree across nodes. spx admin init gains --ebs-provider, --oci-compartment-id and --oci-availability-domain, and formation carries the choice to every joining node.
2026-11-11 14:35:10 +10:00
brynmailer-mdc b6018925bc feat: add the iscsiadm initiator and the ocibsd stale-session logout on start
The real Initiator runs iscsiadm through an injectable command runner, writes every node record with manual startup and a 120 s replacement timeout before logging in, and waits for the by-path device. Provider.Start reads the compartment's volumes, then logs out only sessions to this cluster's OCI volumes that OCI no longer attaches to this node; PublishVolume is refused until it has run.
2026-11-11 14:35:10 +10:00
brynmailer-mdc 90f5aef0d7 feat: write imported and SQS store images to block exports
The image import and the SQS store volume publish at the latest contract
version, since a block export has no version 1 form, and write through a
shared imagewrite.Writer. It dispatches on the export's kind: an nbd export
goes to the NBD writer, and a block export is written to its device path with
qemu-img convert -n, refusing an image larger than the export. The SQS
preparer's writer takes the published export and is supplied by the daemon.
2026-11-11 14:35:10 +10:00
brynmailer-mdc ca6a0163af test: publish at the latest version in the EBS conformance suite
Lifecycle publishes use the latest contract version. Per-version publish
cases require a provider whose latest export is a block device to refuse a
caller below the descriptor version, and ocibsd now refuses such a publish
before attaching. The ocibsd suite runs over NATS against the fakes.
2026-11-11 14:35:10 +10:00
brynmailer-mdc b8b767bdd8 feat: add the ocibsd EBS provider over OCI block volumes 2026-11-11 14:35:10 +10:00
brynmailer-mdc bb7c33d273 feat: add an OCI block storage client and its fake
BlockClient covers what an EBS provider on OCI block volumes needs: volume
create, get, list by tag, update and delete; iSCSI attach, get, list and
detach; and incremental backup create, get, list by tag and delete. Creates
carry an opc-retry-token, and attachments are never shareable.

BlockFake models the behaviours the provider has to handle: the 50 GB
floor, retry tokens, replies lost after the call took effect, the 409s on
deleting a volume or a backup while a backup is in progress, backups that
stay CREATING until a test settles them, attachment state transitions and
the 32 iSCSI attachments per instance.
2026-11-11 14:35:10 +10:00
brynmailer-mdc 6854a2c7cc feat: add the SnapshotCopy capability and send the volume type to the EBS provider
CopySnapshot is now optional behaviour. A provider that leaves SnapshotCopy
false refuses it as unsupported_capability, the conformance suite runs its
copy cases only when it is advertised, and AWS CopySnapshot answers
UnsupportedOperation on the refusal. viperblockd, qemunbd and the memory
reference set advertise it.

AWS CreateVolume and the root volume create send the volume type in
CreateVolumeRequest.Parameters, which the existing providers ignore.
2026-11-11 14:35:10 +10:00
Julian Sommer 748d7e2132 chore: go 1.27.4 2026-11-11 14:29:12 +10:00
Bryn Mailer 7488de37cb feat: pending EBS snapshots settled by a reaper (#1678)
* feat: add EBS contract version 4 with the snapshot filter and state

CreateSnapshot may answer pending, and a filtered ListSnapshots reads one
snapshot and its state on every provider. The NATS client returns a pending
completion, and a viperblock repeat of CreateSnapshot returns the first
snapshot instead of retaking it.

* test: cover pending snapshots and the snapshot filter in conformance

The conformance suite polls the filtered read until a snapshot completes,
covers idempotent CreateSnapshot, an absent snapshot and the filter below v4,
and runs the memory provider in pending mode in process and over NATS.

* fix: answer a repeated viperblock CreateSnapshot from the stored snapshot

A repeat is answered from the stored snapshot before the volume is opened,
so it still returns the first snapshot after the source volume is deleted,
and it reports the creation time the snapshot config records. An identity
that cannot be read is now internal rather than already_exists. The
conformance suite repeats CreateSnapshot after deleting the source volume.

* feat: record a pending snapshot before the provider call and settle it in a reaper

CreateSnapshot takes the snapshot's claim, writes the volume reference and a pending document, then calls the provider. A definite refusal of the first call removes both; an ambiguous failure repeats the call once, and nothing after the first call deletes.

PendingSnapshotReaper settles each pending document from the provider, or repeats the call under the claim, taking it over only from a node whose heartbeat has stopped. A starting daemon releases its own node's claims on pending documents before serving requests.

* test: cover pending snapshot creation, the reaper and restart release

Includes the ADR-0003:S7 invariant that the pending-snapshot reaper deletes no snapshot, volume or AMI.

* fix: write the pending snapshot document before its reference and repeat with the volume handle

CreateSnapshot writes the pending document before the volume reference, so a crash between them leaves a document the reaper discovers rather than a reference that blocks DeleteVolume. The reaper restores a missing reference before it settles a document, and drops it again if the document was deleted meanwhile.

The reaper's repeat CreateSnapshot sends the source volume's provider handle, or none when the volume document is gone. A starting daemon releases its node's claims on snapshot documents in any state.

* feat: keep RDS snapshots creating while their EC2 snapshot is pending

A DB snapshot whose EC2 snapshot answers pending stays creating, and the reconciler settles every creating record that names its EC2 snapshot: completed makes it available, error makes it failed with an event. resolveCreatingSnapshot adopts the snapshot ID and applies the same outcomes.

A deletion stays in deleting while its final snapshot is pending, and the wait does not count toward transitionTimeout. A final snapshot that errors keeps the instance in deleting with an event and its data volume kept. A teardown waiting on a cut final snapshot no longer stops the engine again.

* feat: wait for a pending snapshot before registering an imported image

When the provider answers the import's CreateSnapshot pending, the importer reads the snapshot with the filtered ListSnapshots every five seconds within imageImportTimeout, and fails on error, on its disappearance or on the timeout. The snapshot and AMI documents are written only once it completes.

* refactor: give the pending snapshot reaper its scope in the daemon

The snapshot domain imported runtime/compute/vm only to name its reaper's
scope, which ADR-0001 rule 5 forbids and which made the vm tests an import
cycle. The daemon now wraps the reaper as cluster-wide.

* fix: fail a creating DB snapshot whose EC2 snapshot is gone

A DB snapshot, or an instance's final snapshot, whose EC2 snapshot document
no longer exists now settles failed with an event naming the missing
snapshot, instead of staying creating to be retried on every pass.

* feat: defer the delete of a snapshot the provider may still hold

DeleteSnapshot on a pending snapshot, or one whose create went unanswered,
marks it under the snapshot's claim and returns. The snapshot is hidden from
then on, and the reaper deletes it at the provider once it settles, then its
reference and document, leaving a seven-day tombstone when a late create may
still land. Every create call now records AmbiguousCreate first, and the
reaper's settle and tag mirroring write under the claim.

CreateVolume, CopySnapshot and RegisterImage refuse a snapshot that has not
completed, and RunInstances and CopyImage an AMI that is not available.
CreateImage records its snapshot and AMI pending before the provider call,
through the CreateSnapshot path, and the reaper settles a pending AMI from
its snapshot.

* chore: fix lint findings on the pending snapshots branch

* fix: publish a qemunbdd snapshot file only once its copy is complete

CreateSnapshot and CopySnapshot write the snapshot to a partial file in the
snapshots directory, sync it, rename it to the snapshot's name and sync the
directory. The partial name does not end in .qcow2, so a copy interrupted by
a failure or a killed process is never listed, looked up or reported
completed, and the next create of that ID replaces it.

* fix: roll back a refused snapshot create in the reaper's order

The rollback marks the pending document DeleteRequested with AmbiguousCreate
cleared, then removes the volume reference, then deletes the document. A
failure or crash after the mark leaves a marked document, which the reaper
finishes by removing the reference and deleting the document, without
repeating the refused create. No reference is left without its document.

* fix: exclude a pending final snapshot's wait from the deletion timeout

finalSnapshotWait counts the time since the final snapshot was reserved
while it has not settled, not only once SettledAt is written, and returns a
failed read of its record instead of a zero wait. reconcileDeleting fails a
deletion past transitionTimeout only when that wait was read, so a transient
failure to read a pending backup's state retries the teardown instead.

* docs: specify pending snapshots in the EBSProvider contract

Snapshots.tla states that a snapshot's data is fixed when CreateSnapshot
returns, even while pending, adds the snapshot_id filter and the state a
ListSnapshots ref carries, and models a pending snapshot settling as
completed or error without a call. The README drops the two open questions
this answers, records that the filter and state arrive at contract version
4, and notes that the control plane defers a delete of a pending snapshot.
e2e-coverage.md records that no suite reaches a pending snapshot and where
the pending paths are tested.

* fix: serialise AMI settlement with DeregisterImage under the image's claim

The pending-snapshot reaper reread a pending AMI and then wrote it back
unconditionally, so a DeregisterImage between the two had its AMI document
recreated, pointing at UEFI objects it had deleted. The reaper now settles
each AMI under a claim keyed by image ID in the snapshot claim group,
rereading under it and writing only a still-pending AMI. DeregisterImage
takes the same claim across both deletes, with the bounded retry
writeUnderClaim uses, and answers IncorrectState while it stays held.
ReleaseNodeClaims now also frees this node's stale AMI claims at start-up.

* fix: settle CreateImage's AMI under the image's claim

CreateImage's Completed hook wrote its in-memory AMI as available without
rereading it, so a DeregisterImage after the AMI was recorded pending had
its document recreated. The hook now takes the image's claim, rereads the
AMI under it and writes available only while it still exists and is still
pending; a claim it cannot take leaves the AMI to the reaper.

* refactor: settle a pending AMI through one snapshot-service function

CreateImage's completion hook and the pending-snapshot reaper each moved a
pending AMI to its settled state under the image claim. Both now call the
snapshot service's settlePendingAMI; the request path keeps LockImage's claim
retries and the reaper keeps a single take.

* refactor: treat a CreateSnapshot answer with no state as not completed

Every provider sets Snapshot.State on a CreateSnapshot answer, and the NATS
client rejects a completion without one, so an empty state no longer counts as
completed. It leaves the document pending for the reaper, as any other answer
that is not completed does.

* refactor: settle a DB snapshot's status from one table and one emitter

EC2 snapshot state maps to a DB snapshot status in rdsSnapshotStatus, and
recordSnapshotSettled emits every created and failed event. A final snapshot
whose record already names its EC2 snapshot is settled only by
reconcileSnapshots; takeFinalSnapshot reads its status, so a deletion may wait
one more reconciler pass.

* refactor: share the reaper's claim, provider delete and settled cleanup

withClaim takes and releases a claim around every reaper and AMI settlement
step, deleteAtProvider is the one provider delete that tolerates not_found, and
cleanUpIfSettled carries out an accepted delete from a provider read made under
the snapshot's claim.

* refactor: take a snapshot claim through one acquire

lock and take share the reservation, the TryLock and its unwinding, and differ
only in whether a dead holder's claim is taken over.

* refactor: decide a repeated CreateSnapshot's outcome in one place

repeatOutcome maps an answer after an unanswered call to the provider's
answer, error, or pending for both the request and the reaper. createSnapshot
sends its call in a loop of at most two attempts, keeping the rollback for a
refusal of the first.

* refactor: build updatePending and writeUnderClaim on one rewrite

rewrite rereads a snapshot document, lets mutate decide whether to write it,
and writes it back; updatePending adds the pending check and writeUnderClaim
the claim.

* refactor: wire CreateImage's snapshot service explicitly

The image service no longer builds a standalone snapshot service when none is
wired, and the unused snapshotVolume is gone. CreateImage on an unwired service
fails with InternalError; test fixtures and the integration helper wire one.

* refactor: name AMI and snapshot states and readiness errors in ebsmetadata

AMI pending and failed sit beside available in ebsmetadata, the snapshot
domain uses its completed state, and Snapshot.NotReadyError and
AMI.UnavailableError give the IncorrectState and InvalidAMIID.Unavailable
answers the snapshot, image, volume and instance domains share.

* refactor: read one provider snapshot through ebsprovider.ReadSnapshot

The reaper, image import's wait and the conformance suite read a snapshot by
ID through one helper. Viperblock builds its completed snapshot answer once,
and qemunbdd clears a partial snapshot file with removeIfExists.

* refactor: send the snapshot domain's CreateSnapshot at the latest version

CreateSnapshot and the reaper's repeat now go at NewLatestVersioned, as the
reaper's reads do. The conformance suite's create and repeat cases are sent at
that version too, so every provider is judged on the creates it now receives.

* test: share the deregister-stays-deleted assertions of the AMI race tests

* test: table the final snapshot failures that keep an RDS deletion

A final snapshot whose EC2 snapshot ended in error and one whose EC2 snapshot
is gone are two rows of one test.

* test: seed pending-snapshot test documents through one seedDoc

seedDoc fills the defaults of a fresh pending document of the fixture's
volume and owner, and writes the reference when asked; it replaces
seedPending, the reference-writing seedDoc and the inline document literals.

* test: drop a doubled blank line from the pending-snapshot delete tests

* test: wire a snapshot service into the UEFI image S7 fixtures' CreateImage

* fix: answer a repeated CreateSnapshot by its snapshot ID when the provider has lost its source

The control plane mints each snapshot ID once, so the ID names the request. qemunbdd answers a repeat after a restart with the snapshot on disk instead of already_exists, which settled a held snapshot as error.

* fix: allow deleting a failed DB snapshot and retake a deleted final snapshot

DeleteDBSnapshot and the retention sweep remove an available or failed DB snapshot, as AWS does, deleting the record only at the revision read. A deletion's timeout counts from the later of its start and its final snapshot's creation.
2026-11-11 14:04:11 +10:00
Julian Sommer e11e5b4d78 fix: build and publish the GPU system AMIs with mkosi on release (#1681) 2026-11-11 10:54:58 +11:00
Josh Siviour 12fa5586e6 feat(lambda): add UpdateFunctionConfiguration (#1679)
* feat(lambda): add UpdateFunctionConfiguration

Change an Active function's role, handler, description, timeout, memory
and environment by compare-and-swap through the function owner, keeping
its identity, code and event source mappings. A new role is checked for
PassRole and Lambda trust, a supplied environment is sealed to the
function and bounded as a serialized object, and the handler must meet
the test-link guest's constraint.

* fix(lambda): refuse unknown tracing modes on configuration update
2026-11-11 09:09:22 +10:00
Julian Sommer 49e24aa9a5 chore: fix ec2 intro doc 2026-11-11 09:07:46 +10:00
Julian Sommer 1963ef65b9 chore(ui): upgrade @hookform/resolvers to 7
v7's only breaking change is the TypeBox resolver; the zod resolver code is unchanged from 6.0.2, and every form uses zod. Held at 7.0.3 by pnpm minimumReleaseAge.
2026-11-11 08:33:58 +10:00
Bryn Mailer 0516909954 fix(ochre): stop the Bedrock reaper reclaiming after it loses its lease (#1677)
* fix(ochre): stop the Bedrock reaper reclaiming after it loses its lease

A sweep now carries the Lost channel of the lease acquisition it began under
and checks it before each endpoint and before a relaunch. A reclaim is fenced
under the key's mutex: it tears down only a READY record of the same
incarnation and generation the sweep read, while the acquisition still holds,
and the READY-to-DRAINING CAS keeps the teardown exclusive. Observation writes
also require the same incarnation, so a stale sweep cannot overwrite a
relaunched endpoint at the same generation.

* fix(ochre): yield a fenced reclaim to a record moved after admission
2026-11-10 12:33:51 +10:00
Bryn Mailer 13dbd9a443 fix(elbv2): make the delete suspend-before-terminate test deterministic (#1676)
* fix(elbv2): make the delete suspend-before-terminate test deterministic

TestLBHealth_DeleteLoadBalancerSuspendsBeforeTerminatingTheVM failed about
1% of runs because its terminate callback could run after the handler had
already deleted lbhealth.<id>, so it saw a nil record despite a durable
suspension.

The test now gates writes to lb.<id> and lbhealth.<id> after the listener
cascade until the terminate callback has run the repair reconciler against
an eligible candidate, and asserts no repair started and no reboot was sent.
Moving SuspendLBHealth below the terminate goroutine now fails every run.

* fix(elbv2): fail the delete health test when the write gate's failsafe fires
2026-11-10 12:33:30 +10:00
Bryn Mailer a2fd140bfa test: wait on daemon signals in TestDaemonReconnectBumpsRetryCount (#1675)
The test waited for the NATS client to report disconnected, but nats.go marks the client reconnecting before it runs the disconnect callback, so the daemon could still report cluster mode and the count was read before any reconnect. Wait for standalone mode, then for the retry count, then assert cluster mode.
2026-11-10 11:47:57 +10:00
Bryn Mailer 4fff7db3ae refactor: retire the viperblockd legacy/v1 subjects (#1668)
* refactor: retire the viperblockd legacy/v1 subjects

viperblockd no longer serves ebs.<node>.mount, ebs.<node>.unmount,
ebs.<node>.abandon, ebs.delete, ebs.sync, ebs.config or the per-volume
ebs.config.<volume> subscription; the provider contract is its only request
route. The control plane skips a leftover EFI request on terminate instead
of sending ebs.delete for it.

mountVolume and unmountVolume return unexported result structs, keeping
Reaped so an unpublish that reaps an unregistered export still reports
TornDown. EBSRequest moves into runtime/compute/vm with its JSON tags
unchanged, the dirty-marker bucket vocabulary moves into the dirty package,
and contracts/viperblockd/legacy/v1 is deleted.

* test: pin the persisted EBSRequest JSON shape in the vm package
2026-11-10 08:40:07 +10:00
dependabot[bot] bd35be1d8a chore: bump the github-actions group across 1 directory with 2 updates (#1658)
Bumps the github-actions group with 2 updates in the / directory: [softprops/action-gh-release](https://github.com/softprops/action-gh-release) and [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `softprops/action-gh-release` from 3.0.3 to 3.0.4
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v3.0.3...v3.0.4)

Updates `pnpm/action-setup` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v6.1.0...v6.1.1)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 15:07:40 -07:00
Ben Duncan 4767510102 chore(ui): take the 2026-11-10 npm group with resolvers held at 6, rebuilt dist and the Playwright 1.65.0 image (#1674) 2026-11-09 14:54:03 -07:00
dependabot[bot] c479bd9c20 chore: bump the go-dependencies group across 1 directory with 16 updates (#1672)
Bumps the go-dependencies group with 16 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) | `1.47.5` | `1.47.7` |
| [github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream](https://github.com/aws/aws-sdk-go-v2) | `1.7.25` | `1.7.26` |
| [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) | `1.33.12` | `1.33.15` |
| [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2) | `1.20.12` | `1.20.15` |
| [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://github.com/aws/aws-sdk-go-v2) | `1.20.5` | `1.20.7` |
| [github.com/aws/aws-sdk-go-v2/service/lambda](https://github.com/aws/aws-sdk-go-v2) | `1.113.4` | `1.114.2` |
| [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) | `1.114.6` | `1.114.8` |
| [github.com/aws/aws-sdk-go-v2/service/sqs](https://github.com/aws/aws-sdk-go-v2) | `1.52.7` | `1.52.10` |
| [github.com/aws/smithy-go](https://github.com/aws/smithy-go) | `1.28.7` | `1.28.8` |
| [github.com/jackc/pgx/v5](https://github.com/jackc/pgx) | `5.11.0` | `5.12.0` |
| [github.com/klauspost/cpuid/v2](https://github.com/klauspost/cpuid) | `2.4.1` | `2.4.2` |
| [github.com/nats-io/nkeys](https://github.com/nats-io/nkeys) | `0.4.16` | `0.4.17` |
| [github.com/oracle/oci-go-sdk/v65](https://github.com/oracle/oci-go-sdk) | `65.128.0` | `65.129.0` |
| [github.com/ovn-kubernetes/libovsdb](https://github.com/ovn-kubernetes/libovsdb) | `0.9.0` | `0.9.1` |
| [github.com/spf13/pflag](https://github.com/spf13/pflag) | `1.0.10` | `1.1.0` |
| [golang.org/x/sync](https://github.com/golang/sync) | `0.24.0` | `0.25.0` |



Updates `github.com/aws/aws-sdk-go-v2` from 1.47.5 to 1.47.7
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.47.5...v1.47.7)

Updates `github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream` from 1.7.25 to 1.7.26
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/aws/protocol/eventstream/v1.7.25...aws/protocol/eventstream/v1.7.26)

Updates `github.com/aws/aws-sdk-go-v2/config` from 1.33.12 to 1.33.15
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.12...config/v1.33.15)

Updates `github.com/aws/aws-sdk-go-v2/credentials` from 1.20.12 to 1.20.15
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/sso/v1.20.12...credentials/v1.20.15)

Updates `github.com/aws/aws-sdk-go-v2/feature/ec2/imds` from 1.20.5 to 1.20.7
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/mq/v1.20.5...service/mq/v1.20.7)

Updates `github.com/aws/aws-sdk-go-v2/service/lambda` from 1.113.4 to 1.114.2
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.113.4...service/s3/v1.114.2)

Updates `github.com/aws/aws-sdk-go-v2/service/s3` from 1.114.6 to 1.114.8
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.114.6...service/s3/v1.114.8)

Updates `github.com/aws/aws-sdk-go-v2/service/sqs` from 1.52.7 to 1.52.10
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/ssm/v1.52.7...service/sqs/v1.52.10)

Updates `github.com/aws/smithy-go` from 1.28.7 to 1.28.8
- [Release notes](https://github.com/aws/smithy-go/releases)
- [Changelog](https://github.com/aws/smithy-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/aws/smithy-go/compare/v1.28.7...v1.28.8)

Updates `github.com/jackc/pgx/v5` from 5.11.0 to 5.12.0
- [Release notes](https://github.com/jackc/pgx/releases)
- [Changelog](https://github.com/jackc/pgx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jackc/pgx/compare/v5.11.0...v5.12.0)

Updates `github.com/klauspost/cpuid/v2` from 2.4.1 to 2.4.2
- [Release notes](https://github.com/klauspost/cpuid/releases)
- [Commits](https://github.com/klauspost/cpuid/compare/v2.4.1...v2.4.2)

Updates `github.com/nats-io/nkeys` from 0.4.16 to 0.4.17
- [Release notes](https://github.com/nats-io/nkeys/releases)
- [Commits](https://github.com/nats-io/nkeys/compare/v0.4.16...v0.4.17)

Updates `github.com/oracle/oci-go-sdk/v65` from 65.128.0 to 65.129.0
- [Release notes](https://github.com/oracle/oci-go-sdk/releases)
- [Changelog](https://github.com/oracle/oci-go-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/oracle/oci-go-sdk/compare/v65.128.0...v65.129.0)

Updates `github.com/ovn-kubernetes/libovsdb` from 0.9.0 to 0.9.1
- [Release notes](https://github.com/ovn-kubernetes/libovsdb/releases)
- [Commits](https://github.com/ovn-kubernetes/libovsdb/compare/v0.9.0...v0.9.1)

Updates `github.com/spf13/pflag` from 1.0.10 to 1.1.0
- [Release notes](https://github.com/spf13/pflag/releases)
- [Commits](https://github.com/spf13/pflag/compare/v1.0.10...v1.1.0)

Updates `golang.org/x/sync` from 0.24.0 to 0.25.0
- [Commits](https://github.com/golang/sync/compare/v0.24.0...v0.25.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2
  dependency-version: 1.47.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
  dependency-version: 1.7.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/credentials
  dependency-version: 1.20.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/feature/ec2/imds
  dependency-version: 1.20.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/service/lambda
  dependency-version: 1.114.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.114.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/service/sqs
  dependency-version: 1.52.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/smithy-go
  dependency-version: 1.28.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/jackc/pgx/v5
  dependency-version: 5.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: github.com/klauspost/cpuid/v2
  dependency-version: 2.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/nats-io/nkeys
  dependency-version: 0.4.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/oracle/oci-go-sdk/v65
  dependency-version: 65.129.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: github.com/ovn-kubernetes/libovsdb
  dependency-version: 0.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/spf13/pflag
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/sync
  dependency-version: 0.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 14:22:03 -07:00
Ben Duncan d611768aab fix(build): fail the microVM build on an incomplete initramfs (#1669)
* fix(build): fail the microVM build on an incomplete initramfs

* test(build): cover microvm_pack_initramfs rejecting and retrying an incomplete archive
2026-11-09 11:57:41 -07:00
Ben Duncan 92e24f9ce5 fix(ec2): clear the launch address stamp when a stop releases the auto-assigned IP (#1671) 2026-11-09 11:34:48 -07:00
Ben Duncan eadd67fd33 feat(ui): read-only topology tabs for VPCs, their resources and security groups (#1670)
* test(ui): cover the Nodes topology's direction, node click and look

The map moves onto a shared canvas next, so its layout toggle, the click
that opens a node's panel, and both themes' screenshots are pinned first.

* refactor(ui): move the Nodes topology's React Flow shell into TopologyCanvas

The theme, fit, zoom controls, hidden attribution, hover tracing, labelled
lines and the direction toggle become one shared canvas, so other maps can
draw on it. The Nodes map renders as before.

* feat(ui): add a VPC topology tab drawn from the AWS API

The VPC panel's Topology tab maps the internet, gateways, Elastic IPs,
the VPC and its databases, subnets by availability zone and the instances
and load balancers in each, with one labelled line per route. The map is
read-only, bounded to 120 cards and 240 lines, and says when a read was
truncated, incomplete or failed.

* fix(ui): bound the VPC map's reads by page size and show the canvas in the catalogue

Every paged read the map makes asks for 100 a page, the budget fill
indexes each card's lines, the subnet click e2e checks the panel opens,
and the design catalogue shows the topology canvas.

* feat(ui): route-based subnet type, internet reachability and boxed resources on the topology maps

Subnets show public or private from their route table on the map, the list and the panel.
The Internet card is green, red or grey by whether a route reaches an attached gateway, and the
NAT gateway's outbound-only path is drawn. Instances and load balancers sit in dashed boxes, the
full-page map lays out left to right or top down, and lines stay beneath the cards.

* fix(ui): bound the topology address read, confirm routes and node answers before drawing them

Addresses are asked for by the instances and NAT gateways the map read, in page-sized batches.
A partial route table read draws only routes from a subnet's own table, the Nodes Internet card is
red only when every node in the cluster answered, the endpoint index is linear, and a side panel
can zoom out far enough to fit a wide map.

* fix(ui): fit the topology map, its controls and legend in the side panel

* fix(ui): leave room for a line's label between neighbouring gateway cards

* feat(ui): topology tabs for subnets, route tables, internet and NAT gateways, and instances

Each panel draws its resource's VPC with the network map, the resource and what it touches
highlighted and the rest dimmed. An instance's map always holds the instance and its subnet,
even past the read's page limit, and a detached internet gateway says it has no map.

* fix(ui): keep a resource's map frame and its own resource past capped reads

* fix(ui): set a route's label clear of the cards its line passes

* feat(ui): add a security group topology tab

* fix(ui): read a security group's instances by VPC and keep stacked labels apart

* feat(ui): show each security group rule's direction and the rules among its members

* fix(ui): say a peer group's VPC was not read rather than place it in this VPC
2026-11-09 11:31:27 -07:00
Josh Siviour 115b788475 feat(lambda): update function code from an inline ZIP (#1657)
* feat(lambda): update function code from an inline ZIP

Add UpdateFunctionCode (PUT /2015-03-31/functions/{name}/code) for an
inline ZipFile. The gateway checks the function is Active with stored
code and honours RevisionId (PreconditionFailedException) and DryRun.
The owner records an owed upload, the gateway stores the new immutable
copy, and the owner swaps only the code reference and LastModified with
a compare-and-swap, so configuration, state and event source mappings
are unchanged. Competing updates lose with ResourceConflictException and
leave their copy owed. S3 code, images, Publish and arm64 are refused.
A daemon that predates the new subjects yields a retryable error and
records nothing.

* test(lambda): cover interleaved code update commits

Hold one commit after the owner has read the record and checked its
upload, let a competing commit win, then release it: the held commit
must lose its conditional write and leave its upload owed.

* test(lambda): add code update methods to the seam fake
2026-11-10 04:21:54 +10:00
Bryn Mailer 81bd03928f refactor: remove system images through the EBS provider (#1666)
* refactor: remove images through the EBS provider and stop deleting {imageID}/config.json

DeregisterImage deletes only the UEFI store and the AMI document. spx admin
images remove previews the image volume's capacity with GetVolume, reporting
unknown rather than failing on a provider error, and deletes the snapshot and
then the volume through the provider instead of sweeping their prefixes.

* chore: separate the embedded provider in the images remove test double
2026-11-09 16:33:20 +10:00
Bryn Mailer 6da160a0ef refactor: list a <root>-efi volume in the orphan report like any other (#1667)
The report no longer knows the -efi suffix: UEFI variable stores live in the
UEFI store, so a leftover <root>-efi provider volume has no owner and is
reported, never deleted. OrphanVolume.Derived and its CLI note go.
2026-11-09 16:27:35 +10:00
Bryn Mailer afb226b70f feat: carry the UEFI variable store through CreateImage, CopyImage and DeregisterImage (#1650)
* feat: carry the UEFI variable store through CreateImage, CopyImage and DeregisterImage

CreateImage copies the object a UEFI instance's store record points at to the
image's own prefix and names it in the AMI document's uefi_data. CopyImage gives
the copy its own object, DeregisterImage deletes the image's prefix before its
document, and RunInstances seeds a new instance's store from the AMI's store
when its firmware matches the node's.

* fix: copy the UEFI store before CreateImage's snapshot and refuse a store deleted mid-request

A refused UEFI copy no longer leaves a root snapshot behind, and a record
that vanishes after a live pointer was read is IncorrectInstanceState, not
an image without a store. CopyImage's rollback log names the UEFI key.

* fix: delete a system image's UEFI store before its AMI document

RemoveSystemImage deletes uefi/<ami>/ first and keeps the document when
that fails, so no store object outlives the image that names it.

* fix: drive the UEFI image-store S7 tests through the prune and the store reaper

The instance prune, the reaper after a terminate of an imaged instance, and
the reaper after a failed launch seeded from an image each leave the
image's store object in place.

* fix: say RemoveSystemImage deletes the UEFI store before config.json

* docs: describe how images carry the UEFI variable store
2026-11-09 14:37:34 +10:00
Bryn Mailer 4ae5cd35a2 feat: wait for the local EBS provider before recovery relaunches (#1665)
The startup readiness wait and the recovery retry now also require this
node's EBS provider to answer an empty HoldsUnsealedWrites request on its
per-node subject, which no other node's provider can answer.
checkViperblockReady becomes checkJetStreamReady, since it only checks
JetStream. vm.Deps gains EBSProviderReady, read by RetryRecoveryFailed
alone; BackingStoreReady and the restore orphan check are unchanged.

The provider contract states that an empty unsealed-writes request answers
none and that per-node subjects are served after startup recovery, and a
conformance case holds every provider to the empty request.
2026-11-09 13:45:41 +10:00
Bryn Mailer f7fa0f5b9a fix: leave a deleted snapshot's storage to the EBS provider (#1664)
* fix: leave a deleted snapshot's storage to the EBS provider

DeleteSnapshot no longer lists and deletes the snap-xxx/ object prefix after
the provider delete. viperblockd's own DeleteSnapshot already removes that
prefix, so the control plane touched viperblock's object-store layout for no
effect. A provider failure still returns before the document delete, so the
snapshot stays visible and the delete can be retried.

The sweep's tests are retired, and a new test asserts DeleteSnapshot touches
only metadata-store keys. recordingstore now also records heads, puts and
deletes.

* test: cover viperblockd's prefix delete across listing pages

DeleteSnapshot now relies on viperblockd's prefix delete to remove a
snapshot's chunks, so the multi-page case the control-plane sweep's test
covered moves to deleteObjectPrefix.

* chore: group the snapshot partition test imports
2026-11-09 13:45:15 +10:00
Bryn Mailer 32af5427d0 refactor: stop sending ebs.sync after ModifyVolume (#1663)
ExpandVolume persists the new size before it returns and refuses a
mounted volume, so the best-effort reload request had nothing to reload.
viperblockd keeps its ebs.sync subscriber until the legacy subjects are
retired.
2026-11-09 13:44:50 +10:00
Bryn Mailer 82446b3c35 fix(ci): give the push E2E's single-node eks suite node SSH to its own VM (#1662) 2026-11-09 13:44:20 +10:00
Bryn Mailer 5017587f75 feat: seed the UEFI variable store at RunInstances and write it back on write and at stop (#1503)
* feat: seed a UEFI instance's variable store at RunInstances instead of an EFI volume

RunInstances no longer creates a <root>-efi volume or appends an EFI
request. The launching node takes the instance's absent store claim,
creates the store record live at the claim's generation, puts its VARS
template gzip-compressed at uefi/<instance-id>/<uuid> with source=template,
and moves the pointer to it by the fenced write. It keeps the region as
vars.fd beside seeded.json, so the launch boots it without a get.

Seeding refuses an ID whose store record or claim already exists and
changes neither. A failed RunInstances terminates the instance, which
sets its store record to deleting and now releases the claim this node
holds, so the store reaper removes the store.

The EFI request paths are gone from buildDrives and the launch's in-use
marking; a UEFI instance that still carries an EFI request is refused
before anything changes.

* feat: write a UEFI instance's variable store back on write and at stop

A running UEFI guest's vars.fd is written back as a new object and the store
record's pointer moved under the instance's claim generation. The stop writes
back between QEMU's exit and the unmount and then unlocks the claim; a failed
write-back keeps the claim and marks seeded.json owed for the write-back
reaper, which retries it, or drops it when another node owns the store.
Superseded objects are pruned, keeping the pointed object and anything under
10 minutes old.

* fix: decide every UEFI write-back skip from vars.fd and leader-read records

A stop, a drop and the reaper compared only the events the writer had
consumed, so a final guest write still queued at the stop was left behind an
unlocked claim. seeded.json and the writer now carry the SHA-256 of the
region the pointer holds, and each skip compares vars.fd with it. A writer
that cannot watch vars.fd polls it every 10 s instead of waiting for the stop.

An owed copy is dropped only on proof another node owns the store: a claim
at a later generation, or a fenced or deleting record. MovePointer and the
prune read the record from the stream leader, and an older claim generation
is a lagging read that keeps the copy.

* chore: separate embedded fields in the UEFI write-back test fakes

* fix: never mark a store deleting that a refused seed did not create

A seed refused because a store already exists under the instance's ID left
RunInstances' failure path to mark that store deleting. The instance now
records that its seed has not created the store, and terminate leaves such a
store alone.

* fix: release UEFI claims over clean copies and recover lost writers

The write-back reaper unlocks a claim this node holds over a stopped
instance's clean copy, so a failed unlock at stop or a restart that left no
writer no longer blocks starts elsewhere. It also adopts a running guest
whose reconnect could not start a writer. A launch that fails after QEMU
started settles its store as a stop does once that QEMU has exited.

* docs: name the missing store record among the UEFI launch refusals

* docs: add the UEFI variable store operator guide

* fix: keep a stopped UEFI store's claim until its QEMU is proven gone

* test: add the uefistore e2e suite for guest UEFI variable durability

A real UEFI guest writes EFI variables through efivarfs, and the suite proves
each one reaches the object the store record points at, survives stop and
start and a daemon restart under the running guest, and that the prune keeps
the pointed object and every object younger than 10 minutes until the prefix
returns to one object (ADR-0003:S7).

* fix: keep UEFI write-back running until a stopped guest's QEMU is gone

A stop whose QEMU is not proven gone now leaves the instance's writer
registered and running with its claim held, so a live guest's variable
changes keep landing. The write-back reaper settles that writer as a stop
does once QEMU is proven gone and no guest may run here.

A reconnect that cannot read the store claim now retries every 5 s on the
UEFI clock until a writer is registered, the claim is held elsewhere, the
guest stops running here or the daemon shuts down, so a running guest is
back under write-back within the loss budget. Reaper adoption stays the
backstop.

* fix: keep the UEFI store e2e suite's NATS connection and write variables without seeking

The store view's connection closed with the first test, failing the next two, and dd's seek is rejected by efivarfs.
2026-11-09 13:43:57 +10:00
Bryn Mailer c2c0a730e4 feat: claim the UEFI variable store and fence its pointer by generation (#1649)
* feat: claim the UEFI variable store and fence its pointer by generation

The store record now carries the boot pointer and the claim generation that
may move it. A launch claims the store through kvsync, takes it over only once
the holder's heartbeat is LivenessWindow old, stamps the record with its
generation, and boots from the pointer it read after the stamp. A record above
the claim's generation refuses the start as retryable and changes nothing.
The store reaper removes the claim before it closes the record.

* fix: refuse a start whose UEFI store a live node holds as InsufficientInstanceCapacity

* fix: keep a UEFI claim guarding a local copy and refuse on an undated heartbeat

A launch that fails after adopting its claim keeps it while seeded.json
names the claim's generation, or cannot be read. A heartbeat that exists but
cannot be dated refuses the takeover. A stamp at the record's own
generation is written by CAS, so a follower's stale pointer is never booted.

* fix: check UEFI firmware before the claim and never overwrite a copy the claim guards

A start checks the pointer's firmware before it touches the claim, reuses
vars.fd whenever seeded.json names the claim's generation, and refuses as
retryable, keeping the file and the claim, when seeded.json cannot be read.
A rollback releases the claim only when QEMU never started and no local
copy may owe a write-back. An EFI-volume instance reads no store record,
and a pointer move to the current key is still confirmed by CAS.

* fix: confirm a stale or absent UEFI holder heartbeat with the stream leader before takeover

* docs: say only a decided terminate marks a UEFI store deleting

* fix: keep a UEFI copy and its claim when vars.fd cannot be checked
2026-11-09 10:51:07 +10:00
Bryn Mailer c5ae0240e1 feat: replace kvlease with kvsync claims and leases over one sync bucket (#1639)
* feat: add kvsync claims and leases over a compare-and-swap store

kvsync provides Claim and Lease, each with a group for per-resource keys,
and RunElection, over a three-method KVStore interface. Every acquisition
returns a token whose generation is the revision of its own write. A lease
expires by its contenders' local observation on a monotonic clock, renews
every ttl/3 and is lost after 2*ttl/3 without a landed renewal. Keys are
registered per store value and a collision panics.

kvstore.Bucket implements KVStore with Load and CompareAndSwap, and
OpenSyncBucket returns the spinifex-sync bucket every lock is built on.

* refactor: port the leader leases to kvsync and delete kvlease

Every leader election now runs a kvsync Lease in the shared sync bucket,
constructed once by the role that owns it. The two vpcd sites and the two
DNS sites share one Lease, so they exclude each other on one node too.

The daemon deletes the nine per-caller lease buckets at start, and
kvlease is removed.

* fix: never recreate a lost bucket through the first-use open

A failed recovery dropped the handle, and the next call's first-use open
created the bucket again, which RecreateIfMissing=false forbids. For the
sync bucket that restarted revisions under live tokens.

* fix: count a lease's validity from the write's send

A peer can see a revision as soon as it commits, so validity measured from
the reply could outlast a takeover. A grant whose reply took two thirds of
the TTL is now refused.

* fix: forget EKS cluster leases before a same-name cluster can take them

The reconciler lease is forgotten while the DELETING record still blocks a
create, and the teardown lease before the teardown gate opens, so cleanup
can no longer release a recreated cluster's lease.

* test: check the sync bucket in the KV quorum suite

The suite looked for the per-caller lease buckets, which the daemon now
deletes. It now requires the sync bucket at full replication and none of
the old lease buckets.

* fix: open a bucket without holding its lock across the network

KV and Reopen held the bucket mutex across the open, so a caller behind a
stuck open waited with no context to stop it. One open is now in flight at a
time and each waiter stops at its own context.

* fix: lose a lease at its deadline even when the renewal is stuck

A renewal CAS that blocked past its context kept the lease reported held
after a peer could take over. Each renewal is now given up at 2*ttl/3 from
the last send, and every renewal is scheduled from that send rather than from
install.

* fix: retry a held per-pass lease once a dead holder can be taken over

A pass that lost the election waited for the loop's next resync, so a dead
holder blocked DNS, vpcd drift and Route 53 for up to two intervals. A lost
pass now asks to run again just past one TTL. vpcd's one-shot startup
election waits up to a minute for JetStream quorum, as it did before.

* fix: keep contending for an EKS cluster's lease while it is live

The reconciler asked for its lease once, and a fresh lease only takes over a
foreign key after watching it for a TTL, so a dead holder's key left the
cluster unreconciled for good. It now asks every ttl/3 while the cluster is
CREATING or ACTIVE, contends again after a loss, and watches the Lost channel
of the acquisition it was given.

* test: per-service leaders stop on a taken-over lease and resume after re-election

* test: cover lease gating, teardown-lease retirement, obsolete buckets and kvsync edge cases

* fix: confirm a claim's adoption by CAS so a stale replica read cannot adopt a taken-over key

* fix: Unlock abandons an in-flight lease renewal instead of waiting it out

* fix: wait out a forming JetStream at start and retry a held pass within a renewal interval

* chore: sort the reconcile lock imports for gofmt

* refactor: trim the kvsync lease internals and their callers

Drop the lease's separate stop channel, the election's held flag, the
reconcile try-leader glue, the unused ErrLeaseHeld, Lease.Holder and the
EKS lease-group fallback, and route constructor checks through one panic.

* refactor: port the ELBv2 data-path leader to kvsync

The data-path reconciler that landed on dev elected its leader through
kvlease in its own spinifex-elbv2-leader bucket. It now holds the
elbv2.datapath lease in the sync bucket, constructed with the daemon's
other leases, and the old bucket joins the set deleted at start.

The RDS delete tests that landed alongside it build their reconciler
through the lease-taking constructor.

* test: give the integration service daemon's EKS service its lease group
2026-11-09 09:49:16 +10:00
Bryn Mailer 1e3e0d5cec refactor: fold kvutil into kvstore and rename Claim to Take (#1608)
* refactor: fold kvutil into kvstore and rename Claim to Take

kvutil was the layer under kvstore, so its bucket, replica, version, key
and CAS helpers now live in kvstore and every importer changes only its
import path and qualifier. kvutil.Claim becomes kvstore.Take so that it
does not read as a lock claim, and the kvutil package is deleted.

The bucket-creation invariant now exempts kvstore/buckets.go, the one
file holding the chokepoint, rather than the whole package.

* refactor: merge the kvstore test exports into one file

* refactor: move the kvutil callers added on dev to kvstore
2026-11-08 10:50:44 +10:00
Josh Siviour 1e1a51a8dc Allow test-link Lambda function environments (#1656)
* feat(lambda): deliver function environment to test-link guests

* fix(lambda): validate environment names before reserved keys
2026-11-08 08:55:22 +10:00
Ben Duncan 805c518f77 fix(ci): give the nightly's single-node service suites node SSH to their own VM (#1653) 2026-11-06 15:30:41 -06:00
Josh Siviour ac117a3a1e feat(lambda): provide test-link SDK gateway settings (#1655) 2026-11-06 21:43:50 +10:00
Josh Siviour 45dc82a85d fix(eks): never terminate a control plane whose cluster record is missing (#1654)
* fix(eks): alarm on a control-plane VM with no cluster record instead of terminating it

* chore(eks): order test imports

* feat(eks): add spx admin eks orphans and share the cluster-record judgement with the billable reaper

* fix(eks): list orphan control planes through an EC2-owned inventory capability

* test(eks): cover the orphan report ENI reader and the inventory fail-closed path

* fix(eks): report only running control planes and treat a missing ENI as gone

* fix(eks): open DNS watch buckets attach-only so a deleted account bucket is not recreated

* fix(eks): read control-plane ENIs from their record so a corrupt one is unreadable, and report the zone
2026-11-06 21:37:27 +10:00
Josh Siviour 2a2097b4cf feat(lambda): trigger test-link functions from SQS queues (#1652)
* feat(lambda): record SQS event-source mappings behind Create, Get, List and Delete

Create refuses every option this build does not implement, reads the
queue as the function's execution role and requires the queue's
visibility window to cover the function's run limit. Delete records
Deleting for the poller to finish. Get and Delete authorize against
the mapping ARN.

* feat(lambda): poll mapped SQS queues in test-link gateways

The poller receives and deletes as the function's execution role and
invokes through the internal executor. A batch is deleted only after
the function succeeds; an error, refusal or unknown outcome leaves the
messages to reappear.

* test(lambda): give the poller tests their in-package reason

* fix: derive the mapping UUID so duplicate creates collide atomically

Create no longer scans every mapping. The UUID is derived from the
account, Region, function and queue, so the keyed create refuses a
duplicate pair in one atomic write with no second record to orphan.

* fix: mark the poller in-process SQS request as received over TLS

The request never leaves the TLS-terminating gateway, so an execution
role that requires aws:SecureTransport must still authorize it.

* fix: renew the poller session per event and back off on failed batches

Each event now runs on a session that outlives its invocation and its
delete, so a later split event is never acknowledged on expired
credentials. A failed, refused or unknown invocation makes the worker
back off before the next receive.

* feat: page ListEventSourceMappings with Marker and NextMarker

* test: drive the poller through InvokeEventSource on invalid and busy outcomes

* fix: skip locally held management addresses when allocating

The shared record can lose an entry an instance on this node still holds. Allocate now treats every address in the local cache as taken, refuses cached or recorded hits that another local instance holds, and resets its results on each CAS attempt.

* fix: refuse ListEventSourceMappings until listing can be bounded

The mapping store can only be listed whole, so a list request would
wait on the whole inventory. Refuse it, and skip repeated keys when the
gateway lists mappings at startup.
2026-11-06 14:54:18 +10:00
Josh Siviour 89a4e24966 feat: heal managed LB VMs whose data NIC fails its gateway probe (#1651)
* feat: add RebootSystemInstance for load balancer VMs

Add a reboot call to SystemInstanceLauncher with a NATS client and a
daemon local and routed implementation, subscribed next to terminate.
It refuses unless the VM is an ELBv2 system VM in the system account,
reusing the ownership classifier, and never terminates.

* test: cover the system reboot client and routing failures

Add tests for the NATS reboot client and the routed reboot paths: a
missing connection, an undecodable reply, a refusing owner, a malformed
subject and the idempotent subscription.

* feat: probe each LB data NIC's gateway and report it in the heartbeat

lb-agent sends an ARP request for each data NIC's gateway on an AF_PACKET
socket bound to it, counts consecutive misses (failing from the third),
and adds the instance ID and per-NIC probe result and counters to the
heartbeat as optional fields. The daemon decodes an older agent's
heartbeat unchanged. The microVM image keeps af_packet, loads it before
lb-agent starts, and fails the build when the module is absent.

* fix: bound the lb-agent instance-id lookup and ARP receive wait

The metadata lookup now runs under one 1s deadline, waits at most that
long inside a tick, and after a failure is retried once every 6 ticks, so
an unreachable metadata service cannot push a heartbeat past one tick.
The ARP probe resets its receive timeout to the time left before each
read, so a non-matching frame cannot extend one exchange to twice the
wait.

* feat: record LB data-path evidence and report active_impaired

Heartbeat NIC evidence is stored under lbhealth.<lbId>, written by CAS only
when the set of failing NICs changes and only when the agent's instance ID
matches the LB's current VM. DeleteLoadBalancer and SetSubnets suspend the
record before touching the VM or ENIs; SetSubnets lifts it on the new VM and
DeleteLoadBalancer removes it. DescribeLoadBalancers reports active_impaired
while a NIC of the current VM is failing.

* fix: resume LB repair when DeleteLoadBalancer fails before the VM is touched

* feat: repair an LB VM whose data NIC fails its gateway probe

* test: e2e subtests for LB data-NIC self-heal and the EKS endpoint NIC

Add a lbrecovery subtest that downs a data NIC on an internet-facing NLB and an internal ALB through QMP and watches the repair, with S7 checks on the target and an unrelated customer instance, plus an EKS endpoint NIC case. List both in the e2e coverage index.

* test: give the endpoint NIC self-heal check its own private-endpoint cluster

The shared cluster has no private endpoint, so the check had no endpoint NIC
to disable. It now builds its own cluster with private and public access,
finds the guest NIC by the endpoint ENI's MAC through query-rx-filter, fails
if no guest NIC matches, and deletes its cluster on cleanup.

* fix: make data-path repair reservation, leadership and suspension converge safely

The reservation callback now resets its admission state on every CAS retry,
leadership is checked before the reservation and again before dispatch, and a
suspension left by a failed resume converges through the heartbeat of the VM
the load balancer names, with a 30-minute backstop. An established failure
clears only on a clean probe, and a heartbeat never rebinds a bound record.

* test: bound post-repair traffic by one deadline from fault injection

* test: check the internal ALB self-heal from a client inside the VPC

* test: log the guest clock offset in the internal ALB self-heal check

* test: link the LB repair invariant tests to ADR-0003:S7

* test: require a client failure after the fault before the recovery counts
2026-11-06 14:48:38 +10:00
Josh Siviour 4be471c196 feat(lambda): boot test-link invocations under Firecracker (#1648)
* feat(lambda): deliver guest configuration on a disk the agent erases

* feat(lambda): boot test-link attempts under Firecracker with a kernel hard kill

* fix(lambda): detect the configuration disk by its missing fw_cfg entry

* test(lambda): cover the configuration-disk seal from the agent and state in-package reasons

* fix(lambda): release a Firecracker attempt whose supervisor never started
2026-11-06 11:18:24 +10:00
Josh Siviour ce29eb12bf feat(lambda): activate test-link functions and invoke a first function end to end (#1646)
* feat(lambda): activate test-link functions once the node serves the pinned image

In lambdatestlink builds the function owner moves a Pending function with
stored code to Active when this node's daemon serves the pinned runtime
image and the cluster has one node. The pass runs at start, after each
code-stored completion and periodically, and writes by compare-and-swap.
Release builds set no readiness, so nothing leaves Pending there.

* build: install the test-link Lambda daemon, image and initializer

install-lambda-testlink puts the outputs of build-lambda-testlink-image and
build-lambda-testlink in /usr/share/spinifex/microvm/lambda-testlink and
/usr/local/bin, where the tagged daemon and gateway read them, and restarts
the services. Controlled-test only; nothing default changes.

* fix(lambda): keep test-link functions the executor cannot run Pending

A function with environment variables or a code copy over the test-link
bound is refused by the executor on every Invoke, so readiness no longer
activates it and names the unsupported setting as its StateReason.

* build: drop install-lambda-testlink in favour of the deploy role

A target stop and restart can leave members running the old spx. The
Ansible deploy role already stops, waits, swaps and restarts any member
still on a stale binary, so the test-link install goes through it.

* docs: describe test-link activation and its install path in the Lambda contract README

* test(lambda): run the activation tests from an external package

The wake test now waits for the start pass before storing code, so only
the code-stored wake can activate the second function.

* test(lambda): mark the readiness test as in-package with its reason

* fix(lambda): refuse handlers AWS refuses and keep ones the test link cannot run Pending

* fix(lambda): stop reporting the test link as serving once it stops accepting

* test(lambda): enable readiness after the first function is stored so no wake is buffered

* revert(lambda): leave CreateFunction handler validation unchanged in this slice

* test(lambda): call ServeControl with the fixed frame limit from dev

* test(lambda): hold a daemon invocation while serving ends on listener failure
2026-11-06 07:43:11 +10:00
Ben Duncan 8ef35a9c1d fix(rds): run one teardown of a DB instance delete at a time (#1647)
* fix(rds): run one teardown of a DB instance delete at a time

The handler that accepted a delete now owns its teardown for its request
budget, a repeat of an accepted delete answers deleting without a second
teardown, and the reconciler resumes only a delete older than that budget.
Two teardowns racing let the handler see the VM mid-shutdown as gone and
answer 500 on the still-attached data volume.

* fix(rds): end the delete handler's teardown exactly when its claim does

The handler's deadline now derives from the persisted transition stamp the
reconciler measures from, and a teardown past that deadline starts no
terminate, since the graceful engine stop swallows the cancellation.
2026-11-05 12:17:48 -06:00
Josh Siviour 79e8c16ea5 fix: route instance metadata options and termination protection to the owner (#1645)
* feat: add owner-routed commands for instance metadata options and termination protection to the ec2.cmd contract

* feat: dispatch metadata-options and termination-protection commands on the owner

* feat: route ModifyInstanceMetadataOptions to the instance owner first

* test: pin the N-1 rejection, account isolation and timeout-after-apply for owner routing

* feat: route DisableApiTermination changes to the instance owner first

* test: pin the N-1 rejection with the previous release's command type and exercise the real stopped fallback
2026-11-05 23:23:41 +10:00
Josh Siviour 17ef43327e feat(lambda): carry 6 MiB test-link request and response payloads (#1644)
* feat(lambda): declare test-link control-socket payload and frame bounds

Replace the NATS-derived control-socket limits with a raw 6 MiB payload
bound and a frame bound that leaves 64 KiB beside a full base64 payload.
The daemon admits at most two connections from accept to close, closes
admitted connections on shutdown, and the gateway's dial stops at its
reply deadline. The control name moves to /3 so mixed limits never meet.

* fix(lambda): join test-link close-on-cancel callbacks before releasing

context.AfterFunc's stop does not wait for a callback already running, so
a connection's close could still be in flight after its admission slot
was released and Serve returned. Join a started callback first, and test
the join, a stalled peer at shutdown and cancellation racing completion.

* fix(lambda): refuse test-link requests whose guest frame cannot fit

The control frame and the guest invocation frame bound different fields,
so a request the control socket accepts could still overflow HostSend
after the grant started. The daemon now encodes the guest frame at its
longest deadline before Start and refuses one that does not fit.

* fix(lambda): check the test-link guest frame after taking the slot

Encode the guest frame at MinInt64, the longest int64 deadline, and only
once the busy slot is held, so a busy refusal never encodes the payload;
a fit refusal releases the slot. Bound every wait in the lifecycle and
guest-frame tests so a regression fails rather than hangs.

* test: cover test-link control frame bounds, admission and late dispatch

* test: prove 6 MiB test-link invoke through the gateway and real daemon refusals

* test: join held exchanges and check the echo runtime's own result

* chore(lambda): describe the executor request bound as the control frame

* test(lambda): prove test-link sender bounds, accepted connections and full lifetimes

The sender test uses a real presigned code URL, the longest function name
and qualifier, and the real frame limit; a 6 MiB + 1 payload is refused
before encoding, shown by bytes allocated. Admission reads the kernel socket
table, and the memory measurement covers both lifetimes through full replies.

* docs(lambda): document the /3 control socket and its bounds

* test(lambda): sample heap only across the measured lifetimes and join the sampler

The sampler now starts after the post-GC baseline and stops after both
replies are read, and cleanup joins it if the test fails first.

* test(lambda): check the /2 control socket cannot reach /3 in either direction
2026-11-05 22:57:51 +10:00
Josh Siviour acc56dca66 feat(accountlifecycle): delete an account's instances, spot requests and volumes through EC2 cleanup (#1643)
* feat(ec2): read what compute and storage an account still holds, and serve teardown-outstanding over NATS

* feat(accountlifecycle): repeat compute and storage cleanup inside the teardown job within a bounded loop

* test(ec2): AccountTeardownOutstanding fails closed on no responder, error reply, bad reply and timeout

* refactor(teardown): remove the legacy instance, volume and spot reapers

* feat(teardown): bind the EC2 compute and storage cleanup into account deletion

* chore(teardown): drop dead test helpers and fix lint after the reaper removal

* chore(layering): drop the paid accountlifecycle to awsapi/volume allowlist entry

* fix(teardown): re-read compute and storage before account removal, list them in the dry run, leave instances alone while a compute owner is stuck

* test(teardown): engine-level S7 over the real cleanups for an expired record with an attached volume; bound the lost-fence test

* feat(teardown): log each convergence pass
2026-11-05 22:24:32 +10:00
Josh Siviour 668f6341e3 feat: bind the runtime image digest to each test-link Invoke (#1641)
* fix: hold the test-link slot when the grant start outcome is unknown

* feat: bind the pinned runtime image digest to each test-link Invoke

The gateway carries the daemon's image digest pin and sends it in every
request; the daemon refuses before Start when it is missing or differs. The
control socket moves to a versioned name so mixed gateway and daemon builds
never connect.

* test: tie the mixed-version control test to the production socket name

The gateway logs a missing or malformed image pin once at startup.

* test: cover withdraw faults, image replacement and the digest's share of the request bound

* test: assert the at-limit request is exactly the probed size

* test: send the prepared digest so owner-refusal cases reach the owner
2026-11-05 21:51:58 +10:00
1319 changed files with 51286 additions and 10955 deletions
+9 -1
View File
@@ -40,7 +40,11 @@
# received. It is last because it SIGSTOPs a cluster-wide
# service: anything sharing the environment would see the same outage and report
# a failure that says nothing about itself.
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota route53 storagefault"
# uefistore is single-only because it runs on the node it tests: it restarts
# that node's daemon under a running guest and reads the variable store with
# the node's own Predastore credentials. Guests outlive a daemon restart, so
# suites sharing the node see a brief control-plane gap, not an outage.
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota route53 uefistore storagefault"
# Suites runnable against a multi-node environment.
#
@@ -195,6 +199,10 @@ e2e_suite_timeout() {
# took 24-29m, and an Oracle host ran out at 30m inside the last test's
# cleanup. The default left it no headroom at all.
single) echo "40m" ;;
# uefistore's prune test has to outwait the 10-minute prune age and a GC
# sweep after its last write-back, on top of three stop/starts and a boot.
# A pass is estimated at 40 minutes, unmeasured; the rest is the watch's cap.
uefistore) echo "60m" ;;
*) echo "30m" ;;
esac
}
+8 -8
View File
@@ -65,7 +65,7 @@ workflows:
- 'actions/setup-go@v7.0.0'
- 'actions/upload-artifact@v7.0.2'
- 'docker/setup-buildx-action@v4.4.1'
- 'softprops/action-gh-release@v3.0.3'
- 'softprops/action-gh-release@v3.0.4'
'.github/workflows/segscan-oracle.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
@@ -79,7 +79,7 @@ workflows:
- 'actions/checkout@v7.0.1'
- 'actions/setup-node@v7.1.0'
- 'actions/upload-artifact@v7.0.2'
- 'pnpm/action-setup@v6.1.0'
- 'pnpm/action-setup@v6.1.1'
dependencies:
'actions/cache@v6.1.0':
ref: 'v6.1.0'
@@ -131,13 +131,13 @@ dependencies:
commit: 'sha1-dfe3c3f87815947d99a8997f908cb6525fc44e9e'
owner_id: 761456
repo_id: 258271226
'pnpm/action-setup@v6.1.0':
ref: 'v6.1.0'
commit: 'sha1-ea17c68df8912ef543352723c149a84f56e3d413'
'pnpm/action-setup@v6.1.1':
ref: 'v6.1.1'
commit: 'sha1-73e46992e05b19abd31f2f3c20e0e7370d762115'
owner_id: 21320719
repo_id: 262289747
'softprops/action-gh-release@v3.0.3':
ref: 'v3.0.3'
commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64'
'softprops/action-gh-release@v3.0.4':
ref: 'v3.0.4'
commit: 'sha1-8a0cd651e5b2365bd640854a3fa5dfc70e4875f3'
owner_id: 2242
repo_id: 204253808
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
topologies:
description: "Space-separated topologies (blank = vm-single vm-multi bm vm-single-principal). One name runs one topology."
description: "Space-separated topologies (blank = vm-single vm-multi bm vm-single-principal vm-single-ocibsd vm-multi-ocibsd). One name runs one topology."
required: false
workbooks:
description: "Space-separated workbooks (blank = the driver's default list)"
@@ -62,9 +62,10 @@ jobs:
# a dedicated label keeps a two-hour cloud wait off the hypervisor labels and
# lifts the six-hour cap a hosted job carries.
runs-on: ${{ inputs.runner || vars.OCI_E2E_RUNNER || 'ubuntu-26.04' }}
# Three topologies including a bare-metal build, each with its own install,
# formation and workbook pass. The sweep still runs if this fires.
timeout-minutes: 300
# Six topologies including a bare-metal build, each with its own install,
# formation and workbook pass, so this is the hosted runner's cap. The sweep
# still runs if this fires, ocibsd volumes and backups included.
timeout-minutes: 360
steps:
# Set here rather than in a job-level env block: the runner context is not
# available at job level, only from a step onwards, and a workflow that
@@ -236,8 +237,8 @@ jobs:
# always(), and before the artifact upload: an OCI bare-metal host left
# running by a cancelled or timed-out job is the most expensive way this
# workflow can fail. The sweep works from the Terraform state files, so it
# can only destroy what this run created.
# workflow can fail. It destroys by state file, then deletes OCI volumes and
# backups by the cluster tag each ocibsd topology recorded.
- name: Sweep anything left behind
if: always() && !inputs.keep_on_fail
working-directory: spinifex/scripts/terraform/oci-spx
+2
View File
@@ -578,6 +578,8 @@ jobs:
echo \"::group::suite ${SUITE}\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-${SUITE}.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
SPINIFEX_NODES='${{ steps.vm.outputs.wan_ip }}' SPINIFEX_SSH_USER=tf-user \
SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./${SUITE}.test -test.v -test.timeout=${TMO} \
2>&1 | tee '${ARTIFACT_DIR}/test-${SUITE}.log'; \
+3 -1
View File
@@ -478,7 +478,9 @@ jobs:
printf '\n%s%s━━ suite %s ━━%s\n' \"\$B\" \"\$C\" \"\$SUITE\" \"\$R\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
TMO=30m; for kv in ${SUITE_TMOS}; do case \$kv in \$SUITE:*) TMO=\${kv#*:} ;; esac; done; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
NODE_SSH=; if [ \"\$SUITE\" = eks ]; then \
NODE_SSH=\"SPINIFEX_NODES=${{ steps.vm.outputs.wan_ip }} SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2\"; fi; \
env \$NODE_SSH GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./\$SUITE.test -test.v -test.timeout=\$TMO ${TEST_RUN_FLAG} \
2>&1 | tee '${ARTIFACT_DIR}/test-'\$SUITE'.log' \
+54 -18
View File
@@ -1,21 +1,20 @@
name: Publish System AMIs
# Builds the system AMIs (libguestfs, on the runner) and publishes each
# compressed qcow2 + .sha256 sidecar to the Cloudflare R2 bucket that backs
# iso.mulgadc.com/system-ami/. Six images ship:
# spinifex-eks-node Alpine EKS K3s node (server|agent, role at boot)
# spinifex-ecs-node Alpine ECS container instance
# spinifex-eks-node-gpu Ubuntu EKS K3s GPU worker (NVIDIA driver + toolkit)
# spinifex-ecs-node-gpu Ubuntu ECS GPU container instance
# spinifex-rds-postgres Alpine RDS PostgreSQL instance
# spinifex-rds-mariadb Alpine RDS MariaDB instance
# Builds the system AMIs and publishes each compressed qcow2 + .sha256 sidecar
# to the Cloudflare R2 bucket that backs iso.mulgadc.com/system-ami/. The Alpine
# images build with libguestfs (build-system-image.sh); the Ubuntu GPU images
# build with mkosi, which installs from resolute-updates and resolute-security,
# so each release ships the newest kernel. Eight images ship:
# spinifex-eks-node Alpine EKS K3s node (server|agent, role at boot)
# spinifex-ecs-node Alpine ECS container instance
# spinifex-rds-postgres Alpine RDS PostgreSQL instance
# spinifex-rds-mariadb Alpine RDS MariaDB instance
# spinifex-eks-node-gpu Ubuntu EKS K3s GPU worker (mkosi)
# spinifex-ecs-node-gpu Ubuntu ECS GPU container instance (mkosi)
# ubuntu-26.04-nvidia-gpu-x86_64 Ubuntu NVIDIA GPU base image (mkosi)
# ubuntu-26.04-amd-gpu-x86_64 Ubuntu AMD ROCm GPU base image (mkosi)
# Operators then install them on any cluster with:
# spx admin images import --name spinifex-eks-node
# spx admin images import --name spinifex-ecs-node
# spx admin images import --name spinifex-eks-node-gpu
# spx admin images import --name spinifex-ecs-node-gpu
# spx admin images import --name spinifex-rds-postgres
# spx admin images import --name spinifex-rds-mariadb
# spx admin images import --name <catalog key>
#
# Publishing overwrites the mirror artifact, so it runs only from release.yml
# once a tag has passed its gates, with siblings at the tags go.mod requires.
@@ -54,9 +53,12 @@ jobs:
VARIANT: ${{ inputs.variant }}
run: |
set -euo pipefail
cpu='{"name":"spinifex-eks-node","manifest":"scripts/images/eks-node/manifest.conf"},{"name":"spinifex-ecs-node","manifest":"scripts/images/ecs-agent/manifest.conf"}'
gpu='{"name":"spinifex-eks-node-gpu","manifest":"scripts/images/eks-node-gpu/manifest.conf"},{"name":"spinifex-ecs-node-gpu","manifest":"scripts/images/ecs-agent-gpu/manifest.conf"}'
rds='{"name":"spinifex-rds-postgres","manifest":"scripts/images/rds-postgres/manifest.conf"},{"name":"spinifex-rds-mariadb","manifest":"scripts/images/rds-mariadb/manifest.conf"}'
# builder=legacy entries carry a manifest for build-system-image.sh;
# builder=mkosi entries carry the mkosi-build.sh --image name and the
# R2 object base, which must equal the catalog URL basename.
cpu='{"name":"spinifex-eks-node","builder":"legacy","manifest":"scripts/images/eks-node/manifest.conf"},{"name":"spinifex-ecs-node","builder":"legacy","manifest":"scripts/images/ecs-agent/manifest.conf"}'
gpu='{"name":"spinifex-eks-node-gpu","builder":"mkosi","image":"spinifex-eks-node-gpu","object":"spinifex-eks-node-gpu-x86_64"},{"name":"spinifex-ecs-node-gpu","builder":"mkosi","image":"spinifex-ecs-node-gpu","object":"spinifex-ecs-node-gpu-x86_64"},{"name":"ubuntu-26.04-nvidia-gpu","builder":"mkosi","image":"ubuntu-gpu-nvidia","object":"ubuntu-26.04-nvidia-gpu-x86_64"},{"name":"ubuntu-26.04-amd-gpu","builder":"mkosi","image":"ubuntu-gpu-amd","object":"ubuntu-26.04-amd-gpu-x86_64"}'
rds='{"name":"spinifex-rds-postgres","builder":"legacy","manifest":"scripts/images/rds-postgres/manifest.conf"},{"name":"spinifex-rds-mariadb","builder":"legacy","manifest":"scripts/images/rds-mariadb/manifest.conf"}'
case "${VARIANT}" in
cpu) items="${cpu}" ;;
gpu) items="${gpu}" ;;
@@ -132,6 +134,8 @@ jobs:
go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
- name: Install build + upload tooling
# mkosi legs build and upload inside the pinned builder container.
if: matrix.builder == 'legacy'
run: |
set -euo pipefail
sudo apt-get update -qq
@@ -144,6 +148,7 @@ jobs:
fi
- name: Build + publish ${{ matrix.name }} to R2
if: matrix.builder == 'legacy'
working-directory: spinifex
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
@@ -168,3 +173,34 @@ jobs:
FLAGS+=(--dry-run)
fi
./scripts/publish-system-image.sh "${MANIFEST}" "${FLAGS[@]}"
- name: Build + publish ${{ matrix.name }} to R2 (mkosi)
if: matrix.builder == 'mkosi'
working-directory: spinifex
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.CF_R2_ENDPOINT_URL }}
R2_BUCKET: spinifex-iso
DRY_RUN: ${{ inputs.dry_run }}
IMAGE: ${{ matrix.image }}
OBJECT: ${{ matrix.object }}
# On-disk scratch for the same reason as the legacy legs: the GPU raw
# and its qcow2 do not fit a RAM-backed /tmp.
MKOSI_OUTPUT_DIR: ${{ runner.temp }}/mkosi-output
run: |
set -euo pipefail
mkdir -p "${MKOSI_OUTPUT_DIR}"
./scripts/mkosi-build.sh --image "${IMAGE}" -- --manifest-format=json
# The kernel this image boots, from mkosi's package manifest.
KERNEL=$(jq -r '.packages[] | select(.name | test("^linux-image-[0-9]")) | "\(.name) \(.version)"' "${MKOSI_OUTPUT_DIR}/${IMAGE}.manifest")
if [[ -z "${KERNEL}" ]]; then
echo "::error::no linux-image-<version> package in ${IMAGE}.manifest" >&2
exit 1
fi
echo "kernel: ${KERNEL}"
FLAGS=(--image "${IMAGE}" --name "${OBJECT}")
if [[ "${DRY_RUN}" == "true" ]]; then
FLAGS+=(--dry-run)
fi
./scripts/mkosi-publish.sh "${FLAGS[@]}"
+1 -1
View File
@@ -150,7 +150,7 @@ jobs:
path: artifacts
- name: Create Draft GitHub Release
uses: softprops/action-gh-release@v3.0.3
uses: softprops/action-gh-release@v3.0.4
with:
draft: true
files: |
+2 -2
View File
@@ -51,7 +51,7 @@ jobs:
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
- name: Setup pnpm
uses: pnpm/action-setup@v6.1.0
uses: pnpm/action-setup@v6.1.1
with:
package_json_file: spinifex/runtime/roles/spinifexui/frontend/package.json
@@ -127,7 +127,7 @@ jobs:
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
- name: Setup pnpm
uses: pnpm/action-setup@v6.1.0
uses: pnpm/action-setup@v6.1.1
with:
package_json_file: spinifex/runtime/roles/spinifexui/frontend/package.json
+2 -2
View File
@@ -515,9 +515,9 @@ install-system:
fi
install-go:
@echo -e "\n....Installing Go 1.27.3 for $(ARCH) ($(GO_ARCH))...."
@echo -e "\n....Installing Go 1.27.4 for $(ARCH) ($(GO_ARCH))...."
@if [ ! -d "/usr/local/go" ]; then \
curl -L https://go.dev/dl/go1.27.3.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
curl -L https://go.dev/dl/go1.27.4.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
else \
echo "Go already installed in /usr/local/go"; \
fi
-1
View File
@@ -17,7 +17,6 @@ var allowlist = []allowance{
{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/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"},
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/vpc", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "agents/ecs/agent", To: "domains/ecs", Debt: "agents/ecs/agent", Reason: "assignment/state wire types from the ECS implementation; needs a guest/controller contract"},
+3 -1
View File
@@ -1,6 +1,6 @@
ARG VERSION=dev
FROM golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9 AS builder
FROM golang:1.27.4-bookworm@sha256:4fa7c968ba93f1635670522dbb01a2c78150443e1385746924566079f36570ea AS builder
ARG VERSION
@@ -51,6 +51,8 @@ RUN cp spinifex/scripts/setup.sh /out/setup.sh 2>/dev/null || true
RUN cp spinifex/scripts/setup-ovn.sh /out/setup-ovn.sh
RUN cd spinifex/scripts && cp install-node.sh smoke-test.sh node-reset.sh uninstall-spx.sh /out/
RUN mkdir -p /out/udev && cp spinifex/build/udev/* /out/udev/
# Staged by setup.sh on an OCI VM; spx admin init or join installs it on ocibsd nodes.
RUN cp -r spinifex/build/ocibsd /out/ocibsd
RUN mkdir -p /out/lib && cp spinifex/scripts/lib/distro.sh /out/lib/distro.sh
RUN mkdir -p /out/systemd && cp spinifex/build/systemd/* /out/systemd/
RUN mkdir -p /out/scripts && cp spinifex/build/scripts/* /out/scripts/
+2
View File
@@ -32,6 +32,8 @@ load_mod qemu_fw_cfg || load_mod fw_cfg_sysfs || true
load_mod failover || true
load_mod net_failover || true
load_mod virtio_net || true
# lb-agent probes each data NIC's gateway on an AF_PACKET socket.
load_mod af_packet || true
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
i=0
+25 -3
View File
@@ -16,8 +16,8 @@ load_mod() {
done
return 1
}
# lambda-agent reads its identity from fw_cfg and unloads qemu_fw_cfg before
# anything else runs. virtio_net needs net_failover and failover, and the
# lambda-agent reads its identity from fw_cfg, or the configuration disk, and
# seals it before anything else runs. virtio_net needs net_failover and failover, and the
# insmod fallback resolves no dependencies, so load the chain bottom-up.
load_mod qemu_fw_cfg || echo "[init] qemu_fw_cfg did not load" >/dev/console
load_mod failover || true
@@ -26,8 +26,30 @@ load_mod virtio_net || echo "[init] virtio_net did not load" >/dev/console
ip link set lo up
# Without fw_cfg data the executor attaches the same entries as a tar archive
# on the second disk; lambda-agent reads them here and erases the disk before
# the function starts. qemu_fw_cfg loads without a device, so test the entry.
CFG_ROOT=/spinifex-cfg
FWCFG_ROOT=/sys/firmware/qemu_fw_cfg
if [ ! -f "$FWCFG_ROOT/by_name/opt/spinifex/netcfg/raw" ]; then
load_mod virtio_blk || echo "[init] virtio_blk did not load" >/dev/console
i=0
while [ "$i" -lt 25 ] && [ ! -b /dev/vdb ]; do
sleep 0.04
i=$((i + 1))
done
if [ -b /dev/vdb ]; then
mkdir -m 0700 "$CFG_ROOT"
if ! mount -t tmpfs -o mode=0700,size=1m tmpfs "$CFG_ROOT" ||
! tar -xf /dev/vdb -C "$CFG_ROOT"; then
echo "[init] configuration disk not readable" >/dev/console
fi
FWCFG_ROOT=$CFG_ROOT
fi
fi
# The guest's addresses come from the same netcfg entry as other system VMs.
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
NETCFG=$FWCFG_ROOT/by_name/opt/spinifex/netcfg/raw
i=0
while [ "$i" -lt 25 ] && [ ! -f "$NETCFG" ]; do
sleep 0.04
+4
View File
@@ -0,0 +1,4 @@
# Give QEMU (group spinifex) read-write on the disks of OCI Block Volume
# attachments, named by ID_PATH, which is also the /dev/disk/by-path link.
# Partitions and every other block device keep their default ownership.
SUBSYSTEM=="block", ACTION!="remove", ENV{DEVTYPE}=="disk", ENV{ID_PATH}=="ip-*-iscsi-iqn.2015-12.com.oracleiaas:*", GROUP="spinifex", MODE="0660"
@@ -0,0 +1,7 @@
# ocibsd nodes only: QEMU, in the daemon's device cgroup, opens the SCSI disk
# an iSCSI login creates, including one attached after the daemon started.
[Unit]
After=spinifex-ocibsd.service
[Service]
DeviceAllow=block-sd rw
+65
View File
@@ -0,0 +1,65 @@
# spinifex-unit-version: 1
[Unit]
Description=Spinifex ocibsd (OCI Block Volumes)
PartOf=spinifex.target
After=spinifex-nats.service iscsid.socket iscsid.service
Wants=spinifex-nats.service iscsid.socket
[Service]
EnvironmentFile=-/etc/spinifex/telemetry.env
Type=simple
Slice=spinifex-system.slice
# Root without capabilities: iscsid answers iscsiadm only for uid 0, and the
# node records, lock and devices it touches are root-owned. iscsid does the
# privileged session and module work.
User=root
Group=spinifex
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service ocibsd start
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
OOMScoreAdjust=-500
MemoryHigh=10%
MemoryMax=20%
EnvironmentFile=/etc/spinifex/systemd.env
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/ocibsd/
StateDirectory=spinifex/ocibsd
# Filesystem access: iscsiadm writes node records and its lock
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=no
DevicePolicy=closed
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/systemd.env /etc/spinifex/ca.pem
ReadWritePaths=/var/log/spinifex -/etc/iscsi -/var/lib/iscsi -/run/lock
# read-only, not yes: a config_file credential may default to ~/.oci/config
ProtectHome=read-only
# Hardening
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
AmbientCapabilities=
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+14
View File
@@ -5,6 +5,7 @@ import (
"crypto/tls"
"errors"
"fmt"
"os"
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
@@ -25,9 +26,22 @@ type startup struct {
}
func defaultStartup() startup {
if _, err := os.Stat(hostlink.ConfigDiskRoot); err == nil {
return configDiskStartup(hostlink.ConfigDiskRoot, hostlink.ConfigDiskDevice)
}
return startup{fwCfgDir: hostlink.FwCfgDir, fwCfgRoot: hostlink.FwCfgRoot, unload: hostlink.UnloadFwCfg}
}
// configDiskStartup reads the entries init extracted from the configuration
// disk, and seals by erasing the disk and removing the extracted tree.
func configDiskStartup(root, device string) startup {
return startup{
fwCfgDir: root + "/by_name/" + lambdav1.FwCfgPrefix,
fwCfgRoot: root,
unload: func() error { return hostlink.SealConfigDisk(device, root) },
}
}
// 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, lambdav1.Scope, int, error) {
@@ -0,0 +1,43 @@
//test:in-package — package main cannot be imported by an external test package.
package main
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
// An identity that does not load still seals the configuration disk: the
// guest key on it is erased before the agent refuses to start.
func TestConfigDiskStartup_RefusedLoadErasesDisk(t *testing.T) {
dir := t.TempDir()
root := filepath.Join(dir, "cfg")
require.NoError(t, os.MkdirAll(filepath.Join(root, "by_name"), 0o700))
device := filepath.Join(dir, "vdb")
require.NoError(t, os.WriteFile(device, bytes.Repeat([]byte("guest-key"), 512), 0o600))
_, _, code, err := configDiskStartup(root, device).connect(t.Context())
require.Error(t, err)
require.Equal(t, exitRefused, code)
got, rerr := os.ReadFile(device)
require.NoError(t, rerr)
require.Len(t, got, 9*512)
require.Equal(t, make([]byte, len(got)), got, "configuration disk erased")
}
// A disk that cannot be erased fails closed: the agent refuses, and the
// extracted tree is kept so the seal check sees it.
func TestConfigDiskStartup_UnerasableDiskRefuses(t *testing.T) {
dir := t.TempDir()
root := filepath.Join(dir, "cfg")
require.NoError(t, os.MkdirAll(filepath.Join(root, "by_name"), 0o700))
_, _, code, err := configDiskStartup(root, filepath.Join(dir, "missing")).connect(t.Context())
require.ErrorContains(t, err, "erase configuration disk")
require.Equal(t, exitRefused, code)
require.DirExists(t, root)
}
+65
View File
@@ -0,0 +1,65 @@
//go:build lambdatestlink
package main
import (
"crypto/x509"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
)
const (
sdkEndpointEntry = "test-sdk-endpoint"
sdkCAEntry = "test-sdk-ca"
sdkBundlePath = "/run/lambda-sdk/ca-bundle.pem"
systemBundlePath = "/etc/ssl/certs/ca-certificates.crt"
)
type sdkConfig struct {
endpoint string
ca []byte
}
// readSDKConfig reads optional public settings before the boot config is sealed.
func readSDKConfig(dir string) (*sdkConfig, error) {
endpoint, endpointErr := os.ReadFile(filepath.Join(dir, sdkEndpointEntry, "raw"))
ca, caErr := os.ReadFile(filepath.Join(dir, sdkCAEntry, "raw"))
if errors.Is(endpointErr, os.ErrNotExist) && errors.Is(caErr, os.ErrNotExist) {
return nil, nil
}
if endpointErr != nil || caErr != nil {
return nil, errors.New("incomplete SDK gateway settings")
}
u, err := url.Parse(string(endpoint))
if err != nil || u.Scheme != "https" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" ||
net.ParseIP(u.Hostname()) == nil || u.Port() == "" {
return nil, errors.New("invalid SDK gateway endpoint")
}
if len(ca) > 64<<10 || !x509.NewCertPool().AppendCertsFromPEM(ca) {
return nil, errors.New("invalid SDK gateway CA")
}
return &sdkConfig{endpoint: string(endpoint), ca: ca}, nil
}
// writeBundle combines the system roots with the cluster's public root.
func (c *sdkConfig) writeBundle(path, systemPath string) error {
system, err := os.ReadFile(systemPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read system CA bundle: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create SDK CA directory: %w", err)
}
if err := os.Chmod(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("make SDK CA directory readable: %w", err)
}
bundle := append(append(system, '\n'), c.ca...)
if err := os.WriteFile(path, bundle, 0o644); err != nil {
return fmt.Errorf("write SDK CA bundle: %w", err)
}
return os.Chmod(path, 0o644)
}
@@ -0,0 +1,106 @@
//go:build lambdatestlink
//test:in-package exercises the unexported boot configuration reader
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSDKConfig_OptionalAndInvalid(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
defer server.Close()
ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
for _, tc := range []struct {
name string
endpoint []byte
ca []byte
wantErr bool
want bool
}{
{name: "absent"},
{name: "valid", endpoint: []byte(server.URL), ca: ca, want: true},
{name: "missing ca", endpoint: []byte(server.URL), wantErr: true},
{name: "missing endpoint", ca: ca, wantErr: true},
{name: "invalid endpoint", endpoint: []byte("http://127.0.0.1:1234"), ca: ca, wantErr: true},
{name: "invalid ca", endpoint: []byte(server.URL), ca: []byte("not a certificate"), wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
for name, value := range map[string][]byte{sdkEndpointEntry: tc.endpoint, sdkCAEntry: tc.ca} {
if value != nil {
require.NoError(t, os.MkdirAll(filepath.Join(dir, name), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(dir, name, "raw"), value, 0o600))
}
}
cfg, err := readSDKConfig(dir)
if tc.wantErr {
require.Error(t, err)
require.Nil(t, cfg)
return
}
require.NoError(t, err)
if !tc.want {
require.Nil(t, cfg)
return
}
bundle := filepath.Join(t.TempDir(), "sdk", "ca.pem")
require.NoError(t, cfg.writeBundle(bundle, filepath.Join(t.TempDir(), "absent")))
data, err := os.ReadFile(bundle)
require.NoError(t, err)
roots := x509.NewCertPool()
require.True(t, roots.AppendCertsFromPEM(data))
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots}}}
resp, err := client.Get(cfg.endpoint)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}
}
func TestSDKConfig_BundleKeepsSystemRoots(t *testing.T) {
cluster := httptest.NewTLSServer(nil)
defer cluster.Close()
clusterCA := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cluster.Certificate().Raw})
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test system root"},
NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
require.NoError(t, err)
systemCA := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
dir := t.TempDir()
systemPath, bundlePath := filepath.Join(dir, "system.pem"), filepath.Join(dir, "sdk", "ca.pem")
require.NoError(t, os.WriteFile(systemPath, systemCA, 0o600))
require.NoError(t, (&sdkConfig{ca: clusterCA}).writeBundle(bundlePath, systemPath))
bundle, err := os.ReadFile(bundlePath)
require.NoError(t, err)
roots := x509.NewCertPool()
require.True(t, roots.AppendCertsFromPEM(bundle))
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots}}}
resp, err := client.Get(cluster.URL)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
systemCert, err := x509.ParseCertificate(der)
require.NoError(t, err)
_, err = systemCert.Verify(x509.VerifyOptions{Roots: roots})
require.NoError(t, err)
}
+53 -15
View File
@@ -12,11 +12,14 @@ import (
"net/http"
"os"
"os/exec"
"sort"
"strings"
"time"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/agent"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/runtimeapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/lambdaenv"
)
const (
@@ -31,13 +34,17 @@ type testlink struct {
pkg taskPackage
runtimeAddr string
startBootstrap func(ctx context.Context, env []string) (*exec.Cmd, error)
caBundlePath string
systemCAPath string
}
func start(ctx context.Context) int {
t := testlink{
startup: defaultStartup(),
pkg: devTaskPackage(),
runtimeAddr: runtimeAddr,
startup: defaultStartup(),
pkg: devTaskPackage(),
runtimeAddr: runtimeAddr,
caBundlePath: sdkBundlePath,
systemCAPath: systemBundlePath,
startBootstrap: func(ctx context.Context, env []string) (*exec.Cmd, error) {
return agent.StartBootstrap(ctx, taskRoot+"/bootstrap", env, os.Stdout, os.Stderr)
},
@@ -53,6 +60,7 @@ func (t testlink) run(parent context.Context) (int, error) {
// The package values are fw_cfg entries, so read them before connect seals it.
spec, specErr := readPackageSpec(t.fwCfgDir)
grant, grantErr := readGrant(t.fwCfgDir)
sdk, sdkErr := readSDKConfig(t.fwCfgDir)
conn, scope, code, err := t.connect(parent)
if err != nil {
return code, errors.Join(err, specErr, grantErr)
@@ -85,6 +93,18 @@ func (t testlink) run(parent context.Context) (int, error) {
}
cred := *first.Credential
first.Credential = nil
if err := lambdaenv.Validate(cred.Environment); err != nil {
return exitRefused, errors.New("first frame carries an invalid function environment")
}
if sdkErr != nil {
slog.Warn("lambda-agent: SDK gateway settings unavailable", "err", sdkErr)
}
if sdk != nil {
if err := sdk.writeBundle(t.caBundlePath, t.systemCAPath); err != nil {
slog.Warn("lambda-agent: SDK gateway settings unavailable", "err", err)
sdk = nil
}
}
if err := t.pkg.install(spec); err != nil {
return exitRefused, fmt.Errorf("install package: %w", err)
}
@@ -100,7 +120,8 @@ func (t testlink) run(parent context.Context) (int, error) {
ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil)
cmd, err := t.startBootstrap(ctx, bootstrapEnv(ln.Addr().String(), spec.handler, cred))
region, _, _ := strings.Cut(string(scope), "/")
cmd, err := t.startBootstrap(ctx, bootstrapEnv(ln.Addr().String(), spec.handler, cred, region, sdk, t.caBundlePath))
if err != nil {
return exitLink, fmt.Errorf("start bootstrap: %w", err)
}
@@ -124,18 +145,35 @@ func (t testlink) run(parent context.Context) (int, error) {
return exitClosed, nil
}
// bootstrapEnv is the bootstrap's whole environment: the runtime variables and
// the execution role's three credential variables.
func bootstrapEnv(runtimeAPI, handler string, cred hostlink.Credential) []string {
return []string{
"AWS_LAMBDA_RUNTIME_API=" + runtimeAPI,
"_HANDLER=" + handler,
"LAMBDA_TASK_ROOT=" + taskRoot,
"PATH=/bin:/sbin:/usr/bin",
"AWS_ACCESS_KEY_ID=" + cred.AccessKeyID,
"AWS_SECRET_ACCESS_KEY=" + cred.SecretAccessKey,
"AWS_SESSION_TOKEN=" + cred.SessionToken,
// bootstrapEnv supplies the runtime, execution-role and SDK settings.
func bootstrapEnv(runtimeAPI, handler string, cred hostlink.Credential, region string, sdk *sdkConfig, caPath string) []string {
vars := map[string]string{
"AWS_LAMBDA_RUNTIME_API": runtimeAPI,
"_HANDLER": handler,
"LAMBDA_TASK_ROOT": taskRoot,
"PATH": "/bin:/sbin:/usr/bin",
"AWS_ACCESS_KEY_ID": cred.AccessKeyID,
"AWS_SECRET_ACCESS_KEY": cred.SecretAccessKey,
"AWS_SESSION_TOKEN": cred.SessionToken,
"AWS_REGION": region,
"AWS_DEFAULT_REGION": region,
}
if sdk != nil {
vars["AWS_ENDPOINT_URL"], vars["AWS_CA_BUNDLE"] = sdk.endpoint, caPath
}
for k, v := range cred.Environment {
vars[k] = v
}
keys := make([]string, 0, len(vars))
for k := range vars {
keys = append(keys, k)
}
sort.Strings(keys)
env := make([]string, 0, len(keys))
for _, k := range keys {
env = append(env, k+"="+vars[k])
}
return env
}
// shutdownGrace is how long past the invocation deadline the agent waits for
+84 -1
View File
@@ -7,8 +7,10 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
@@ -70,6 +72,7 @@ type bootstrapStarts struct {
sealed bool
// handlerOut receives the _HANDLER the bootstrap process itself saw.
handlerOut string
caBundle string
// env is the environment the agent gave the bootstrap.
env []string
}
@@ -94,6 +97,7 @@ func (b *bootstrapStarts) testlink(t *testing.T, entries map[string][]byte) test
b.started = make(chan struct{}, 1)
b.task = t.TempDir()
b.handlerOut = filepath.Join(t.TempDir(), "handler")
b.caBundle = filepath.Join(t.TempDir(), "sdk", "ca-bundle.pem")
return testlink{
startup: g.startup(),
pkg: taskPackage{
@@ -102,7 +106,9 @@ func (b *bootstrapStarts) testlink(t *testing.T, entries map[string][]byte) test
mountDir: func(string) error { return nil },
sealDir: func(string) error { b.sealed = true; return nil },
},
runtimeAddr: "127.0.0.1:0",
runtimeAddr: "127.0.0.1:0",
caBundlePath: b.caBundle,
systemCAPath: filepath.Join(t.TempDir(), "system-ca.pem"),
startBootstrap: func(ctx context.Context, env []string) (*exec.Cmd, error) {
b.n++
b.env = env
@@ -176,10 +182,87 @@ func TestTestlinkServesOneInvocationThenStops(t *testing.T) {
"AWS_ACCESS_KEY_ID=" + testCredential.AccessKeyID,
"AWS_SECRET_ACCESS_KEY=" + testCredential.SecretAccessKey,
"AWS_SESSION_TOKEN=" + testCredential.SessionToken,
"AWS_REGION=ap-southeast-2",
"AWS_DEFAULT_REGION=ap-southeast-2",
}, b.env, "the bootstrap gets exactly the runtime and credential variables")
assert.NotEmpty(t, envValue(b.env, "AWS_LAMBDA_RUNTIME_API"))
}
func TestTestlinkProvidesSDKSettingsToBootstrap(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
defer server.Close()
h := newHost(t)
h.accept(ctx)
entries := h.entries()
entries[sdkEndpointEntry] = []byte(server.URL)
entries[sdkCAEntry] = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
var b bootstrapStarts
done := runTestlinkAsync(ctx, b.testlink(t, entries))
conn := h.guest(t)
defer conn.Close()
hs := hostlink.NewTestStream(conn)
req := hostlink.Request{Credential: &testCredential, GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: []byte(`{}`)}
require.NoError(t, hs.HostSend(req))
_, err := hs.HostResult(ctx)
require.NoError(t, err)
res := wait(t, done, "agent to stop")
require.NoError(t, res.err)
assert.Equal(t, server.URL, envValue(b.env, "AWS_ENDPOINT_URL"))
assert.Equal(t, b.caBundle, envValue(b.env, "AWS_CA_BUNDLE"))
assert.Equal(t, "ap-southeast-2", envValue(b.env, "AWS_REGION"))
assert.Equal(t, "ap-southeast-2", envValue(b.env, "AWS_DEFAULT_REGION"))
ca, err := os.ReadFile(b.caBundle)
require.NoError(t, err)
assert.Contains(t, string(ca), string(entries[sdkCAEntry]))
info, err := os.Stat(b.caBundle)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o644), info.Mode().Perm())
}
func TestTestlinkProvidesFunctionEnvironment(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
conn := h.guest(t)
defer conn.Close()
hs := hostlink.NewTestStream(conn)
cred := testCredential
cred.Environment = map[string]string{"GREETING": "hello $WORLD", "PATH": "/my/bin"}
req := hostlink.Request{Credential: &cred, GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: []byte(`{}`)}
require.NoError(t, hs.HostSend(req))
_, err := hs.HostResult(ctx)
require.NoError(t, err)
require.NoError(t, wait(t, done, "agent to stop").err)
assert.Equal(t, "hello $WORLD", envValue(b.env, "GREETING"))
assert.Equal(t, "/my/bin", envValue(b.env, "PATH"))
assert.Equal(t, testCredential.SecretAccessKey, envValue(b.env, "AWS_SECRET_ACCESS_KEY"))
}
func TestTestlinkRejectsReservedFunctionEnvironment(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
var b bootstrapStarts
done := runTestlinkAsync(ctx, b.testlink(t, h.entries()))
conn := h.guest(t)
defer conn.Close()
cred := testCredential
cred.Environment = map[string]string{"AWS_SECRET_ACCESS_KEY": "forged"}
req := hostlink.Request{Credential: &cred, GrantID: "g-1", AttemptID: "a-1", RequestID: "r-1", DeadlineMs: time.Now().Add(5 * time.Second).UnixMilli(), Payload: []byte(`{}`)}
require.NoError(t, hostlink.NewTestStream(conn).HostSend(req))
result := wait(t, done, "agent to refuse the environment")
assert.Equal(t, exitRefused, result.code)
assert.Error(t, result.err)
assert.NotContains(t, result.err.Error(), "forged")
assert.Zero(t, b.n)
}
var testCredential = hostlink.Credential{AccessKeyID: "ASIAGUESTKEY", SecretAccessKey: "guest-secret", SessionToken: "guest-token"}
func envValue(env []string, name string) string {
+25 -1
View File
@@ -1,6 +1,9 @@
package clusterv1
import "time"
import (
"encoding/json"
"time"
)
// ClusterStateBucket is the KV bucket holding each daemon's heartbeat alongside
// other cluster-wide state.
@@ -11,6 +14,27 @@ func HeartbeatKey(node string) string {
return "heartbeat." + node
}
// LivenessWindow is how recently a node must have heartbeated for a reader
// deciding on a takeover to count it live. It is six heartbeats, compared
// across node clocks, where NTP skew is small against it.
const LivenessWindow = 60 * time.Second
// LiveAt reports whether a heartbeat written at written still vouches for its
// node at now: it does while it is under LivenessWindow old.
func LiveAt(written, now time.Time) bool {
return now.Sub(written) < LivenessWindow
}
// HeartbeatWritten dates a stored heartbeat value. One that cannot be decoded
// or dated vouches for nothing, which is an answer rather than a fault.
func HeartbeatWritten(data []byte) (time.Time, bool) {
var beat HeartbeatTimestamp
if json.Unmarshal(data, &beat) != nil {
return time.Time{}, false
}
return beat.Time()
}
// HeartbeatTimestamp is the read side of a heartbeat for node liveness. It
// decodes only the timestamp, so a reader does not depend on the capacity
// figures the daemon writes beside it.
+20
View File
@@ -33,3 +33,23 @@ func TestHeartbeatTimestamp_UndatedBeatVouchesForNothing(t *testing.T) {
assert.False(t, ok, "timestamp %q", ts)
}
}
// A beat is live strictly under the window, so one exactly the window old is
// the first a takeover may treat as stopped.
func TestLiveAt_IsStrictlyUnderTheWindow(t *testing.T) {
now := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)
assert.True(t, clusterv1.LiveAt(now.Add(-clusterv1.LivenessWindow+time.Nanosecond), now))
assert.False(t, clusterv1.LiveAt(now.Add(-clusterv1.LivenessWindow), now))
assert.True(t, clusterv1.LiveAt(now.Add(time.Second), now), "a beat from a clock running ahead is live")
}
func TestHeartbeatWritten_DatesAStoredBeatAndRefusesAnUndecodableOne(t *testing.T) {
written, ok := clusterv1.HeartbeatWritten([]byte(`{"node":"node-a","timestamp":"2026-10-30T01:02:03Z"}`))
require.True(t, ok)
assert.True(t, written.Equal(time.Date(2026, 10, 30, 1, 2, 3, 0, time.UTC)))
for _, raw := range []string{"not json", `{"node":"node-a"}`} {
_, ok := clusterv1.HeartbeatWritten([]byte(raw))
assert.False(t, ok, raw)
}
}
+31 -1
View File
@@ -12,6 +12,11 @@ const (
// this contract family. It is the NATS permission/subscription pattern for
// the per-instance command route, rather than the broader descendant match.
InstanceCommandSubjectWildcard = InstanceCommandSubjectPrefix + "*"
// HandledHeader is set to "1" on every reply an owner sends to a
// modify-instance-metadata-options or set-disable-api-termination command.
// An owner that predates those commands cannot set it.
HandledHeader = "Spinifex-Ec2-Cmd-Handled"
)
// InstanceCommandSubject returns the targeted command subject for instanceID.
@@ -23,7 +28,9 @@ func InstanceCommandSubject(instanceID string) string {
}
// EC2InstanceCommand is the NATS wire format for EC2 instance commands
// (stop, terminate, start, attach/detach-volume, attach/detach-eni).
// (stop, terminate, start, reboot, attach/detach-volume, attach/detach-eni,
// IAM profile, spot lineage, tags, monitoring, metadata options and
// termination protection).
type EC2InstanceCommand struct {
ID string `json:"id"`
Attributes EC2CommandAttributes `json:"attributes"`
@@ -35,6 +42,9 @@ type EC2InstanceCommand struct {
SpotLineageData *SpotLineageData `json:"spot_lineage_data,omitempty"`
InstanceTagsData *InstanceTagsData `json:"instance_tags_data,omitempty"`
InstanceMonitoringData *InstanceMonitoringData `json:"instance_monitoring_data,omitempty"`
InstanceMetadataOptionsData *InstanceMetadataOptionsData `json:"instance_metadata_options_data,omitempty"`
DisableApiTerminationData *DisableApiTerminationData `json:"disable_api_termination_data,omitempty"`
}
// EC2CommandAttributes indicates which action the daemon should perform.
@@ -52,6 +62,9 @@ type EC2CommandAttributes struct {
SetInstanceTags bool `json:"set_instance_tags,omitempty"`
RemoveInstanceTags bool `json:"remove_instance_tags,omitempty"`
SetInstanceMonitoring bool `json:"set_instance_monitoring,omitempty"`
ModifyInstanceMetadataOptions bool `json:"modify_instance_metadata_options,omitempty"`
SetDisableApiTermination bool `json:"set_disable_api_termination,omitempty"`
}
// AttachVolumeData carries parameters for an attach-volume command.
@@ -103,6 +116,23 @@ type InstanceMonitoringData struct {
Enabled bool `json:"enabled"`
}
// InstanceMetadataOptionsData carries the requested metadata options for a
// modify-instance-metadata-options command. Unset fields are left unchanged.
type InstanceMetadataOptionsData struct {
HttpTokens string `json:"http_tokens,omitempty"`
HttpEndpoint string `json:"http_endpoint,omitempty"`
HttpProtocolIpv6 string `json:"http_protocol_ipv6,omitempty"`
InstanceMetadataTags string `json:"instance_metadata_tags,omitempty"`
HttpPutResponseHopLimit *int64 `json:"http_put_response_hop_limit,omitempty"`
}
// DisableApiTerminationData carries the desired termination-protection value
// for a set-disable-api-termination command. Carried as data so "false" is an
// explicit value, not the absence of one.
type DisableApiTerminationData struct {
Value bool `json:"value"`
}
// SpotLineageData carries the SIR id stamped onto a spot-launched instance.
// Lifecycle is always "spot" for this command, so only the SIR id travels.
type SpotLineageData struct {
+70 -2
View File
@@ -24,6 +24,9 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
SetInstanceTags: true,
RemoveInstanceTags: true,
SetInstanceMonitoring: true,
ModifyInstanceMetadataOptions: true,
SetDisableApiTermination: true,
},
AttachVolumeData: &AttachVolumeData{VolumeID: "vol-1", Device: "/dev/sdf"},
DetachVolumeData: &DetachVolumeData{VolumeID: "vol-2", Device: "/dev/sdg", Force: true},
@@ -33,6 +36,15 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
SpotLineageData: &SpotLineageData{SpotInstanceRequestId: "sir-1"},
InstanceTagsData: &InstanceTagsData{Tags: map[string]string{"Name": "example"}, TagKeys: []string{"old"}},
InstanceMonitoringData: &InstanceMonitoringData{Enabled: true},
InstanceMetadataOptionsData: &InstanceMetadataOptionsData{
HttpTokens: "required",
HttpEndpoint: "enabled",
HttpProtocolIpv6: "disabled",
InstanceMetadataTags: "enabled",
HttpPutResponseHopLimit: int64Ptr(3),
},
DisableApiTerminationData: &DisableApiTerminationData{Value: false},
}
data, err := json.Marshal(command)
@@ -52,7 +64,9 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
"set_spot_lineage": true,
"set_instance_tags": true,
"remove_instance_tags": true,
"set_instance_monitoring": true
"set_instance_monitoring": true,
"modify_instance_metadata_options": true,
"set_disable_api_termination": true
},
"attach_volume_data": {"volume_id": "vol-1", "device": "/dev/sdf"},
"detach_volume_data": {"volume_id": "vol-2", "device": "/dev/sdg", "force": true},
@@ -61,10 +75,64 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
"iam_profile_association_data": {"instance_profile_arn": "arn:aws:iam::123456789012:instance-profile/example"},
"spot_lineage_data": {"spot_instance_request_id": "sir-1"},
"instance_tags_data": {"tags": {"Name": "example"}, "tag_keys": ["old"]},
"instance_monitoring_data": {"enabled": true}
"instance_monitoring_data": {"enabled": true},
"instance_metadata_options_data": {
"http_tokens": "required",
"http_endpoint": "enabled",
"http_protocol_ipv6": "disabled",
"instance_metadata_tags": "enabled",
"http_put_response_hop_limit": 3
},
"disable_api_termination_data": {"value": false}
}`, string(data))
}
func int64Ptr(v int64) *int64 { return &v }
// An owner that predates the metadata and termination-protection commands
// sends and receives payloads without the new fields.
func TestEC2InstanceCommandNewFieldsAreOptionalOnTheWire(t *testing.T) {
var n1 EC2InstanceCommand
require.NoError(t, json.Unmarshal([]byte(`{
"id": "i-0123456789abcdef0",
"attributes": {"stop_instance": true},
"instance_monitoring_data": {"enabled": true}
}`), &n1))
require.Equal(t, "i-0123456789abcdef0", n1.ID)
require.True(t, n1.Attributes.StopInstance)
require.False(t, n1.Attributes.ModifyInstanceMetadataOptions)
require.False(t, n1.Attributes.SetDisableApiTermination)
require.Nil(t, n1.InstanceMetadataOptionsData)
require.Nil(t, n1.DisableApiTerminationData)
unset, err := json.Marshal(EC2InstanceCommand{ID: "i-1", Attributes: EC2CommandAttributes{StopInstance: true}})
require.NoError(t, err)
require.NotContains(t, string(unset), "modify_instance_metadata_options")
require.NotContains(t, string(unset), "set_disable_api_termination")
require.NotContains(t, string(unset), "instance_metadata_options_data")
require.NotContains(t, string(unset), "disable_api_termination_data")
}
func TestInstanceMetadataOptionsDataOmitsUnsetFields(t *testing.T) {
data, err := json.Marshal(InstanceMetadataOptionsData{HttpPutResponseHopLimit: int64Ptr(1)})
require.NoError(t, err)
require.JSONEq(t, `{"http_put_response_hop_limit": 1}`, string(data))
data, err = json.Marshal(InstanceMetadataOptionsData{})
require.NoError(t, err)
require.JSONEq(t, `{}`, string(data))
}
func TestDisableApiTerminationDataKeepsExplicitFalse(t *testing.T) {
data, err := json.Marshal(DisableApiTerminationData{Value: false})
require.NoError(t, err)
require.JSONEq(t, `{"value": false}`, string(data))
}
func TestHandledHeaderName(t *testing.T) {
require.Equal(t, "Spinifex-Ec2-Cmd-Handled", HandledHeader)
}
func TestInstanceCommandSubjects(t *testing.T) {
require.Equal(t, "ec2.cmd.i-0123456789abcdef0", InstanceCommandSubject("i-0123456789abcdef0"))
require.Equal(t, "ec2.cmd.*", InstanceCommandSubjectWildcard)
+8 -3
View File
@@ -21,13 +21,16 @@ Tenant code may be able to reach the listener, but it cannot open the agent's co
**Authentication:** mutual TLS 1.3, bound to the environment ID and realization generation, using material the user runtime cannot read (ADR-0009:S17).
For each generation the executor creates ephemeral self-signed host and guest certificates whose single URI SAN is `spinifex-lambda://<Region>/<account>/<function ID>/<environment ID>/<generation>/<role>`, with role `host` or `guest`.
Each end validates the peer certificate against the one pinned peer certificate and separately requires that URI SAN with the opposite role; hostname verification is not relied on.
ALPN carries the L-GUEST major version, `spinifex-lambda-guest/1`, and an unsupported version fails the handshake. Builds with the `lambdatestlink` tag offer only `spinifex-lambda-testlink/2` on both host and guest, whose first frame carries the execution-role credential, so a test-link end and a release end fail the handshake; release builds are unchanged.
In the same builds, the gateway reaches the host daemon on the local abstract socket `spinifex-lambda-testlink-control/3`, and each request carries the runtime-image digest the gateway was built with; the daemon refuses a missing or different digest before starting the attempt, and a gateway and daemon on different socket names never connect.
ALPN carries the L-GUEST major version, `spinifex-lambda-guest/1`, and an unsupported version fails the handshake. Builds with the `lambdatestlink` tag offer only `spinifex-lambda-testlink/3` on both host and guest, whose first frame carries the execution-role credential and function environment, so a test-link end and a release end fail the handshake; release builds are unchanged.
In the same builds, the gateway reaches the host daemon on the local abstract socket `spinifex-lambda-testlink-control/4`, and each request carries the runtime-image digest the gateway was built with; the daemon refuses a missing or different digest before starting the attempt, and a gateway and daemon on different socket names never connect.
On that socket a request's raw payload is at most 6 MiB, checked by the gateway before encoding and by the daemon after decoding.
Its encoded frame is at most the base64 length of 6 MiB plus 64 KiB of metadata headroom and 64 bytes of field slack, and a receiver checks the frame length before allocating; metadata has no separate bound within the frame.
The credential travels as its own frame of at most 8 KiB, and the reply has the same payload and frame bounds as the request.
The credential and environment travel as their own frame of at most 40 KiB, and the reply has the same payload and frame bounds as the request.
The daemon holds at most two connections at once, from accept to close, and leaves the rest unaccepted in the listen backlog.
Under TLS 1.3 the guest's handshake can complete before the host refuses its certificate, so the guest treats the connection as accepted only after the host's first message.
In the same builds, the function owner moves a created function with stored code from `Pending` to `Active` once this node's daemon serves the pinned image and the cluster configuration has exactly one node; a code copy over the test-link bound stays `Pending`, and `StateReason` names the unmet condition.
Release builds never leave `Pending`.
The Mulga deploy role installs a test-link build with `deploy_lambda_testlink=true`; this profile does not meet Q-199 fail-closed revocation, and its guests take management addresses whose record can expire (mulga-0hsub).
**Bootstrap material:** the executor passes these fw_cfg entries under `opt/spinifex/lambda/`, and the agent reads them as root and unloads `qemu_fw_cfg` before user code starts.
@@ -41,6 +44,8 @@ Under TLS 1.3 the guest's handshake can complete before the host refuses its cer
The guest's own interface settings come from `opt/spinifex/netcfg`, the entry other system VMs use.
The agent unloads `qemu_fw_cfg` even when an entry is missing, and refuses to start if the fw_cfg tree is still readable afterwards.
On a VMM without fw_cfg (the test-link Firecracker executor), the executor attaches the same entries as a tar archive on the second, writable virtio-blk disk, laid out as `by_name/<entry>/raw`.
The runtime image's init extracts it to a root-only tmpfs at `/spinifex-cfg`, and the agent reads the entries there, then overwrites the whole disk with zeros and removes the tree; it refuses to start if the tree is still present.
Until the message format exists, the agent authenticates and holds the connection but accepts no host message and starts no user code.
**Authority:** one environment's attempt only.
-95
View File
@@ -1,95 +0,0 @@
# Viperblockd legacy contracts, version 1
This package owns the deployed, transitional configuration-update and
state-reload request/reply protocols between Spinifex's legacy control plane
and `viperblockd`. It makes those existing compatibility boundaries explicit
while the generic `providers/ebs` contract is adopted; it is not the EBS
provider abstraction and new provider integrations must not depend on it.
## Configuration update routes
`ConfigUpdateSubject` is `ebs.config`. Every `viperblockd` daemon subscribes
to it in the `spinifex-workers` queue group. It is the fallback path for an
encrypted volume with no live mount: the selected daemon opens the volume
exclusively and reseals its state.
`VolumeConfigUpdateSubject(volume)` is `ebs.config.<volume>`. The daemon with
the live mounted volume subscribes directly so the sole holder of the volume's
state sequence number applies the update.
Both routes carry `EBSConfigUpdateRequest` and respond with
`EBSConfigUpdateResponse`. The JSON field spelling, route shapes, and
request/reply semantics are deployed compatibility behaviour. Incompatible
changes require a new contract version or an explicit migration plan.
## State reload route
`SyncSubject` is `ebs.sync`. After a successful EC2 `ModifyVolume`, the daemon
uses request/reply to ask a `spinifex-workers` queue-group member to reload the
volume's state from its backend. The selected Viperblockd worker responds with
`EBSSyncResponse`; it returns an error when it does not own a live mount of the
volume. The daemon records that error as a warning after the API response has
already been returned. This is deployed legacy behaviour, not a guarantee that
every modification has reached its owning mount.
The route carries `EBSSyncRequest` and `EBSSyncResponse`. Their JSON shape and
the queue-group route are compatibility behaviour.
## Volume lifecycle routes
`MountSubject(node)` and `UnmountSubject(node)` retain the deployed mount and
unmount routes. With a node name they address that node's Viperblockd process
(`ebs.<node>.mount` or `ebs.<node>.unmount`); without one they retain the
single-node `spinifex-workers` queue-group routes. Both carry `EBSRequest`.
`EBSMountResponse` supplies the exported NBD URI, while
`EBSUnMountResponse` reports the seal result. The response broadcasts remain
`ebs.mount.response` and `ebs.unmount.response` alongside ordinary
request/reply replies.
`DeleteSubject` retains the `ebs.delete` queue-group route. It carries
`EBSDeleteRequest` and responds with `EBSDeleteResponse` after Viperblockd has
removed its local state and export. These routes and JSON shapes are deployed
legacy compatibility behaviour, not a generic EBS-provider interface.
## Abandon route
`VolumeAbandonSubject(node)` is `ebs.<node>.abandon`. The daemon uses
request/reply to ask the Viperblock adapter on that node to tear an export
down without sealing, because the instance using it belongs to another node
now. The route carries `VolumeAbandonRequest` and responds with
`VolumeAbandonResponse`. `Abandoned` is false with no error when the volume
was not exported there, which is the ordinary case: the request is
idempotent by design, since the caller is reconciling rather than
transacting.
## Dirty-marker bucket
`DirtyBucket` is the JetStream KV bucket `VIPERBLOCK_VOLUME_DIRTY`, keyed by
volume name under `VolumeKeyPattern`, storing `DirtyRecord` JSON. It records
which node holds writes for a volume that the backend may not have.
Owner: `providers/ebs/viperblock` owns the dirty-volume state and its
transitions. `services/viperblockd` was only its transitional custodian.
Producers: only the Viperblock adapter writes, clears or changes a dirty
record.
Consumers: Viperblock recovery and mount logic on any node. `spx admin` may
obtain a read-only operational view.
Storage contract: the bucket name, `VolumeKeyPattern` and the `DirtyRecord`
JSON shape are this package's legacy v1 contract. There is no TTL; a record
persists until an explicit adapter-owned recovery or unseal transition
removes it.
Compatibility: this is a deployed legacy v1 contract, and coordinated
writers and readers support N/N-1. No renames, type changes or semantic
reinterpretation of existing fields; a breaking change needs a successor
contract and a migration.
CLI authority: the CLI is never a writer and must not perform arbitrary
bucket operations.
This package does not authorize requests, validate a volume ID, store volume
state, or define the generic storage-provider API. Those concerns remain with
the consuming daemon, the EC2 domain, and `providers/ebs` respectively.
@@ -1,36 +0,0 @@
package viperblocklegacyv1
// VolumeAbandonSubject is where the local daemon asks for an export to be torn
// down because the instance using it belongs to another node now.
//
// It exists because an unmount is the wrong request and killing the guest is not
// enough. An unmount seals, and this node's copy is the stale one, so sealing
// would publish an older state over the winner's. Meanwhile the export holds the
// volume lease, and a lease this node keeps renewing is one the winner can never
// acquire — the guest would be stopped here and unable to start anywhere.
//
// Node-addressed, like mount and unmount: the export is local, and no other
// node's viperblockd has anything it could tear down.
func VolumeAbandonSubject(node string) string {
if node == "" {
return "ebs.abandon"
}
return "ebs." + node + ".abandon"
}
// VolumeAbandonRequest asks for one volume's export to go away without sealing.
// Reason is logged, so a node that dropped an export can be told apart from one
// that was fenced.
type VolumeAbandonRequest struct {
Volume string `json:"volume"`
Reason string `json:"reason"`
}
// VolumeAbandonResponse reports what happened. Abandoned is false with no error
// when the volume was not exported here, which is the ordinary case and not a
// failure: the request is idempotent by design, since the caller is reconciling
// rather than transacting.
type VolumeAbandonResponse struct {
Abandoned bool `json:"abandoned"`
Error string `json:"error,omitempty"`
}
@@ -1,63 +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"
)
// TestVolumeAbandonSubject_BothForms pins the abandon route: node-addressed,
// with an empty-node fallback for a single-node daemon.
func TestVolumeAbandonSubject_BothForms(t *testing.T) {
assert.Equal(t, "ebs.node-a.abandon", viperblocklegacyv1.VolumeAbandonSubject("node-a"))
assert.Equal(t, "ebs.abandon", viperblocklegacyv1.VolumeAbandonSubject(""))
}
// TestVolumeAbandonRequest_JSON pins the request runtime/daemon/vm_adapters.go sends
// on the abandon route.
func TestVolumeAbandonRequest_JSON(t *testing.T) {
golden := `{"volume":"vol-1","reason":"instance owned by another node"}`
req := viperblocklegacyv1.VolumeAbandonRequest{Volume: "vol-1", Reason: "instance owned by another node"}
marshaled, err := json.Marshal(req)
require.NoError(t, err)
assert.JSONEq(t, golden, string(marshaled))
var decoded viperblocklegacyv1.VolumeAbandonRequest
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
assert.Equal(t, req, decoded)
}
// TestVolumeAbandonResponse_JSON pins both response shapes: the ordinary
// reconciling case with no error, and the failure case where Error is set.
// Error is omitempty, so the two must not be mistaken for the same shape.
func TestVolumeAbandonResponse_JSON(t *testing.T) {
t.Run("abandoned, no error", func(t *testing.T) {
golden := `{"abandoned":true}`
resp := viperblocklegacyv1.VolumeAbandonResponse{Abandoned: true}
marshaled, err := json.Marshal(resp)
require.NoError(t, err)
assert.JSONEq(t, golden, string(marshaled))
var decoded viperblocklegacyv1.VolumeAbandonResponse
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
assert.Equal(t, resp, decoded)
})
t.Run("not abandoned, with error", func(t *testing.T) {
golden := `{"abandoned":false,"error":"nbdkit did not exit"}`
resp := viperblocklegacyv1.VolumeAbandonResponse{Abandoned: false, Error: "nbdkit did not exit"}
marshaled, err := json.Marshal(resp)
require.NoError(t, err)
assert.JSONEq(t, golden, string(marshaled))
var decoded viperblocklegacyv1.VolumeAbandonResponse
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
assert.Equal(t, resp, decoded)
})
}
@@ -1,37 +0,0 @@
// Package viperblocklegacyv1 contains the versioned, transitional wire
// contracts between Spinifex's legacy control-plane routes and viperblockd.
package viperblocklegacyv1
import "encoding/json"
const (
// ConfigUpdateSubject is the legacy queue-group route for an encrypted
// volume configuration update when no mounted volume owns the request.
ConfigUpdateSubject = "ebs.config"
)
// VolumeConfigUpdateSubject returns the legacy route for an update handled by
// the node with a live mount of volume. Volume remains unvalidated here: route
// authorization and volume-name validation are responsibilities of the
// consumer, as they were before this contract was extracted.
func VolumeConfigUpdateSubject(volume string) string {
return ConfigUpdateSubject + "." + volume
}
// EBSConfigUpdateRequest carries a control-plane VolumeConfig update for an
// encrypted volume. config.json is a sealed VBState; only the master-key holder
// (viperblockd) can reseal it, so the EC2 edge ships the new config here instead
// of rewriting the object directly. VolumeConfig is a marshaled
// viperblock.VolumeConfig (RawMessage keeps this contract dependency-free).
type EBSConfigUpdateRequest struct {
Volume string `json:"Volume"`
VolumeConfig json.RawMessage `json:"VolumeConfig"`
}
// EBSConfigUpdateResponse is the request/reply response for either legacy
// configuration-update route.
type EBSConfigUpdateResponse struct {
Volume string `json:"Volume"`
Success bool `json:"Success"`
Error string `json:"Error"`
}
@@ -1,35 +0,0 @@
package viperblocklegacyv1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestConfigUpdateSubjects(t *testing.T) {
require.Equal(t, "ebs.config", ConfigUpdateSubject)
require.Equal(t, "ebs.config.vol-123", VolumeConfigUpdateSubject("vol-123"))
// The deployed route builder did not validate its final token. Validation
// remains with the handler and is not silently introduced by this move.
require.Equal(t, "ebs.config.", VolumeConfigUpdateSubject(""))
}
func TestConfigUpdateRequestJSONContract(t *testing.T) {
data, err := json.Marshal(EBSConfigUpdateRequest{
Volume: "vol-123",
VolumeConfig: json.RawMessage(`{"size_gib":8}`),
})
require.NoError(t, err)
require.JSONEq(t, `{"Volume":"vol-123","VolumeConfig":{"size_gib":8}}`, string(data))
}
func TestConfigUpdateResponseJSONContract(t *testing.T) {
data, err := json.Marshal(EBSConfigUpdateResponse{
Volume: "vol-123",
Success: true,
Error: "",
})
require.NoError(t, err)
require.JSONEq(t, `{"Volume":"vol-123","Success":true,"Error":""}`, string(data))
}
-22
View File
@@ -1,22 +0,0 @@
package viperblocklegacyv1
const (
// SyncSubject is the legacy queue-group route that asks a viperblockd
// worker to reload a mounted volume's state from its backend.
SyncSubject = "ebs.sync"
)
// EBSSyncRequest identifies the volume whose mounted Viperblock state should
// be reloaded by the selected legacy worker.
type EBSSyncRequest struct {
Volume string `json:"Volume"`
}
// EBSSyncResponse reports the selected worker's reload result. A worker that
// does not own the mounted volume returns an error rather than forwarding the
// request.
type EBSSyncResponse struct {
Volume string `json:"Volume"`
Synced bool `json:"Synced"`
Error string `json:"Error"`
}
@@ -1,28 +0,0 @@
package viperblocklegacyv1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestSyncSubject(t *testing.T) {
require.Equal(t, "ebs.sync", SyncSubject)
}
func TestSyncRequestJSONContract(t *testing.T) {
data, err := json.Marshal(EBSSyncRequest{Volume: "vol-123"})
require.NoError(t, err)
require.JSONEq(t, `{"Volume":"vol-123"}`, string(data))
}
func TestSyncResponseJSONContract(t *testing.T) {
data, err := json.Marshal(EBSSyncResponse{
Volume: "vol-123",
Synced: true,
Error: "",
})
require.NoError(t, err)
require.JSONEq(t, `{"Volume":"vol-123","Synced":true,"Error":""}`, string(data))
}
-87
View File
@@ -1,87 +0,0 @@
package viperblocklegacyv1
import "fmt"
const (
// DeleteSubject is the legacy queue-group route that deletes a volume's
// Viperblockd-local state and any active export.
DeleteSubject = "ebs.delete"
// MountResponseSubject is the legacy broadcast emitted after an ebs.mount
// request is handled. Request/reply callers receive the same payload.
MountResponseSubject = "ebs.mount.response"
// UnmountResponseSubject is the legacy broadcast emitted after an
// ebs.unmount request is handled. Request/reply callers receive the same
// payload.
UnmountResponseSubject = "ebs.unmount.response"
)
// MountSubject returns the deployed mount route. An empty node retains the
// single-node queue-group route; a named node selects the Viperblockd worker
// colocated with the instance runtime.
func MountSubject(node string) string {
if node == "" {
return "ebs.mount"
}
return fmt.Sprintf("ebs.%s.mount", node)
}
// UnmountSubject returns the deployed unmount route. Its empty-node behaviour
// matches MountSubject for single-node compatibility.
func UnmountSubject(node string) string {
if node == "" {
return "ebs.unmount"
}
return fmt.Sprintf("ebs.%s.unmount", node)
}
// EBSRequest is the deployed request payload shared by the legacy mount and
// unmount routes. It is intentionally separate from the generic EBS provider
// contract: this is Viperblockd's transitional NATS protocol.
type EBSRequest struct {
Name string `json:"Name"`
VolType string `json:"VolType"`
Boot bool `json:"Boot"`
EFI bool `json:"EFI"`
DeleteOnTermination bool `json:"DeleteOnTermination"`
NBDURI string `json:"NBDURI"`
DeviceName string `json:"DeviceName"`
HotplugPort int `json:"HotplugPort,omitempty"`
// BlockDevice and BlockSizeBytes describe a host block device export, the
// alternative to NBDURI; the guest sees BlockSizeBytes of it. Exactly one
// of NBDURI and BlockDevice may be set.
BlockDevice string `json:"BlockDevice,omitempty"`
BlockSizeBytes uint64 `json:"BlockSizeBytes,omitempty"`
}
// EBSMountResponse is the response to an EBSRequest on MountSubject.
type EBSMountResponse struct {
URI string `json:"URI"`
Mounted bool `json:"Mounted"`
Error string `json:"Error"`
Retryable bool `json:"Retryable"`
}
// EBSUnMountResponse is the response to an EBSRequest on UnmountSubject.
// NotFound reports an idempotent retry after an already-complete seal; Reaped
// reports a leaked export that was cleaned up without a registry entry.
type EBSUnMountResponse struct {
Volume string `json:"Volume"`
Mounted bool `json:"Mounted"`
Error string `json:"Error"`
NotFound bool `json:"NotFound"`
Reaped bool `json:"Reaped,omitempty"`
}
// EBSDeleteRequest identifies the volume to delete on DeleteSubject.
type EBSDeleteRequest struct {
Volume string `json:"Volume"`
}
// EBSDeleteResponse is the response to EBSDeleteRequest on DeleteSubject.
type EBSDeleteResponse struct {
Volume string `json:"Volume"`
Success bool `json:"Success"`
Error string `json:"Error"`
}
@@ -1,78 +0,0 @@
package viperblocklegacyv1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestVolumeSubjects(t *testing.T) {
require.Equal(t, "ebs.delete", DeleteSubject)
require.Equal(t, "ebs.mount", MountSubject(""))
require.Equal(t, "ebs.unmount", UnmountSubject(""))
require.Equal(t, "ebs.node-a.mount", MountSubject("node-a"))
require.Equal(t, "ebs.node-a.unmount", UnmountSubject("node-a"))
require.Equal(t, "ebs.mount.response", MountResponseSubject)
require.Equal(t, "ebs.unmount.response", UnmountResponseSubject)
}
func TestEBSRequestJSONContract(t *testing.T) {
data, err := json.Marshal(EBSRequest{
Name: "vol-123",
VolType: "gp3",
Boot: true,
EFI: true,
DeleteOnTermination: true,
NBDURI: "nbd:unix:/run/spinifex/vol-123.sock",
DeviceName: "/dev/sdf",
HotplugPort: 3,
})
require.NoError(t, err)
require.JSONEq(t, `{
"Name":"vol-123", "VolType":"gp3", "Boot":true, "EFI":true,
"DeleteOnTermination":true,
"NBDURI":"nbd:unix:/run/spinifex/vol-123.sock",
"DeviceName":"/dev/sdf", "HotplugPort":3
}`, string(data))
block, err := json.Marshal(EBSRequest{
Name: "vol-456",
BlockDevice: "/dev/sdx",
BlockSizeBytes: 10 << 30,
})
require.NoError(t, err)
require.JSONEq(t, `{
"Name":"vol-456", "VolType":"", "Boot":false, "EFI":false,
"DeleteOnTermination":false, "NBDURI":"", "DeviceName":"",
"BlockDevice":"/dev/sdx", "BlockSizeBytes":10737418240
}`, string(block))
}
func TestVolumeResponseJSONContracts(t *testing.T) {
mount, err := json.Marshal(EBSMountResponse{
URI: "nbd:unix:/run/spinifex/vol-123.sock", Mounted: true, Retryable: true,
})
require.NoError(t, err)
require.JSONEq(t, `{
"URI":"nbd:unix:/run/spinifex/vol-123.sock", "Mounted":true,
"Error":"", "Retryable":true
}`, string(mount))
unmount, err := json.Marshal(EBSUnMountResponse{
Volume: "vol-123", NotFound: true, Reaped: true,
})
require.NoError(t, err)
require.JSONEq(t, `{
"Volume":"vol-123", "Mounted":false, "Error":"",
"NotFound":true, "Reaped":true
}`, string(unmount))
deleteRequest, err := json.Marshal(EBSDeleteRequest{Volume: "vol-123"})
require.NoError(t, err)
require.JSONEq(t, `{"Volume":"vol-123"}`, string(deleteRequest))
deleteResponse, err := json.Marshal(EBSDeleteResponse{Volume: "vol-123", Success: true})
require.NoError(t, err)
require.JSONEq(t, `{"Volume":"vol-123", "Success":true, "Error":""}`, string(deleteResponse))
}
+2 -2
View File
@@ -174,8 +174,8 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify`, `--checksum`, `--ami-name` (override the registered `DescribeImages` name, which otherwise defaults to `ami-{distro}-{version}-{arch}`; used for locally-built appliances such as `--ami-name spinifex-eks-node`), `--tmp-dir` (default: `<spinifex-dir>/images`) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports require an explicit `--boot-mode` because there is no catalog metadata to inherit from, and are verified only when `--checksum <sums-file>` is passed. `--checksum` takes the sums file the image was published with (`SHA256SUMS`, `SHA512SUMS`, `<image>.sha256`/`.sha512`, coreutils, BSD or bare-digest format), looks up the entry for the image's basename, and infers sha256 or sha512 from the digest length. A mismatch exits 1 before anything is written to storage and leaves the file in place. `--checksum` proves only that the image matches the sums file supplied with it, not that either is authentic; verify the sums file's vendor signature separately. `--checksum` is refused without `--file` or with `--skip-verify`. A `--file` import without `--checksum` prints `Importing <file> without checksum validation` to stderr and proceeds; `--name --file` does not fall back to the catalog digest. Every import records the source artifact's digest on the AMI with who verified it (`operator`, `catalog` or `unverified`, the unverified cases hashed as sha256), prints it on success, and shows it in `spx admin images describe`. The digest is of the file as supplied, not of the stored volume. `--skip-verify` bypasses verification for catalog imports and prints a warning to stderr; use only for debugging or when upstream mirrors are confirmed-broken. Import never modifies the image: the bytes written to storage are the bytes that were verified. Compressed and qcow2 images are extracted into a hidden `.import-*` scratch directory under `--tmp-dir`, which is removed when the import succeeds or fails, so the catalog directory keeps only the downloaded source (for `--name`) and the AMI manifest. Point `--tmp-dir` at a disk-backed filesystem with room for the full raw image, never at a tmpfs such as `/tmp`. A guest that needs to trust the deployment CA for in-guest SDK-over-TLS calls fetches it at runtime from `http://169.254.169.254/spinifex/ca.pem` (see the IMDS section), so a CA rotation reaches guests without re-importing anything. |
| `spx admin images list` | — | Lists available OS images that can be imported or downloaded |
| `spx admin images describe` | `--image-id` (required) | Read-only. Prints the AMI's name, owner, creation date, boot mode and architecture, then its source digest: algorithm and value, verification (`operator` with the sums file name, `catalog` with the sums URL, or `unverified`), and the source file name. AMIs imported before digest recording print `Source digest: not recorded`. The digest is not exposed through `DescribeImages`. |
| `spx admin images promote` | `--image-id` (required), `--yes` | Reads `ami-<id>/config.json`, validates the AMI is account-owned, then rewrites `ImageOwnerAlias` to `"system"` in-place. No block data is copied. The change takes effect immediately — the AMI becomes visible to all accounts via `DescribeImages`. Prompts for confirmation (skipped with `--yes`). Already-system AMIs are refused. |
| `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Loads `ami-<id>/config.json`, walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-<id>` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap — then prompts (skipped with `--yes`) before deleting `ami-<id>/config.json` (the DescribeImages barrier) followed by the rest of `ami-<id>/` and `snap-ami-<id>/`. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and config-corrupt checks for salvage of orphaned blocks. |
| `spx admin images promote` | `--image-id` (required), `--yes` | Reads the AMI's metadata document, validates the AMI is account-owned, then rewrites its `ImageOwnerAlias` to `"system"` and re-keys its snapshot document under the global account. No block data is copied. The change takes effect immediately — the AMI becomes visible to all accounts via `DescribeImages`. Prompts for confirmation (skipped with `--yes`). Already-system AMIs are refused. |
| `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Connects to NATS and predastore. Loads the AMI's metadata document, asks the EBS provider for the capacity of the image's volume (`absent` when the provider answers not found, `unknown` with the provider's error on any other failure, which never aborts the preview), and walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-<id>` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap. It then prompts (skipped with `--yes`) before deleting the image's UEFI variable store, then its metadata document (the DescribeImages barrier), then asking the provider to delete snapshot `snap-ami-<id>` and then volume `ami-<id>`. Both provider deletes succeed on an absent target, so a rerun finishes a half-done removal; the provider refuses to delete a volume that is still published. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and corrupt-document checks for salvage of an orphaned volume and snapshot. |
### GPU Management
+3 -3
View File
@@ -111,9 +111,9 @@ Daemons (`spinifex/runtime/daemon/daemon.go`) subscribe to NATS topics and handl
When a daemon handles `RunInstances`:
1. **Resource Check**: Validates CPU/memory availability via `ResourceManager`
2. **Volume Generation**: Creates boot and EFI volumes via Viperblock
A UEFI instance whose record carries a variable store pointer has no EFI volume: its `vars.fd` is fetched from the object under `uefi/` in the configured Predastore bucket that the pointer names, recorded in the `spinifex-uefi-stores` JetStream bucket (or reused when `seeded.json` on this node already names the same object) after a firmware architecture and VARS size check, and a record with both or neither is refused.
RunInstances still creates the EFI volume, nothing writes `vars.fd` back to the object store yet, and a terminated instance's store is removed by a cluster-wide reaper.
2. **Volume Generation**: Creates the boot volume through the EBS provider and, for a UEFI guest, seeds its variable store as an object under `uefi/<instance-id>/` with a store record in `spinifex-uefi-stores` that points at it
At launch a UEFI instance's `vars.fd` is fetched from the object its store pointer names (or reused when `seeded.json` on this node already names the same object) after a firmware architecture and VARS size check, and a UEFI instance with no live store record naming a pointer, or one that still carries an EFI volume request, is refused.
The store is written back to the object store on write and at stop, and a terminated instance's store is removed by a cluster-wide reaper.
3. **Volume Mount**: Publishes each volume through the EBS provider contract (`PublishVolume` on `ebs.provider.v1.{node}.mount`), receives its export
4. **QEMU Launch**: Builds and executes QEMU command with NBD-backed disks
5. **QMP Monitoring**: Establishes QEMU Machine Protocol connection for VM management
+1 -1
View File
@@ -695,7 +695,7 @@ series above; "needs decision" is open.
| `gateway/{ecs,elbv2}` | — | Done. The action adapters moved to `domains/{ecs,elbv2}/awsapi` in `spinifex#1273` with no API, NATS subject, storage or behaviour change; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
| `gateway/spx` | — | Done. The gather helpers, storage-status config and image-promote request moved to `operator/admin` with no behaviour change and the package is removed. The `spinifex` Query dispatch and the `/admin/{method}` handlers that call them now live in `operator/admin` as one seam registration. |
| `gateway/tagging` | — | Done. `domains/tagging` owns the read-only cross-resource `GetResources` aggregation and its API semantics (`0d830e901`, `aedd0b2c4`); AWSGW composes the owner listers, the EC2 tag index and ELBv2 (`f3545e3ea`); the dispatch moved to `domains/tagging/awsapi` and the root `gateway` no longer serves Tagging. The package is removed, and its two allowlist rows and frozen-legacy entry are gone. Owner tag mutation capabilities remain future direction, so the RDS and ECS tag-writing edges above stay open. |
| `services/viperblockd` | — | Done. The adapter is `providers/ebs/viperblock` (with the read-only `dirty` reader), the shared vocabulary is `contracts/viperblockd/legacy/v1`, and the role is `runtime/roles/viperblockd`. `services/` is empty. |
| `services/viperblockd` | — | Done. The adapter is `providers/ebs/viperblock` (with the `dirty` package, which owns the dirty-marker bucket and its read-only reader), it serves only the `providers/ebs` contract, and the role is `runtime/roles/viperblockd`. `services/` is empty. |
| `utils` | — | Done and deleted. `ec2_subject.go` went to `contracts/ec2/v1`, the vpcd protocol to `contracts/network/v1` with the typed client in `domains/network/projection`, and the master-key loader to `domains/ec2/ebs/encryption`. |
| `vpcd` | — | Done. The IMDS local-file reader and record-space opener moved to `domains/ec2/instance/state` and are injected through `guestmetadata.LocalStateReader`/`RecordLoader` (recorded above), so `vpcd` no longer imports `daemon` directly; its inter-process boundary now exists (`contracts/network/v1`, with `domains/network/subscribers` as the consumer and `domains/network/projection` as the producer client). The package moved unchanged to `runtime/roles/vpcd` (recorded below), and the legacy directory is removed. |
| `daemon` | `runtime/daemon` (whole-package move, above) | Done. Q-28's ownership and deployment refactor remains deferred debt; the amended ADR-0001 row makes it not a relocation prerequisite. |
+160
View File
@@ -0,0 +1,160 @@
---
title: "UEFI Variable Store"
seoTitle: "UEFI Variable Store Durability and Recovery — Spinifex Docs"
description: "Where a UEFI instance's variable store lives, how much a node loss can lose, how owed write-backs recover, and which firmware differences refuse a launch."
category: "Admin"
tags:
- uefi
- firmware
- instances
- storage
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "Host and Guest Lifecycle"
url: "/docs/host-lifecycle"
---
# UEFI Variable Store
> Where a UEFI instance keeps its firmware variables, how durable they are, and what an operator sees when something goes wrong.
## Table of Contents
- [Overview](#overview)
- [Where the Store Lives](#where-the-store-lives)
- [Durability](#durability)
- [Owed Write-Backs](#owed-write-backs)
- [Starting on Another Node](#starting-on-another-node)
- [Firmware Compatibility](#firmware-compatibility)
- [Instances Refused at Launch](#instances-refused-at-launch)
- [Terminate](#terminate)
- [Images](#images)
- [Inspecting Stores](#inspecting-stores)
- [Metrics](#metrics)
---
## Overview
An instance whose boot mode is `uefi` or `uefi-preferred` boots through UEFI firmware, which keeps its variables (`BootOrder`, `BootNext`, secure-boot state) in a VARS region. Spinifex gives each such instance its own variable store, so those variables survive a reboot, a stop and start, and a start on a different node. A direct-boot microvm boots without UEFI and has no store.
RunInstances seeds the store from the launching image's store when the image carries one (see [Images](#images)), and from the launching node's VARS template otherwise. From then on the node running the instance writes the guest's changes back to the platform object store, and every later start boots from the last change that landed there.
## Where the Store Lives
A store has three parts.
| Part | Where | What it holds |
|---|---|---|
| Store objects | The platform object store, in the bucket set as the node's Predastore bucket, under `uefi/<instance-id>/` | The VARS region, gzip-compressed, one object per write. Each write goes to a fresh key, so no write replaces an object a record points at. Each object's metadata names the instance, the writer node, the source (`template`, `write-back` or `image:<ami-id>`) and the firmware it was written under. |
| Store record | KV bucket `spinifex-uefi-stores`, keyed by instance ID | The instance and account, the state (`live` or `deleting`), the claim generation that fences writes, and the pointer: the one object the instance boots from, with the firmware it was written under, the writer node and the write time. |
| Local copy | `<data-dir>/uefi/<instance-id>/` on the node running the instance (`/var/lib/spinifex/uefi/<instance-id>/` by default) | `vars.fd`, the file QEMU opens as pflash unit 1, and `seeded.json`, which records the key the copy came from or last landed at, the claim generation, the firmware, and whether a write-back is owed. |
The record outlives the instance record, so the store's deletion obligation is not lost when a terminated instance record expires. The bucket is never recreated empty if it goes missing, because an empty bucket would report every store as having no obligations.
Only one node may write a store at a time. That node holds the store's claim, `uefi.<instance-id>` in the cluster's sync bucket, and a write moves the pointer only while the record still carries that claim's generation. A node that takes the claim stamps the record with its own generation before it boots, so a write from the previous holder is refused rather than landing over newer variables.
## Durability
The running node watches `vars.fd` for writes. Once the guest changes a variable, the change is written back 2 seconds after the last write, or 30 seconds after the first write in the window, whichever is sooner. A guest that writes continuously therefore still lands a write-back every 30 seconds. A node that cannot watch the file polls it every 10 seconds instead, and logs `Cannot watch the UEFI variable store; polling it for changes instead`.
A write-back puts the region at a fresh key (bounded at 10 seconds) and then moves the store record's pointer to it (bounded at 10 seconds). A write-back that fails is retried 30 seconds later, and the changes stay owed until one lands.
When an instance stops, the node writes the store back once QEMU has exited and before the instance's volumes unmount, then releases the claim.
**What a node loss can lose:** the variable changes made since the last write-back that landed. While the object store is reachable that is at most the 30-second window plus the write in flight. While write-backs are failing, it is every change since the last one that landed, however long the failures last. The node's local copy still holds those changes, and they are recovered if the node comes back before another node takes the store over (see below).
## Owed Write-Backs
A write-back is owed whenever `vars.fd` may hold changes no object has. The node records that in `seeded.json` as `write_back_owed` when a window opens and clears it when the write lands. A stop whose write-back fails keeps the claim, marks the copy owed and logs `The stop could not write the UEFI variable store back; this node keeps its claim and retries`.
Each node runs a `uefi-write-back` reaper on the garbage-collection sweep, every 2 minutes while KV is healthy. For each local copy whose instance is not running on the node, it:
- writes an owed copy back and releases the claim once it lands (`trigger=reaper` on the write-back metric);
- releases the claim over a clean copy once the stream leader confirms the record still points at the object the copy came from;
- drops an owed copy whose claim another node now holds, or whose record a later claim has stamped, logging `vm/gc: dropping an owed UEFI variable store write-back; its changes are lost` and counting `mulga.vm.uefi_store.write_backs_lost`;
- removes the copy of a store that is being deleted.
The same reaper starts a writer for a running guest that has none, such as one whose reconnect after a daemon restart could not read the claim. A guest that outlives a daemon restart has its writer resumed with the window open, so any change the restart missed is written back.
The reaper also deletes objects no pointer names under each prefix its write-backs touched, keeping the pointer's object and anything younger than 10 minutes.
An operator does nothing to recover an owed write-back on a node that is up: the reaper retries until it lands. If it keeps failing, the node's log carries `Failed to write the UEFI variable store back` with the error, and the fix is whatever stops the node writing to the object store.
## Starting on Another Node
A start on a node that does not hold the claim takes it if it is free. If another node holds it, the start is refused while that node's heartbeat is fresh (under 60 seconds old) or cannot be read, because that node may still land newer variables. StartInstances returns `InsufficientInstanceCapacity` for that refusal, and the node logs `Refusing to start: another live node holds the UEFI variable store claim`. This is what an operator sees when a stopped instance's owed write-back has not landed yet: start it on the node that holds the claim, or let the reaper land the write-back and release the claim.
Once the holder's heartbeat is 60 seconds old or absent, the starting node takes the claim over and logs `Took over the UEFI variable store claim from a node whose heartbeat has stopped; variable changes it never wrote back are lost`. If the old node comes back, its reaper finds the claim held elsewhere and drops its owed copy.
A start on the node that holds the claim under its copy's generation boots that copy, because it may carry changes no object has.
## Firmware Compatibility
A store records the firmware it was written under: architecture, VARS size and the SHA-256 of the CODE blob. A node's firmware is the first installed pair from its firmware candidates (`/usr/share/OVMF/OVMF_CODE_4M.fd` and `OVMF_VARS_4M.fd` first on x86_64, `/usr/share/AAVMF/AAVMF_CODE.fd` and `AAVMF_VARS.fd` first on arm64).
| Difference | Result |
|---|---|
| Architecture | The launch fails before the claim is taken, so the store is left untouched. |
| VARS size | The launch fails before the claim is taken, so the store is left untouched. pflash needs a byte-exact region. |
| CODE hash only | The instance boots. The node logs `UEFI variable store was written under a different CODE build; booting it` and counts `mulga.vm.uefi_store.firmware_mismatches` with `kind=code`. |
A refused launch logs `UEFI variable store firmware does not match this node; refusing the launch` naming both firmwares, counts the mismatch with `kind=arch` or `kind=vars_size`, and records a `Server.InternalError` state reason naming both firmwares.
Every node of an architecture should carry the same firmware package. A node with a different VARS size cannot boot any store written elsewhere, and a node with a different CODE build boots stores with a counted warning on every launch.
## Instances Refused at Launch
A UEFI instance is refused before anything about it changes when:
- it still carries an EFI volume request (`<root>-efi`), which no node boots;
- it has no store record, its record is `deleting`, or its record names no pointer;
- its record cannot be read as valid.
These starts fail with `Server.InternalError`, and the node logs `refusing to launch a UEFI instance without exactly one variable store` or `Refusing to launch a UEFI instance without a variable store` with the reason. A start whose store object is missing or does not decode to the node's VARS size also fails, and retrying does not help.
## Terminate
Terminate moves the store record to `deleting`, releases a claim the node holds, and removes the node's local copy. It does not delete objects. A cluster-wide `uefi-store` reaper deletes every object under the instance's prefix, then the claim, then the record, and only for an instance whose terminate is decided. It completes a terminate whose mark failed from the terminated instance record. A `deleting` record whose instance is not terminating is left for an operator and logged as `uefi-store: refusing to delete the store of an instance that is not terminating`.
## Images
An image made from a UEFI instance carries a copy of the instance's variable store, so instances launched from it start with the same variables.
- **CreateImage** reads the instance's store record from the stream leader and copies the object its pointer names to a fresh key under `uefi/<ami-id>/`, before the root snapshot is taken. The AMI document records that key and its firmware. The image therefore carries the last write-back that landed, not changes still waiting in the write-back window. An instance with no store record, or a record with no pointer, gives an image with no store. A store being deleted refuses the request with `IncorrectInstanceState`.
- **CopyImage** copies the source image's store to a fresh key under the new image's prefix, so deregistering either image leaves the other's store intact.
- **RunInstances** from an image that carries a store seeds the new instance from it, with the object source `image:<ami-id>`. A store whose CODE hash differs from the node's seeds with a warning and counts `kind=code`. A store whose architecture or VARS size differs cannot boot on the node, so the instance is seeded from the node's VARS template instead, logged as `The image's UEFI variable store cannot boot under this node's firmware; seeding from the VARS template` and counted as `kind=image`.
- **DeregisterImage** deletes the image's store objects before the AMI document. If that delete fails, the request fails with `Server.InternalError` and the image stays, so a retry finishes it. `spx admin images remove` deletes the store first in the same way.
A CreateImage or CopyImage that fails after copying the store can leave an object under `uefi/<ami-id>/` with no AMI document, which `spx admin volumes orphans` reports.
## Inspecting Stores
`spx admin volumes orphans` reports UEFI variable stores nothing owns before it reports orphaned volumes:
- `uefi/i-*/` prefixes with neither an instance record nor a store record;
- `uefi/ami-*/` prefixes with no AMI document;
- prefixes named for neither an instance nor an image;
- store records that are not `deleting` and have no instance record.
It only reads, and attaches to the buckets rather than creating them. It never deletes: an absent instance record is not proof of a terminate, so settling each one is an operator decision.
To read one instance's store record:
```bash
nats kv get spinifex-uefi-stores i-0123456789abcdef0
```
## Metrics
| Metric | Meaning |
|---|---|
| `mulga.vm.uefi_store.seeds` | UEFI launches by where the store came from: `source=local` (the node's copy) or `source=object`. |
| `mulga.vm.uefi_store.write_backs` | Write-backs by `outcome` (`landed`, `failed`, `fenced`, `deleting`) and `trigger` (`change`, `stop`, `reaper`). |
| `mulga.vm.uefi_store.write_back_skips` | Writers stopped without writing back, by the path that stopped them. |
| `mulga.vm.uefi_store.write_backs_lost` | Owed write-backs dropped because another node took the claim over. |
| `mulga.vm.uefi_store.pruned_objects` | Superseded store objects a prune deleted. |
| `mulga.vm.uefi_store.firmware_mismatches` | Launches whose store was written under different firmware, by `kind`. `kind=code` boots the store, and `kind=image` seeds a new instance from the VARS template instead of the image's store. |
| `mulga.vm.uefi_store.claim_refusals` | Starts refused because another node holds the claim and may be live, by `reason`. |
| `mulga.vm.uefi_store.claim_takeovers` | Claims taken over from a node whose heartbeat had stopped. |
+15
View File
@@ -20,3 +20,18 @@ The same form is used for a bad `Architecture`, `BootMode` or `Name` length.
A missing `Name`, `BlockDeviceMappings` or root snapshot answers `MissingParameter` and names what is missing.
Registering with `--ena-support`, `--no-ena-support` and neither, copying the image, and three refusals were run on a three-node cluster with the AWS CLI.
Launching an instance from an `EnaSupport` image was not run, so the launch path is unchanged by construction and not by test.
### CreateSnapshot Error Mapping
`CreateSnapshot` drains and snapshots the volume inside the EBS provider.
A provider that cannot snapshot a volume because it is in use, as the `qemunbd` provider cannot for an attached volume, answers `ErrVolumeInUse`, which becomes `IncorrectState` with the message `The volume 'vol-...' is in use. Detach it before creating a snapshot.`
The `viperblockd` provider drains a mounted export and snapshots it, so an attached volume succeeds there.
A provider that cannot answer now, for example because another node holds the volume, answers `ServiceUnavailable` so the caller retries.
Any other provider failure is `ServerInternal`.
The snapshot taken for `CreateImage` maps these errors the same way.
### EBS Encryption by Default Follows the Platform
Whether new volumes are encrypted is decided by the platform's encryption key, not by the account.
`GetEbsEncryptionByDefault` and `EnableEbsEncryptionByDefault` report `true` when the platform has an encryption key configured and `false` otherwise, and `EnableEbsEncryptionByDefault` does not change that.
`DisableEbsEncryptionByDefault` is refused with `OperationNotPermitted` and the message `EBS encryption by default cannot be disabled: this platform encrypts every volume` while the platform encrypts, and answers `false` where it does not.
+112
View File
@@ -41,6 +41,8 @@ resources:
- [Harden it before production](#harden-it-before-production)
- [Next steps](#next-steps)
- [Removing the deployment](#removing-the-deployment)
- [EBS on OCI Block Volumes](#ebs-on-oci-block-volumes)
- [Testing the OCI Block Volumes provider live](#testing-the-oci-block-volumes-provider-live)
- [Troubleshooting](#troubleshooting)
---
@@ -773,6 +775,116 @@ oci limits resource-availability get --service-name vcn \
--limit-name reserved-public-ip-count --compartment-id "$TENANCY_OCID"
```
## EBS on OCI Block Volumes
On an OCI virtual machine, EBS volumes can be backed by OCI Block Volumes through the `ocibsd` provider instead of Viperblock. Each EBS volume is one OCI block volume, attached over iSCSI to the node that runs the instance using it.
### How the provider is chosen
There is no flag. `spx admin init` takes the provider from the first of these that gives one:
1. The existing `/etc/spinifex/spinifex.toml`, whose provider the node keeps; a node table that omits it is `viperblockd`.
2. The `SPINIFEX_EBS_PROVIDER` environment variable: `viperblockd`, `qemunbd` or `ocibsd`.
3. The OCI instance metadata service (IMDS v2), read with a one-second timeout. An OCI VM shape selects `ocibsd`; a `BM.*` shape, or no answer, selects `viperblockd`.
Init prints its choice and where it came from, for example `EBS provider: ocibsd (detected: OCI virtual machine <shape>)`. Joining nodes take the provider from formation rather than choosing their own. The choice is written once for every node and cannot be changed without wiping and re-forming the cluster.
For `ocibsd`, init fills the compartment and availability domain from instance metadata unless the existing config names them, generates a cluster ID that tags every OCI volume and backup the cluster makes, and defaults `auth` to `instance_principal` when the existing config names no credential. It then reads the compartment's volumes with that credential, and stops with an error naming the needed policy, `manage volume-family` and `use instances`, if the read is refused. It never falls back to Viperblock.
The settings land in the node's table in `spinifex.toml`:
```toml
[nodes.<node>.ebs]
provider = "ocibsd"
[nodes.<node>.ebs.oci]
compartment_id = "<compartment-ocid>"
availability_domain = "<availability-domain>"
cluster_id = "<generated-cluster-id>"
auth = "instance_principal"
```
On an OCI VM, `setup.sh` installs the iSCSI initiator (`open-iscsi` with apt, `iscsi-initiator-utils` with dnf) and stages the `ocibsd` files under `/usr/local/share/spinifex/ocibsd`. When the provider resolves to `ocibsd`, init and join install the `spinifex-ocibsd.service` unit, its udev rule and a `spinifex-daemon` drop-in, mask `spinifex-viperblock.service`, and enable `spinifex-ocibsd.service`, so exactly one service answers for EBS on the node.
To keep Viperblock on an OCI VM, name it before init:
```bash
sudo env SPINIFEX_EBS_PROVIDER=viperblockd spx admin init <your usual flags>
```
`validate-topology.sh` does this for every topology except `vm-single-ocibsd` and `vm-multi-ocibsd`, which rely on detection and need `--distro`.
### One availability domain per cluster
Every volume is created in the availability domain the cluster recorded at init, whichever node handles the request. Config validation refuses nodes that name different compartments or availability domains, and `spinifex-ocibsd` refuses to start on a node whose instance metadata places it in another availability domain. Deploy every node of an `ocibsd` cluster into one availability domain.
### IAM policy
The node's credential needs two grants in the compartment, on top of the four the address allocator needs:
```text
Allow dynamic-group <dynamic-group-name> to manage volume-family in compartment id <compartment-ocid>
Allow dynamic-group <dynamic-group-name> to use instances in compartment id <compartment-ocid>
```
`manage volume-family` covers volumes, backups and attachments, and `use instances` covers attaching them to the node. The instance-principal policy that `--setup-instance-principal` on an `ocibsd` topology (or `setup-identity.sh --ebs-provider ocibsd`) creates grants both, for a Dynamic Group matching `ALL {instance.compartment.id = '<compartment-ocid>'}`. Built for `viperblockd`, the default, it grants neither, and because the policy is shared by every deployment in the compartment, re-running `setup-identity.sh` without the flag removes them.
For an API key, grant the same two verbs to the key user's group, alongside the four from [section 2.2](#grant-the-group-its-four-permissions):
```text
Allow group <group-name> to manage volume-family in compartment id <compartment-ocid>
Allow group <group-name> to use instances in compartment id <compartment-ocid>
```
`terraform output -json api_key_policy_statements` prints all six for the group named by `api_key_group_name` (default `spinifex-nodes`) when `ebs_provider = "ocibsd"`, and only the four address grants otherwise. Because init defaults to the instance principal, an API-key node needs its credential named in `spinifex.toml` before init, which init then keeps:
```toml
[nodes.<node>.ebs]
provider = "ocibsd"
[nodes.<node>.ebs.oci]
auth = "config_file"
config_file = "/etc/spinifex/oci/config"
config_profile = "spinifex"
```
`validate-topology.sh` writes this for you on an API-key run of an `ocibsd` topology, after the credential hook has installed the key.
### The 50 GB floor
OCI creates no block volume smaller than 50 GB, where OCI's GB is 2^30 bytes. The OCI volume is the requested size rounded up to a whole GB, and never less than 50 GB. Spinifex records the requested size in a `spinifex-capacity-bytes` tag, reports it as the EBS volume's size, and presents exactly that size to the guest, so a 1 GiB EBS volume is 1 GiB to the API and the guest. OCI bills for the 50 GB volume underneath it.
## Testing the OCI Block Volumes provider live
`providers/ebs/ocibsd/live_test.go` runs the EBS provider conformance suite, in process and over NATS, against a real compartment and one OCI instance.
It also checks that OCI's GB is 2^30 bytes on the attached device, and that recreating a deleted volume under the same ID never answers with the deleted volume.
The test runs on your workstation with the API-key profile from `~/.oci/config`, and drives `sudo iscsiadm` and the device check on the instance over SSH, so no credential is placed on the instance.
Every subtest skips unless `SPINIFEX_OCIBSD_LIVE` is set.
| Variable | Meaning |
| --- | --- |
| `SPINIFEX_OCIBSD_LIVE` | Any value runs the live tests |
| `SPINIFEX_OCIBSD_COMPARTMENT_ID` | Compartment the volumes and backups are created in |
| `SPINIFEX_OCIBSD_AVAILABILITY_DOMAIN` | The instance's availability domain, where every volume is created |
| `SPINIFEX_OCIBSD_INSTANCE_ID` | OCID of the instance volumes are attached to |
| `SPINIFEX_OCIBSD_SSH_HOST` | Address of that instance |
| `SPINIFEX_OCIBSD_SSH_USER` | SSH user with passwordless `sudo`, `opc` when unset |
| `SPINIFEX_OCIBSD_SSH_KEY` | Private key for that user |
| `SPINIFEX_OCIBSD_SSH_KNOWN_HOSTS` | Known-hosts file, a fresh temporary one when unset |
| `SPINIFEX_OCIBSD_CLUSTER_ID` | `spinifex-cluster` tag value for the run, random when unset and logged either way |
| `SPINIFEX_OCIBSD_OCI_PROFILE` | OCI config profile, `DEFAULT` when unset |
The instance needs `iscsi-initiator-utils` and the Oracle Cloud Agent "Block Volume Management" plugin disabled, so no agent logs sessions in or out behind the test.
The profile needs `manage volume-family` and `use instances` in the compartment.
```bash
cd spinifex
SPINIFEX_OCIBSD_LIVE=1 go test ./providers/ebs/ocibsd/ -run TestLive -count=1 -v -timeout 60m
```
The suite deletes what it creates, but a failed run can leave volumes and backups behind, and OCI bills for them.
Sweep everything tagged `spinifex-cluster=<cluster ID>` in the compartment after every run.
## Troubleshooting
The deploy stops at the first failure and names the log it wrote, all under `.validate-<topology>/`. Add `--keep-on-fail` to leave a failed deployment up so you can log in and look. **It keeps billing** until you run `--destroy-only`.
+1 -1
View File
@@ -4,7 +4,7 @@
# distroless base (no shell, no package manager) for a small, production-shaped
# image suitable for pushing to ECR and pulling onto EKS workers.
FROM golang:1.27.3-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb AS build
FROM golang:1.27.4-alpine@sha256:0a5f7fe796599ff487605987432e7619ae45beeb2fba4c2ce0b907879932aebf AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/mulgadc/spinifex-demo
go 1.27.3
go 1.27.4
+10 -2
View File
@@ -6,7 +6,7 @@ Suites live in `tests/e2e/<name>/` and build to `tests/e2e/_bin/<name>.test`. Ev
| Set | Suites |
| --- | --- |
| `E2E_SUITES_SINGLE` | `single iam cert eks ecs storagegrowth partialblock rds quota route53 storagefault` |
| `E2E_SUITES_SINGLE` | `single iam cert eks ecs storagegrowth partialblock rds quota route53 uefistore storagefault` |
| `E2E_SUITES_MULTI` | `multinode lb cert quota route53 kvquorum lbrecovery instancerecovery storagefault` |
| `E2E_SUITES_NIGHTLY_SINGLE` | `single cert iam` |
| `E2E_SUITES_NIGHTLY_MULTI` | `multinode cert lb` |
@@ -108,6 +108,8 @@ Three entry points, in the order they run:
**Its own suite and its own cell, for instancerecovery's reasons plus one.** It takes a node away, so anything sharing the environment reports that outage as its own failure; it needs its own VPC, public subnet and a backend placement no shared fixture describes; and a failure here has to read as "the load balancer did not survive its host" rather than as one more red subtest in a suite about load balancer features.
**A dead data NIC is the other half, and there the state does change.** `LBSelfHealsADeadDataNIC` disables one data NIC of a running load balancer guest through a second QMP client and runs for an internet-facing NLB and an internal ALB. The backends and an unrelated customer instance in the same subnet are launched more than 21 minutes before the fault, so a repair that reached a customer instance would find one the platform has long stopped expecting to change. A state watch samples `DescribeLoadBalancers` every two seconds and requires the sequence `active`, `active_impaired`, `active` with nothing in between, while the internet-facing run also watches the name continuously and serves traffic through it within five minutes. Afterwards the guest, its interfaces and its address are unchanged but its process is not, the backends and the bystander keep their instance ID, launch time and process (ADR-0003:S7), and the daemon journals exactly one `ELBv2 data-path repair started` line carrying the fields an operator needs. The internal ALB has no client inside the VPC, so it asserts the state sequence and target health rather than traffic through its name.
### `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, 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.
@@ -129,7 +131,7 @@ The client reports on its serial console because the runner has no route into th
### `eks`, `ecs`, `ecr` — container services
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod whose spec names only a ServiceAccount with the role-arn annotation, where the pod-identity webhook injects the token volume, `AWS_*` environment, STS endpoint and CA bundle and the pod assumes the role, while a pod of an unannotated ServiceAccount is left untouched. The unmodified aws-load-balancer-controller chart 1.11.0 installs with the values an AWS user passes plus the gateway endpoint override, the Shield and WAF switches and the single-subnet feature gate, and reconciles an Ingress into an active ALB. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod whose spec names only a ServiceAccount with the role-arn annotation, where the pod-identity webhook injects the token volume, `AWS_*` environment, STS endpoint and CA bundle and the pod assumes the role, while a pod of an unannotated ServiceAccount is left untouched. The unmodified aws-load-balancer-controller chart 1.11.0 installs with the values an AWS user passes plus the gateway endpoint override, the Shield and WAF switches and the single-subnet feature gate, and reconciles an Ingress into an active ALB. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. The private-endpoint NIC of the cluster's endpoint NLB is disabled through QMP and every worker must be Ready again within ten minutes while the cluster stays `ACTIVE` and the NLB keeps its guest instance. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
### `rds` — managed databases
@@ -138,10 +140,16 @@ The create/describe/connect path against a live cluster: a DB VM that actually b
### Storage and durability
- **`storagefault`** — what a guest does when its storage backend stops answering, and what stop/start does when the seal failed. **The claim is about corruption, not availability.** SIGSTOPs a cluster-wide service, so it always runs last and in its own cell. Three-node, because the RS(2,1) shard floor and cross-node takeover after a failed seal do not exist on one node.
- **No suite reaches a `pending` snapshot.** Every e2e cell runs the default `viperblockd` provider, which finishes a snapshot before `CreateSnapshot` answers, so `single`'s `TestSnapshotLifecycle`, `TestSnapshotBackedLaunch` and `TestCreateImage`, and `rds`'s `TestSnapshotRestore` and `TestDelete`, run the create that writes the snapshot document `pending` before the provider call and settle it `completed` within the request, and the pending-snapshot reaper finds nothing to settle. The `pending` paths are unit-tested against the memory provider answering `pending`: the reaper settling snapshots and AMIs and carrying out deferred deletes, the request's rollback and its ambiguous calls, and `DeleteSnapshot` of a pending snapshot in `domains/ec2/snapshot`; `CreateImage` and the readiness gates in `domains/ec2/image`, `domains/ec2/instance` and `domains/ec2/volume`; DB snapshots and the final snapshot waiting in `creating` in `domains/rds/instance`; and the import's wait in `operator/admin`. The contract's `pending` answer, its filtered `ListSnapshots` and the `state` it reports are in the conformance suite (`providers/ebs/conformance`), which every provider runs.
- **`partialblock`** — concurrent sub-block writes to the same block surviving a cold reread through NBD from a real guest.
- **`storagegrowth`** — volume growth and image pull. Requires `external_mode=pool`; skips on a nat env, which has no EIP and therefore no guest SSH.
- **`reboot`** — a slow guest waited for and keeping what it wrote, and a wedged guest forced off and relaunched once its budget expires. Proves a real reboot by requiring a *new* boot ID.
- **`diskperf`** — guest disk performance gate, pinned fio profile on a dedicated blank volume. Also requires `external_mode=pool` for guest SSH.
- **`uefistore`** — a real UEFI guest's variable writes reaching the variable store and surviving what it is built to survive. Single-node, and it runs on the node: it restarts that node's daemon and reads the store with the node's own Predastore credentials. Its three tests share one Ubuntu guest, which writes non-volatile variables (attributes 0x7) through efivarfs, and each landing is proved by fetching the object the store record points at and finding the variable's UTF-16 name and its run-unique value in the VARS region.
- `TestUEFIGuestVariableSurvivesStopStart`: the pointer moves to a fresh object holding the variable within 60 seconds (the documented bound is 30), the stop leaves the store holding it, and the guest reads it back after the start with a new boot ID.
- `TestUEFIWriterResumesAfterDaemonRestart`: a variable lands, `spinifex-daemon` restarts under the guest (whose boot ID must not change), a second variable lands, and both survive a stop and start.
- **`TestS7_UEFIStorePruneNeverDeletesThePointedObject` is the ADR-0003:S7 evidence for the prune.** It lands three versions of one variable and requires all three objects to remain, then polls the record and the prefix every 10 seconds until the prefix holds one object, failing if the pointed object or any object under 10 minutes old disappears. The prune must finish on an instance older than the 10-minute prune age, and the last version survives a final stop and start.
- Host reboot and node loss are not covered: both need host control or a second node that this suite does not have.
### Other
+30 -30
View File
@@ -1,30 +1,31 @@
module github.com/mulgadc/spinifex
go 1.27.3
go 1.27.4
require (
github.com/aws/aws-sdk-go v1.55.8
github.com/aws/aws-sdk-go-v2 v1.47.5
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25
github.com/aws/aws-sdk-go-v2/config v1.33.12
github.com/aws/aws-sdk-go-v2/credentials v1.20.12
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7
github.com/aws/smithy-go v1.28.7
github.com/aws/aws-sdk-go-v2 v1.47.7
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.26
github.com/aws/aws-sdk-go-v2/config v1.33.15
github.com/aws/aws-sdk-go-v2/credentials v1.20.15
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.7
github.com/aws/aws-sdk-go-v2/service/lambda v1.114.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.8
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.10
github.com/aws/smithy-go v1.28.8
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/containerd/containerd/v2 v2.4.2
github.com/distribution/reference v0.6.0
github.com/fsnotify/fsnotify v1.10.1
github.com/go-chi/chi/v5 v5.5.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82
github.com/jackc/pgx/v5 v5.11.0
github.com/jackc/pgx/v5 v5.12.0
github.com/klauspost/compress v1.20.3
github.com/klauspost/cpuid/v2 v2.4.1
github.com/klauspost/cpuid/v2 v2.4.2
github.com/miekg/dns v1.1.73
github.com/minio/highwayhash v1.0.4
github.com/mulgadc/bluebottle v1.24.0
@@ -33,14 +34,14 @@ require (
github.com/mulgadc/viperblock v1.24.0
github.com/nats-io/nats-server/v2 v2.15.2
github.com/nats-io/nats.go v1.55.0
github.com/nats-io/nkeys v0.4.16
github.com/nats-io/nkeys v0.4.17
github.com/opencontainers/runtime-spec v1.3.0
github.com/oracle/oci-go-sdk/v65 v65.128.0
github.com/ovn-kubernetes/libovsdb v0.9.0
github.com/oracle/oci-go-sdk/v65 v65.129.0
github.com/ovn-kubernetes/libovsdb v0.9.1
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pterm/pterm v0.12.84
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/pflag v1.1.0
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1
go.etcd.io/etcd/api/v3 v3.7.3
@@ -55,7 +56,7 @@ require (
go.uber.org/goleak v1.3.1-0.20260602155401-5f791e3ef83b
go.yaml.in/yaml/v3 v3.0.6
golang.org/x/crypto v0.58.0
golang.org/x/sync v0.24.0
golang.org/x/sync v0.25.0
golang.org/x/sys v0.49.0
golang.org/x/term v0.47.0
golang.org/x/time v0.16.0
@@ -72,17 +73,17 @@ require (
github.com/Microsoft/hcsshim v0.15.0-rc.4 // indirect
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.10 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.39.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.8 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boltdb/bolt v1.3.1 // indirect
@@ -119,7 +120,6 @@ require (
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -144,7 +144,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jackc/puddle/v2 v2.2.3 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/jstemmer/go-junit-report/v2 v2.1.0 // indirect
+56 -56
View File
@@ -22,46 +22,46 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.47.5 h1:5yA+A+BiXbDq1ZLbYwPViwhEGDMdR0MEhL8Jh2LnLS4=
github.com/aws/aws-sdk-go-v2 v1.47.5/go.mod h1:iNIkogrRvFwm+1OQIUOP2lPBtlXZboOIUWiCa+pHEMw=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25 h1:MW0Iql1SOTgQPj8mCxrU/eH/K/AIDWi08ItOsXaWRLU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25/go.mod h1:+ucVYmHYB3JqVueY0E1RHahdXAnKQbIHqybSbvOUbOM=
github.com/aws/aws-sdk-go-v2/config v1.33.12 h1:IV1YARaXahwCqUIyP8IlWsP85Lil+5ozTozDQXPumRk=
github.com/aws/aws-sdk-go-v2/config v1.33.12/go.mod h1:h51R06bzMVwNBRZOX5ylHhNDnkB/azZeq/LvxM6q2pQ=
github.com/aws/aws-sdk-go-v2/credentials v1.20.12 h1:6P7Q7yH/GIwHg6MLKv3ySsBqaCoE1z+C0OTzYMenhEY=
github.com/aws/aws-sdk-go-v2/credentials v1.20.12/go.mod h1:Kw/SaoW/wc7IqJErqXJIzKo8b7Aelxfbuc6XlbrRnsc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5 h1:iXjy2SFhGYNB1jWZBhQqq8HbhsbPF23w+/zbMvPXsTg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5/go.mod h1:k0RUiBnyXU0AXRJ9tJmY2jQke6XUIX2PNVnWtvgaPtc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 h1:gzIWc3DTPfPbWKrQnmL6BV6WD7NPD1htxzgdk/bQ/Z4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8/go.mod h1:vvUA5cDAZWGo4R14FZPZfXfJCmgsafOWEq78Kg0D+ug=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 h1:PWv+vG8aCLS8qd+HPdYhK7Vees90aKHWFFQ1AGYD/8E=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8/go.mod h1:xxPZEpflYHJA12366ZGyq66z9dHPCoeCIu99Jck+MPw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 h1:WOp5De9iWdEHDPh2Kb0tBP3vGBm/dVAuRGh4a4tqduo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8/go.mod h1:z8W8YgfhbWl62XqcTtK+CxPrE+iHGRVUOtrrbIOp7ZY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 h1:MCIR6iZaCKO4UE0hUvoWWLI6JVjppopV64JfAJaMjng=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23/go.mod h1:pphWFS+z0MmIPivj8c/6bATd1Z+Av7Ust75DPdEFsPA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9 h1:GACgECI422Xt5n4Pojr3O0aocPv8lfH4PJe2Aon5Wv8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9/go.mod h1:lPfnRkHpn1qWZJLbVEw10rIqSuCee1pNQTyGkUjAYh8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 h1:+PMpWlJYOn2tm7sm6OZ/ZLd4jStl17T7wjNapd1Rczs=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8/go.mod h1:cnG3xZLGQL4c/bt8pjdzlFtZsW7epBBuNS04a5xMjdo=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8 h1:RS1Wt+K0r4X0ci9Vf4JYTUfHzsQihYA2LgGJeEneKpk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8/go.mod h1:adMnTy5kyoUW86jhXnGSVc/mvMQI4GZd1qYWMeaS8M8=
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4 h1:wvAdq1sNfwuedO1BX8GjY+oZWzoyeD4hhdjJI+4EPnI=
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4/go.mod h1:HgkYN4d72Fawg3MAJQepmNnROFoN11XYu14hgFfChTY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6 h1:to7+Vr3rLkpYRwVKJ5QRriI0eE306vrVN/6TnVHgxvY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6/go.mod h1:Jc89ASa2k7HMAskj3bSnt8bf+k+p2IApzkoXnEjkgUw=
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0 h1:3vu/eUqh0rjh7Zy4N4YKcpZX4Aud+zinB3VsLhhlQFw=
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0/go.mod h1:wxAk0D2oVBSjuXEIzvcKxTZfM90zticdAbh/VD42L0Y=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7 h1:Eip+mZ0wFZY/10e5w89lWhF09Pa8tbbGwxePP3T2iMc=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7/go.mod h1:bXp38eBNyvSevFIao60mYcC3pQIAkPYWOddhFVv9z8E=
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0 h1:kIEpD5dUry7VET3Ybl7zj7PPHRB4lb52G9iTz+a1Tqk=
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0/go.mod h1:UGsEYgWz4Gi+46vDQ5zkFLqUgJziyOE2SXjZjgmfmHc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0 h1:WfFev+HgTodggcNon5HieiLxxwWwb8Br8R2+nDkixvk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0/go.mod h1:gJSdIY6YUzDvqQLcMtPVZgBRHrFwzHryvpE8Igzbxps=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 h1:BVjKrtRUtSYKztBCCfE1oAB+d3ViKcW2cKZfserOqMQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6/go.mod h1:eSukFLeLkPMm+1OxDR+F0joauZdyBkF47Ow7MqjZiJw=
github.com/aws/smithy-go v1.28.7 h1:h/n1SHrwVtaDYLz8mR1/Kq4Xh4RaFbFW7bOYckg9mfE=
github.com/aws/smithy-go v1.28.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.47.7 h1:jBmtzRtGWVAzbfc4RBWo1dTte0ZZH8p9U42YJIw2+Bw=
github.com/aws/aws-sdk-go-v2 v1.47.7/go.mod h1:I8lFSH4MGrZiw2m7miCrRYHsgpC1+HW8uiVGOiPYPcw=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.26 h1:SJNA4XaM8Z09adeTHf/vME93jzl44lJV70pSawIBXac=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.26/go.mod h1:l0ME5pd2QytH8UZzETcrt90x9Bm+3TyBVeWfVEcZ82c=
github.com/aws/aws-sdk-go-v2/config v1.33.15 h1:eSO7lvznhY5nMN1K9xK+pPTwNNH0chVmCIzH3C5as+E=
github.com/aws/aws-sdk-go-v2/config v1.33.15/go.mod h1:QLBxqEJFcgbrsw2dHaWMDW6DcIFWkHplXJMZGB/s2Ew=
github.com/aws/aws-sdk-go-v2/credentials v1.20.15 h1:LUFG3364w8wKBhGxGQ2XUxCCs+yOUrSg0dWUtBJskp8=
github.com/aws/aws-sdk-go-v2/credentials v1.20.15/go.mod h1:Q+tKQpy3sU1MjBqXzhZauN7ffcNRF9p7Rvw293sYwE4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.7 h1:5GGOwjXU738VrsBT0EDFtP5JtLPmJQILp8RS6P+KqLM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.7/go.mod h1:jY/VeijtHNuTiy5XI9HjpLiamtaaLw+KInZULfookJ8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.10 h1:DKtPO+uoekvZX1tIFGVlWypYGSYkHPb7rliUI1wZtEw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.10/go.mod h1:uAi0RDdZZz8IRpXERptWALeCgQG9Ye/HiMLxkxlt8IU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.10 h1:r8p95YELmfmuV9VvAaFcLesVBZduCCCccxpzQKkNThw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.10/go.mod h1:W3M52Mqai5TjgzyL/dEG+UwE+Xz9PykyjXelvbAzWIA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.10 h1:iytL6kFchhbCWWEfLzMGq1+yRecF7+BOXnp70J9zNRM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.10/go.mod h1:OaBcfeWMHPSyFqiHL/KN1zVIricTvYDNiO4XhR781eM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.24 h1:jGzHjc2jDx08Rji1JTBtMxuXUDG7muTEw+ExYXqFzPg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.24/go.mod h1:uJt75pK2cEyY80F5C+DK9YkCkJWsMkJViHxLYZRHl8Y=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.11 h1:+YhA5h9QMjVsOHfkfY/iVU7FfbT25LcGttfVzzed6v8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.11/go.mod h1:Xn22cWokj55agZo3I0w1JFQUiHhA3qLmHi/F5t1COi0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.10 h1:3nCD0vZOTTMypcaEEJW0rAWqEgUXVzfmNn3dDI1IQ7I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.10/go.mod h1:KJbydA7vvlrQSceENaM/C//yI18Rt36x//hG0Bw3uW0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.10 h1:bCz6PRKhO7ErM3F0XqLl8pbQq+r0Fra6R8PpfPnjgbo=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.10/go.mod h1:N8UvNaHipKSUnUFR07OpGdjU8B5aS/XBxgrcKURb2DQ=
github.com/aws/aws-sdk-go-v2/service/lambda v1.114.2 h1:SbfR8t3crViVjf8bCh9ohXwQYDbPJV/6P6CE3d9iKXc=
github.com/aws/aws-sdk-go-v2/service/lambda v1.114.2/go.mod h1:is4sHkblHDcfDUxxZVrDR9hSzf5I4id6qgX6hH0tUDA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.8 h1:0g/xXBWQEyR6Mcxo1ukBYqzeRX0bQ/ZdXrtKkIo+yT0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.8/go.mod h1:g6EsKHPrO5GpWYKwBfenLLtjuoPRE+WDIlLPQOIixLI=
github.com/aws/aws-sdk-go-v2/service/signin v1.12.3 h1:8cQxxirJ0d0tvmS89hYUpnVArXIKlzwRyiaSfdaokvQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.12.3/go.mod h1:cou3YaR7daAzApgGTG9il1c3iCh/bs72w6p4WFvJnaY=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.10 h1:ojdbdSOuwvVqcY+yfFO7Kz9Qow7brLZCALVBhorjUv8=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.10/go.mod h1:zPf8oFenYL6wvZ8izCh+fzFsSwpNNIBQP965awjBaXQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.39.3 h1:r5DHs3x9vC1p3VQh+V9EFfHIN5QmWDdS0yKITZM2kBI=
github.com/aws/aws-sdk-go-v2/service/sso v1.39.3/go.mod h1:u/xiahjixIqyBFiYNeQkmuimZycycBUJEomieSVsrXk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.3 h1:ENjUto22h2MiHmv8WehZ2874SSCh5knHKsNxeyjzKpY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.3/go.mod h1:jmVZvJzjoaStVpr4Nd11mMYCnyrq+sWT6UiDXVnHyco=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.8 h1:lervRo8V4a9tticNT+nPjqDq9olQPDmgqYczjvJbNio=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.8/go.mod h1:NBCcbmgAjvOsYwA6CPgMmipPUxsEep1JxKb3RWBr9rA=
github.com/aws/smithy-go v1.28.8 h1:aEmdAq7gVCoGCnvf1NnELlu3I6D/nhqrC321FyFiQkQ=
github.com/aws/smithy-go v1.28.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -234,10 +234,10 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgx/v5 v5.12.0 h1:hWwax8YiGT0YiN50U1oIDn5kgR7KC2tG3rmavwpPkxY=
github.com/jackc/pgx/v5 v5.12.0/go.mod h1:u2Fi6no03HCSBYE+ZVdLxbwdu3xyi2vU+K/YNJioSds=
github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
@@ -249,8 +249,8 @@ github.com/jstemmer/go-junit-report/v2 v2.1.0 h1:X3+hPYlSczH9IMIpSC9CQSZA0L+BipY
github.com/jstemmer/go-junit-report/v2 v2.1.0/go.mod h1:mgHVr7VUo5Tn8OLVr1cKnLuEy0M92wdRntM99h7RkgQ=
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/cpuid/v2 v2.4.1 h1:I9KxadAkvo+QjtcEE2f2tyfEvmIla/T0m+14PrZNl0U=
github.com/klauspost/cpuid/v2 v2.4.1/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/klauspost/cpuid/v2 v2.4.2 h1:xRw7IZEsAgVCp6trS/DgWbTAqpixhmywgVgBjYGg1bQ=
github.com/klauspost/cpuid/v2 v2.4.2/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/klauspost/reedsolomon v1.14.2 h1:SafJYwpBBQBI6amHUygcjxZjXeN2HpiENHQDwuPWCCQ=
github.com/klauspost/reedsolomon v1.14.2/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -317,8 +317,8 @@ github.com/nats-io/nats-server/v2 v2.15.2 h1:yLsgt2kslgymvxvjFq1WGWck5hkat2YYfbX
github.com/nats-io/nats-server/v2 v2.15.2/go.mod h1:VXHCZgDglV5wO2B8ZE58yG2HzqxQxYUbQqibUh5Xcr8=
github.com/nats-io/nats.go v1.55.0 h1:Ng8QKHdvdHIWoaiq0+d/aH1BWkxvcJxWJ0Y/9BPKx3M=
github.com/nats-io/nats.go v1.55.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nkeys v0.4.17 h1:JAJrlDhOyQqOt0a1eg4cbIZ1Kc7+vmkCigAltpwQpVA=
github.com/nats-io/nkeys v0.4.17/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -327,10 +327,10 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg=
github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
github.com/oracle/oci-go-sdk/v65 v65.128.0 h1:QWh0D4VsQ+94XMFy4sapwHVuW5+VOF4dom+h8/cyJsw=
github.com/oracle/oci-go-sdk/v65 v65.128.0/go.mod h1:87yiCsoPSaOZkpt5KIEEThoSirgT8WolahE3I5/qz78=
github.com/ovn-kubernetes/libovsdb v0.9.0 h1:WNQM8oCmh793mKa7PIIsF5JYoCa91lgXxG1nEEDStFU=
github.com/ovn-kubernetes/libovsdb v0.9.0/go.mod h1:mbCZzSSa9FQ0YEFgeO2rXW/OYoMh6vqNrPGpsS2XYAo=
github.com/oracle/oci-go-sdk/v65 v65.129.0 h1:keTL99Tc9APm7Cm3QQLtiHZ3KU9ZRkiXJzzgjzjpKRk=
github.com/oracle/oci-go-sdk/v65 v65.129.0/go.mod h1:87yiCsoPSaOZkpt5KIEEThoSirgT8WolahE3I5/qz78=
github.com/ovn-kubernetes/libovsdb v0.9.1 h1:+ltjpC2PEObBWqg2UuG83fH+qdnTuAibXqGrWdUJ1Iw=
github.com/ovn-kubernetes/libovsdb v0.9.1/go.mod h1:mbCZzSSa9FQ0YEFgeO2rXW/OYoMh6vqNrPGpsS2XYAo=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
@@ -375,8 +375,8 @@ github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qq
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.1.0 h1:6W6MbVUrnMb8PEhjkqwsVLY7ryuzRfFHF4mQd7L94ag=
github.com/spf13/pflag v1.1.0/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -485,8 +485,8 @@ golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1,8 +1,9 @@
// Package recordingstore provides an objectstore.ObjectStore fake that records
// every key it is asked to read and every prefix it is asked to list. It exists
// so tests can assert an access pattern rather than only an answer: that an
// account-scoped listing never touched another account's prefix, which an
// in-memory filter over a whole-cluster read would satisfy just as well.
// every key it is asked to read, head, write or delete and every prefix it is
// asked to list. It exists so tests can assert an access pattern rather than
// only an answer: that an account-scoped listing never touched another
// account's prefix, which an in-memory filter over a whole-cluster read would
// satisfy just as well.
package recordingstore
import (
@@ -16,14 +17,17 @@ import (
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
)
// Store wraps a MemoryObjectStore, appending to Gets and ListPrefixes before
// delegating. Both slices are safe to read once the call under test returns.
// Store wraps a MemoryObjectStore, recording each call's key or prefix before
// delegating. The recordings are safe to read once the call under test returns.
type Store struct {
*objectstore.MemoryObjectStore
mu sync.Mutex
gets []string
listPrefixes []string
heads []string
puts []string
deletes []string
}
// New returns a recording store backed by an empty in-memory store.
@@ -47,6 +51,27 @@ func (s *Store) ListObjectsV2(ctx context.Context, input *s3.ListObjectsV2Input)
return s.MemoryObjectStore.ListObjectsV2(ctx, input)
}
func (s *Store) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3.HeadObjectOutput, error) {
s.record(&s.heads, aws.StringValue(input.Key))
return s.MemoryObjectStore.HeadObject(ctx, input)
}
func (s *Store) PutObject(ctx context.Context, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
s.record(&s.puts, aws.StringValue(input.Key))
return s.MemoryObjectStore.PutObject(ctx, input)
}
func (s *Store) DeleteObject(ctx context.Context, input *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) {
s.record(&s.deletes, aws.StringValue(input.Key))
return s.MemoryObjectStore.DeleteObject(ctx, input)
}
func (s *Store) record(into *[]string, key string) {
s.mu.Lock()
defer s.mu.Unlock()
*into = append(*into, key)
}
// Gets returns every key read so far.
func (s *Store) Gets() []string {
s.mu.Lock()
@@ -61,12 +86,33 @@ func (s *Store) ListPrefixes() []string {
return append([]string(nil), s.listPrefixes...)
}
// Heads returns every key headed so far.
func (s *Store) Heads() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.heads...)
}
// Puts returns every key written so far.
func (s *Store) Puts() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.puts...)
}
// Deletes returns every key deleted so far.
func (s *Store) Deletes() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.deletes...)
}
// Reset drops what has been recorded, so a test can seed fixtures and then
// assert only on the call it is actually exercising.
func (s *Store) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.gets, s.listPrefixes = nil, nil
s.gets, s.listPrefixes, s.heads, s.puts, s.deletes = nil, nil, nil, nil, nil
}
// TouchedPrefix reports whether any recorded get or listing named a key under
+20 -2
View File
@@ -209,6 +209,7 @@ echo "[build-microvm-image] nginx: binary + stream module present"
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
# *fw_cfg*.ko* — QEMU fw_cfg sysfs driver (delivers boot blobs)
# *failover*.ko* — net_failover + failover (virtio_net deps for live migration)
# af_packet.ko* — AF_PACKET sockets (lb-agent's per-NIC gateway ARP probe)
# All other loadable modules are removed. Drivers compiled into the kernel
# (CONFIG_VIRTIO_MMIO=y etc.) are unaffected — they live in vmlinuz, not here.
echo "[build-microvm-image] stripping kernel modules to virtio+fw_cfg only..."
@@ -222,6 +223,7 @@ for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
find "$kernel_dir" -name "virtio*.ko.gz" -o -name "virtio*.ko" \
-o -name "*fw_cfg*.ko.gz" -o -name "*fw_cfg*.ko" \
-o -name "*failover*.ko.gz" -o -name "*failover*.ko" \
-o -name "af_packet.ko.gz" -o -name "af_packet.ko" \
2>/dev/null | while read -r mod; do
rel="${mod#"$kernel_dir/"}"
dest_dir="$tmp_keep/$(dirname "$rel")"
@@ -240,6 +242,15 @@ for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
done)
rm -rf "$tmp_keep"
# lb-agent's data-path probe needs AF_PACKET; without it every LB would
# report a dead NIC. Accept a kernel with CONFIG_PACKET built in.
if ! find "$kernel_dir" -name "af_packet.ko*" | grep -q .; then
if ! grep -q "CONFIG_PACKET=y" "$KERNEL_CONFIG" 2>/dev/null; then
echo "ERROR: af_packet.ko missing from the kept modules — lb-agent's data-path probe cannot open its socket" >&2
exit 1
fi
fi
# Decompress kept modules to plain .ko. Alpine ships .ko.gz, but a host
# kmod that lacks gzip support (e.g. Debian trixie's kmod 34) silently
# produces an EMPTY modules.dep from compressed modules — the guest's
@@ -310,12 +321,19 @@ echo "[build-microvm-image] building initramfs: $INITRAMFS_OUT"
# Create the static device nodes and pack the archive inside a single fakeroot
# session: mknod/cpio see faked device entries and write real char-device
# records into the cpio, while the host filesystem never gets an actual node.
fakeroot sh -c '
# Pack to a .partial and list it before renaming, so a pack cut short (fakeroot
# losing its daemon, a full disk) fails here and never leaves a stale archive.
fakeroot bash -c '
set -euo pipefail
cd "$1" || exit 1
mknod -m 600 dev/console c 5 1
mknod -m 666 dev/null c 1 3
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
' _ "$CHROOT_DIR" "$INITRAMFS_OUT.partial" \
&& gzip -t "$INITRAMFS_OUT.partial" \
&& zcat "$INITRAMFS_OUT.partial" | cpio -t --quiet >/dev/null \
|| { rm -f "$INITRAMFS_OUT.partial" "$INITRAMFS_OUT"; echo "ERROR: initramfs $INITRAMFS_OUT is incomplete or unreadable" >&2; exit 1; }
mv -f "$INITRAMFS_OUT.partial" "$INITRAMFS_OUT"
# --- Log artifact sizes ---
echo ""
+1
View File
@@ -40,6 +40,7 @@ mkdir -p "$STAGING/scripts"
cp "$PROJECT_ROOT/build/scripts/"* "$STAGING/scripts/"
mkdir -p "$STAGING/udev"
cp "$PROJECT_ROOT/build/udev/"* "$STAGING/udev/"
cp -r "$PROJECT_ROOT/build/ocibsd" "$STAGING/ocibsd"
cp "$PROJECT_ROOT/build/logrotate/spinifex" "$STAGING/logrotate-spinifex"
tar czf /tmp/spinifex-local.tar.gz -C "$STAGING" .
+19 -2
View File
@@ -50,6 +50,11 @@
# --yes Skip the confirmation prompt
# --dry-run Print what would happen and touch nothing
#
# Environment:
# SPINIFEX_EBS_PROVIDER Passed to spx admin init on the first host, naming
# what backs EBS in place of its detection; joining
# nodes take the provider from formation.
#
# THE HOST FIREWALL
#
# Nodes installed from the ISO boot armed, scoped to the only cluster member
@@ -159,7 +164,7 @@ while [[ $# -gt 0 ]]; do
--no-firewall) MANAGE_FIREWALL=false; shift ;;
--yes|-y) ASSUME_YES=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
-h|--help) sed -n '2,67p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
-h|--help) sed -n '2,72p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
-*) echo "ERROR: unknown option: $1" >&2; exit 2 ;;
*) HOSTS+=("$1"); shift ;;
esac
@@ -210,6 +215,18 @@ else
done
fi
# init_env prints the env prefix that carries SPINIFEX_EBS_PROVIDER to init, or
# nothing when it is unset. It is joined into a remote shell, so the value is
# limited to a provider name's characters rather than quoted.
init_env() {
case "$1" in
"") ;;
*[!a-z]*) echo "SPINIFEX_EBS_PROVIDER must be a provider name such as viperblockd, got: $1"; return 1 ;;
*) printf 'env SPINIFEX_EBS_PROVIDER=%s ' "$1" ;;
esac
}
INIT_ENV="$(init_env "${SPINIFEX_EBS_PROVIDER:-}")" || fail "$INIT_ENV"
case "$IPSEC" in
on) IPSEC_ENABLED=true ;;
off) IPSEC_ENABLED=false ;;
@@ -671,7 +688,7 @@ log "initializing ${HOSTS[0]} as ${NODE_NAMES[0]} (waiting for $((N - 1)) node(s
# init blocks in a formation server until every node has joined, so it runs
# detached and the token is read back out of its output.
on "${HOSTS[0]}" "sudo rm -f $INIT_LOG; \
sudo setsid sh -c 'umask 077; spx admin init ${init_args[*]} >$INIT_LOG 2>&1' </dev/null >/dev/null 2>&1 & \
sudo setsid ${INIT_ENV}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=""
+14 -3
View File
@@ -1,7 +1,7 @@
#!/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.
# Tests remote_join_cmd and init_env from install-node.sh: the join token reaches spx only on
# stdin, so sudo's log never holds it; other values arrive as plain arguments even when they
# hold shell syntax; and a SPINIFEX_EBS_PROVIDER that is not a provider name is refused.
# preflight runs this with sh
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
set -euo pipefail
@@ -38,4 +38,15 @@ check "spx got the token" "$(grep -o '<--token><[^>]*>' "$FAKE_SPX_ARGV")" "<--t
check "spx got the other values as given" "$(cat "$FAKE_SPX_ARGV")" "<admin><join><--force><--node><node2><--bind><10.0.0.2><--cluster-bind><10.0.0.2><--advertise><203.0.113.2><--host><10.0.0.1:4432;touch PWNED><--token><$TOKEN><--region><us-east-1><--az><az \$(touch PWNED2)>"
check "no value was run as a command" "$(find "$work" -maxdepth 1 -name 'PWNED*' | wc -l | tr -d ' ')" 0
# init_env: its value is joined into a remote sh -c unquoted, so anything beyond
# a provider name's characters is refused, not passed on.
eval "$(sed -n '/^init_env()/,/^}/p' "$here/install-node.sh")"
check "no provider sets nothing" "$(init_env "")" ""
check "a provider is carried to init" "$(init_env viperblockd)" "env SPINIFEX_EBS_PROVIDER=viperblockd "
check "a provider with shell syntax is refused" \
"$(init_env 'ocibsd;touch PWNED3' >/dev/null 2>&1 && echo accepted || echo refused)" refused
check "a provider with a space is refused" \
"$(init_env 'ocibsd $(touch PWNED4)' >/dev/null 2>&1 && echo accepted || echo refused)" refused
check "no refused value was run as a command" "$(find "$work" -maxdepth 1 -name 'PWNED*' | wc -l | tr -d ' ')" 0
[ "$fails" -eq 0 ]
+9 -2
View File
@@ -382,10 +382,17 @@ microvm_pack_initramfs() {
# Create the static device nodes and pack the archive inside a single fakeroot
# session: mknod/cpio see faked device entries and write real char-device
# records into the cpio, while the host filesystem never gets an actual node.
fakeroot sh -c '
# Pack to a .partial and list it before renaming, so a pack cut short fails
# here and never leaves a stale archive.
fakeroot bash -c '
set -euo pipefail
cd "$1" || exit 1
mknod -m 600 dev/console c 5 1
mknod -m 666 dev/null c 1 3
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
' _ "$CHROOT_DIR" "$INITRAMFS_OUT.partial" \
&& gzip -t "$INITRAMFS_OUT.partial" \
&& zcat "$INITRAMFS_OUT.partial" | cpio -t --quiet >/dev/null \
|| { rm -f "$INITRAMFS_OUT.partial" "$INITRAMFS_OUT"; echo "ERROR: initramfs $INITRAMFS_OUT is incomplete or unreadable" >&2; exit 1; }
mv -f "$INITRAMFS_OUT.partial" "$INITRAMFS_OUT"
}
+77
View File
@@ -0,0 +1,77 @@
#!/bin/sh
# microvm-image_test.sh — microvm_pack_initramfs publishes only an archive that
# lists completely, removes a rejected one, and succeeds again on a retry.
#
# The real pack step runs against a small chroot; fakeroot and mknod are
# replaced so it needs no root, and cpio is wrapped to simulate a cut pack.
set -eu
SCRIPTS_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- microvm_pack_initramfs"
if ! command -v cpio >/dev/null 2>&1 || ! command -v bash >/dev/null 2>&1; then
echo " skip - cpio or bash not installed"
exit 0
fi
REAL_CPIO=$(command -v cpio)
mkdir -p "$TMP/bin" "$TMP/chroot/dev" "$TMP/chroot/etc" "$TMP/stage"
printf '#!/bin/sh\nexec "$@"\n' > "$TMP/bin/fakeroot"
printf '#!/bin/sh\nexit 0\n' > "$TMP/bin/mknod"
chmod +x "$TMP/bin/fakeroot" "$TMP/bin/mknod"
# Large enough that a cut at 64 KiB loses whole entries and the trailer.
head -c 400000 /dev/urandom > "$TMP/chroot/etc/blob"
printf 'init\n' > "$TMP/chroot/init"
# pack <cpio mode>: runs the real step; mode "cut" truncates the archive stream
# and exits 0, mode "fail" writes it whole and exits 1, mode "ok" is untouched.
pack() {
cat > "$TMP/bin/cpio" <<EOF
#!/bin/bash
case " \$* " in *" -o "*) ;; *) exec "$REAL_CPIO" "\$@" ;; esac
case "$1" in
cut) "$REAL_CPIO" "\$@" | head -c 65536; exit 0 ;;
fail) "$REAL_CPIO" "\$@"; exit 1 ;;
*) exec "$REAL_CPIO" "\$@" ;;
esac
EOF
chmod +x "$TMP/bin/cpio"
PATH="$TMP/bin:$PATH" bash -c '
set -euo pipefail
. "$1/lib/microvm-image.sh"
TAG="[test]" CHROOT_DIR="$2" STAGE_DIR="$3"
microvm_pack_initramfs
' _ "$SCRIPTS_DIR" "$TMP/chroot" "$TMP/stage" > "$TMP/log" 2>&1
}
OUT="$TMP/stage/initramfs.cpio.gz"
for mode in cut fail; do
printf 'stale\n' > "$OUT"
if pack "$mode"; then
fail "$mode: pack succeeded, want failure"
elif [ -e "$OUT" ] || [ -e "$OUT.partial" ]; then
fail "$mode: rejected archive left behind"
else
pass "$mode: pack fails and leaves no archive"
fi
done
if ! pack ok; then
fail "retry after failure: $(tr '\n' ' ' < "$TMP/log")"
elif [ -e "$OUT.partial" ]; then
fail "retry after failure: partial file left behind"
elif ! zcat "$OUT" | "$REAL_CPIO" -t --quiet 2>/dev/null | grep -qx 'etc/blob'; then
fail "retry after failure: archive does not list etc/blob"
else
pass "retry after failure publishes a complete archive"
fi
[ "$fails" -eq 0 ] || exit 1
+98 -3
View File
@@ -17,7 +17,7 @@
# SETUP_STAGES Comma-separated subset of stages to run:
# deps, aws, dns, users, sudoers, firewall, timesync, files,
# directories, env, systemd, sysctl, needrestart, modules,
# logrotate, udev, fixown, migrations
# logrotate, udev, ocibsd, fixown, migrations
# Unset = run every stage appropriate for the current mode.
set -e
@@ -43,6 +43,13 @@ FIREWALL_TRACK="${FIREWALL_DIR}/conntrack.nft"
FIREWALL_MODE_FILE="${SPINIFEX_FIREWALL_MODE_FILE:-${FIREWALL_DIR}/mode}"
FIREWALL_APPLY="/usr/local/lib/spinifex/spinifex-firewall-apply"
# The OCI instance metadata service spx admin init detects ocibsd by. v2 needs
# the bearer header; it is a confused-deputy guard, not a credential.
OCI_IMDS_SHAPE_URL="http://169.254.169.254/opc/v2/instance/shape"
# Where the tarball's ocibsd/ is staged; init and join install it from here.
OCIBSD_STAGE_DIR="/usr/local/share/spinifex/ocibsd"
SPINIFEX_TOML="/etc/spinifex/spinifex.toml"
# The daemon runs with ProtectKernelModules=yes, so every module it needs must
# already be loaded when it starts.
SPINIFEX_KERNEL_MODULES="openvswitch geneve vhost_net tun"
@@ -1351,6 +1358,7 @@ install_deps() {
apt) install_apt_deps ;;
dnf) install_dnf_deps ;;
esac
install_iscsi_initiator "$pm"
fi
# Mask the standalone dhcpcd.service auto-enabled on Debian Trixie when
@@ -1385,6 +1393,53 @@ install_apt_deps() {
fi
}
# is_oci_vm is spx admin init's detection: the OCI metadata service answers
# within a second with a shape that is not bare metal. Asked once per run,
# because off OCI each ask costs the full second.
is_oci_vm() {
if [ -z "${OCI_VM:-}" ]; then
local shape=""
OCI_VM=no
if command -v curl >/dev/null 2>&1 \
&& shape=$(curl -sf -m 1 -H 'Authorization: Bearer Oracle' "$OCI_IMDS_SHAPE_URL" 2>/dev/null); then
case "$shape" in
"" | BM.*) ;;
*) OCI_VM=yes ;;
esac
fi
fi
[ "$OCI_VM" = yes ]
}
# node_runs_ocibsd reports whether this node's existing config names ocibsd in
# a [nodes.<name>.ebs] table, the provider being the same on every node.
node_runs_ocibsd() {
local text
[ -e "$SPINIFEX_TOML" ] || return 1
if [ -r "$SPINIFEX_TOML" ]; then
text=$(cat "$SPINIFEX_TOML") || return 1
else
text=$($SUDO cat "$SPINIFEX_TOML") || return 1
fi
printf '%s\n' "$text" | awk '
/^[[:space:]]*\[/ { ebs = ($0 ~ /^[[:space:]]*\[nodes\.[^].]+\.ebs\][[:space:]]*(#.*)?$/); next }
ebs && /^[[:space:]]*provider[[:space:]]*=[[:space:]]*"ocibsd"/ { found = 1 }
END { exit !found }'
}
# The initiator ocibsd drives, on the only host it can back EBS on.
install_iscsi_initiator() {
is_oci_vm || return 0
info "OCI virtual machine detected, installing the iSCSI initiator..."
case "$1" in
apt) DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq open-iscsi > /dev/null ;;
dnf) $SUDO dnf install -y -q iscsi-initiator-utils > /dev/null ;;
esac
if [ "${ISO_BUILD:-0}" != "1" ]; then
$SUDO systemctl enable --now iscsid.socket
fi
}
# The package table is the other place keyed on the OS ID: dnf alone does not
# say which repositories or package names a host has.
install_dnf_deps() {
@@ -2135,6 +2190,45 @@ install_udev() {
info "udev rules installed"
}
# install_ocibsd stages the tarball's ocibsd/ on an OCI virtual machine, where
# spx admin init or join installs it once the provider resolves to ocibsd. On
# a node whose config already runs ocibsd it also reinstalls the staged files.
install_ocibsd() {
stage "staging the ocibsd files"
local ocibsd_node=false f
node_runs_ocibsd && ocibsd_node=true
if ! $ocibsd_node && ! is_oci_vm; then
return 0
fi
if [ ! -d "$EXTRACT_DIR/ocibsd" ]; then
$ocibsd_node && fatal "This node runs ocibsd and the tarball has no ocibsd/ directory to reinstall it from"
warn "The tarball has no ocibsd/ directory, so OCI Block Volumes cannot back EBS on this node"
return 0
fi
$SUDO rm -rf "$OCIBSD_STAGE_DIR"
$SUDO install -d -m 0755 "$OCIBSD_STAGE_DIR/spinifex-daemon.service.d"
for f in spinifex-ocibsd.service 99-spinifex-ocibsd.rules spinifex-daemon.service.d/ocibsd.conf; do
$SUDO install -m 0644 "$EXTRACT_DIR/ocibsd/$f" "$OCIBSD_STAGE_DIR/$f"
done
info " $OCIBSD_STAGE_DIR (staged)"
$ocibsd_node || return 0
$SUDO install -m 0644 "$OCIBSD_STAGE_DIR/spinifex-ocibsd.service" /etc/systemd/system/spinifex-ocibsd.service
$SUDO install -d -m 0755 /etc/systemd/system/spinifex-daemon.service.d
$SUDO install -m 0644 "$OCIBSD_STAGE_DIR/spinifex-daemon.service.d/ocibsd.conf" \
/etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf
$SUDO install -d /etc/udev/rules.d
$SUDO install -m 0644 "$OCIBSD_STAGE_DIR/99-spinifex-ocibsd.rules" /etc/udev/rules.d/99-spinifex-ocibsd.rules
# install_systemd has just written the viperblock unit over its mask.
$SUDO ln -sf /dev/null /etc/systemd/system/spinifex-viperblock.service
info " ocibsd unit, device drop-in and udev rule reinstalled; spinifex-viperblock.service masked"
if [ "${ISO_BUILD:-0}" != "1" ]; then
$SUDO systemctl daemon-reload
$SUDO systemctl enable spinifex-ocibsd.service
$SUDO udevadm control --reload-rules
fi
}
# --- Upgrade handling ---
handle_upgrade() {
if $SUDO systemctl is-active --quiet spinifex.target 2>/dev/null; then
@@ -2273,12 +2367,12 @@ main() {
detect_os
detect_arch
# Stages that need EXTRACT_DIR: files, directories, systemd, logrotate, udev.
# Stages that need EXTRACT_DIR: files, directories, systemd, logrotate, udev, ocibsd.
# Only download/extract when at least one such stage is enabled, and before
# anything on the host changes, so the hand-off finds it untouched.
if stage_enabled files || stage_enabled directories \
|| stage_enabled systemd || stage_enabled logrotate \
|| stage_enabled udev; then
|| stage_enabled udev || stage_enabled ocibsd; then
download_spinifex
hand_off_to_packaged_setup "${args[@]}"
fi
@@ -2306,6 +2400,7 @@ main() {
stage_enabled modules && install_kernel_modules
stage_enabled logrotate && install_logrotate
stage_enabled udev && install_udev
stage_enabled ocibsd && install_ocibsd
stage_enabled swap && setup_swap
# Migrations are only safe on a live system (need a running NATS and a
+157
View File
@@ -0,0 +1,157 @@
#!/bin/sh
# setup_ocibsd_test.sh — setup.sh's systemd and udev stages never install the
# tarball's ocibsd/ files; install_ocibsd stages them on an OCI VM, and
# reinstalls them on upgrade only where the node's config already runs ocibsd.
# A stub SUDO records every privileged command and runs the file ones under a
# scratch root, so what each case leaves on disk is checked as well.
set -eu
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
SETUP="$SCRIPT_DIR/setup.sh"
BUILD="$(dirname -- "$SCRIPT_DIR")/build"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
# The tarball layout the release image and dev-install.sh assemble.
EXTRACT="$TMP/extract"
mkdir -p "$EXTRACT/systemd" "$EXTRACT/udev"
cp "$BUILD"/systemd/* "$EXTRACT/systemd/"
cp "$BUILD"/udev/* "$EXTRACT/udev/"
cp -r "$BUILD/ocibsd" "$EXTRACT/ocibsd"
# install, ln, rm and mkdir run with /etc and /usr/local/share/spinifex moved
# under $ROOT; anything else is only recorded.
cat > "$TMP/sudo" <<'EOF'
#!/usr/bin/env bash
echo "$*" >> "$CALLS"
case "$1" in
install | ln | rm | mkdir) ;;
tee) cat > /dev/null; exit 0 ;;
*) exit 0 ;;
esac
args=()
for a in "$@"; do
case "$a" in
/etc/* | /usr/local/share/spinifex/*) a="$ROOT$a" ;;
esac
args+=("$a")
done
exec "${args[@]}"
EOF
chmod +x "$TMP/sudo"
# run_setup <case> <oci_vm yes|no> <config body or -> <functions...>
# runs the named setup.sh functions in a fresh root for one case.
run_setup() {
name="$1" vm="$2" body="$3"
shift 3
ROOT="$TMP/$name/root"
CALLS="$TMP/$name/calls"
mkdir -p "$ROOT/etc/systemd/system" "$ROOT/etc/spinifex" "$ROOT/usr/local/share/spinifex"
: > "$CALLS"
[ "$body" = - ] || printf '%s\n' "$body" > "$ROOT/etc/spinifex/spinifex.toml"
export ROOT CALLS
OUT=$(INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '
. "$1"; SUDO="$2"; EXTRACT_DIR="$3"; OCI_VM="$4"; SPINIFEX_TOML="$ROOT/etc/spinifex/spinifex.toml"
shift 4
for f in "$@"; do $f; done' _ "$SETUP" "$TMP/sudo" "$EXTRACT" "$vm" "$@" 2>&1) \
|| fail "$name: $* failed: $OUT"
}
echo "-- the systemd and udev stages leave the tarball's ocibsd/ alone"
run_setup units yes - install_systemd install_udev
if grep -q " /etc/systemd/system/spinifex-viperblock.service$" "$CALLS" && grep -q " /etc/udev/rules.d/99-spinifex-vfio.rules$" "$CALLS"; then
pass "installs the systemd/ units and udev/ rules"
else
fail "did not install the systemd/ and udev/ files: $(cat "$CALLS")"
fi
if grep -q "ocibsd" "$CALLS"; then
fail "installed an ocibsd/ file: $(grep ocibsd "$CALLS")"
else
pass "installs nothing from ocibsd/"
fi
STAGED="usr/local/share/spinifex/ocibsd"
FILES="spinifex-ocibsd.service 99-spinifex-ocibsd.rules spinifex-daemon.service.d/ocibsd.conf"
installed_count() {
n=0
for p in etc/systemd/system/spinifex-ocibsd.service etc/udev/rules.d/99-spinifex-ocibsd.rules \
etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf; do
[ ! -e "$ROOT/$p" ] || n=$((n + 1))
done
echo "$n"
}
staged_ok() {
for f in $FILES; do
cmp -s "$EXTRACT/ocibsd/$f" "$ROOT/$STAGED/$f" || return 1
done
}
echo "-- a fresh install on an OCI VM stages ocibsd/ and installs none of it"
run_setup fresh yes - install_systemd install_udev install_ocibsd
if staged_ok; then pass "the three files are staged"; else fail "the stage does not match the tarball: $(ls -R "$ROOT/$STAGED" 2>&1)"; fi
[ "$(installed_count)" = 0 ] && pass "nothing is installed" || fail "installed $(installed_count) ocibsd file(s) on a node init has not resolved"
[ -L "$ROOT/etc/systemd/system/spinifex-viperblock.service" ] && fail "masked viperblock" || pass "viperblock is not masked"
echo "-- an upgrade of an ocibsd node replaces the installed files with the new stage"
OCIBSD_TOML='[nodes.node1.ebs]
provider = "ocibsd"
[nodes.node1.ebs.oci]
auth = "instance_principal"'
ROOT="$TMP/upgrade/root"
mkdir -p "$ROOT/$STAGED/spinifex-daemon.service.d" "$ROOT/etc/systemd/system/spinifex-daemon.service.d" "$ROOT/etc/udev/rules.d"
for f in $FILES; do echo "# the previous release" > "$ROOT/$STAGED/$f"; done
echo "# the previous release" > "$ROOT/etc/systemd/system/spinifex-ocibsd.service"
echo "# the previous release" > "$ROOT/etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf"
echo "# the previous release" > "$ROOT/etc/udev/rules.d/99-spinifex-ocibsd.rules"
ln -s /dev/null "$ROOT/etc/systemd/system/spinifex-viperblock.service"
run_setup upgrade yes "$OCIBSD_TOML" install_systemd install_udev install_ocibsd
if staged_ok; then pass "the stage holds the new files"; else fail "the stage kept the previous release"; fi
if cmp -s "$EXTRACT/ocibsd/spinifex-ocibsd.service" "$ROOT/etc/systemd/system/spinifex-ocibsd.service" \
&& cmp -s "$EXTRACT/ocibsd/spinifex-daemon.service.d/ocibsd.conf" "$ROOT/etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf" \
&& cmp -s "$EXTRACT/ocibsd/99-spinifex-ocibsd.rules" "$ROOT/etc/udev/rules.d/99-spinifex-ocibsd.rules"; then
pass "the unit, drop-in and udev rule are the new ones"
else
fail "an installed ocibsd file is not the new one"
fi
if [ "$(readlink "$ROOT/etc/systemd/system/spinifex-viperblock.service")" = /dev/null ]; then
pass "viperblock is masked again after the unit reinstall"
else
fail "the reinstalled viperblock unit is not masked"
fi
grep -q '^systemctl daemon-reload$' "$CALLS" && grep -q '^udevadm control --reload-rules$' "$CALLS" \
&& pass "systemd and udev are reloaded" || fail "no reload: $(cat "$CALLS")"
echo "-- an ocibsd node is refreshed even when the metadata service does not answer"
run_setup upgrade-no-imds no "$OCIBSD_TOML" install_ocibsd
[ "$(installed_count)" = 3 ] && pass "the three files are installed" || fail "installed $(installed_count) of 3"
echo "-- an upgrade of a viperblock node on an OCI VM installs none of them"
run_setup viperblock yes '[nodes.node1.ebs]
provider = "viperblockd"' install_systemd install_udev install_ocibsd
[ "$(installed_count)" = 0 ] && pass "nothing is installed" || fail "installed $(installed_count) ocibsd file(s) on a viperblock node"
[ -f "$ROOT/etc/systemd/system/spinifex-viperblock.service" ] && [ ! -L "$ROOT/etc/systemd/system/spinifex-viperblock.service" ] \
&& pass "the viperblock unit is installed and not masked" || fail "the viperblock unit was masked"
echo "-- off OCI nothing is staged"
run_setup off-oci no '[nodes.node1.daemon]' install_ocibsd
[ ! -e "$ROOT/$STAGED" ] && pass "no stage" || fail "staged ocibsd/ off OCI"
echo "-- the initiator is installed on an OCI VM only"
run_setup initiator-apt yes - "install_iscsi_initiator apt"
grep -q 'apt-get install -y -qq open-iscsi' "$CALLS" && grep -q '^systemctl enable --now iscsid.socket$' "$CALLS" \
&& pass "apt installs open-iscsi and enables iscsid.socket" || fail "apt: $(cat "$CALLS")"
run_setup initiator-dnf yes - "install_iscsi_initiator dnf"
grep -q 'dnf install -y -q iscsi-initiator-utils' "$CALLS" && pass "dnf installs iscsi-initiator-utils" || fail "dnf: $(cat "$CALLS")"
run_setup initiator-off no - "install_iscsi_initiator apt"
[ -s "$CALLS" ] && fail "installed an initiator off OCI: $(cat "$CALLS")" || pass "no initiator off OCI"
if [ "$fails" -gt 0 ]; then
echo "$fails failure(s)"
exit 1
fi
+17 -3
View File
@@ -230,11 +230,11 @@ Create them once per tenancy, then every deployment and rebuild afterwards uses
**`adopt` references the dynamic group by nothing at all.** Its matching rule is `instance.compartment.id`, so it covers every instance in the compartment and names no OCID — which is why adopting needs no read on an identity resource and no tenancy rights. The cost is that a missing policy is invisible at apply time: the node forms, passes every health check, and then refuses every launch wanting a public address. The allocator gate in `validate-topology.sh` is what catches that, by requiring `ocinet credential authorised to allocate` in each node's `spinifex-daemon` journal.
The policy grants four verbs in one compartment: `use vnics`, `manage private-ips`, `manage public-ips` and `use subnets`.
That is exactly what allocating an external address does and nothing more.
The policy grants `use vnics`, `manage private-ips`, `manage public-ips` and `use subnets` in one compartment for the allocator, and with `ebs_provider = "ocibsd"` also `manage volume-family` and `use instances` for the `ocibsd` EBS provider. `setup-identity.sh --ebs-provider ocibsd` creates it with them; the policy is shared by every deployment in the compartment, so running it without the flag removes them.
The first four are exactly what allocating an external address does and nothing more; the last two are what creating, attaching, backing up and deleting a block volume does.
`use subnets` looks unrelated and is not: `CreatePrivateIp` is checked against `SUBNET_ATTACH` and `CreatePrivateIp` is how an address is registered, so a policy without it authorises nothing and every allocation returns a 404.
**An API key is the fallback**, and the default because it needs nothing from a tenancy admin. Grant that user only the operations Spinifex performs on addresses and nothing else; it is not a tenancy admin, and broader rights widen the blast radius of a node compromise for no benefit.
**An API key is the fallback**, and the default because it needs nothing from a tenancy admin. Grant that user only the operations Spinifex performs on addresses and volumes and nothing else: `terraform output -json api_key_policy_statements` prints the same statements for the group named by `api_key_group_name` (default `spinifex-nodes`), for a tenancy admin to apply; it is not a tenancy admin, and broader rights widen the blast radius of a node compromise for no benefit.
`spx-oci-config.sh` installs it on **every** node, as `/etc/spinifex/oci/{oci_api_key.pem,config}` — not a home directory, because the daemon's unit sets `ProtectHome=yes`. Both files are needed: `oci_config_file` names an ordinary OCI SDK config, so a node holding only the PEM forms and then fails every allocation. The profile it writes is `spinifex`, matching `oci_config_profile`:
@@ -275,6 +275,20 @@ The count that later gates read is the number of addresses the `hosts_file` outp
**A clean teardown can still leave public IPs behind, and they count against the tenancy.** The addresses Spinifex allocates for guests are created by the running node, so they are in no Terraform state and `destroy` neither sees nor removes them. The node's own reconcile collects detached ones every ten minutes, which is exactly the sweep a destroy takes away. Twenty accumulated in `spxbm` over four runs and exhausted the tenancy's 50 reserved-public-IP limit, at which point every later launch in every compartment failed. [Check for leftover public IPs afterwards](../../../docs/oci-integration/README.md#check-for-leftover-public-ips-afterwards) has the query and the rule for reading it.
### The ocibsd topologies
`vm-single-ocibsd` and `vm-multi-ocibsd` are `vm-single` and `vm-multi` with EBS backed by OCI Block Volumes. They build with `ebs_provider = "ocibsd"`, which disables the Block Volume Management plugin and attaches each node's data volume paravirtualized, because every iSCSI session on the node is `ocibsd`'s to manage. They need `--distro`, because a published release predates the `ocibsd` files that `setup.sh` stages, and an external pool, because an API-key run gives `ocibsd` the pool's credential.
The ocibsd topologies name no EBS provider. `spx admin init` detects that it is on an OCI virtual machine, reads the compartment, availability domain and region from the instance metadata, and generates the cluster's `cluster_id`. Every other topology sets `SPINIFEX_EBS_PROVIDER=viperblockd`, so its viperblock baseline is not replaced by detection on OCI. The run checks the `EBS provider:` line init prints: `detected` on the ocibsd topologies under the instance principal, and `from SPINIFEX_EBS_PROVIDER` on the rest.
Under the instance principal, `ocibsd` uses the instance's own certificate. An API-key run runs the credential hook before formation instead, and seeds each node's `spinifex.toml` with `provider = "ocibsd"` and the hook's `/etc/spinifex/oci/config`, which init keeps as an existing config, so its `EBS provider:` line reads `from the existing config`.
Every volume and backup `ocibsd` creates is tagged `spinifex-cluster=<cluster_id>`, and none of them is in any Terraform state, so the run records the ID in `.validate-<topology>/ocibsd-clusters` before any volume exists.
The `ebs provider` gate then checks the units, the viperblock mask, the daemon's device drop-in, and that the compartment and availability domain init detected are the ones the state reports on every node, imports an AMI onto OCI and boots from it, and drives stop and start, attach and detach, snapshot and restore with a guest-written marker read back over the serial console. On `vm-multi-ocibsd` it also starts the stopped instance on another node. It ends by terminating everything and polling OCI until nothing tagged with the instance's volumes or snapshot is left.
`e2e-oci.sh` deletes everything tagged with each recorded `cluster_id` once that topology's nodes are destroyed, including from `--sweep-only`, and fails the run if anything tagged remains. A standalone `validate-topology.sh` run does not sweep, so follow it with `./e2e-oci.sh --sweep-only`.
The workbook runs **on the node** against `127.0.0.1`, because the node certificate carries no SAN for its public address — a workbook driven from outside the VCN is still blocked.
### Validate infrastructure Terraform already created
+10 -4
View File
@@ -45,6 +45,11 @@ locals {
# after the boot disk (mulga-poc has oraclevda1 pointing at sdb1).
data_device = "/dev/oracleoci/oraclevdb"
# The Block Volume Management plugin logs iSCSI sessions in and out by itself,
# and on an ocibsd node those sessions are ocibsd's to manage. With the plugin
# off, the node's data volume attaches paravirtualized, which needs no login.
ocibsd_nodes = var.ebs_provider == "ocibsd"
# adopt and create differ only in who creates the dynamic group and policy; a node
# authenticates identically either way, because the matching rule covers the whole
# compartment and so names no instance.
@@ -106,11 +111,12 @@ resource "oci_core_instance" "mulgadc" {
user_data = base64encode(local.user_data)
}
# Required for automatic UHP iSCSI multipath setup and login.
# Required for automatic UHP iSCSI multipath setup and login, and disabled on
# an ocibsd node, where ocibsd owns every iSCSI session (see ocibsd_nodes).
agent_config {
plugins_config {
name = "Block Volume Management"
desired_state = "ENABLED"
desired_state = local.ocibsd_nodes ? "DISABLED" : "ENABLED"
}
}
@@ -165,10 +171,10 @@ resource "oci_core_volume" "mulgadc_data" {
resource "oci_core_volume_attachment" "mulgadc_data" {
count = var.node_count
attachment_type = "iscsi"
attachment_type = local.ocibsd_nodes ? "paravirtualized" : "iscsi"
# Required by OCI for Ultra High Performance iSCSI volume attachments.
device = local.data_device
is_agent_auto_iscsi_login_enabled = true
is_agent_auto_iscsi_login_enabled = local.ocibsd_nodes ? null : true
instance_id = oci_core_instance.mulgadc[count.index].id
volume_id = oci_core_volume.mulgadc_data[count.index].id
}
+64 -5
View File
@@ -8,8 +8,10 @@
#
# SPX_REF Ref to prove. Default: the spinifex checkout's current branch.
# OCI_TOPOLOGIES Space-separated. Default: "vm-single vm-multi bm
# vm-single-principal". Set one name to test one thing: a
# single VM topology is roughly a quarter of the full run.
# vm-single-principal vm-single-ocibsd vm-multi-ocibsd". Set
# one name to test one thing. The two ocibsd topologies back
# EBS with OCI Block Volumes, need OCI_SOURCE=tree and an
# external pool, and are recorded SKIPPED without them.
# OCI_SOURCE tree (build SPX_REF) or release (the published installer).
# OCI_VERSION With OCI_SOURCE=release, install this exact release tag and
# ignore OCI_CHANNEL. Preferred for anything repeatable: a tag
@@ -82,7 +84,7 @@ SPINIFEX_ROOT="$(cd "$HERE/../../.." && pwd)"
# under the workspace. Deriving one from the other is only right in the first case.
MULGA_ROOT="${MULGA_ROOT:-$SPINIFEX_ROOT/..}"
TOPOLOGIES="${OCI_TOPOLOGIES:-vm-single vm-multi bm vm-single-principal}"
TOPOLOGIES="${OCI_TOPOLOGIES:-vm-single vm-multi bm vm-single-principal vm-single-ocibsd vm-multi-ocibsd}"
SOURCE="${OCI_SOURCE:-tree}"
CHANNEL="${OCI_CHANNEL:-latest}"
INSTALL_VERSION="${OCI_VERSION:-}"
@@ -289,6 +291,35 @@ publish_summary() {
fi
}
# ocibsd's volumes and backups live in no Terraform state, so a destroy leaves any
# a failed run made. Each cluster_id a topology recorded is swept by its tag once
# the nodes are gone, and anything still tagged afterwards fails the run.
sweep_ocibsd() {
local dir="$1" record="$1/ocibsd-clusters" cluster compartment region left
[ -s "$record" ] || return 0
left="$(mktemp)"
while IFS=$'\t' read -r cluster compartment region; do
[ -n "$cluster" ] || continue
log "sweep: OCI volumes and backups of ocibsd cluster $cluster"
if OCIBSD_COMPARTMENT="$compartment" python3 "$HERE/scripts/oci_env.py" \
${OCI_PROFILE:+--profile "$OCI_PROFILE"} --region "$region" -- \
python3 "$HERE/scripts/oci_volumes.py" sweep --cluster "$cluster" \
< /dev/null >> "$ARTIFACT_DIR/sweep.log" 2>&1; then
log "sweep: nothing tagged with ocibsd cluster $cluster remains"
else
log "sweep: OCI still holds volumes or backups of ocibsd cluster $cluster. See $ARTIFACT_DIR/sweep.log"
echo "sweep ${dir##*/.validate-} ocibsd cluster $cluster: FAIL" >> "$VERDICT"
printf '%s\t%s\t%s\n' "$cluster" "$compartment" "$region" >> "$left"
fi
done < "$record"
# Kept only for what is left, so the next sweep retries exactly that.
if [ -s "$left" ]; then
mv "$left" "$record"
else
rm -f "$left" "$record"
fi
}
# A topology destroys itself from its own EXIT trap, so this only catches a run
# the trap never reached: a killed process, a runner reclaimed mid-job, a reboot.
# It works from the state files rather than by name filter, so it can only ever
@@ -297,12 +328,16 @@ sweep() {
local found=0 dir topology
local sweep_env=(env)
for dir in "$STATE_ROOT"/.validate-*; do
[ -s "$dir/terraform.tfstate" ] || continue
[ -d "$dir" ] || continue
if [ ! -s "$dir/terraform.tfstate" ]; then
sweep_ocibsd "$dir"
continue
fi
python3 -c '
import json, sys
state = json.load(open(sys.argv[1]))
sys.exit(0 if any(r["instances"] for r in state.get("resources", [])) else 1)
' "$dir/terraform.tfstate" 2>/dev/null || continue
' "$dir/terraform.tfstate" 2>/dev/null || { sweep_ocibsd "$dir"; continue; }
topology="${dir##*/.validate-}"
# The child reads OCI_STATE_ROOT from the environment, so it looks in the
# same place this loop found the state rather than beside its own script.
@@ -319,6 +354,7 @@ sys.exit(0 if any(r["instances"] for r in state.get("resources", [])) else 1)
if "${sweep_env[@]}" "$HERE/validate-topology.sh" --topology "$topology" --destroy-only \
>> "$ARTIFACT_DIR/sweep.log" 2>&1; then
log "sweep: $topology destroyed"
sweep_ocibsd "$dir"
else
log "sweep: $topology WOULD NOT DESTROY -- it is still billing. See $ARTIFACT_DIR/sweep.log"
echo "sweep $topology: FAIL" >> "$VERDICT"
@@ -342,6 +378,10 @@ sweep
if [ "$SWEEP_ONLY" = 1 ]; then
log "--sweep-only: done"
if grep -q FAIL "$VERDICT"; then
cat "$VERDICT"
exit 1
fi
exit 0
fi
@@ -394,6 +434,22 @@ for topology in $TOPOLOGIES; do
build_principal_env
run_env=(env "${PRINCIPAL_ENV[@]}")
fi
# ocibsd's files ship in the distro this checkout builds, and its volumes
# need the credential the pool uses, so neither can be stood in for.
case "$topology" in
*-ocibsd)
if [ "$SOURCE" != tree ]; then
log "$topology: SKIPPED -- the ocibsd files ship only with a tree build"
echo "$topology: SKIPPED (OCI_SOURCE=$SOURCE)" >> "$VERDICT"
continue
fi
if [ "${OCI_NO_EXTERNAL_POOL:-0}" = 1 ]; then
log "$topology: SKIPPED -- ocibsd needs the OCI credential the external pool installs"
echo "$topology: SKIPPED (OCI_NO_EXTERNAL_POOL=1)" >> "$VERDICT"
continue
fi
;;
esac
args=(--topology "$topology" --ssh-public-key "$SSH_PUBLIC_KEY" --ssh-private-key "$SSH_PRIVATE_KEY")
[ "$DRY_RUN" = 1 ] && args+=(--dry-run)
# Two separate choices. --no-external-pool is the one that forms without any
@@ -460,5 +516,8 @@ if [ "$RUN_RC" = 0 ] && ! grep -q FAIL "$VERDICT"; then
else
log "FAIL"
printf 'FAIL\te2e-oci\t%d.000s\n' "$SECONDS"
# A failed sweep is a FAIL in the verdict with every topology passing, and is
# still resources left billing, so it fails the run as well.
RUN_RC=1
fi
exit "$RUN_RC"
+30 -12
View File
@@ -29,26 +29,44 @@ resource "oci_identity_dynamic_group" "nodes" {
}
# Narrower than it looks. The allocator creates, moves and deletes secondary
# private IPs on a node's own VNIC and attaches public IPs to them, which is what
# these four verbs cover; nothing here grants compute, storage or identity.
# private IPs on a node's own VNIC and attaches public IPs to them, and ocibsd
# creates, attaches and backs up EBS volumes. Nothing here grants identity.
locals {
network_grants = [
"use vnics",
"manage private-ips",
"manage public-ips",
# CreatePrivateIp needs SUBNET_ATTACH and DeletePrivateIp needs SUBNET_DETACH,
# both of which only the subnets `use` verb carries. Without it every allocation
# is refused as a 404, which reads as a missing resource rather than a missing grant.
"use subnets",
]
# ocibsd: volumes, backups and attachments, and attaching them to an instance.
# Without these every ocibsd call is refused and its service fails at start.
# A viperblockd deployment touches no OCI volume, so it is not granted them.
ocibsd_grants = [
"manage volume-family",
"use instances",
]
node_grants = concat(local.network_grants, var.ebs_provider == "ocibsd" ? local.ocibsd_grants : [])
}
resource "oci_identity_policy" "nodes" {
provider = oci.home
count = var.instance_principal == "create" ? 1 : 0
compartment_id = var.compartment_ocid
name = "${var.deployment_name}-nodes-network"
description = "Allows Spinifex nodes to manage the private and public IPs backing external addresses."
description = "Allows Spinifex nodes to manage the IPs backing external addresses and the OCI volumes backing EBS."
statements = [
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to use vnics in compartment id ${var.compartment_ocid}",
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to manage private-ips in compartment id ${var.compartment_ocid}",
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to manage public-ips in compartment id ${var.compartment_ocid}",
# CreatePrivateIp needs SUBNET_ATTACH and DeletePrivateIp needs SUBNET_DETACH,
# both of which only the subnets `use` verb carries. Without it every allocation
# is refused as a 404, which reads as a missing resource rather than a missing grant.
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to use subnets in compartment id ${var.compartment_ocid}",
for grant in local.node_grants :
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to ${grant} in compartment id ${var.compartment_ocid}"
]
lifecycle {
lifecycle {
precondition {
condition = local.tenancy_home_region != ""
error_message = "OCI did not report a tenancy home region; cannot create instance-principal IAM resources."
+17
View File
@@ -31,3 +31,20 @@ output "nodes" {
output "hosts_file" {
value = join("\n", [for instance in oci_core_instance.mulgadc : instance.public_ip])
}
# The api_key route's equivalent of the instance-principal policy, for a tenancy
# admin to apply to the API user's group. Sensitive only to keep the compartment
# OCID out of apply.log; read it with terraform output api_key_policy_statements.
output "api_key_policy_statements" {
sensitive = true
value = [
for grant in local.node_grants :
"Allow group ${var.api_key_group_name} to ${grant} in compartment id ${var.compartment_ocid}"
]
}
# Where spx admin init should detect the nodes on ocibsd, so the run can check
# what init read from the instance metadata and knows where to sweep afterwards.
output "availability_domain" { value = local.compute_availability_domain }
output "region" { value = var.region }
output "ebs_provider" { value = var.ebs_provider }
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Find and sweep the OCI volumes and backups ocibsd tags, which no Terraform state holds.
Run under oci_env.py for the credential, with the compartment in OCIBSD_COMPARTMENT.
Standard library and the openssl binary only; the key reaches openssl through a pipe.
oci_volumes.py volumes --tag spinifex-volume-id=vol-0abc
oci_volumes.py backups --tag spinifex-snapshot-id=snap-0abc
oci_volumes.py sweep --cluster <cluster_id> [--timeout 900]
"""
from __future__ import annotations
import argparse
import base64
import email.utils
import json
import os
from pathlib import Path
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
API = "/20160918"
# Gone for good; OCI keeps the record for a while and it cannot be deleted again.
GONE = {"TERMINATED"}
# OCI's own answers to a delete it will accept later: an attachment or a backup
# still in progress (409), or a precondition such as a busy lifecycle (412).
RETRY_LATER = {409, 412, 429, 500, 502, 503, 504}
class OCIError(Exception):
def __init__(self, status: int, body: str):
super().__init__(f"OCI answered {status}: {body[:300]}")
self.status = status
class Client:
def __init__(self, env: dict[str, str]):
missing = [k for k in ("TF_VAR_tenancy_ocid", "TF_VAR_user_ocid", "TF_VAR_fingerprint", "TF_VAR_region") if not env.get(k)]
if missing:
raise ValueError(f"run this under oci_env.py: {', '.join(missing)} unset")
self.key_id = f"{env['TF_VAR_tenancy_ocid']}/{env['TF_VAR_user_ocid']}/{env['TF_VAR_fingerprint']}"
self.host = f"iaas.{env['TF_VAR_region']}.oraclecloud.com"
if env.get("TF_VAR_private_key"):
self.key = env["TF_VAR_private_key"].encode()
elif env.get("TF_VAR_private_key_path"):
self.key = Path(env["TF_VAR_private_key_path"]).read_bytes()
else:
raise ValueError("run this under oci_env.py: no private key in TF_VAR_private_key or TF_VAR_private_key_path")
def _sign(self, data: bytes) -> str:
read_end, write_end = os.pipe()
try:
os.write(write_end, self.key)
finally:
os.close(write_end)
try:
result = subprocess.run(
["openssl", "dgst", "-sha256", "-sign", f"/dev/fd/{read_end}"],
input=data,
capture_output=True,
pass_fds=(read_end,),
check=False,
)
finally:
os.close(read_end)
if result.returncode != 0:
raise ValueError(f"openssl could not sign with the OCI key: {result.stderr.decode(errors='replace').strip()}")
return base64.b64encode(result.stdout).decode()
def request(self, method: str, path: str, query: dict[str, str] | None = None) -> tuple[dict[str, str], object]:
target = API + path
if query:
target += "?" + urllib.parse.urlencode(query)
date = email.utils.formatdate(usegmt=True)
headers = {"date": date, "host": self.host}
signed = ["(request-target)", "date", "host"]
lines = [f"(request-target): {method.lower()} {target}", f"date: {date}", f"host: {self.host}"]
# A PUT or POST also signs its body, which this signer does not do.
if method not in ("GET", "DELETE"):
raise ValueError(f"unsupported method {method}")
signature = self._sign("\n".join(lines).encode())
headers["authorization"] = (
f'Signature version="1",keyId="{self.key_id}",algorithm="rsa-sha256",'
f'headers="{" ".join(signed)}",signature="{signature}"'
)
req = urllib.request.Request(f"https://{self.host}{target}", method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
body = resp.read()
return dict(resp.headers), json.loads(body) if body else None
except urllib.error.HTTPError as error:
raise OCIError(error.code, error.read().decode(errors="replace")) from None
def list_all(self, kind: str, compartment: str) -> list[dict]:
items: list[dict] = []
query = {"compartmentId": compartment, "limit": "100"}
while True:
headers, page = self.request("GET", f"/{kind}", query)
items.extend(page or [])
next_page = {k.lower(): v for k, v in headers.items()}.get("opc-next-page")
if not next_page:
return items
query["page"] = next_page
def matching(items: list[dict], tags: dict[str, str]) -> list[dict]:
"""The live items carrying every tag, with no OCID beyond the item's own."""
found = []
for item in items:
freeform = item.get("freeformTags") or {}
if item.get("lifecycleState") in GONE:
continue
if all(freeform.get(k) == v for k, v in tags.items()):
found.append(
{
"id": item["id"],
"displayName": item.get("displayName"),
"lifecycleState": item.get("lifecycleState"),
"freeformTags": freeform,
}
)
return found
def parse_tags(pairs: list[str]) -> dict[str, str]:
tags = {}
for pair in pairs:
key, sep, value = pair.partition("=")
if not sep or not key:
raise ValueError(f"--tag wants key=value, got {pair!r}")
tags[key] = value
return tags
def sweep(client: Client, compartment: str, cluster: str, timeout: int, interval: int) -> int:
"""Delete every live volume and backup tagged with this cluster until none is left.
OCI refuses both deletes while a backup is CREATING, so a refusal is retried
until the deadline, which is what makes a run killed mid-snapshot sweepable.
"""
tags = {"spinifex-cluster": cluster}
deadline = time.monotonic() + timeout
while True:
left = []
for kind in ("volumeBackups", "volumes"):
for item in matching(client.list_all(kind, compartment), tags):
left.append((kind, item))
if item["lifecycleState"] == "TERMINATING":
continue
try:
client.request("DELETE", f"/{kind}/{item['id']}")
print(f"sweep: deleting {kind[:-1]} {item['displayName']} ({item['lifecycleState']})")
except OCIError as error:
if error.status == 404:
continue
if error.status not in RETRY_LATER:
raise
print(f"sweep: {kind[:-1]} {item['displayName']} not deletable yet: {error.status}")
if not left:
print(f"sweep: nothing tagged spinifex-cluster={cluster} is left")
return 0
if time.monotonic() >= deadline:
for kind, item in left:
print(f"sweep: STILL PRESENT {kind[:-1]} {item['displayName']} {item['lifecycleState']}")
print(f"sweep: {len(left)} tagged resource(s) left after {timeout}s")
return 1
time.sleep(interval)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True)
for name in ("volumes", "backups"):
listing = sub.add_parser(name, help=f"print the live {name} carrying every --tag, as JSON")
listing.add_argument("--tag", action="append", default=[], required=True)
sweeping = sub.add_parser("sweep", help="delete every live volume and backup tagged with --cluster")
sweeping.add_argument("--cluster", required=True)
sweeping.add_argument("--timeout", type=int, default=900)
sweeping.add_argument("--interval", type=int, default=15)
args = parser.parse_args()
compartment = os.environ.get("OCIBSD_COMPARTMENT", "")
if not compartment.startswith("ocid1."):
raise ValueError("OCIBSD_COMPARTMENT is not set to a compartment OCID")
client = Client(dict(os.environ))
if args.command == "sweep":
if not args.cluster.strip():
raise ValueError("--cluster is empty, which would match nothing and report clean")
return sweep(client, compartment, args.cluster, args.timeout, args.interval)
kind = "volumes" if args.command == "volumes" else "volumeBackups"
print(json.dumps(matching(client.list_all(kind, compartment), parse_tags(args.tag))))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OCIError, ValueError, OSError) as error:
print(f"oci_volumes.py: {error}", file=sys.stderr)
raise SystemExit(1)
+13 -4
View File
@@ -21,6 +21,7 @@ STATE_DIR="${OCI_STATE_ROOT:-$HERE}/.identity"
SSH_PUBLIC_KEY="${OCI_SSH_PUBLIC_KEY:-$HOME/.ssh/oci-spx.pub}"
DESTROY=0
DRY_RUN=0
EBS_PROVIDER=viperblockd
log() { printf '[setup-identity] %s\n' "$*"; }
die() {
@@ -30,14 +31,17 @@ die() {
usage() {
cat >&2 <<EOF
usage: ${0##*/} [--dry-run] [--destroy]
usage: ${0##*/} [--dry-run] [--destroy] [--ebs-provider viperblockd|ocibsd]
Creates the OCI dynamic group and policy for instance-principal authentication.
Needs a tenancy-admin OCI credential; the compartment-scoped user the rest of this
configuration runs as cannot create either resource.
--dry-run Show the plan, change nothing.
--destroy Remove them again. Every node using "adopt" loses its credential.
--dry-run Show the plan, change nothing.
--destroy Remove them again. Every node using "adopt" loses its credential.
--ebs-provider ocibsd also grants manage volume-family and use instances. The
policy is shared by every deployment in the compartment, so a
later run without it removes those grants. Default viperblockd.
EOF
exit 2
}
@@ -46,11 +50,16 @@ while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--destroy) DESTROY=1; shift ;;
--ebs-provider) EBS_PROVIDER="${2:?}"; shift 2 ;;
-h | --help) usage ;;
*) die "unknown option $1" ;;
esac
done
case "$EBS_PROVIDER" in
viperblockd | ocibsd) ;;
*) die "--ebs-provider must be viperblockd or ocibsd, got '$EBS_PROVIDER'" ;;
esac
[ -r "$SSH_PUBLIC_KEY" ] || die "no readable SSH public key at $SSH_PUBLIC_KEY (set OCI_SSH_PUBLIC_KEY)"
mkdir -p "$STATE_DIR"
@@ -75,7 +84,7 @@ if [ "$DESTROY" = 1 ]; then
exit 0
fi
args=(-var 'instance_principal=create' -state "$STATE_DIR/terraform.tfstate" "${targets[@]}")
args=(-var 'instance_principal=create' -var "ebs_provider=$EBS_PROVIDER" -state "$STATE_DIR/terraform.tfstate" "${targets[@]}")
if [ "$DRY_RUN" = 1 ]; then
log "dry run: planning the dynamic group and policy only"
+555 -26
View File
@@ -49,7 +49,7 @@ WORKBOOKS=""
# set -u an unset array is an error rather than an empty one.
HOSTS=()
TOPOLOGIES="bm vm-single vm-multi vm-single-principal"
TOPOLOGIES="bm vm-single vm-multi vm-single-principal vm-single-ocibsd vm-multi-ocibsd"
# What a topology is worth when nothing says otherwise, so a CI run of a named
# topology is the same every time. Supplied as TF_VAR_, which terraform.auto.tfvars
@@ -61,8 +61,14 @@ TOPOLOGIES="bm vm-single vm-multi vm-single-principal"
#
# A case rather than an associative array: macOS ships bash 3.2, where declare -A
# is an indexed assignment and the key is read as an arithmetic variable.
#
# TOPO_EBS_PROVIDER is what backs EBS. The -ocibsd topologies are their viperblock
# twins on OCI Block Volumes, so a difference between a pair is the provider's.
# They set no provider and rely on init's detection on an OCI VM; every other
# topology names viperblockd in SPINIFEX_EBS_PROVIDER to keep its baseline.
topo_defaults() {
TOPO_FORCE_PRINCIPAL=0
TOPO_EBS_PROVIDER=viperblockd
case "$1" in
bm) TOPO_DEFAULT_SHAPE="BM.Standard.E2.64"; TOPO_DEFAULT_NODES=1 ;;
vm-single) TOPO_DEFAULT_SHAPE="VM.Standard.E6.Flex"; TOPO_DEFAULT_NODES=1 ;;
@@ -74,6 +80,10 @@ topo_defaults() {
TOPO_DEFAULT_SHAPE="${OCI_PRINCIPAL_SHAPE:-VM.Standard.E6.Flex}"
TOPO_DEFAULT_NODES=1
TOPO_FORCE_PRINCIPAL=1 ;;
vm-single-ocibsd)
TOPO_DEFAULT_SHAPE="VM.Standard.E6.Flex"; TOPO_DEFAULT_NODES=1; TOPO_EBS_PROVIDER=ocibsd ;;
vm-multi-ocibsd)
TOPO_DEFAULT_SHAPE="VM.Standard.E6.Flex"; TOPO_DEFAULT_NODES=3; TOPO_EBS_PROVIDER=ocibsd ;;
*) return 1 ;;
esac
}
@@ -86,7 +96,7 @@ die() {
usage() {
cat >&2 <<EOF
usage: ${0##*/} --topology <bm|vm-single|vm-multi|vm-single-principal> [options]
usage: ${0##*/} --topology <bm|vm-single|vm-multi|vm-single-principal|vm-single-ocibsd|vm-multi-ocibsd> [options]
Builds a topology, or installs on existing OCI hosts, then installs Spinifex,
forms the cluster, and verifies the OCI address allocator.
@@ -95,6 +105,13 @@ forms the cluster, and verifies the OCI address allocator.
topology is the easiest expensive mistake here.
vm-single-principal is vm-single authenticating as the
instance, and implies --instance-principal.
vm-single-ocibsd and vm-multi-ocibsd set no EBS
provider, so init detects ocibsd on the OCI VM, and
add the ebs provider gate; the others set
SPINIFEX_EBS_PROVIDER=viperblockd. The ocibsd pair
need --distro. Their OCI volumes and backups are
in no Terraform state: the destroy here leaves any a
failed run made, and e2e-oci.sh sweeps them by tag.
--ssh-public-key PATH Public half installed on each node. Default $SSH_PUBLIC_KEY
--ssh-private-key PATH Private half used to reach them. Default $SSH_PRIVATE_KEY
--instance-principal Configure the pool with oci_auth="instance_principal"
@@ -106,7 +123,9 @@ forms the cluster, and verifies the OCI address allocator.
Create the dynamic group and policy before deployment.
Needs a tenancy-admin profile. Opt-in because these are
tenancy-level resources. Use --identity-oci-profile to
use a different profile for this one-time setup.
use a different profile for this one-time setup. The
policy carries the volume grants only on an ocibsd
topology, and it is shared, so run it from one of those.
--identity-oci-profile NAME
Tenancy-admin profile for --setup-instance-principal.
Defaults to --oci-profile / OCI_PROFILE.
@@ -209,6 +228,19 @@ fi
[ -z "$EXISTING_HOSTS_FILE" ] || [ -z "$EXISTING_STATE_FILE" ] \
|| die "--existing-hosts-file and --existing-state-file are mutually exclusive"
# ocibsd needs a node credential, which a run with no pool never installs, and the
# compartment and region from the state, which a hosts file does not carry, to
# sweep and check its volumes. setup.sh stages its files from the tarball, and a
# published release predates them.
if [ "$TOPO_EBS_PROVIDER" = ocibsd ] && [ "$DESTROY_ONLY" = 0 ]; then
[ "$SKIP_POOL" != 1 ] \
|| die "--no-external-pool installs no OCI credential, so ocibsd on $TOPOLOGY could not reach the OCI API"
[ -z "$EXISTING_HOSTS_FILE" ] \
|| die "$TOPOLOGY reads its compartment and availability domain from Terraform state; use --existing-state-file"
[ -n "$DISTRO" ] || [ "$DRY_RUN" = 1 ] \
|| die "$TOPOLOGY needs --distro: a published release predates the ocibsd files setup.sh stages and init installs"
fi
if [ -n "$EXISTING_HOSTS_FILE" ] || [ -n "$EXISTING_STATE_FILE" ]; then
EXISTING_INFRA=1
[ "$DESTROY_ONLY" = 0 ] \
@@ -281,7 +313,7 @@ tf() {
# deployment sizes itself in that file and the topology only supplies the
# default a CI run wants.
TF_VAR_compute_shape="$TOPO_DEFAULT_SHAPE" TF_VAR_node_count="$TOPO_DEFAULT_NODES" \
python3 "$HERE/scripts/oci_env.py" --ssh-public-key-path "$SSH_PUBLIC_KEY" \
TF_VAR_ebs_provider="$TOPO_EBS_PROVIDER" python3 "$HERE/scripts/oci_env.py" --ssh-public-key-path "$SSH_PUBLIC_KEY" \
${OCI_PROFILE:+--profile "$OCI_PROFILE"} -- \
terraform -chdir="$HERE" "$@"
}
@@ -383,18 +415,18 @@ identity_resources() {
"$HERE/setup-identity.sh" --destroy
elif [ "$DRY_RUN" = 1 ]; then
OCI_CLI_PROFILE="$identity_profile" OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \
"$HERE/setup-identity.sh" --dry-run
"$HERE/setup-identity.sh" --dry-run --ebs-provider "$TOPO_EBS_PROVIDER"
else
OCI_CLI_PROFILE="$identity_profile" OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \
"$HERE/setup-identity.sh"
"$HERE/setup-identity.sh" --ebs-provider "$TOPO_EBS_PROVIDER"
fi
else
if [ "$action" = destroy ]; then
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh" --destroy
elif [ "$DRY_RUN" = 1 ]; then
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh" --dry-run
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh" --dry-run --ebs-provider "$TOPO_EBS_PROVIDER"
else
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh"
OCI_SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" "$HERE/setup-identity.sh" --ebs-provider "$TOPO_EBS_PROVIDER"
fi
fi
}
@@ -443,8 +475,10 @@ mark_stage() { : > "$STATE_DIR/stage-$1.done"; }
formation_complete() {
local host
stage_done formation || return 1
# A line only init writes, not a non-empty file: an ocibsd API-key run seeds the
# file with the credential before init, so a fresh node already has one.
for host in "${HOSTS[@]}"; do
ssh_node "$host" 'test -s /etc/spinifex/spinifex.toml' || return 1
ssh_node "$host" "grep -q '^data_dir = ' /etc/spinifex/spinifex.toml" || return 1
done
}
@@ -634,6 +668,7 @@ if [ "$DRY_RUN" = 1 ]; then
| sed -E 's/ +=/ =/' | sort -u | sed 's/^/ /'
log "full plan in $STATE_DIR/plan.txt"
plan="install Spinifex on $NODES node(s), form the cluster"
[ "$TOPO_EBS_PROVIDER" = ocibsd ] && plan="$plan on ocibsd, run the ebs provider gate"
[ "$SKIP_POOL" = 1 ] && plan="$plan, configure no external pool"
if [ "$SKIP_WORKLOAD" = 1 ]; then
plan="$plan, launch no guests"
@@ -669,6 +704,29 @@ else
|| die "read instance_principal='$PRINCIPAL_MODE' from the tfvars and the apply built '$built_principal'; set it in one place"
fi
# Where ocibsd should keep the cluster's volumes, read back from what was built
# rather than from this run's inputs, to check what init detected and to sweep.
# Never logged: the compartment is an account's OCID.
topo_output() {
if [ "$EXISTING_INFRA" = 1 ]; then
state_output "$1"
else
tf output -raw "${tf_state[@]}" "$1"
fi
}
OCIBSD_COMPARTMENT=""
OCIBSD_AD=""
OCIBSD_REGION=""
if [ "$TOPO_EBS_PROVIDER" = ocibsd ]; then
built_provider="$(topo_output ebs_provider 2>/dev/null)" \
|| die "the state has no ebs_provider output; it predates ocibsd, so rebuild it"
[ "$built_provider" = ocibsd ] \
|| die "$TOPOLOGY needs nodes built for ocibsd and the state says ebs_provider='$built_provider'; a tfvars override wins over the topology"
OCIBSD_COMPARTMENT="$(topo_output compartment_ocid)" || die "could not read compartment_ocid from the state"
OCIBSD_AD="$(topo_output availability_domain)" || die "could not read availability_domain from the state"
OCIBSD_REGION="$(topo_output region)" || die "could not read region from the state"
fi
# Written for every topology, not just the multi-node one that passes it to
# install-node.sh: it is also how an operator gets the addresses afterwards
# without a terraform invocation and the right -state path.
@@ -768,6 +826,42 @@ REMOTE
done
record install PASS "$INSTALLED_VERSION"
# spx admin init's EBS choice, written to every node once (ADR-0018:S3). The ocibsd
# topologies set nothing, so init detects ocibsd on the OCI VM; the rest name
# viperblockd, which keeps the viperblock baseline on OCI.
INIT_EBS_PROVIDER=""
[ "$TOPO_EBS_PROVIDER" = ocibsd ] || INIT_EBS_PROVIDER=viperblockd
# ocibsd defaults to the instance principal. An API-key run gives init the key
# instead: the hook installs it first, and each node's config is seeded with
# provider and credential, which init keeps as an existing config's values.
API_KEY_EBS=0
[ "$TOPO_EBS_PROVIDER" = ocibsd ] && [ "$PRINCIPAL_MODE" = off ] && API_KEY_EBS=1
PRESEEDED_EBS=0
if [ "$API_KEY_EBS" = 1 ] && ! formation_complete; then
log "running the credential hook before formation, so init can check the ocibsd credential"
"$CREDENTIAL_HOOK" "$SSH_PRIVATE_KEY" "${HOSTS[@]}" > "$STATE_DIR/credential-hook.log" 2>&1 \
|| die "the credential hook failed; see $STATE_DIR/credential-hook.log"
for i in "${!HOSTS[@]}"; do
# Named as init and install-node.sh name them: the host on one node,
# node1..nodeN on several.
if [ "$NODES" = 1 ]; then name='$(hostname -s)'; else name="node$((i + 1))"; fi
ssh_node "${HOSTS[$i]}" "sed \"s/@NODE@/$name/\" | sudo tee /etc/spinifex/spinifex.toml >/dev/null" <<'TOML' \
|| die "could not seed the ocibsd credential on ${HOSTS[$i]}"
[nodes.@NODE@.ebs]
provider = "ocibsd"
[nodes.@NODE@.ebs.oci]
auth = "config_file"
config_file = "/etc/spinifex/oci/config"
config_profile = "spinifex"
TOML
done
PRESEEDED_EBS=1
record "ocibsd credential" PASS "API key installed and seeded before init"
fi
# A single node is its own documented path: install-node.sh refuses fewer than two
# hosts, because there is nothing to join. Both branches use the flags the guide
# names, so what is validated is what the guide tells a customer to type.
@@ -776,19 +870,29 @@ if formation_complete; then
record formation SKIP "checkpoint verified"
elif [ "$NODES" = 1 ]; then
log "initializing the single node"
ssh_node "${HOSTS[0]}" '
set -e
sudo spx admin init --node "$(hostname -s)" --nodes 1 \
--region ap-southeast-2 --az ap-southeast-2a \
--external-mode=nat --ipsec=false
sudo systemctl start spinifex.target
' > "$STATE_DIR/form.log" 2>&1 \
ssh_node "${HOSTS[0]}" "INIT_EBS_PROVIDER=$INIT_EBS_PROVIDER PRESEEDED_EBS=$PRESEEDED_EBS bash -s" \
> "$STATE_DIR/form.log" 2>&1 <<'REMOTE' \
|| die "single-node init failed; see $STATE_DIR/form.log"
set -e
ebs_env=()
[ -z "$INIT_EBS_PROVIDER" ] || ebs_env=("SPINIFEX_EBS_PROVIDER=$INIT_EBS_PROVIDER")
# The seeded config is the only one there, so replacing it needs --force.
force=()
[ "$PRESEEDED_EBS" = 0 ] || force=(--force)
sudo env -u SPINIFEX_EBS_PROVIDER ${ebs_env[@]+"${ebs_env[@]}"} spx admin init ${force[@]+"${force[@]}"} \
--node "$(hostname -s)" --nodes 1 \
--region ap-southeast-2 --az ap-southeast-2a \
--external-mode=nat --ipsec=false
sudo systemctl start spinifex.target
REMOTE
mark_stage formation
record formation PASS
else
log "forming the cluster"
"$REPO_ROOT/scripts/install-node.sh" \
# env -u first, so a SPINIFEX_EBS_PROVIDER in this shell cannot stand in for
# detection on the ocibsd topologies.
env -u SPINIFEX_EBS_PROVIDER ${INIT_EBS_PROVIDER:+SPINIFEX_EBS_PROVIDER=$INIT_EBS_PROVIDER} \
"$REPO_ROOT/scripts/install-node.sh" \
--hosts-file "$STATE_DIR/hosts" \
--user ubuntu \
--identity "$SSH_PRIVATE_KEY" \
@@ -800,6 +904,53 @@ else
record formation PASS
fi
# What init printed about its EBS choice, from the log formation left: init's own
# output on one node, the init log install-node.sh keeps on the first host on several.
init_ebs_line() {
if [ "$NODES" = 1 ]; then
grep -m1 '^EBS provider: ' "$STATE_DIR/form.log"
else
ssh_node "${HOSTS[0]}" "sudo grep -m1 '^EBS provider: ' /var/log/spx-init.log"
fi
}
# The provider has to be the topology's for the reason the topology names: the
# ocibsd pair detected on the VM, the rest told viperblockd by the environment,
# so a baseline flipped by detection or an ocibsd run that was configured fails.
case "$TOPO_EBS_PROVIDER:$API_KEY_EBS" in
ocibsd:0) want_ebs='EBS provider: ocibsd (detected: OCI virtual machine ' ;;
ocibsd:1) want_ebs='EBS provider: ocibsd (from the existing config)' ;;
*) want_ebs='EBS provider: viperblockd (from SPINIFEX_EBS_PROVIDER)' ;;
esac
ebs_line="$(init_ebs_line)" || die "init printed no EBS provider line; see $STATE_DIR/form.log"
case "$ebs_line" in
"$want_ebs"*) record "ebs provider choice" PASS "${ebs_line#EBS provider: }" ;;
*) die "init chose '$ebs_line', and $TOPOLOGY needs '$want_ebs'" ;;
esac
# ocibsd tags every volume and backup with the cluster_id spx admin init generated,
# and no Terraform state holds them, so e2e-oci.sh sweeps by that tag after the
# destroy. Recorded before any volume exists, so a killed run is still sweepable.
OCIBSD_CLUSTERS="$STATE_DIR/ocibsd-clusters"
OCIBSD_CLUSTER_ID=""
if [ "$TOPO_EBS_PROVIDER" = ocibsd ]; then
OCIBSD_CLUSTER_ID="$(ssh_node "${HOSTS[0]}" 'sudo python3 -' <<'PY'
import sys, tomllib
nodes = tomllib.load(open("/etc/spinifex/spinifex.toml", "rb")).get("nodes", {})
# A leader's config also lists the other nodes, without their EBS tables.
ids = {n["ebs"]["oci"].get("cluster_id", "") for n in nodes.values() if "oci" in n.get("ebs", {})}
if len(ids) != 1 or "" in ids:
sys.exit("expected one cluster_id across [nodes.*.ebs.oci], found %r" % sorted(ids))
print(ids.pop())
PY
)" || die "could not read the ocibsd cluster_id from ${HOSTS[0]}"
[[ "$OCIBSD_CLUSTER_ID" =~ ^[0-9a-f]{32}$ ]] \
|| die "${HOSTS[0]} reports cluster_id '$OCIBSD_CLUSTER_ID', which is not the 128-bit hex value init writes"
# Appended: a record a failed sweep left behind must survive this run's.
line="$(printf '%s\t%s\t%s' "$OCIBSD_CLUSTER_ID" "$OCIBSD_COMPARTMENT" "$OCIBSD_REGION")"
grep -qxF "$line" "$OCIBSD_CLUSTERS" 2>/dev/null || printf '%s\n' "$line" >> "$OCIBSD_CLUSTERS"
log "ocibsd cluster_id $OCIBSD_CLUSTER_ID, recorded for the sweep"
fi
# Runs after formation and before the pool is configured, which is the only window
# where a node has /etc/spinifex but has not yet started the allocator. Nothing in
# this repository knows what it does: an API-key deployment needs a credential on
@@ -809,7 +960,7 @@ if pool_complete; then
log "OCI pool configuration already completed (resuming; pool setup skipped)"
record pool SKIP "checkpoint verified"
else
if [ "$SKIP_POOL" != 1 ] && [ "$PRINCIPAL_MODE" = off ]; then
if [ "$SKIP_POOL" != 1 ] && [ "$PRINCIPAL_MODE" = off ] && [ "$PRESEEDED_EBS" = 0 ]; then
log "running the credential hook"
# Arguments, not a file: the hook is told where the nodes are and how to reach
# them, and decides for itself what to put there.
@@ -936,9 +1087,9 @@ fi
# Parse the STATUS column, never grep for Ready: NotReady contains it, and that
# read passed a single-node run whose only node was NotReady on 0.0.0.0. The table
# is colourised, so the escapes come off first.
# is colourised and drawn with box characters, so both come off first.
count_ready() {
sed 's/\x1b\[[0-9;]*m//g' "$STATE_DIR/nodes.txt" \
sed -e 's/\x1b\[[0-9;]*m//g' -e 's/│/|/g' "$STATE_DIR/nodes.txt" \
| awk -F'|' 'NR > 1 { gsub(/^[ \t]+|[ \t]+$/, "", $2); if ($2 == "Ready") n++ } END { print n + 0 }'
}
@@ -962,10 +1113,382 @@ fi
if [ "$SKIP_WORKLOAD" = 1 ]; then
log "--skip-workload: stopping before the workbook"
if [ "$TOPO_EBS_PROVIDER" = ocibsd ]; then
record "ebs provider" SKIPPED "--skip-workload"
fi
record workbooks SKIPPED "--skip-workload"
exit 0
fi
# The ebs provider gate, ocibsd only. The workbooks reach EBS only through RDS,
# ECS and EKS, so this boots from an imported AMI, stops, attaches, snapshots,
# restores and terminates, checking each against OCI's own view of the volumes.
GATE_IMAGE=""
GATE_LOG="$STATE_DIR/ebs-gate.log"
# The AWS CLI on node 1, as the workbook driver runs it. Each argument is quoted
# for the remote shell, so a JMESPath expression arrives as one word.
node_aws() {
ssh_node "${HOSTS[0]}" "AWS_PROFILE=spinifex aws --output text $(printf '%q ' "$@")"
}
# OCI's live volumes or backups carrying every key=value given, as JSON, read from
# this runner with the credential the apply used; nothing on a node is trusted.
oci_tagged() {
local kind="$1" tag tags=()
shift
for tag in "$@"; do tags+=(--tag "$tag"); done
OCIBSD_COMPARTMENT="$OCIBSD_COMPARTMENT" python3 "$HERE/scripts/oci_env.py" \
${OCI_PROFILE:+--profile "$OCI_PROFILE"} --region "$OCIBSD_REGION" -- \
python3 "$HERE/scripts/oci_volumes.py" "$kind" "${tags[@]}" 2>>"$GATE_LOG"
}
oci_count() {
oci_tagged "$@" | python3 -c 'import json, sys; print(len(json.load(sys.stdin)))'
}
# get-console-output is base64 on the wire and the CLI may or may not decode it.
console_text() {
node_aws ec2 get-console-output --instance-id "$1" --query Output 2>/dev/null \
| python3 -c '
import base64, binascii, sys
raw = sys.stdin.read().strip()
try:
sys.stdout.write(base64.b64decode(raw, validate=True).decode("utf-8", "replace"))
except (binascii.Error, ValueError):
sys.stdout.write(raw)
' | tr -d '\r'
}
# wait_console <instance> <ERE> <seconds> prints the last console line matching.
wait_console() {
local id="$1" pattern="$2" deadline=$((SECONDS + $3)) line
while [ "$SECONDS" -lt "$deadline" ]; do
line="$(console_text "$id" | grep -E "$pattern" | tail -1 || true)"
if [ -n "$line" ]; then
printf '%s\n' "$line"
return 0
fi
sleep 10
done
return 1
}
# wait_new_boot <instance> <token> <boots seen> <seconds> prints the boot ID of
# the first boot not yet seen that found the root marker, proving a restart kept it.
wait_new_boot() {
local id="$1" tok="$2" seen="$3" deadline=$((SECONDS + $4)) boot
while [ "$SECONDS" -lt "$deadline" ]; do
for boot in $(console_text "$id" \
| sed -n "s/.*SPXGATE boot=\([0-9a-f-]*\) root-present serial=[^ ]* $tok\$/\1/p"); do
case " $seen " in
*" $boot "*) ;;
*) printf '%s\n' "$boot"; return 0 ;;
esac
done
sleep 10
done
return 1
}
# The node whose QEMU runs the instance, by process, as the multinode suite finds it.
hosting_node() {
local host found=""
for host in "${HOSTS[@]}"; do
if ssh_node "$host" "ps -eo args | grep -F -- '$1' | grep -q '[q]emu-system'"; then
[ -z "$found" ] || die "$1 runs on both $found and $host"
found="$host"
fi
done
[ -n "$found" ] || die "no node runs a QEMU for $1"
printf '%s\n' "$found"
}
wait_gone_from_oci() {
local deadline=$((SECONDS + 600)) left vol
while :; do
left=0
for vol in "$@"; do
case "$vol" in
snap-*) left=$((left + $(oci_count backups "spinifex-snapshot-id=$vol"))) ;;
*) left=$((left + $(oci_count volumes "spinifex-volume-id=$vol"))) ;;
esac
done
[ "$left" = 0 ] && return 0
[ "$SECONDS" -lt "$deadline" ] || return 1
sleep 15
done
}
# Reports on the serial console, which get-console-output reads: the root marker
# at each boot, then each Spinifex disk (serial vol...) as it appears, writing a
# marker to a blank one and reading one back from a restored one.
write_gate_user_data() {
cat > "$1" <<'USERDATA'
#cloud-config
write_files:
- path: /usr/local/sbin/spx-ebs-gate
permissions: "0755"
content: |
#!/bin/sh
BOOT=$(cat /proc/sys/kernel/random/boot_id)
say() { echo "SPXGATE boot=$BOOT $*" > /dev/ttyS0; }
serial() { cat "/sys/block/$1/serial" 2>/dev/null || cat "/sys/block/$1/device/serial" 2>/dev/null || true; }
src=$(findmnt -no SOURCE /)
rootdisk=$(lsblk -no PKNAME "$src" | head -1)
[ -n "$rootdisk" ] || rootdisk=${src##*/}
mark=/var/lib/spx-ebs-gate/root
if [ -s "$mark" ]; then
say "root-present serial=$(serial "$rootdisk") $(cat "$mark")"
else
mkdir -p /var/lib/spx-ebs-gate
cat /proc/sys/kernel/random/uuid > "$mark.tmp" && sync && mv "$mark.tmp" "$mark" && sync
say "root-written serial=$(serial "$rootdisk") $(cat "$mark")"
fi
seen=" "
while :; do
next=" "
for sys in /sys/block/*; do
d=${sys##*/}
[ "$d" != "$rootdisk" ] || continue
s=$(serial "$d")
case "$s" in vol*) ;; *) continue ;; esac
case "$seen" in *" $d "*) next="$next$d "; continue ;; esac
head=$(dd if="/dev/$d" bs=4096 count=1 iflag=direct 2>/dev/null | head -c 64 | tr -d '\000')
case "$head" in
"SPXGATE-DATA "*)
say "data-present serial=$s ${head#SPXGATE-DATA }"; next="$next$d " ;;
"")
if blkid -p "/dev/$d" >/dev/null 2>&1; then
say "data-foreign serial=$s"; next="$next$d "
else
tok=$(cat /proc/sys/kernel/random/uuid)
if printf 'SPXGATE-DATA %s\n' "$tok" \
| dd of="/dev/$d" bs=4096 count=1 conv=sync oflag=direct,sync 2>/dev/null; then
say "data-written serial=$s $tok"; next="$next$d "
fi
fi ;;
*) say "data-foreign serial=$s"; next="$next$d " ;;
esac
done
seen=$next
sleep 3
done
- path: /etc/systemd/system/spx-ebs-gate.service
content: |
[Unit]
Description=Spinifex EBS provider gate reporter
After=local-fs.target
[Service]
ExecStart=/usr/local/sbin/spx-ebs-gate
Restart=always
[Install]
WantedBy=multi-user.target
runcmd:
- [systemctl, daemon-reload]
- [systemctl, enable, --now, spx-ebs-gate.service]
USERDATA
}
# What the gate leaves for a failure it cannot explain from its own output: each
# node's iSCSI sessions and records, block devices and their holders, and the
# provider's and kernel's recent log. Best effort, so a failed read never masks
# the gate's own error.
gate_diagnose() {
local host
for host in "${HOSTS[@]}"; do
{
printf '\n=== diagnostics from %s ===\n' "$host"
ssh_node "$host" 'sudo bash -s' <<'DIAG' 2>&1
for c in "iscsiadm -m session -P 3" "iscsiadm -m node" "lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,HCTL" "ls -l /dev/disk/by-path"; do
printf -- '--- %s\n' "$c"; $c 2>&1
done
printf -- '--- holders of iSCSI devices\n'
for d in /dev/disk/by-path/*iqn.2015-12.com.oracleiaas:*; do
[ -e "$d" ] || continue
dev=$(readlink -f "$d"); printf '%s -> %s\n' "$d" "$dev"
ls /sys/class/block/"${dev##*/}"/holders 2>&1; fuser -v "$dev" 2>&1
done
printf -- '--- journalctl -u spinifex-ocibsd\n'; journalctl -u spinifex-ocibsd -n 300 --no-pager 2>&1
printf -- '--- journalctl -k\n'; journalctl -k -n 150 --no-pager 2>&1
DIAG
} >> "$GATE_LOG" || true
done
}
gate_die() {
gate_diagnose
die "$@"
}
ebs_provider_gate() {
local host out ami snap_ami itype subnet iid az root_vol dot tok boot boots line
local data_vol data_serial data_tok snap restored from to
: > "$GATE_LOG"
log "ebs provider gate: units, masks, drop-ins and the detected placement on every node"
for host in "${HOSTS[@]}"; do
# The placement init read from the instance metadata must be where this run
# built the nodes; compared on the node, so the compartment is never printed.
ssh_node "$host" "WANT_COMPARTMENT='$OCIBSD_COMPARTMENT' WANT_AD='$OCIBSD_AD' bash -s" >> "$GATE_LOG" 2>&1 <<'REMOTE' \
|| gate_die "$host is not an ocibsd node as installed; see $GATE_LOG"
fail() { echo "FAIL $(hostname -s): $*"; exit 1; }
[ "$(systemctl is-enabled spinifex-viperblock.service 2>&1)" = masked ] \
|| fail "spinifex-viperblock.service is not masked"
if systemctl is-active --quiet spinifex-viperblock.service; then fail "spinifex-viperblock.service is running"; fi
systemctl is-active --quiet spinifex-ocibsd.service || fail "spinifex-ocibsd.service is not active"
[ "$(systemctl is-enabled spinifex-ocibsd.service)" = enabled ] || fail "spinifex-ocibsd.service is not enabled"
systemctl show -p DropInPaths --value spinifex-daemon.service \
| grep -q /etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf \
|| fail "the daemon's ocibsd device drop-in is not loaded"
systemctl show -p DeviceAllow --value spinifex-daemon.service | grep -q 'block-sd rw' \
|| fail "the daemon's device cgroup does not allow block-sd"
test -f /etc/udev/rules.d/99-spinifex-ocibsd.rules || fail "no ocibsd udev rule"
sudo WANT_COMPARTMENT="$WANT_COMPARTMENT" WANT_AD="$WANT_AD" python3 - <<"PY" \
|| fail "a node in spinifex.toml is not on ocibsd where the nodes were built"
import os, sys, tomllib
nodes = tomllib.load(open("/etc/spinifex/spinifex.toml", "rb")).get("nodes", {})
# Only tables with an ebs section: a leader also lists the other nodes, bare.
# Each node's own table always has one, and this runs on every node.
nodes = {n: v for n, v in nodes.items() if "ebs" in v}
other = {n: v["ebs"].get("provider") for n, v in nodes.items() if v["ebs"].get("provider") != "ocibsd"}
if not nodes or other:
sys.exit("providers other than ocibsd: %r" % other)
for n, v in sorted(nodes.items()):
oci = v["ebs"].get("oci", {})
if oci.get("compartment_id") != os.environ["WANT_COMPARTMENT"]:
sys.exit("%s: compartment_id is not the compartment the nodes were built in" % n)
if oci.get("availability_domain") != os.environ["WANT_AD"]:
sys.exit("%s: availability_domain %r, and the nodes were built in %r" % (n, oci.get("availability_domain"), os.environ["WANT_AD"]))
print("ebs providers:", ", ".join("%s=ocibsd" % n for n in sorted(nodes)))
PY
echo "ok $(hostname -s)"
REMOTE
done
record "ebs provider units" PASS "ocibsd active, viperblock masked, device drop-in, placement from metadata on $NODES node(s)"
GATE_IMAGE=ubuntu-26.04-x86_64
log "ebs provider gate: importing $GATE_IMAGE onto an OCI volume"
out="$(ssh_node "${HOSTS[0]}" "sudo spx admin images import --name $GATE_IMAGE --config /etc/spinifex/spinifex.toml" 2>&1)" \
|| { printf '%s\n' "$out" >> "$GATE_LOG"; gate_die "importing $GATE_IMAGE failed; see $GATE_LOG"; }
printf '%s\n' "$out" >> "$GATE_LOG"
ami="$(printf '%s\n' "$out" | sed -n 's/.*Image-ID (AMI): \(ami-[0-9a-f]*\).*/\1/p' | tail -1)"
[ -n "$ami" ] || gate_die "the import printed no AMI ID; see $GATE_LOG"
snap_ami="$(node_aws ec2 describe-images --image-ids "$ami" --query 'Images[0].BlockDeviceMappings[0].Ebs.SnapshotId')"
[ "$(oci_count backups "spinifex-snapshot-id=$snap_ami" "spinifex-cluster=$OCIBSD_CLUSTER_ID")" = 1 ] \
|| gate_die "$ami's snapshot $snap_ami is not one OCI backup tagged with this cluster"
record "ami import" PASS "$ami, snapshot $snap_ami is an OCI backup"
itype="$(node_aws ec2 describe-instance-types \
--query 'sort_by(InstanceTypes[?VCpuInfo.DefaultVCpus>=`2` && MemoryInfo.SizeInMiB>=`2048`], &MemoryInfo.SizeInMiB)[0].InstanceType')"
{ [ -n "$itype" ] && [ "$itype" != None ]; } || gate_die "no instance type with 2 vCPU and 2 GiB on this cluster"
subnet="$(node_aws ec2 describe-subnets --filters Name=default-for-az,Values=true \
--query 'Subnets[0].SubnetId' 2>>"$GATE_LOG")" || subnet=""
[ "$subnet" != None ] || subnet=""
write_gate_user_data "$STATE_DIR/ebs-gate-user-data.yaml"
scp -i "$SSH_PRIVATE_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -q "$STATE_DIR/ebs-gate-user-data.yaml" "ubuntu@${HOSTS[0]}:/tmp/ebs-gate-user-data.yaml" \
|| gate_die "could not copy the gate's user-data"
log "ebs provider gate: booting $itype from $ami"
iid="$(node_aws ec2 run-instances --image-id "$ami" --instance-type "$itype" --count 1 \
${subnet:+--subnet-id "$subnet"} --user-data file:///tmp/ebs-gate-user-data.yaml \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=ebs-provider-gate}]' \
--query 'Instances[0].InstanceId')" || gate_die "run-instances from $ami failed"
node_aws ec2 wait instance-running --instance-ids "$iid" || gate_die "$iid never reached running"
read -r az root_vol dot <<EOF
$(node_aws ec2 describe-instances --instance-ids "$iid" \
--query 'Reservations[0].Instances[0].[Placement.AvailabilityZone,BlockDeviceMappings[0].Ebs.VolumeId,BlockDeviceMappings[0].Ebs.DeleteOnTermination]')
EOF
[ "$dot" = True ] || gate_die "$iid's root volume $root_vol is not delete-on-termination"
line="$(wait_console "$iid" 'SPXGATE boot=[0-9a-f-]+ root-written ' 900)" \
|| gate_die "$iid never reported its root marker on the console"
boot="$(printf '%s\n' "$line" | sed -n 's/.*boot=\([0-9a-f-]*\) .*/\1/p')"
tok="${line##* }"
boots="$boot"
[ "$(oci_count volumes "spinifex-volume-id=$root_vol" "spinifex-cluster=$OCIBSD_CLUSTER_ID")" = 1 ] \
|| gate_die "$iid's root volume $root_vol is not one OCI volume tagged with this cluster"
record "boot from ocibsd" PASS "$iid on $root_vol, an OCI volume tagged with this cluster"
log "ebs provider gate: stop and start $iid"
node_aws ec2 stop-instances --instance-ids "$iid" >/dev/null || gate_die "stop-instances $iid failed"
node_aws ec2 wait instance-stopped --instance-ids "$iid" || gate_die "$iid never stopped"
node_aws ec2 start-instances --instance-ids "$iid" >/dev/null || gate_die "start-instances $iid failed"
node_aws ec2 wait instance-running --instance-ids "$iid" || gate_die "$iid never ran again"
boot="$(wait_new_boot "$iid" "$tok" "$boots" 900)" \
|| gate_die "$iid did not find its root marker after a stop and start"
boots="$boots $boot"
record "stop and start" PASS "the root marker survived"
log "ebs provider gate: attach and detach a data volume"
data_vol="$(node_aws ec2 create-volume --availability-zone "$az" --size 1 --volume-type gp3 \
--query VolumeId)" || gate_die "create-volume failed"
node_aws ec2 wait volume-available --volume-ids "$data_vol" || gate_die "$data_vol never became available"
node_aws ec2 attach-volume --volume-id "$data_vol" --instance-id "$iid" --device /dev/sdf >/dev/null \
|| gate_die "attach-volume $data_vol failed"
node_aws ec2 wait volume-in-use --volume-ids "$data_vol" || gate_die "$data_vol never attached"
data_serial="${data_vol//-/}"
line="$(wait_console "$iid" "SPXGATE boot=[0-9a-f-]+ data-written serial=$data_serial " 600)" \
|| gate_die "the guest never wrote to $data_vol"
data_tok="${line##* }"
[ "$(oci_count volumes "spinifex-volume-id=$data_vol" "spinifex-cluster=$OCIBSD_CLUSTER_ID")" = 1 ] \
|| gate_die "$data_vol is not one OCI volume tagged with this cluster"
node_aws ec2 detach-volume --volume-id "$data_vol" >/dev/null || gate_die "detach-volume $data_vol failed"
node_aws ec2 wait volume-available --volume-ids "$data_vol" || gate_die "$data_vol never detached"
record "attach and detach" PASS "$data_vol written in the guest"
log "ebs provider gate: snapshot, restore and read back"
snap="$(node_aws ec2 create-snapshot --volume-id "$data_vol" --query SnapshotId)" \
|| gate_die "create-snapshot $data_vol failed"
node_aws ec2 wait snapshot-completed --snapshot-ids "$snap" || gate_die "$snap never completed"
[ "$(oci_count backups "spinifex-snapshot-id=$snap" "spinifex-cluster=$OCIBSD_CLUSTER_ID")" = 1 ] \
|| gate_die "$snap is not one OCI backup tagged with this cluster"
restored="$(node_aws ec2 create-volume --availability-zone "$az" --snapshot-id "$snap" \
--volume-type gp3 --query VolumeId)" || gate_die "create-volume from $snap failed"
node_aws ec2 wait volume-available --volume-ids "$restored" || gate_die "$restored never became available"
node_aws ec2 attach-volume --volume-id "$restored" --instance-id "$iid" --device /dev/sdg >/dev/null \
|| gate_die "attach-volume $restored failed"
node_aws ec2 wait volume-in-use --volume-ids "$restored" || gate_die "$restored never attached"
wait_console "$iid" "SPXGATE boot=[0-9a-f-]+ data-present serial=${restored//-/} $data_tok\$" 600 >/dev/null \
|| gate_die "the guest did not read $data_vol's marker back from $restored"
node_aws ec2 detach-volume --volume-id "$restored" >/dev/null || gate_die "detach-volume $restored failed"
node_aws ec2 wait volume-available --volume-ids "$restored" || gate_die "$restored never detached"
record "snapshot and restore" PASS "$snap restored to $restored with the marker intact"
# A stopped instance restarts on its last node unless that node's daemon does
# not answer, so the daemon is stopped there for the start. The guest is down
# first, so no write is in flight; this is not a fence test.
if [ "$NODES" -gt 1 ]; then
from="$(hosting_node "$iid")"
log "ebs provider gate: moving $iid off $from"
node_aws ec2 stop-instances --instance-ids "$iid" >/dev/null || gate_die "stop-instances $iid failed"
node_aws ec2 wait instance-stopped --instance-ids "$iid" || gate_die "$iid never stopped"
ssh_node "$from" 'sudo systemctl stop spinifex-daemon.service' || gate_die "could not stop the daemon on $from"
node_aws ec2 start-instances --instance-ids "$iid" >/dev/null || gate_die "start-instances $iid failed"
node_aws ec2 wait instance-running --instance-ids "$iid" || gate_die "$iid never ran on another node"
ssh_node "$from" 'sudo systemctl start spinifex-daemon.service' || gate_die "could not restart the daemon on $from"
to="$(hosting_node "$iid")"
[ "$to" != "$from" ] || gate_die "$iid started on $from again, so nothing moved"
boot="$(wait_new_boot "$iid" "$tok" "$boots" 900)" \
|| gate_die "$iid did not find its root marker on $to"
record "cross-node start" PASS "$iid moved from $from to $to with its root marker"
fi
log "ebs provider gate: terminate and delete"
node_aws ec2 terminate-instances --instance-ids "$iid" >/dev/null || gate_die "terminate-instances $iid failed"
node_aws ec2 wait instance-terminated --instance-ids "$iid" || gate_die "$iid never terminated"
node_aws ec2 delete-volume --volume-id "$data_vol" || gate_die "delete-volume $data_vol failed"
node_aws ec2 delete-volume --volume-id "$restored" || gate_die "delete-volume $restored failed"
node_aws ec2 delete-snapshot --snapshot-id "$snap" || gate_die "delete-snapshot $snap failed"
wait_gone_from_oci "$root_vol" "$data_vol" "$restored" "$snap" \
|| gate_die "OCI still holds a live volume or backup for $root_vol, $data_vol, $restored or $snap after 600s"
record "terminate" PASS "no OCI volume or backup left for the instance, its volumes or the snapshot"
}
if [ "$TOPO_EBS_PROVIDER" = ocibsd ]; then
ebs_provider_gate
fi
# The same driver the GitHub nightly runs (cell 17), on the same published
# workbooks, so a workbook that passes on a hypervisor and fails on OCI is a
# difference in OCI and not in the test. It owns its own per-workbook assertions
@@ -1002,12 +1525,18 @@ scp -i "$SSH_PRIVATE_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev
# to absorb parallel uploads. Three are appliances rather than distros: RDS, ECS
# and EKS each boot their own, found by a spinifex:managed-by tag and never by name.
log "importing the images the workbooks need"
ssh_node "${HOSTS[0]}" '
set -e
for img in ubuntu-26.04-x86_64 spinifex-rds-postgres spinifex-ecs-node spinifex-eks-node; do
sudo spx admin images import --name "$img" --config /etc/spinifex/spinifex.toml >/dev/null
done
' > "$STATE_DIR/images.log" 2>&1 || die "image import failed; see $STATE_DIR/images.log"
# The ebs provider gate has already imported its own, and is not asked twice.
WORKBOOK_IMAGES=""
for img in ubuntu-26.04-x86_64 spinifex-rds-postgres spinifex-ecs-node spinifex-eks-node; do
[ "$img" = "$GATE_IMAGE" ] || WORKBOOK_IMAGES="$WORKBOOK_IMAGES $img"
done
ssh_node "${HOSTS[0]}" "WORKBOOK_IMAGES='$WORKBOOK_IMAGES' bash -s" > "$STATE_DIR/images.log" 2>&1 <<'REMOTE' \
|| die "image import failed; see $STATE_DIR/images.log"
set -e
for img in $WORKBOOK_IMAGES; do
sudo spx admin images import --name "$img" --config /etc/spinifex/spinifex.toml >/dev/null
done
REMOTE
# Every workbook that is a workbook. The shared driver's own default is five, which
# leaves ECS and all three EKS variants untested on every platform -- their
+22
View File
@@ -68,6 +68,28 @@ variable "instance_principal" {
}
}
# What backs EBS on these nodes. ocibsd attaches an OCI block volume per EBS
# volume at runtime, so the Oracle Cloud Agent's Block Volume Management plugin
# is disabled and the node's own data volume attaches paravirtualized instead.
variable "ebs_provider" {
description = "EBS provider the nodes are installed with: viperblockd, or ocibsd for OCI Block Volumes."
type = string
default = "viperblockd"
validation {
condition = contains(["viperblockd", "ocibsd"], var.ebs_provider)
error_message = "ebs_provider must be viperblockd or ocibsd."
}
}
# Named only in the api_key_policy_statements output, which a tenancy admin
# applies to the group holding the API user that spx-oci-config.sh installs.
variable "api_key_group_name" {
description = "IAM group of the API-key user the nodes authenticate as when instance_principal is off."
type = string
default = "spinifex-nodes"
}
variable "vcn_cidr" {
description = "VCN address space. A /22 leaves room for the per-node secondary private IPs Spinifex allocates for external addresses."
type = string
+2 -1
View File
@@ -349,7 +349,8 @@ rm_path /etc/systemd/system/spinifex-*.service \
/etc/systemd/system/spinifex.slice \
/etc/systemd/system/spinifex-system.slice \
/etc/systemd/system/spinifex-guests.slice \
/etc/systemd/system/system.slice.d/spinifex-reserve.conf
/etc/systemd/system/system.slice.d/spinifex-reserve.conf \
/etc/systemd/system/spinifex-daemon.service.d/ocibsd.conf
# Drop-ins we wrote into *other people's* units. These are the dangerous ones:
# they are not in a spinifex-named path, so a namespace glob misses them, and
+1
View File
@@ -42,6 +42,7 @@ ASSUME Capabilities \in [online_expansion : BOOLEAN,
read_only_publish : BOOLEAN,
volume_enumeration : BOOLEAN,
snapshot_enumeration : BOOLEAN,
snapshot_copy : BOOLEAN,
owner_routing : BOOLEAN,
exclusion : [scope : {"none", "node", "cluster"},
claim_ttl_seconds : Nat,
+5 -6
View File
@@ -17,6 +17,8 @@ The contract that every implementation of the `EBSProvider` interface must meet.
A failed call leaves the state unchanged, but a timeout can arrive after the provider acted, and a crash can leave work half done.
- Is the schema version part of the contract?
Every method refuses an unsupported version with `unsupported_version`, and the conformance suite tests it, but the specification does not model versions.
A block export arrives at contract version 2: a PublishVolume call below it, for a volume that only a host block device can export, is refused with `unsupported_version`.
A snapshot's `state` in a ListSnapshots ref, and the `snapshot_id` filter, arrive at contract version 4: a ref carries no state below it, and a filtered call below it is refused with `unsupported_version` rather than answered with every snapshot.
- Are `internal` and `unavailable` the only errors that a failed provider may cause?
- Must a present but malformed ID be refused with `invalid_argument`?
IDs are opaque here, so only an omitted one is refused.
@@ -100,13 +102,10 @@ The contract that every implementation of the `EBSProvider` interface must meet.
### Snapshots
- Should a pending snapshot's move to `completed` or `error` be modelled, as a step that happens without a call?
Without it a pending snapshot never completes, and no snapshot is ever in `error`.
#### CreateSnapshot
- May success return a `pending` snapshot?
AWS starts a snapshot as `pending`, while the memory implementation returns `completed` and the NATS implementation waits for completion and refuses anything else.
- May a provider forget a snapshot's source?
The control plane mints every snapshot ID and records it before the first call, so the ID alone names the request. A provider that no longer knows a snapshot's source, such as qemu-nbd after a restart, answers a retry with the snapshot, and refuses with `already_exists` only when it knows the source differs. The specification models a provider that always knows the source.
- Must a retry compare the volume handle?
It is ignored, as in the memory implementation, so a volume deleted and recreated under the same ID returns the old snapshot.
- Should a copy's ID count as a retry?
@@ -133,7 +132,7 @@ The contract that every implementation of the `EBSProvider` interface must meet.
- Is the handle check part of the contract?
The Viperblock implementation ignores the handle and deletes anyway.
- May a pending snapshot be deleted?
AWS allows it.
AWS allows it, while OCI refuses to delete a backup that is still being created, so the control plane never asks: it defers a customer's delete of a pending snapshot until the snapshot settles.
- What does success promise?
The Go docs say the snapshot is gone, but a snapshot whose handle did not match is left in place.
- Should a snapshot that does not exist be refused with `not_found`?
+51 -17
View File
@@ -13,8 +13,9 @@ CreateSnapshotRequests == [snapshot_id : Optional(SnapshotIDs),
CreateSnapshot(req, val, err) ==
(*******************************************************************************************************************)
(* A volume's data is frozen as a snapshot, from which new volumes can be restored. A retry of a call that *)
(* already succeeded returns the same snapshot. *)
(* A volume's data is frozen as a snapshot, from which new volumes can be restored. The snapshot holds the *)
(* volume's data as it was when the call returned, even while it is pending, and a pending snapshot settles later *)
(* without a call (SettleSnapshot). A retry of a call that already succeeded returns the same snapshot. *)
(*******************************************************************************************************************)
LET snapID == req.snapshot_id
volID == req.volume_id
@@ -69,13 +70,14 @@ CopySnapshot(req, val, err) ==
volID == req.volume_id
Errors ==
LET checks == [invalid_argument |-> \/ None \in {srcSnapID, dstSnapID, volID}
\/ srcSnapID = dstSnapID
\* Only compared, not looked up, since a snapshot outlives its volume.
\/ /\ KeyExists(snapshots, srcSnapID)
/\ snapshots[srcSnapID].source_volume_id /= volID,
already_exists |-> KeyExists(snapshots, dstSnapID),
not_found |-> srcSnapID \notin Optional(DOMAIN snapshots)]
LET checks == [invalid_argument |-> \/ None \in {srcSnapID, dstSnapID, volID}
\/ srcSnapID = dstSnapID
\* Only compared, not looked up, since a snapshot outlives its volume.
\/ /\ KeyExists(snapshots, srcSnapID)
/\ snapshots[srcSnapID].source_volume_id /= volID,
already_exists |-> KeyExists(snapshots, dstSnapID),
not_found |-> srcSnapID \notin Optional(DOMAIN snapshots),
unsupported_capability |-> ~Capabilities.snapshot_copy]
IN {e \in DOMAIN checks : checks[e]}
\* The request was rejected, or the provider failed to answer.
@@ -97,27 +99,45 @@ CopySnapshot(req, val, err) ==
ListSnapshotsRequests == [max_results : -1..(MaxListResults + 1),
starting_token : Optional(ListTokens)]
starting_token : Optional(ListTokens),
snapshot_id : Optional(SnapshotIDs)]
\* The ref that ListSnapshots returns for a snapshot, carrying its current state.
SnapshotRef(id) == [id |-> id,
source_volume_id |-> snapshots[id].source_volume_id,
handle |-> snapshots[id].handle,
state |-> snapshots[id].state]
ListSnapshots(req, val, err) ==
(*******************************************************************************************************************)
(* Some of the snapshots are listed, a page at a time, and nothing changes. A call with no starting token that *)
(* returns no next token lists every snapshot. *)
(* returns no next token lists every snapshot. A call that names a snapshot returns that snapshot in its current *)
(* state, or nothing when it does not exist, so an accepted snapshot is never reported absent while it is pending. *)
(*******************************************************************************************************************)
LET Errors ==
LET checks == [unsupported_capability |-> ~Capabilities.snapshot_enumeration]
IN {e \in DOMAIN checks : checks[e]}
LET snapID == req.snapshot_id
Errors ==
\* A call that names a snapshot reads only that one, so it needs no enumeration.
LET checks == [unsupported_capability |-> snapID = None /\ ~Capabilities.snapshot_enumeration]
IN {e \in DOMAIN checks : checks[e]}
\* The request was rejected, or the provider failed to answer.
IN \/ /\ val = None
/\ err \in Errors \union ProviderErrors
/\ UNCHANGED vars
\* The request named one snapshot, so paging is ignored.
\/ /\ Errors = {}
/\ snapID /= None
/\ val = [snapshots |-> IF KeyExists(snapshots, snapID) THEN {SnapshotRef(snapID)} ELSE {},
next_token |-> None]
/\ err = None
/\ UNCHANGED vars
\* The request was accepted.
\/ /\ Errors = {}
/\ LET refs == {[id |-> id,
source_volume_id |-> snapshots[id].source_volume_id,
handle |-> snapshots[id].handle] : id \in DOMAIN snapshots}
/\ snapID = None
/\ LET refs == {SnapshotRef(id) : id \in DOMAIN snapshots}
\* The page and the next token are the implementation's choice.
IN \E page \in SUBSET refs, next \in Optional(ListTokens) :
/\ Cardinality(page) <= PageSize(req.max_results)
@@ -156,4 +176,18 @@ DeleteSnapshot(req, err) ==
/\ err = None
/\ UNCHANGED <<volumes, publications>>
-----------------------------------------------------------------------------------------------------------------------
\* Steps without a call
SettleSnapshot(snapID) ==
(*******************************************************************************************************************)
(* A pending snapshot settles as completed or error, as the provider finishes it. Its data is unchanged, since it *)
(* was fixed when CreateSnapshot returned, and a snapshot that has settled never changes state again. *)
(*******************************************************************************************************************)
/\ KeyExists(snapshots, snapID)
/\ snapshots[snapID].state = "pending"
/\ \E s \in {"completed", "error"} :
snapshots' = [snapshots EXCEPT ![snapID].state = s]
/\ UNCHANGED <<volumes, publications>>
=======================================================================================================================
+22 -2
View File
@@ -21,6 +21,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
@@ -94,6 +95,18 @@ type Agent struct {
statsFn func(socketPath string) ([]ServerStatus, error)
// For testing: override the active health prober (nginx/NLB).
probeFn func(targets []HealthTarget) []ServerStatus
// Data-path probe: netcfg and sysfs locations, the ARP exchange, the
// instance ID lookup and per-NIC miss counters (overridable in tests).
netcfgPath string
sysNetDir string
probeWait time.Duration
arpProbeFn arpProbeFn
instanceIDFn func() (string, error)
instanceID string
idSkip int
probeMu sync.Mutex
probeStates map[string]*nicProbeState
}
// New creates a new LB agent for the given load balancer.
@@ -150,6 +163,12 @@ func New(lbID, gatewayURL, accessKey, secretKey, region string) (*Agent, error)
reloadNginxFn: reloadNginx,
statsFn: queryHAProxyStats,
probeFn: probeHealthTargets,
netcfgPath: netcfgPath,
sysNetDir: sysClassNet,
probeWait: probeWait,
arpProbeFn: arpProbe,
instanceIDFn: fetchInstanceID,
probeStates: map[string]*nicProbeState{},
}, nil
}
@@ -207,7 +226,7 @@ func (a *Agent) tick() {
}
}
resp, err := a.sendHeartbeat(servers)
resp, err := a.sendHeartbeat(servers, a.dataPathEvidence())
if err != nil {
// Include the dial target so the serial console disambiguates a
// transport failure (never reached AWSGW — "send request: dial ...")
@@ -288,7 +307,7 @@ func (a *Agent) enginePaths(engine string) (configPath, pidPath, certDir string,
}
// sendHeartbeat sends a heartbeat with health report to the gateway.
func (a *Agent) sendHeartbeat(servers []ServerStatus) (*heartbeatResponse, error) {
func (a *Agent) sendHeartbeat(servers []ServerStatus, ev dataPathEvidence) (*heartbeatResponse, error) {
params := url.Values{}
params.Set("Action", "LBAgentHeartbeat")
params.Set("Version", "2015-12-01")
@@ -300,6 +319,7 @@ func (a *Agent) sendHeartbeat(servers []ServerStatus) (*heartbeatResponse, error
params.Set("Servers.member."+idx+".Server", s.Server)
params.Set("Servers.member."+idx+".Status", s.Status)
}
ev.addTo(params)
body, err := a.signedPost(params)
if err != nil {
+62
View File
@@ -0,0 +1,62 @@
//go:build linux
package elbv2
import (
"errors"
"fmt"
"net"
"net/netip"
"syscall"
"time"
)
// arpProbe sends one ARP request for gateway out of iface on an AF_PACKET
// socket bound to it and waits for the gateway's reply on the same socket.
func arpProbe(iface string, mac net.HardwareAddr, src, gateway netip.Addr, wait time.Duration) error {
ifi, err := net.InterfaceByName(iface)
if err != nil {
return fmt.Errorf("interface %s: %w", iface, err)
}
proto := htons(syscall.ETH_P_ARP)
fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(proto))
if err != nil {
return fmt.Errorf("packet socket: %w", err)
}
defer syscall.Close(fd)
addr := &syscall.SockaddrLinklayer{Protocol: proto, Ifindex: ifi.Index}
if err := syscall.Bind(fd, addr); err != nil {
return fmt.Errorf("bind %s: %w", iface, err)
}
deadline := time.Now().Add(wait)
if err := syscall.Sendto(fd, buildARPRequest(mac, src, gateway), 0, addr); err != nil {
return fmt.Errorf("send ARP request: %w", err)
}
buf := make([]byte, 128)
for {
left := time.Until(deadline)
if left <= 0 {
return errARPNoReply
}
tv := syscall.NsecToTimeval(int64(left))
if err := syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &tv); err != nil {
return fmt.Errorf("set receive deadline: %w", err)
}
n, _, err := syscall.Recvfrom(fd, buf, 0)
if err != nil {
if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EINTR) {
continue
}
return fmt.Errorf("receive ARP reply: %w", err)
}
if isARPReplyFrom(buf[:n], mac, gateway) {
return nil
}
}
}
func htons(v uint16) uint16 { return v<<8 | v>>8 }
+15
View File
@@ -0,0 +1,15 @@
//go:build !linux
package elbv2
import (
"errors"
"net"
"net/netip"
"time"
)
// arpProbe is only meaningful inside the Linux guest.
func arpProbe(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
return errors.New("ARP probe is only supported on linux")
}
+303
View File
@@ -0,0 +1,303 @@
package elbv2
import (
"bufio"
"bytes"
"encoding/binary"
"errors"
"fmt"
"net"
"net/netip"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
// netcfgPath is the fw_cfg blob the microVM init script reads to configure NICs.
netcfgPath = "/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw"
sysClassNet = "/sys/class/net"
// probeFailureThreshold is the number of consecutive missed probes after
// which a NIC is reported as failing (about 15 s at the poll interval).
probeFailureThreshold = 3
// arpExchangeWait bounds one ARP exchange. probeWait bounds how long a tick
// waits for all probes, so a stuck probe never delays the heartbeat.
arpExchangeWait = time.Second
probeWait = 1500 * time.Millisecond
)
// nicConfig is one NIC of the netcfg blob that has a gateway to probe.
type nicConfig struct {
MAC string
Addr netip.Addr
Gateway netip.Addr
}
// NICReport is the per-NIC data-path evidence carried in the heartbeat.
type NICReport struct {
MAC string
ProbeOK bool
ConsecutiveFailures int64
RxPackets int64
TxPackets int64
RxDropped int64
TxDropped int64
}
// arpProbeFn sends one ARP request for gateway out of iface and returns nil
// when the gateway's reply arrives within timeout.
type arpProbeFn func(iface string, mac net.HardwareAddr, src, gateway netip.Addr, timeout time.Duration) error
// parseNetcfg reads the KEY=value netcfg blob and returns the NICs that have a
// gateway, in NIC order. The mgmt NIC carries no gateway and is skipped, as
// is any NIC without a usable MAC, address or gateway.
func parseNetcfg(blob []byte) []nicConfig {
type raw struct{ mac, cidr, gw string }
byIndex := map[int]*raw{}
sc := bufio.NewScanner(bytes.NewReader(blob))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
key, val, ok := strings.Cut(line, "=")
if !ok || !strings.HasPrefix(key, "NIC") {
continue
}
idxStr, field, ok := strings.Cut(strings.TrimPrefix(key, "NIC"), "_")
if !ok {
continue
}
idx, err := strconv.Atoi(idxStr)
if err != nil {
continue
}
r := byIndex[idx]
if r == nil {
r = &raw{}
byIndex[idx] = r
}
switch field {
case "MAC":
r.mac = strings.ToLower(val)
case "CIDR":
r.cidr = val
case "GW":
r.gw = val
}
}
idxs := make([]int, 0, len(byIndex))
for i := range byIndex {
idxs = append(idxs, i)
}
sort.Ints(idxs)
var out []nicConfig
for _, i := range idxs {
r := byIndex[i]
if r.mac == "" || r.cidr == "" || r.gw == "" {
continue
}
prefix, err := netip.ParsePrefix(r.cidr)
if err != nil || !prefix.Addr().Is4() {
continue
}
gw, err := netip.ParseAddr(r.gw)
if err != nil || !gw.Is4() {
continue
}
out = append(out, nicConfig{MAC: r.mac, Addr: prefix.Addr(), Gateway: gw})
}
return out
}
// ifaceByMAC returns the interface under sysNetDir whose address is mac.
func ifaceByMAC(sysNetDir, mac string) (string, error) {
entries, err := os.ReadDir(sysNetDir)
if err != nil {
return "", err
}
for _, e := range entries {
b, err := os.ReadFile(filepath.Join(sysNetDir, e.Name(), "address"))
if err != nil {
continue
}
if strings.EqualFold(strings.TrimSpace(string(b)), mac) {
return e.Name(), nil
}
}
return "", fmt.Errorf("no interface with MAC %s", mac)
}
// readCounters fills the sysfs statistics of iface into r.
func readCounters(sysNetDir, iface string, r *NICReport) {
read := func(name string) int64 {
b, err := os.ReadFile(filepath.Join(sysNetDir, iface, "statistics", name))
if err != nil {
return 0
}
v, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
return v
}
r.RxPackets = read("rx_packets")
r.TxPackets = read("tx_packets")
r.RxDropped = read("rx_dropped")
r.TxDropped = read("tx_dropped")
}
var errARPNoReply = errors.New("no ARP reply from the gateway")
// buildARPRequest returns the Ethernet frame asking who has target.
func buildARPRequest(mac net.HardwareAddr, src, target netip.Addr) []byte {
f := make([]byte, 0, 42)
f = append(f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff)
f = append(f, mac...)
f = binary.BigEndian.AppendUint16(f, 0x0806)
f = binary.BigEndian.AppendUint16(f, 1) // Ethernet
f = binary.BigEndian.AppendUint16(f, 0x0800) // IPv4
f = append(f, 6, 4)
f = binary.BigEndian.AppendUint16(f, 1) // request
f = append(f, mac...)
s := src.As4()
f = append(f, s[:]...)
f = append(f, 0, 0, 0, 0, 0, 0)
t := target.As4()
f = append(f, t[:]...)
return f
}
// isARPReplyFrom reports whether frame is an ARP reply sent by sender to mac.
func isARPReplyFrom(frame []byte, mac net.HardwareAddr, sender netip.Addr) bool {
if len(frame) < 42 || binary.BigEndian.Uint16(frame[12:14]) != 0x0806 {
return false
}
if binary.BigEndian.Uint16(frame[20:22]) != 2 || !bytes.Equal(frame[32:38], mac) {
return false
}
s := sender.As4()
return bytes.Equal(frame[28:32], s[:])
}
// nicProbeState carries the miss counter of one NIC across ticks. A probe that
// has not returned is still in flight, and counts as a miss without starting
// another goroutine behind it.
type nicProbeState struct {
mu sync.Mutex
failures int64
inflight bool
}
type probeResult struct {
ok bool
set bool
}
// probeNICs probes every gateway-bearing data NIC once, in parallel, and
// returns within probeWait however long a probe blocks.
func (a *Agent) probeNICs() []NICReport {
blob, err := os.ReadFile(a.netcfgPath)
if err != nil {
return nil
}
nics := parseNetcfg(blob)
if len(nics) == 0 {
return nil
}
reports := make([]NICReport, len(nics))
results := make([]chan probeResult, len(nics))
for i, n := range nics {
results[i] = make(chan probeResult, 1)
st := a.probeState(n.MAC)
st.mu.Lock()
busy := st.inflight
if !busy {
st.inflight = true
}
st.mu.Unlock()
if busy {
results[i] <- probeResult{set: true}
continue
}
go func(n nicConfig, st *nicProbeState, out chan<- probeResult) {
ok := a.probeOne(n)
st.mu.Lock()
st.inflight = false
st.mu.Unlock()
out <- probeResult{ok: ok, set: true}
}(n, st, results[i])
}
deadline := time.NewTimer(a.probeWait)
defer deadline.Stop()
expired := false
for i, n := range nics {
var res probeResult
if expired {
select {
case res = <-results[i]:
default:
}
} else {
select {
case res = <-results[i]:
case <-deadline.C:
// Out of time: an unfinished probe is a miss for this tick.
expired = true
}
}
st := a.probeState(n.MAC)
st.mu.Lock()
if res.ok {
st.failures = 0
} else {
st.failures++
}
failures := st.failures
st.mu.Unlock()
r := NICReport{
MAC: n.MAC,
ProbeOK: failures < probeFailureThreshold,
ConsecutiveFailures: failures,
}
if iface, err := ifaceByMAC(a.sysNetDir, n.MAC); err == nil {
readCounters(a.sysNetDir, iface, &r)
}
reports[i] = r
}
return reports
}
// probeOne runs one ARP exchange for n, false on any failure.
func (a *Agent) probeOne(n nicConfig) bool {
iface, err := ifaceByMAC(a.sysNetDir, n.MAC)
if err != nil {
return false
}
hw, err := net.ParseMAC(n.MAC)
if err != nil {
return false
}
return a.arpProbeFn(iface, hw, n.Addr, n.Gateway, arpExchangeWait) == nil
}
func (a *Agent) probeState(mac string) *nicProbeState {
a.probeMu.Lock()
defer a.probeMu.Unlock()
st := a.probeStates[mac]
if st == nil {
st = &nicProbeState{}
a.probeStates[mac] = st
}
return st
}
+484
View File
@@ -0,0 +1,484 @@
package elbv2
import (
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
const (
testDataMAC = "02:00:00:aa:00:01"
testMgmtMAC = "02:00:00:aa:00:02"
testNetcfg = "NIC0_MAC=" + testDataMAC + "\nNIC0_CIDR=10.0.1.5/24\nNIC0_GW=10.0.1.1\nNIC0_DEFAULT=1\n" +
"NIC1_MAC=" + testMgmtMAC + "\nNIC1_CIDR=10.255.0.9/24\nNIC1_DEFAULT=0\n"
)
// datapathAgent returns a test agent whose netcfg and sysfs live in a temp dir.
func datapathAgent(t *testing.T, gwURL, netcfg string) *Agent {
t.Helper()
a := newTestAgent(t, gwURL)
dir := t.TempDir()
a.netcfgPath = filepath.Join(dir, "netcfg")
if err := os.WriteFile(a.netcfgPath, []byte(netcfg), 0o600); err != nil {
t.Fatal(err)
}
a.sysNetDir = filepath.Join(dir, "net")
for name, mac := range map[string]string{"ens3": testDataMAC, "ens4": testMgmtMAC} {
stats := filepath.Join(a.sysNetDir, name, "statistics")
if err := os.MkdirAll(stats, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(a.sysNetDir, name, "address"), []byte(mac+"\n"), 0o600); err != nil {
t.Fatal(err)
}
for file, v := range map[string]string{"rx_packets": "100", "tx_packets": "200", "rx_dropped": "3", "tx_dropped": "4"} {
if err := os.WriteFile(filepath.Join(stats, file), []byte(v+"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
}
a.instanceIDFn = func() (string, error) { return "i-0123456789abcdef0", nil }
return a
}
func TestParseNetcfg_SkipsMgmtNICAndMissingGateway(t *testing.T) {
blob := testNetcfg +
"NIC2_MAC=02:00:00:aa:00:03\nNIC2_CIDR=10.0.2.5/24\nNIC2_GW=10.0.2.1\nNIC2_DEFAULT=0\n" +
"NIC3_MAC=02:00:00:aa:00:04\nNIC3_DHCP=1\nNIC3_DEFAULT=0\n" +
"NIC4_CIDR=10.0.4.5/24\nNIC4_GW=10.0.4.1\n" +
"NIC5_MAC=02:00:00:aa:00:06\nNIC5_CIDR=not-a-cidr\nNIC5_GW=10.0.5.1\n"
got := parseNetcfg([]byte(blob))
if len(got) != 2 {
t.Fatalf("got %d NICs, want the 2 with a gateway: %+v", len(got), got)
}
if got[0].MAC != testDataMAC || got[0].Gateway != netip.MustParseAddr("10.0.1.1") || got[0].Addr != netip.MustParseAddr("10.0.1.5") {
t.Errorf("first NIC = %+v", got[0])
}
if got[1].MAC != "02:00:00:aa:00:03" || got[1].Gateway != netip.MustParseAddr("10.0.2.1") {
t.Errorf("second NIC = %+v", got[1])
}
}
func TestParseNetcfg_OrdersByNICIndexAndLowercasesMAC(t *testing.T) {
blob := "NIC10_MAC=02:00:00:AA:00:0A\nNIC10_CIDR=10.0.9.5/24\nNIC10_GW=10.0.9.1\n" +
"NIC2_MAC=02:00:00:AA:00:02\nNIC2_CIDR=10.0.2.5/24\nNIC2_GW=10.0.2.1\n"
got := parseNetcfg([]byte(blob))
if len(got) != 2 || got[0].MAC != "02:00:00:aa:00:02" || got[1].MAC != "02:00:00:aa:00:0a" {
t.Fatalf("order or case wrong: %+v", got)
}
}
func TestIfaceByMACAndCounters(t *testing.T) {
a := datapathAgent(t, "http://unused", testNetcfg)
iface, err := ifaceByMAC(a.sysNetDir, "02:00:00:AA:00:01")
if err != nil || iface != "ens3" {
t.Fatalf("iface = %q, err = %v", iface, err)
}
if _, err := ifaceByMAC(a.sysNetDir, "02:00:00:ff:ff:ff"); err == nil {
t.Error("an unknown MAC must not resolve to an interface")
}
var r NICReport
readCounters(a.sysNetDir, iface, &r)
if r.RxPackets != 100 || r.TxPackets != 200 || r.RxDropped != 3 || r.TxDropped != 4 {
t.Errorf("counters = %+v", r)
}
}
func TestARPFrames_RequestShapeAndReplyMatching(t *testing.T) {
mac, _ := net.ParseMAC(testDataMAC)
src, gw := netip.MustParseAddr("10.0.1.5"), netip.MustParseAddr("10.0.1.1")
req := buildARPRequest(mac, src, gw)
if len(req) != 42 || req[12] != 0x08 || req[13] != 0x06 || req[21] != 1 {
t.Fatalf("request frame malformed: % x", req)
}
if string(req[38:42]) != string(gw.AsSlice()) {
t.Errorf("target IP not the gateway: % x", req[38:42])
}
reply := make([]byte, 42)
copy(reply[0:6], mac)
reply[12], reply[13] = 0x08, 0x06
reply[21] = 2
copy(reply[28:32], gw.AsSlice())
copy(reply[32:38], mac)
if !isARPReplyFrom(reply, mac, gw) {
t.Error("a reply from the gateway to our MAC must match")
}
if isARPReplyFrom(reply, mac, netip.MustParseAddr("10.0.1.99")) {
t.Error("a reply from another host must not match")
}
reply[21] = 1
if isARPReplyFrom(reply, mac, gw) {
t.Error("a request must not count as a reply")
}
if isARPReplyFrom(reply[:20], mac, gw) {
t.Error("a short frame must not match")
}
}
func TestProbeNICs_ProbesOnlyTheGatewayNIC(t *testing.T) {
a := datapathAgent(t, "http://unused", testNetcfg)
var mu sync.Mutex
var probed []string
a.arpProbeFn = func(iface string, _ net.HardwareAddr, src, gw netip.Addr, _ time.Duration) error {
mu.Lock()
defer mu.Unlock()
probed = append(probed, fmt.Sprintf("%s %s>%s", iface, src, gw))
return nil
}
reports := a.probeNICs()
if len(reports) != 1 || reports[0].MAC != testDataMAC || !reports[0].ProbeOK || reports[0].ConsecutiveFailures != 0 {
t.Fatalf("reports = %+v", reports)
}
if len(probed) != 1 || probed[0] != "ens3 10.0.1.5>10.0.1.1" {
t.Errorf("probed = %v, want only the data NIC", probed)
}
if reports[0].RxPackets != 100 || reports[0].TxDropped != 4 {
t.Errorf("counters missing from report: %+v", reports[0])
}
}
func TestProbeNICs_TwoMissesThenSuccessNeverReportsFailing(t *testing.T) {
a := datapathAgent(t, "http://unused", testNetcfg)
results := []error{errARPNoReply, errARPNoReply, nil, errARPNoReply, errARPNoReply, nil}
var n atomic.Int32
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
return results[n.Add(1)-1]
}
for i, want := range []int64{1, 2, 0, 1, 2, 0} {
r := a.probeNICs()
if len(r) != 1 || !r[0].ProbeOK || r[0].ConsecutiveFailures != want {
t.Fatalf("tick %d: report = %+v, want ok with %d failures", i, r, want)
}
}
}
func TestProbeNICs_ThreeMissesReportFailingAndRecoveryClears(t *testing.T) {
a := datapathAgent(t, "http://unused", testNetcfg)
var fail atomic.Bool
fail.Store(true)
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
if fail.Load() {
return errARPNoReply
}
return nil
}
for i := 1; i <= 4; i++ {
r := a.probeNICs()
wantOK := i < 3 // failing from the third consecutive miss
if r[0].ProbeOK != wantOK || r[0].ConsecutiveFailures != int64(i) {
t.Fatalf("miss %d: report = %+v, want ProbeOK=%v", i, r[0], wantOK)
}
}
fail.Store(false)
if r := a.probeNICs(); !r[0].ProbeOK || r[0].ConsecutiveFailures != 0 {
t.Errorf("a successful probe must clear the failure: %+v", r[0])
}
}
func TestProbeNICs_BlockedProbeIsBoundedAndCountsAsMiss(t *testing.T) {
a := datapathAgent(t, "http://unused", testNetcfg)
a.probeWait = 50 * time.Millisecond
release := make(chan struct{})
defer close(release)
var calls atomic.Int32
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
calls.Add(1)
<-release
return nil
}
for i := 1; i <= 3; i++ {
start := time.Now()
r := a.probeNICs()
if d := time.Since(start); d > time.Second {
t.Fatalf("tick %d waited %s on a stuck probe", i, d)
}
if len(r) != 1 || r[0].ConsecutiveFailures != int64(i) {
t.Fatalf("tick %d: report = %+v, want %d misses", i, r, i)
}
}
if got := calls.Load(); got != 1 {
t.Errorf("a stuck probe was started %d times, want 1 (no goroutine pile-up)", got)
}
}
func TestTick_BlockedProbeDoesNotDelayHeartbeatBeyondOneTick(t *testing.T) {
var got atomic.Pointer[url.Values]
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if r.FormValue("Action") == "LBAgentHeartbeat" {
v := r.Form
got.Store(&v)
}
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
}))
defer gw.Close()
a := datapathAgent(t, gw.URL, testNetcfg)
a.localConfigHash = "h1"
a.probeWait = 50 * time.Millisecond
release := make(chan struct{})
defer close(release)
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
<-release
return nil
}
start := time.Now()
a.tick()
if d := time.Since(start); d >= pollInterval {
t.Fatalf("tick took %s with a stuck probe, want under one %s tick", d, pollInterval)
}
if got.Load() == nil {
t.Fatal("the heartbeat was never sent")
}
}
func TestHeartbeat_EncodesEvidenceWhenPresent(t *testing.T) {
var got atomic.Pointer[url.Values]
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
v := r.Form
got.Store(&v)
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
}))
defer gw.Close()
a := datapathAgent(t, gw.URL, testNetcfg)
a.localConfigHash = "h1"
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error { return errARPNoReply }
a.tick()
v := got.Load()
if v == nil {
t.Fatal("no heartbeat received")
}
want := map[string]string{
"InstanceID": "i-0123456789abcdef0",
"NICs.member.1.MAC": testDataMAC,
"NICs.member.1.ProbeOK": "true",
"NICs.member.1.ConsecutiveFailures": "1",
"NICs.member.1.RxPackets": "100",
"NICs.member.1.TxPackets": "200",
"NICs.member.1.RxDropped": "3",
"NICs.member.1.TxDropped": "4",
}
for k, w := range want {
if g := v.Get(k); g != w {
t.Errorf("%s = %q, want %q", k, g, w)
}
}
if v.Get("NICs.member.2.MAC") != "" {
t.Error("the mgmt NIC must not be reported")
}
}
func TestHeartbeat_OmitsEvidenceWithoutNetcfg(t *testing.T) {
var got atomic.Pointer[url.Values]
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
v := r.Form
got.Store(&v)
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
}))
defer gw.Close()
a := datapathAgent(t, gw.URL, testNetcfg)
a.localConfigHash = "h1"
a.netcfgPath = filepath.Join(t.TempDir(), "absent")
a.instanceIDFn = func() (string, error) { return "", errors.New("must not be asked without NICs") }
a.tick()
v := got.Load()
if v == nil {
t.Fatal("no heartbeat received")
}
for k := range *v {
if k == "InstanceID" || len(k) >= 4 && k[:4] == "NICs" {
t.Errorf("unexpected evidence parameter %s", k)
}
}
}
func TestDataPathEvidence_InstanceIDRetriedUntilKnown(t *testing.T) {
a := datapathAgent(t, "http://unused", testNetcfg)
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error { return nil }
var calls atomic.Int32
a.instanceIDFn = func() (string, error) {
if calls.Add(1) < 3 {
return "", errors.New("metadata not ready")
}
return "i-late", nil
}
// A failure backs the lookup off for imdsRetryTicks-1 ticks.
ticks := func(n int) string {
var id string
for range n {
id = a.dataPathEvidence().InstanceID
}
return id
}
if got := ticks(imdsRetryTicks); got != "" || calls.Load() != 1 {
t.Fatalf("after %d ticks: id %q, calls %d; want no id and one lookup", imdsRetryTicks, got, calls.Load())
}
if got := ticks(imdsRetryTicks); got != "" || calls.Load() != 2 {
t.Fatalf("second window: id %q, calls %d; want no id and two lookups", got, calls.Load())
}
if got := ticks(imdsRetryTicks); got != "i-late" || calls.Load() != 3 {
t.Fatalf("third window: id %q, calls %d; want i-late and three lookups", got, calls.Load())
}
ticks(imdsRetryTicks)
if calls.Load() != 3 {
t.Errorf("metadata asked %d times, want 3 (stop once known)", calls.Load())
}
}
func TestTick_BlockedInstanceIDLookupDoesNotDelayHeartbeat(t *testing.T) {
var got atomic.Pointer[url.Values]
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if r.FormValue("Action") == "LBAgentHeartbeat" {
v := r.Form
got.Store(&v)
}
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
}))
defer gw.Close()
a := datapathAgent(t, gw.URL, testNetcfg)
a.localConfigHash = "h1"
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error { return nil }
release := make(chan struct{})
defer close(release)
var calls atomic.Int32
a.instanceIDFn = func() (string, error) {
calls.Add(1)
<-release
return "", errors.New("unreachable")
}
for i := range 3 {
start := time.Now()
a.tick()
if d := time.Since(start); d >= pollInterval {
t.Fatalf("tick %d took %s with IMDS blocked, want under one %s tick", i, d, pollInterval)
}
}
v := got.Load()
if v == nil {
t.Fatal("the heartbeat was never sent")
}
if v.Get("InstanceID") != "" {
t.Error("InstanceID must be omitted while the lookup fails")
}
if v.Get("NICs.member.1.MAC") == "" {
t.Error("NIC evidence must still be sent")
}
if calls.Load() != 1 {
t.Errorf("blocked lookup started %d times in 3 ticks, want 1 (backoff)", calls.Load())
}
}
func TestFetchInstanceID_SharesOneDeadline(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * imdsWait)
}))
defer srv.Close()
old := imdsBase
imdsBase = srv.URL + "/latest"
defer func() { imdsBase = old }()
start := time.Now()
if _, err := fetchInstanceID(); err == nil {
t.Fatal("a hung metadata service must fail the lookup")
}
if d := time.Since(start); d > imdsWait+imdsWait/2 {
t.Fatalf("lookup took %s, want about one %s deadline", d, imdsWait)
}
}
func TestARPProbe_NoResponderFailsClosed(t *testing.T) {
mac, _ := net.ParseMAC(testDataMAC)
// Nobody answers an ARP request sent on loopback, so a probe that returned
// nil here would mask a dead data path. It fails either on the socket (no
// CAP_NET_RAW) or on the missing reply.
err := arpProbe("lo", mac, netip.MustParseAddr("10.9.9.2"), netip.MustParseAddr("10.9.9.1"), 100*time.Millisecond)
if err == nil {
t.Fatal("a probe with no responder must fail")
}
if err := arpProbe("no-such-iface0", mac, netip.MustParseAddr("10.9.9.2"), netip.MustParseAddr("10.9.9.1"), 100*time.Millisecond); err == nil {
t.Fatal("a probe on a missing interface must fail")
}
}
func TestFetchInstanceID(t *testing.T) {
serve := func(tokenStatus, idStatus int, wantToken string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPut && r.URL.Path == "/latest/api/token":
w.WriteHeader(tokenStatus)
fmt.Fprint(w, "tok123")
case r.URL.Path == "/latest/meta-data/instance-id":
if got := r.Header.Get("X-Aws-Ec2-Metadata-Token"); got != wantToken {
t.Errorf("token header = %q, want %q", got, wantToken)
}
w.WriteHeader(idStatus)
fmt.Fprint(w, " i-abc123 \n")
default:
http.NotFound(w, r)
}
}))
}
saved := imdsBase
defer func() { imdsBase = saved }()
for _, tc := range []struct {
name string
tokenStatus, idStatus int
wantToken string
wantID string
wantErr bool
}{
{"token then id", http.StatusOK, http.StatusOK, "tok123", "i-abc123", false},
{"token refused falls back to v1", http.StatusNotFound, http.StatusOK, "", "i-abc123", false},
{"id endpoint error", http.StatusOK, http.StatusServiceUnavailable, "tok123", "", true},
} {
t.Run(tc.name, func(t *testing.T) {
srv := serve(tc.tokenStatus, tc.idStatus, tc.wantToken)
defer srv.Close()
imdsBase = srv.URL + "/latest"
id, err := fetchInstanceID()
if (err != nil) != tc.wantErr || id != tc.wantID {
t.Fatalf("id = %q, err = %v", id, err)
}
})
}
imdsBase = "http://127.0.0.1:1/latest"
if _, err := fetchInstanceID(); err == nil {
t.Error("an unreachable metadata service must fail")
}
}
+133
View File
@@ -0,0 +1,133 @@
package elbv2
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
imdsWait = time.Second
imdsRetryTicks = 6
)
// imdsBase is the in-VM metadata endpoint (a variable so tests can point it at a fake).
var imdsBase = "http://169.254.169.254/latest"
// dataPathEvidence is the optional part of a heartbeat: which VM the agent runs
// in and what each data NIC's gateway probe saw. An empty value adds nothing,
// which is what an agent without a netcfg blob sends.
type dataPathEvidence struct {
InstanceID string
NICs []NICReport
}
// addTo encodes the evidence as additional heartbeat query parameters.
func (e dataPathEvidence) addTo(params url.Values) {
if e.InstanceID != "" {
params.Set("InstanceID", e.InstanceID)
}
for i, n := range e.NICs {
p := "NICs.member." + strconv.Itoa(i+1) + "."
params.Set(p+"MAC", n.MAC)
params.Set(p+"ProbeOK", strconv.FormatBool(n.ProbeOK))
params.Set(p+"ConsecutiveFailures", strconv.FormatInt(n.ConsecutiveFailures, 10))
params.Set(p+"RxPackets", strconv.FormatInt(n.RxPackets, 10))
params.Set(p+"TxPackets", strconv.FormatInt(n.TxPackets, 10))
params.Set(p+"RxDropped", strconv.FormatInt(n.RxDropped, 10))
params.Set(p+"TxDropped", strconv.FormatInt(n.TxDropped, 10))
}
}
// dataPathEvidence gathers the probe results and instance ID for one heartbeat.
// Neither step can fail the heartbeat: missing evidence is simply omitted.
func (a *Agent) dataPathEvidence() dataPathEvidence {
nics := a.probeNICs()
if len(nics) == 0 {
return dataPathEvidence{}
}
if a.instanceID == "" {
a.lookupInstanceID()
}
return dataPathEvidence{InstanceID: a.instanceID, NICs: nics}
}
// lookupInstanceID asks for the instance ID without ever holding up a tick: the
// call is bounded by imdsWait and, after a failure, retried only every
// imdsRetryTicks ticks.
func (a *Agent) lookupInstanceID() {
if a.idSkip > 0 {
a.idSkip--
return
}
type result struct {
id string
err error
}
ch := make(chan result, 1)
go func() {
id, err := a.instanceIDFn()
ch <- result{id, err}
}()
timer := time.NewTimer(imdsWait)
defer timer.Stop()
select {
case r := <-ch:
if r.err == nil && r.id != "" {
a.instanceID = r.id
return
}
case <-timer.C:
}
a.idSkip = imdsRetryTicks - 1
}
// fetchInstanceID reads the instance ID from the in-VM metadata service. The
// token request and the read share one deadline.
func fetchInstanceID() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), imdsWait)
defer cancel()
c := &http.Client{}
var token string
if req, err := http.NewRequestWithContext(ctx, http.MethodPut, imdsBase+"/api/token", nil); err == nil {
req.Header.Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "60")
if resp, err := c.Do(req); err == nil {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
token = strings.TrimSpace(string(b))
}
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imdsBase+"/meta-data/instance-id", nil)
if err != nil {
return "", err
}
if token != "" {
req.Header.Set("X-Aws-Ec2-Metadata-Token", token)
}
resp, err := c.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("instance-id: status %d", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 256))
if err != nil {
return "", err
}
id := strings.TrimSpace(string(b))
if id == "" {
return "", fmt.Errorf("instance-id: empty")
}
return id, nil
}
@@ -0,0 +1,71 @@
package hostlink
import (
"errors"
"fmt"
"io"
"os"
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
"golang.org/x/sys/unix"
)
// On a VMM without fw_cfg the executor attaches the same entries as a tar
// archive on a second disk, which the runtime image's init extracts here in
// the fw_cfg sysfs layout, so the fw_cfg readers work unchanged.
const (
ConfigDiskRoot = "/spinifex-cfg"
ConfigDiskDir = ConfigDiskRoot + "/by_name/" + lambdav1.FwCfgPrefix
ConfigDiskDevice = "/dev/vdb"
)
// zeroChunk bounds each write while erasing the configuration disk.
const zeroChunk = 64 << 10
// unmount is a var so tests without privilege can stand in for the tmpfs.
var unmount = unix.Unmount
// SealConfigDisk overwrites the whole configuration disk with zeros, then
// removes the extracted tree. The tree stays if the disk cannot be erased, so
// SealFwCfg's check on root fails closed.
func SealConfigDisk(device, root string) error {
if err := zeroDevice(device); err != nil {
return fmt.Errorf("hostlink: erase configuration disk: %w", err)
}
if err := unmount(root, 0); err != nil && !errors.Is(err, unix.EINVAL) {
return fmt.Errorf("hostlink: unmount %s: %w", root, err)
}
if err := os.RemoveAll(root); err != nil {
return fmt.Errorf("hostlink: remove %s: %w", root, err)
}
return nil
}
func zeroDevice(device string) error {
f, err := os.OpenFile(device, os.O_WRONLY, 0)
if err != nil {
return err
}
size, err := f.Seek(0, io.SeekEnd)
if err == nil {
_, err = f.Seek(0, io.SeekStart)
}
if err != nil {
_ = f.Close()
return err
}
zeros := make([]byte, zeroChunk)
for left := size; left > 0; {
n := min(left, int64(len(zeros)))
if _, err := f.Write(zeros[:n]); err != nil {
_ = f.Close()
return err
}
left -= n
}
if err := f.Sync(); err != nil {
_ = f.Close()
return err
}
return f.Close()
}
@@ -0,0 +1,63 @@
//test:in-package reaches the unexported configDisk and fwCfgTree readers to seal and read a real archive
package hostlink
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)
// configDisk writes a fake disk holding secret and an extracted tree; the
// stand-in unmount reports the tree is not a mount, as on a plain directory.
func configDisk(t *testing.T, secret []byte) (device, root string) {
t.Helper()
dir := t.TempDir()
device = filepath.Join(dir, "vdb")
require.NoError(t, os.WriteFile(device, append(bytes.Repeat([]byte{0xa5}, 3*zeroChunk+7), secret...), 0o600))
root = fwCfgTree(t)
require.NoError(t, os.WriteFile(filepath.Join(root, "by_name", "key"), secret, 0o600))
old := unmount
unmount = func(string, int) error { return unix.EINVAL }
t.Cleanup(func() { unmount = old })
return device, root
}
func TestSealConfigDisk(t *testing.T) {
secret := []byte("guest-key-material")
t.Run("erases the disk and removes the tree", func(t *testing.T) {
device, root := configDisk(t, secret)
before, err := os.Stat(device)
require.NoError(t, err)
require.NoError(t, SealFwCfg(root, func() error { return SealConfigDisk(device, root) }))
disk, err := os.ReadFile(device)
require.NoError(t, err)
assert.Len(t, disk, int(before.Size()))
assert.Equal(t, make([]byte, len(disk)), disk, "configuration disk not fully erased")
_, err = os.Stat(root)
assert.ErrorIs(t, err, os.ErrNotExist)
})
t.Run("an unwritable disk keeps the tree and fails closed", func(t *testing.T) {
device, root := configDisk(t, secret)
require.NoError(t, os.Chmod(device, 0o400))
err := SealFwCfg(root, func() error { return SealConfigDisk(device, root) })
require.Error(t, err)
assert.Contains(t, err.Error(), "still readable")
_, err = os.Stat(root)
assert.NoError(t, err)
})
t.Run("a failed unmount keeps the tree", func(t *testing.T) {
device, root := configDisk(t, secret)
unmount = func(string, int) error { return unix.EBUSY }
require.Error(t, SealFwCfg(root, func() error { return SealConfigDisk(device, root) }))
_, err := os.Stat(root)
assert.NoError(t, err)
})
}
+5 -4
View File
@@ -28,11 +28,12 @@ type Request struct {
Credential *Credential `json:"credential,omitempty"`
}
// Credential is one execution-role session. It is never logged or stored.
// Credential holds one execution-role session and its function environment.
type Credential struct {
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
SessionToken string `json:"session_token"`
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
SessionToken string `json:"session_token"`
Environment map[string]string `json:"environment,omitempty"`
}
// LogValue keeps every value out of logs.
@@ -4,5 +4,5 @@ package hostlink
// Protocol is the only ALPN protocol a test-link host and guest offer. It
// differs from the release protocol because the first frame carries the
// execution credential, which an older guest would ignore.
const Protocol = "spinifex-lambda-testlink/2"
// execution credential and function environment, which an older guest would ignore.
const Protocol = "spinifex-lambda-testlink/3"
+102 -19
View File
@@ -322,14 +322,27 @@ type ViperblockConfig struct {
}
// EBS provider selectors. Viperblockd routes EBS calls to the viperblockd
// daemon and qemunbd to the qemunbdd daemon, both over the versioned
// ebs.provider.v1.* NATS contract. Embedded named the removed in-process
// daemon, qemunbd to the qemunbdd daemon and ocibsd to the ocibsd daemon, all
// over the versioned ebs.provider.v1.* NATS contract. Embedded named the removed in-process
// engine and survives solely so a config that still selects it can be
// rejected by name.
const (
EBSProviderEmbedded = "embedded"
EBSProviderViperblockd = "viperblockd"
EBSProviderQEMUNBD = "qemunbd"
EBSProviderOCIBSD = "ocibsd"
)
// OCI credential selections, as in oci_auth on an external pool and auth in
// [nodes.<name>.ebs.oci]. An unset auth reads a config file.
const (
// OCIAuthConfigFile authenticates from an API-key config file. The default,
// because it is what an operator already has from `oci setup config`.
OCIAuthConfigFile = "config_file"
// OCIAuthInstancePrincipal authenticates as the instance, from the
// certificate its metadata service serves. No key material on any node and
// nothing to rotate, at the cost of a dynamic group and an IAM policy.
OCIAuthInstancePrincipal = "instance_principal"
)
// Per-volume export tunables. Both are applied per nbdkit process, and
@@ -353,10 +366,13 @@ const (
// boundary, not one provider's settings, so a second provider never needs a
// rename.
type EBSConfig struct {
// Provider is "viperblockd" or "qemunbd" and may be left unset. Volumes
// are persisted in ebsmetadata.
// Provider is "viperblockd", "qemunbd" or "ocibsd" and may be left unset.
// Volumes are persisted in ebsmetadata.
Provider string `json:"Provider" mapstructure:"provider"`
// OCI is the [nodes.<name>.ebs.oci] table, read only by ocibsd.
OCI EBSOCIConfig `json:"OCI" mapstructure:"oci"`
// DefaultThreads is nbdkit's -t: worker threads per NBD connection, which
// bounds how many requests are in flight inside viperblock for one volume.
// QEMU opens a single connection per volume, so this is the whole
@@ -370,9 +386,27 @@ type EBSConfig struct {
CacheSizeMB *int `json:"CacheSizeMB" mapstructure:"cache_size_mb"`
}
// EBSOCIConfig is where ocibsd keeps the cluster's volumes and how it
// authenticates. Auth, ConfigFile and ConfigProfile mean what oci_auth,
// oci_config_file and oci_config_profile mean on an oci external pool.
type EBSOCIConfig struct {
// CompartmentID and AvailabilityDomain are the same on every node: a
// volume is created there whichever node answers, and attached anywhere.
CompartmentID string `json:"CompartmentID" mapstructure:"compartment_id"`
AvailabilityDomain string `json:"AvailabilityDomain" mapstructure:"availability_domain"`
// ClusterID is generated once by spx admin init and tags every OCI
// resource ocibsd creates, so one compartment can hold several clusters.
ClusterID string `json:"ClusterID" mapstructure:"cluster_id"`
Auth string `json:"Auth" mapstructure:"auth"`
ConfigFile string `json:"ConfigFile" mapstructure:"config_file"`
ConfigProfile string `json:"ConfigProfile" mapstructure:"config_profile"`
}
func (c EBSOCIConfig) isZero() bool { return c == EBSOCIConfig{} }
// SupportedEBSProvider reports whether name is a provider a node may run.
func SupportedEBSProvider(name string) bool {
return name == EBSProviderViperblockd || name == EBSProviderQEMUNBD
return name == EBSProviderViperblockd || name == EBSProviderQEMUNBD || name == EBSProviderOCIBSD
}
// ResolvedProvider normalizes an empty Provider to EBSProviderViperblockd,
@@ -889,6 +923,63 @@ func validateSingleEBSProvider(cc *ClusterConfig) error {
return nil
}
// validateOCIAuth checks an OCI credential selection, where prefix is what
// the keys are named with ("oci_" on an external pool, "" in [ebs.oci]).
func validateOCIAuth(prefix, auth, configFile, configProfile string) error {
// Named explicitly rather than inferred, so a node that was meant to
// use its own identity and has no dynamic group fails at config load
// instead of falling back to a key file nobody installed.
switch auth {
case "", OCIAuthConfigFile, OCIAuthInstancePrincipal:
default:
return fmt.Errorf("%sauth must be %q or %q, got %q", prefix, OCIAuthConfigFile, OCIAuthInstancePrincipal, auth)
}
// Credentials come from the metadata service under instance principal,
// so a config file named beside it is one of two sources of truth and
// the one that would be silently ignored.
if auth == OCIAuthInstancePrincipal && (configFile != "" || configProfile != "") {
return fmt.Errorf("%[1]sconfig_file/%[1]sconfig_profile are not valid with %[1]sauth=\"instance_principal\" (the instance's own certificate is the credential)", prefix)
}
return nil
}
// validateEBSOCI checks every node's [ebs.oci] table: present and complete
// exactly when the node runs ocibsd, and naming one compartment and one AD
// across the cluster, since any node may create a volume another attaches.
func validateEBSOCI(cc *ClusterConfig) error {
var first string
var want EBSOCIConfig
for _, name := range slices.Sorted(maps.Keys(cc.Nodes)) {
ebs := cc.Nodes[name].EBS
if ebs.ResolvedProvider() != EBSProviderOCIBSD {
if !ebs.OCI.isZero() {
return fmt.Errorf("config: [nodes.%s.ebs.oci] is only valid with provider = %q", name, EBSProviderOCIBSD)
}
continue
}
oci := ebs.OCI
if oci.CompartmentID == "" || oci.AvailabilityDomain == "" {
return fmt.Errorf("config: [nodes.%s.ebs.oci] compartment_id and availability_domain are required with provider = %q", name, EBSProviderOCIBSD)
}
if err := validateOCIAuth("", oci.Auth, oci.ConfigFile, oci.ConfigProfile); err != nil {
return fmt.Errorf("config: [nodes.%s.ebs.oci] %w", name, err)
}
if first == "" {
first, want = name, oci
continue
}
if oci.ClusterID != want.ClusterID {
return fmt.Errorf("config: [nodes.%s.ebs.oci] cluster_id %q differs from [nodes.%s.ebs.oci] cluster_id %q; every node must carry the cluster's one ID",
name, oci.ClusterID, first, want.ClusterID)
}
if oci.CompartmentID != want.CompartmentID || oci.AvailabilityDomain != want.AvailabilityDomain {
return fmt.Errorf("config: [nodes.%s.ebs.oci] names compartment %q in %q but [nodes.%s.ebs.oci] names %q in %q; every node must name the same compartment and availability domain",
first, want.CompartmentID, want.AvailabilityDomain, name, oci.CompartmentID, oci.AvailabilityDomain)
}
}
return nil
}
func validateClusterConfig(cc *ClusterConfig) error {
if viper.IsSet("network.external_dhcp") {
return fmt.Errorf("config: [network] external_dhcp is no longer supported; remove the key (static WAN-pool allocation only)")
@@ -905,7 +996,7 @@ func validateClusterConfig(cc *ClusterConfig) error {
// ebsmetadata one-way without the operator ever asking for it.
return fmt.Errorf("config: [nodes.%s.ebs] provider=%q has been removed; set provider = %q or remove the key (this is a one-way switch: volumes move to ebsmetadata)", nodeName, EBSProviderEmbedded, EBSProviderViperblockd)
default:
return fmt.Errorf("config: [nodes.%s.ebs] provider=%q unsupported; use %q or %q, or remove the key", nodeName, nodeCfg.EBS.Provider, EBSProviderViperblockd, EBSProviderQEMUNBD)
return fmt.Errorf("config: [nodes.%s.ebs] provider=%q unsupported; use %q, %q or %q, or remove the key", nodeName, nodeCfg.EBS.Provider, EBSProviderViperblockd, EBSProviderQEMUNBD, EBSProviderOCIBSD)
}
// Range checks only. Both settings are deliberately unbounded above by
// anything host-aware: they are operator tunables, and the host cost is
@@ -923,6 +1014,9 @@ func validateClusterConfig(cc *ClusterConfig) error {
if err := validateSingleEBSProvider(cc); err != nil {
return err
}
if err := validateEBSOCI(cc); err != nil {
return err
}
if err := validateNorthstarCluster(cc); err != nil {
return err
@@ -993,19 +1087,8 @@ func validateClusterConfig(cc *ClusterConfig) error {
if (p.OCIVNICID == "") == (p.OCIVNICIface == "") {
return fmt.Errorf("config: [[network.external_pools]] %q: source=\"oci\" requires exactly one of oci_vnic_id or oci_vnic_iface", p.Name)
}
// Named explicitly rather than inferred, so a node that was meant to
// use its own identity and has no dynamic group fails at config load
// instead of falling back to a key file nobody installed.
switch p.OCIAuth {
case "", "config_file", "instance_principal":
default:
return fmt.Errorf("config: [[network.external_pools]] %q: oci_auth must be \"config_file\" or \"instance_principal\", got %q", p.Name, p.OCIAuth)
}
// Credentials come from the metadata service under instance principal,
// so a config file named beside it is one of two sources of truth and
// the one that would be silently ignored.
if p.OCIAuth == "instance_principal" && (p.OCIConfigFile != "" || p.OCIConfigProfile != "") {
return fmt.Errorf("config: [[network.external_pools]] %q: oci_config_file/oci_config_profile are not valid with oci_auth=\"instance_principal\" (the instance's own certificate is the credential)", p.Name)
if err := validateOCIAuth("oci_", p.OCIAuth, p.OCIConfigFile, p.OCIConfigProfile); err != nil {
return fmt.Errorf("config: [[network.external_pools]] %q: %w", p.Name, err)
}
if p.BindBridge != "" || p.DHCPMAC != "" {
return fmt.Errorf("config: [[network.external_pools]] %q: bind_bridge/dhcp_mac are only valid with source=\"dhcp\"", p.Name)
+54
View File
@@ -1053,6 +1053,7 @@ region = "us-east-1"
{"unset defaults to viperblockd", "", EBSProviderViperblockd},
{"viperblockd explicit", "[nodes.n1.ebs]\nprovider = \"viperblockd\"\n", EBSProviderViperblockd},
{"qemunbd explicit", "[nodes.n1.ebs]\nprovider = \"qemunbd\"\n", EBSProviderQEMUNBD},
{"ocibsd explicit", "[nodes.n1.ebs]\nprovider = \"ocibsd\"\n\n[nodes.n1.ebs.oci]\ncompartment_id = \"c\"\navailability_domain = \"ad\"\n", EBSProviderOCIBSD},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -1148,6 +1149,59 @@ func TestLoadConfig_EBSProvider_SingleProviderPerCluster(t *testing.T) {
}
}
func TestLoadConfig_EBSOCI(t *testing.T) {
oci := func(node, compartment, ad, extra string) string {
return fmt.Sprintf("[nodes.%s.ebs]\nprovider = \"ocibsd\"\n\n[nodes.%s.ebs.oci]\ncompartment_id = %q\navailability_domain = %q\n%s", node, node, compartment, ad, extra)
}
const comp, ad = "ocid1.compartment.oc1..a", "Uocm:US-ASHBURN-AD-1"
cases := []struct {
name string
ebs []string
wantErr string
}{
{"one node", []string{oci("n1", comp, ad, "")}, ""},
{"every node agrees, with unset peers", []string{oci("n1", comp, ad, ""), "", oci("n3", comp, ad, "auth = \"instance_principal\"\n")}, ""},
{"config file credentials", []string{oci("n1", comp, ad, "auth = \"config_file\"\nconfig_file = \"/etc/spinifex/oci/config\"\nconfig_profile = \"SPX\"\n")}, ""},
{"no compartment", []string{oci("n1", "", ad, "")}, "[nodes.n1.ebs.oci] compartment_id and availability_domain are required"},
{"no availability domain", []string{oci("n1", comp, "", "")}, "[nodes.n1.ebs.oci] compartment_id and availability_domain are required"},
{"no table at all", []string{"[nodes.n1.ebs]\nprovider = \"ocibsd\"\n"}, "compartment_id and availability_domain are required"},
{"unknown auth", []string{oci("n1", comp, ad, "auth = \"api_key\"\n")}, `[nodes.n1.ebs.oci] auth must be "config_file" or "instance_principal", got "api_key"`},
{"instance principal beside a config file", []string{oci("n1", comp, ad, "auth = \"instance_principal\"\nconfig_file = \"/x\"\n")},
`config_file/config_profile are not valid with auth="instance_principal"`},
{"nodes disagree on the AD", []string{oci("n1", comp, ad, ""), oci("n2", comp, "Uocm:US-ASHBURN-AD-2", "")},
"every node must name the same compartment and availability domain"},
{"nodes agree on the cluster ID", []string{oci("n1", comp, ad, "cluster_id = \"c1\"\n"), oci("n2", comp, ad, "cluster_id = \"c1\"\n")}, ""},
{"nodes disagree on the cluster ID", []string{oci("n1", comp, ad, "cluster_id = \"c1\"\n"), oci("n2", comp, ad, "cluster_id = \"c2\"\n")},
`[nodes.n2.ebs.oci] cluster_id "c2" differs from [nodes.n1.ebs.oci] cluster_id "c1"`},
{"nodes disagree on the compartment", []string{oci("n1", comp, ad, ""), oci("n2", "ocid1.compartment.oc1..b", ad, "")},
"every node must name the same compartment and availability domain"},
{"the table on another provider", []string{"[nodes.n1.ebs]\nprovider = \"viperblockd\"\n\n[nodes.n1.ebs.oci]\ncompartment_id = \"x\"\n"},
`[nodes.n1.ebs.oci] is only valid with provider = "ocibsd"`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := "node = \"n1\"\n"
for i, ebs := range tc.ebs {
body += fmt.Sprintf("\n[nodes.n%d]\nregion = \"us-east-1\"\n%s", i+1, ebs)
}
resetViper(t)
path := filepath.Join(t.TempDir(), "spinifex.toml")
require.NoError(t, os.WriteFile(path, []byte(body), 0600))
cfg, err := LoadConfig(path)
if tc.wantErr != "" {
require.ErrorContains(t, err, tc.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, EBSProviderOCIBSD, cfg.Nodes["n1"].EBS.ResolvedProvider())
got := cfg.Nodes["n1"].EBS.OCI
assert.Equal(t, comp, got.CompartmentID)
assert.Equal(t, ad, got.AvailabilityDomain)
})
}
}
func TestEBSConfig_ResolvedTunablesDefault(t *testing.T) {
var c EBSConfig
assert.Equal(t, DefaultNBDKitThreads, c.ResolvedThreads())
+1 -2
View File
@@ -14,7 +14,6 @@ import (
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
@@ -184,7 +183,7 @@ func NewStore(ctx context.Context, nc *nats.Conn, masterKey []byte) (*Store, err
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketACM, KVBucketACMVersion)
kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketACM, KVBucketACMVersion)
if err != nil {
return nil, err
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"time"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
"golang.org/x/time/rate"
)
@@ -171,7 +171,7 @@ func (s *Service) RunBedrockRPMSync(ctx context.Context, js jetstream.JetStream)
if s == nil || !s.limits.RequestsPerMinuteEnabled {
return
}
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketBedrockRPMUsage, 1)
kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketBedrockRPMUsage, 1)
if err != nil {
slog.Warn("bedrock rpm sync: open usage bucket failed, sync disabled", "err", err)
return
+3 -3
View File
@@ -9,7 +9,7 @@ import (
"time"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -187,7 +187,7 @@ func (s *Service) PutAccountQuota(ctx context.Context, accountID string, over Ov
over.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
over.UpdatedBy = updatedBy
_, err := kvutil.Update(ctx, s.overrides, accountID, kvutil.CASConfig{
_, err := kvstore.Update(ctx, s.overrides, accountID, kvstore.CASConfig{
CreateIfAbsent: true,
Attempts: overrideMaxAttempts,
Exhausted: func(string, int) error { return errOverridesContended },
@@ -230,7 +230,7 @@ func (s *Service) clearAccountQuota(ctx context.Context, accountID string) error
return nil
case errors.Is(err, jetstream.ErrKeyNotFound):
return nil
case kvutil.IsRevisionConflict(err):
case kvstore.IsRevisionConflict(err):
// A concurrent write landed between the read and the guarded
// delete; retry against whatever revision now holds.
continue
+4 -4
View File
@@ -22,10 +22,10 @@ const ReconcileInterval = 30 * time.Second
// dimension; the live-counted dimensions need no stored state.
const KVBucketAccountUsage = "spinifex-account-usage"
// KVBucketQuotaReconcile is the leader-lock bucket for the gateway quota
// reconcile. It is deliberately distinct from vpcd's network-reconcile lock so
// the two independent loops never share one mutex and block each other.
const KVBucketQuotaReconcile = "spinifex-quota-reconcile"
// KeyReconcile is the sync-bucket leader lease for the gateway quota reconcile.
// It is deliberately distinct from vpcd's network-reconcile lease so the two
// independent loops never share one mutex and block each other.
const KeyReconcile = "quota.reconcile"
// KVBucketAccountQuota holds the per-account limit overrides, keyed by
// accountID. It is separate from the accounts bucket so a quota write never
+3 -3
View File
@@ -8,7 +8,7 @@ import (
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -73,7 +73,7 @@ func (s *Service) sealBucket(ctx context.Context) (jetstream.KeyValue, error) {
slog.ErrorContext(ctx, "Quota override seal has no JetStream client wired to open the override bucket")
return nil, errors.New(awserrors.ErrorInternalError)
}
kv, err := kvutil.GetOrCreateBucket(ctx, s.js, KVBucketAccountQuota, 1)
kv, err := kvstore.GetOrCreateBucket(ctx, s.js, KVBucketAccountQuota, 1)
if err != nil {
slog.ErrorContext(ctx, "Failed to open quota override bucket for seal", "err", err)
return nil, errors.New(awserrors.ErrorInternalError)
@@ -136,7 +136,7 @@ func (s *Service) SealAccountOverrides(ctx context.Context, accountID string) (*
switch {
case writeErr == nil:
return &OverrideSeal{Revision: newRev}, nil
case kvutil.IsRevisionConflict(writeErr):
case kvstore.IsRevisionConflict(writeErr):
continue
default:
slog.ErrorContext(ctx, "Failed to write sealed quota override record", "accountID", accountID, "err", writeErr)
+7 -7
View File
@@ -8,7 +8,7 @@ import (
"time"
nsconfig "github.com/mulgadc/northstar/pkg/config"
"github.com/nats-io/nats.go"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
)
@@ -48,7 +48,7 @@ func (w *Writer) PlatformDelegation(name string) (servers []string, zone string,
// Delegate writes, or with remove withdraws, an NS set in the base zone that
// delegates each name to this cluster's nameservers. It takes the zone-writer
// election first, so it never races the reconcile's own writes to the zone.
func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, names []string, remove bool) (*ChangeResult, error) {
func (w *Writer) Delegate(ctx context.Context, lease *kvsync.Lease, names []string, remove bool) (*ChangeResult, error) {
if !w.Enabled() {
return nil, errors.New("northstar is not configured on this node")
}
@@ -77,7 +77,7 @@ func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, nam
if len(batch.Changes) == 0 {
return &ChangeResult{}, nil
}
release, err := w.awaitWriterElection(ctx, nc, holder)
release, err := w.awaitWriterElection(ctx, lease)
if err != nil {
return nil, err
}
@@ -85,19 +85,19 @@ func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, nam
return w.ApplyBatch(batch)
}
func (w *Writer) awaitWriterElection(ctx context.Context, nc *nats.Conn, holder string) (func(), error) {
if nc == nil {
func (w *Writer) awaitWriterElection(ctx context.Context, lease *kvsync.Lease) (func(), error) {
if lease == nil {
return func() {}, nil
}
tick := time.NewTicker(delegateLockPoll)
defer tick.Stop()
for {
if release, ok := reconcilelock.AcquireLeader(ctx, nc, KVBucketDNSReconcile, holder); ok {
if release, ok := reconcilelock.AcquireLeader(ctx, lease); ok {
return release, nil
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("another node is writing the zone: %w", ctx.Err())
return nil, fmt.Errorf("another writer has the zone: %w", ctx.Err())
case <-tick.C:
}
}
+33 -10
View File
@@ -14,7 +14,7 @@ import (
func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
_, err := w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net", "B.r53e2e.spx3.net."}, false)
_, err := w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net", "B.r53e2e.spx3.net."}, false)
require.NoError(t, err)
for _, name := range []string{"a.r53e2e.spx3.net.", "b.r53e2e.spx3.net"} {
@@ -29,7 +29,7 @@ func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
assert.True(t, ok, "a name below the base domain is decided by the base zone")
assert.Empty(t, servers)
_, err = w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, true)
_, err = w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net"}, true)
require.NoError(t, err)
servers, _, _, err = w.PlatformDelegation("a.r53e2e.spx3.net")
require.NoError(t, err)
@@ -43,11 +43,11 @@ func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
// servers written while the cluster published a larger set.
func TestDelegateRemoveWithdrawsServersFromAnEarlierSet(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
_, err := w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, false)
_, err := w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net"}, false)
require.NoError(t, err)
w.nameservers = w.nameservers[:1]
_, err = w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, true)
_, err = w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net"}, true)
require.NoError(t, err)
servers, _, _, err := w.PlatformDelegation("a.r53e2e.spx3.net")
require.NoError(t, err)
@@ -66,7 +66,7 @@ func TestPlatformDelegationIgnoresNamesOutsideTheBaseDomain(t *testing.T) {
func TestDelegateRefusesPlatformAndForeignNames(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
for _, name := range []string{"ns1.spx3.net", "x.elb.spx3.net", "compute.spx3.net", "example.com", "spx3.net"} {
_, err := w.Delegate(t.Context(), nil, "node1", []string{"ok.spx3.net", name}, false)
_, err := w.Delegate(t.Context(), nil, []string{"ok.spx3.net", name}, false)
require.Error(t, err, name)
}
servers, _, _, err := w.PlatformDelegation("ok.spx3.net")
@@ -76,22 +76,45 @@ func TestDelegateRefusesPlatformAndForeignNames(t *testing.T) {
func TestDelegateWaitsForTheZoneWriterElection(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
_, nc, _ := testutil.StartTestJetStream(t)
_, _, js := testutil.StartTestJetStream(t)
prev := delegateLockPoll
delegateLockPoll = 10 * time.Millisecond
t.Cleanup(func() { delegateLockPoll = prev })
release, ok := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "node2")
release, ok := reconcilelock.AcquireLeader(t.Context(), dnsLease(js, "node2"))
require.True(t, ok)
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
_, err := w.Delegate(ctx, nc, "node1", []string{"a.spx3.net"}, false)
require.ErrorContains(t, err, "another node is writing the zone")
node1 := dnsLease(js, "node1")
_, err := w.Delegate(ctx, node1, []string{"a.spx3.net"}, false)
require.ErrorContains(t, err, "another writer has the zone")
release()
_, err = w.Delegate(t.Context(), nc, "node1", []string{"a.spx3.net"}, false)
_, err = w.Delegate(t.Context(), node1, []string{"a.spx3.net"}, false)
require.NoError(t, err)
servers, _, _, err := w.PlatformDelegation("a.spx3.net")
require.NoError(t, err)
assert.Len(t, servers, 2)
}
// The reconcile pass and a delegation on one node share the DNS lease, so a
// delegation waits for this node's own pass as it would for another node's.
func TestDelegateWaitsForThisNodesOwnReconcilePass(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
_, _, js := testutil.StartTestJetStream(t)
prev := delegateLockPoll
delegateLockPoll = 10 * time.Millisecond
t.Cleanup(func() { delegateLockPoll = prev })
shared := dnsLease(js, "node1")
release, ok := reconcilelock.AcquireLeader(t.Context(), shared)
require.True(t, ok, "the reconcile pass holds the lease")
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
_, err := w.Delegate(ctx, shared, []string{"a.spx3.net"}, false)
require.ErrorContains(t, err, "another writer has the zone")
release()
_, err = w.Delegate(t.Context(), shared, []string{"a.spx3.net"}, false)
require.NoError(t, err)
}
+22 -19
View File
@@ -11,7 +11,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler"
"github.com/nats-io/nats.go"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
)
// DefaultReconcileInterval is how often the drift backstop rebuilds managed
@@ -33,6 +33,12 @@ const DefaultReconcileInterval = reconciler.DefaultResync
// costs a cycle for nothing.
const DefaultComputeTimeout = 2 * time.Minute
// NewReconcileLease returns the zone writer's election lease, shared by the
// drift reconcile and Delegate so the two never write the zone together.
func NewReconcileLease(store kvsync.KVStore, holder string) *kvsync.Lease {
return reconcilelock.NewLeaderLease(store, KeyReconcile, holder)
}
// DesiredFunc returns the full desired managed record set built from the live
// resource inventory across all tenants. The daemon supplies it by enumerating
// instances, load balancers, and EKS clusters.
@@ -102,7 +108,8 @@ type Reconciler struct {
// lands in. Distinct from both baseDomain and internalDomain, so pruning it
// needs its own read the same way internalDomain does for EC2.
serviceZone string
nc *nats.Conn
// lease elects the one node that writes the zones; nil skips the election.
lease *kvsync.Lease
// writer applies the converged batch. The reconcile owns the zone object on
// the node that won the election, so the batch never leaves this process.
writer *Writer
@@ -111,7 +118,6 @@ type Reconciler struct {
// computeTimeout bounds how long one pass spends building the desired set.
// A field rather than a constant so a test can shrink it.
computeTimeout time.Duration
holder string
sources []reconciler.Source
}
@@ -121,18 +127,15 @@ type Reconciler struct {
// changes should wake the loop. They are optional: with none supplied the loop
// falls back to the interval alone, which is the behaviour that predates the
// watch.
func NewReconciler(cfg *config.Config, cluster *config.ClusterConfig, nc *nats.Conn, writer *Writer, desired DesiredFunc, sources ...reconciler.Source) *Reconciler {
func NewReconciler(cfg *config.Config, cluster *config.ClusterConfig, lease *kvsync.Lease, writer *Writer, desired DesiredFunc, sources ...reconciler.Source) *Reconciler {
r := &Reconciler{
nc: nc,
lease: lease,
writer: writer,
desired: desired,
interval: DefaultReconcileInterval,
computeTimeout: DefaultComputeTimeout,
sources: sources,
}
if cfg != nil {
r.holder = cfg.Node
}
zoneCfg, ok := zoneS3Config(cfg)
if !ok || desired == nil || !writer.Enabled() {
return r
@@ -166,25 +169,24 @@ func (r *Reconciler) Run(ctx context.Context) {
// No revisit deadline: the desired set is a function of the buckets
// being watched, so a change is the only reason to run early.
Reconcile: func(ctx context.Context) (time.Duration, error) {
r.reconcileOnce(ctx)
return 0, nil
return r.reconcileOnce(ctx), nil
},
Resync: r.interval,
})
}
// reconcileOnce computes the converging batch and applies it, on whichever node
// wins this cycle's election. The read that finds the stale records and the
// wins this cycle's election, and returns the election's revisit when it lost. The read that finds the stale records and the
// write that removes them now happen in one process, so a record re-added
// between them is no longer deleted by a batch that predates it.
func (r *Reconciler) reconcileOnce(ctx context.Context) {
func (r *Reconciler) reconcileOnce(ctx context.Context) time.Duration {
if !r.enabled {
return
return 0
}
if r.nc != nil {
release, elected := reconcilelock.AcquireLeader(ctx, r.nc, KVBucketDNSReconcile, r.holder)
if r.lease != nil {
release, retry, elected := reconcilelock.ElectPass(ctx, r.lease)
if !elected {
return
return retry
}
defer release()
}
@@ -193,18 +195,19 @@ func (r *Reconciler) reconcileOnce(ctx context.Context) {
// A corrupt zone is handled in readZone; anything reaching here left the
// desired state unapplied, so surface it rather than burying it in WARN.
slog.Error("dns reconcile: compute batch failed, retrying next cycle", "error", err)
return
return 0
}
if len(batch) == 0 {
return
return 0
}
slog.Debug("dns reconcile: converging", "changes", len(batch))
res, err := r.writer.ApplyBatch(&ChangeBatch{Changes: batch})
if err != nil {
slog.Warn("dns reconcile: apply failed, retrying next cycle", "changes", len(batch), "error", err)
return
return 0
}
slog.Debug("dns reconcile: converged", "changes", res.Applied, "zones", res.Zones)
return 0
}
// computeBatchBounded runs computeBatch with a deadline, so a pass that never
+21 -12
View File
@@ -8,41 +8,50 @@ import (
"github.com/mulgadc/spinifex/internal/testkit"
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
"github.com/nats-io/nats.go"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// electingReconcilerFor is reconcilerFor with a live connection, so a pass runs
// the election instead of skipping it.
func electingReconcilerFor(w *Writer, nc *nats.Conn, holder string, desired func() DesiredSet) *Reconciler {
// electingReconcilerFor is reconcilerFor with a lease, so a pass runs the
// election instead of skipping it.
func electingReconcilerFor(w *Writer, lease *kvsync.Lease, desired func() DesiredSet) *Reconciler {
r := reconcilerFor(w, desired)
r.nc = nc
r.holder = holder
r.lease = lease
return r
}
// dnsLease returns holder's DNS lease on a store of its own, standing in for
// a separate process.
func dnsLease(js jetstream.JetStream, holder string) *kvsync.Lease {
return NewReconcileLease(kvstore.OpenSyncBucket(js), holder)
}
// The zone lock is gone, and what replaces it is that a zone has one writer:
// the node holding the reconcile lease. A node that loses the election must
// therefore not touch the object at all — if it did, two nodes could be
// read-modify-writing the same zone with nothing serialising them.
func TestReconcileOnce_ALoserOfTheElectionDoesNotWriteTheZone(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
_, _, js := testutil.StartTestJetStream(t)
w, objects := newTestWriter(t)
before := objects[testBase+".toml"]
require.NotEmpty(t, before)
// A peer holds the lease for the whole pass, so this node cannot win it.
release, elected := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "peer")
release, elected := reconcilelock.AcquireLeader(t.Context(), dnsLease(js, "peer"))
require.True(t, elected, "the peer has to hold the lease or this test proves nothing")
r := electingReconcilerFor(w, nc, "node1", func() DesiredSet {
r := electingReconcilerFor(w, dnsLease(js, "node1"), func() DesiredSet {
return DesiredSet{Changes: []Change{upsert("lb-1.elb."+testBase, "10.200.1.9")}}
})
r.reconcileOnce(t.Context())
revisit := r.reconcileOnce(t.Context())
assert.Equal(t, before, objects[testBase+".toml"],
"a node that did not win the election must leave the zone object exactly as it found it")
assert.Positive(t, revisit, "the loser asks to retry so a dead holder is taken over")
assert.LessOrEqual(t, revisit, reconcilelock.LeaderTTL/3, "the retry comes within a renewal interval")
release()
}
@@ -50,10 +59,10 @@ func TestReconcileOnce_ALoserOfTheElectionDoesNotWriteTheZone(t *testing.T) {
// The counterpart, so the test above is not passing because the pass was broken
// for some other reason: with the lease free, the same reconciler writes.
func TestReconcileOnce_TheWinnerOfTheElectionWritesTheZone(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
_, _, js := testutil.StartTestJetStream(t)
w, objects := newTestWriter(t)
r := electingReconcilerFor(w, nc, "node1", func() DesiredSet {
r := electingReconcilerFor(w, dnsLease(js, "node1"), func() DesiredSet {
return DesiredSet{Changes: []Change{upsert("lb-1.elb."+testBase, "10.200.1.9")}}
})
r.reconcileOnce(t.Context())
+5 -7
View File
@@ -224,7 +224,7 @@ func TestReconciler_RunReturnsWhenDisabled(t *testing.T) {
// path of Run: the startup pass must fire before any watch or resync activity,
// and cancelling ctx must still let the loop return.
func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
_, _, js := testutil.StartTestJetStream(t)
endpoint, _ := fakeS3(t, "northstar")
var passes atomic.Int64
@@ -235,8 +235,7 @@ func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1",
AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET",
},
nc: nc,
holder: "test-node",
lease: dnsLease(js, "test-node"),
desired: func() DesiredSet {
passes.Add(1)
return DesiredSet{}
@@ -268,7 +267,7 @@ func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
// acquirable afterwards, not merely on the call returning, since it is the
// leadership and not the pass that the rest of the cluster waits on.
func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
_, _, js := testutil.StartTestJetStream(t)
endpoint, _ := fakeS3(t, "northstar")
blocked := make(chan struct{})
@@ -281,8 +280,7 @@ func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1",
AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET",
},
nc: nc,
holder: "stuck-node",
lease: dnsLease(js, "stuck-node"),
computeTimeout: 100 * time.Millisecond,
desired: func() DesiredSet {
<-blocked
@@ -302,7 +300,7 @@ func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
t.Fatal("reconcileOnce never returned: a stuck pass still holds the lease forever")
}
release, elected := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "next-node")
release, elected := reconcilelock.AcquireLeader(t.Context(), dnsLease(js, "next-node"))
require.True(t, elected, "another node must be able to take the next cycle")
release()
}
+3 -3
View File
@@ -11,9 +11,9 @@ const (
// PUT so every northstar instance reloads just that zone immediately, instead
// of waiting for the next S3 sync poll. No queue group: all servers consume.
SubjectZoneReload = "dns.zone.reload"
// KVBucketDNSReconcile elects the one node that writes the zones for a drift
// cycle. Its own bucket, so the election never blocks another reconcile loop.
KVBucketDNSReconcile = "spinifex-dns-reconcile"
// KeyReconcile elects the one node that writes the zones for a drift cycle.
// Its own key, so the election never blocks another reconcile loop.
KeyReconcile = "dns.reconcile"
// PrivateZone is the fixed AWS-parity private DNS zone (IMDS synthHostname).
PrivateZone = "compute.internal"
// DefaultTTL is applied when a change omits a TTL.
+2 -2
View File
@@ -8,7 +8,7 @@ import (
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -109,7 +109,7 @@ func (s *AccountSettingsServiceImpl) SealAccount(ctx context.Context, accountID
switch {
case writeErr == nil:
return &AccountSettingsSeal{Revision: newRev}, nil
case kvutil.IsRevisionConflict(writeErr):
case kvstore.IsRevisionConflict(writeErr):
continue
default:
slog.ErrorContext(ctx, "Failed to write sealed account settings record", "accountID", accountID, "err", writeErr)
+7 -7
View File
@@ -13,7 +13,7 @@ import (
ebsencryption "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/encryption"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -57,7 +57,7 @@ func NewAccountSettingsServiceImplWithNATS(ctx context.Context, cfg *config.Conf
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
settingsKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketAccountSettings, 10)
settingsKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketAccountSettings, 10)
if err != nil {
return nil, fmt.Errorf("failed to create account settings KV bucket: %w", err)
}
@@ -115,13 +115,13 @@ func mapSettingsError(ctx context.Context, accountID string, err error) error {
return errors.New(awserrors.ErrorBlocked)
case errors.Is(err, errSettingsContended):
slog.ErrorContext(ctx, "Account settings CAS retries exhausted under contention", "accountID", accountID)
case errors.Is(err, kvutil.ErrRead):
case errors.Is(err, kvstore.ErrRead):
slog.ErrorContext(ctx, "Failed to read account settings from KV", "accountID", accountID, "err", err)
case errors.Is(err, kvutil.ErrDecode):
case errors.Is(err, kvstore.ErrDecode):
slog.ErrorContext(ctx, "Corrupt account settings record in KV", "accountID", accountID, "err", err)
case errors.Is(err, kvutil.ErrEncode):
case errors.Is(err, kvstore.ErrEncode):
slog.ErrorContext(ctx, "Failed to marshal account settings record", "accountID", accountID, "err", err)
case errors.Is(err, kvutil.ErrWrite):
case errors.Is(err, kvstore.ErrWrite):
slog.ErrorContext(ctx, "Failed to write account settings to KV", "accountID", accountID, "err", err)
default:
slog.ErrorContext(ctx, "Account settings operation failed", "accountID", accountID, "err", err)
@@ -133,7 +133,7 @@ func mapSettingsError(ctx context.Context, accountID string, err error) error {
// changed is always true, so every success is a leader-accepted write over an
// active value; a seal landing between the read and the write fails the CAS.
func (s *AccountSettingsServiceImpl) mutateSettings(ctx context.Context, accountID string, set func(*AccountSettingsRecord)) error {
_, err := kvutil.Update(ctx, s.settingsKV, settingsKey(accountID), kvutil.CASConfig{
_, err := kvstore.Update(ctx, s.settingsKV, settingsKey(accountID), kvstore.CASConfig{
CreateIfAbsent: true,
Exhausted: func(string, int) error { return errSettingsContended },
}, func(rec *AccountSettingsRecord) (bool, error) {
@@ -0,0 +1,155 @@
package accountcleanup
import (
"context"
"log/slog"
"slices"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
)
// ReasonRecordRemains marks a resource whose record the owner still holds.
const ReasonRecordRemains = "record-remains"
// ComputeRemainder reads what compute the account still holds, changing nothing.
type ComputeRemainder interface {
ComputeRemaining(ctx context.Context, accountID string) (*Outcome, error)
}
// StorageRemainder reads what volumes and tag residue the account still holds,
// changing nothing.
type StorageRemainder interface {
StorageRemaining(ctx context.Context, accountID string) (*Outcome, error)
}
var (
_ ComputeRemainder = (*ComputeCleanup)(nil)
_ StorageRemainder = (*StorageCleanup)(nil)
)
func validRemainderAccount(accountID string) error {
if accountID == "" {
return awserrors.Errorf(awserrors.ErrorMissingParameter, "The request must specify an account ID.")
}
if !awsidentifiers.IsAccountID(accountID) {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "The account ID must be 12 digits.")
}
return nil
}
// ComputeRemaining lists every instance the account still holds and every spot
// request that can still launch. A read that does not complete is an incomplete
// reason, never a shorter list. The result is a snapshot, not evidence of absence.
func (c *ComputeCleanup) ComputeRemaining(ctx context.Context, accountID string) (*Outcome, error) {
if err := validRemainderAccount(accountID); err != nil {
return nil, err
}
if c == nil || c.spot == nil || c.instances == nil || c.teardown == nil {
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "Compute cleanup is not fully wired.")
}
res := &Outcome{AccountID: accountID}
defer res.finish()
scan, err := c.spot.ScanAccount(ctx, accountID)
if err != nil {
slog.WarnContext(ctx, "ComputeRemaining: spot scan failed", "account_id", accountID, "err", err)
res.incomplete(ReasonSpotInventoryIncomplete)
} else {
for _, r := range slices.Concat(scan.Active, scan.Terminal) {
if !terminalSpotState(r.State) {
res.Items = append(res.Items, Item{ResourceID: r.RequestID, Kind: KindSpotRequest, State: r.State, Disposition: DispositionResidue, Reason: ReasonRecordRemains})
}
}
}
inventory, err := c.instances.AccountInstances(ctx, accountID)
if err != nil || !inventoryUsable(inventory) {
slog.WarnContext(ctx, "ComputeRemaining: instance inventory incomplete", "account_id", accountID, "err", err)
res.incomplete(ReasonInstanceInventoryIncomplete)
} else {
for _, reservation := range inventory.Reservations {
for _, in := range reservation.Instances {
state := ""
if in.State != nil {
state = aws.StringValue(in.State.Name)
}
res.Items = append(res.Items, Item{ResourceID: aws.StringValue(in.InstanceId), Kind: KindInstance, State: state, Disposition: DispositionResidue, Reason: ReasonRecordRemains})
}
}
}
outstanding, err := c.teardown.AccountTeardownOutstanding(accountID)
if err != nil {
slog.WarnContext(ctx, "ComputeRemaining: teardown unread", "account_id", accountID, "err", err)
res.incomplete(ReasonTeardownUnread)
} else {
for _, o := range outstanding {
if !res.hasInstance(o.InstanceID) {
res.Items = append(res.Items, Item{ResourceID: o.InstanceID, Kind: KindInstance, State: o.State, Disposition: DispositionResidue, Reason: ReasonTerminating})
}
}
}
return res, nil
}
// StorageRemaining lists every volume record the account still holds, deleting
// ones included, and the tag entries of resources whose owner holds no record.
func (c *StorageCleanup) StorageRemaining(ctx context.Context, accountID string) (*Outcome, error) {
if err := validRemainderAccount(accountID); err != nil {
return nil, err
}
if c == nil || c.volumes == nil || c.instances == nil || c.teardown == nil || c.spot == nil || c.tags == nil {
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "Storage cleanup is not fully wired.")
}
res := &Outcome{AccountID: accountID}
defer res.finish()
volumes, err := c.volumes.VolumeInventory(ctx, accountID)
if err != nil {
slog.WarnContext(ctx, "StorageRemaining: volume inventory incomplete", "account_id", accountID, "err", err)
res.incomplete(ReasonVolumeInventoryIncomplete)
}
for _, v := range volumes {
reason := ReasonRecordRemains
if v.State == ec2.VolumeStateDeleting {
reason = ReasonVolumeDeleting
}
res.Items = append(res.Items, Item{ResourceID: v.VolumeID, Kind: KindVolume, State: v.State, Disposition: DispositionResidue, Reason: reason})
}
live, err := c.instances.AccountInstances(ctx, accountID)
if err != nil || !inventoryUsable(live) {
slog.WarnContext(ctx, "StorageRemaining: no complete instance view", "account_id", accountID, "err", err)
res.incomplete(ReasonHolderProofIncomplete)
return res, nil
}
outstanding, err := c.teardown.AccountTeardownOutstanding(accountID)
if err != nil {
slog.WarnContext(ctx, "StorageRemaining: teardown unread", "account_id", accountID, "err", err)
res.incomplete(ReasonHolderProofIncomplete)
return res, nil
}
tagged, err := c.tags.ScanAccountResourceIDs(ctx, accountID, storageTagPrefixes...)
if err != nil {
slog.WarnContext(ctx, "StorageRemaining: tag index read failed", "account_id", accountID, "err", err)
res.incomplete(ReasonTagIndexUnread)
return res, nil
}
owned, resolved := c.ownedResourceIDs(ctx, accountID, live, outstanding)
for _, id := range tagged {
switch {
case !resolved[tagPrefix(id)]:
res.incomplete(ReasonTagOwnerUnresolved)
res.Items = append(res.Items, Item{ResourceID: id, Kind: KindTag, Disposition: DispositionResidue, Reason: ReasonOwnerRecordsUnresolved})
case owned[id]:
// The resource itself is reported; its tags are not residue.
default:
res.Items = append(res.Items, Item{ResourceID: id, Kind: KindTag, Disposition: DispositionResidue, Reason: ReasonRecordRemains})
}
}
return res, nil
}
@@ -0,0 +1,97 @@
package accountcleanup
import (
"errors"
"testing"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountinventory"
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
ec2spotinstance "github.com/mulgadc/spinifex/spinifex/domains/ec2/spotinstance"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestComputeRemainingReportsEveryHeldInstanceAndOpenSpot(t *testing.T) {
r := newComputeRig()
r.spot.scans = []*ec2spotinstance.AccountScan{{
Active: []ec2spotinstance.AccountSpotRequest{{RequestID: "sir-0aaaaaaaaaaaaaaa1", State: ec2.SpotInstanceStateOpen}},
Terminal: []ec2spotinstance.AccountSpotRequest{{RequestID: "sir-0aaaaaaaaaaaaaaa2", State: ec2.SpotInstanceStateClosed}},
}}
r.teardown.outstanding = []ec2instance.OutstandingInstance{{InstanceID: "i-0aaaaaaaaaaaaaa99", State: ec2.InstanceStateNameTerminated}}
res, err := r.cleanup().ComputeRemaining(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, StatusIncomplete, res.Status)
assert.NotNil(t, itemFor(res, runningID))
assert.NotNil(t, itemFor(res, stoppedID))
assert.NotNil(t, itemFor(res, "i-0aaaaaaaaaaaaaa99"))
assert.NotNil(t, itemFor(res, "sir-0aaaaaaaaaaaaaaa1"))
assert.Nil(t, itemFor(res, "sir-0aaaaaaaaaaaaaaa2"), "a closed request launches nothing")
assert.Empty(t, r.terminator.terminated, "a re-read changes nothing")
}
func TestComputeRemainingCleanWhenNothingHeld(t *testing.T) {
r := newComputeRig()
r.inventory.out = listing(nil)
res, err := r.cleanup().ComputeRemaining(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, StatusClean, res.Status)
}
func TestComputeRemainingUnreadStoreIsIncompleteNeverClean(t *testing.T) {
for name, mutate := range map[string]func(*computeRig){
"instances": func(r *computeRig) { r.inventory.out, r.inventory.err = nil, errors.New("node silent") },
"spot": func(r *computeRig) { r.spot.scanErr = errors.New("spot unreadable") },
"teardown": func(r *computeRig) { r.teardown.err = errors.New("no responder") },
} {
t.Run(name, func(t *testing.T) {
r := newComputeRig()
r.inventory.out = listing(nil)
mutate(r)
res, err := r.cleanup().ComputeRemaining(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, StatusIncomplete, res.Status)
assert.NotEmpty(t, res.IncompleteReasons)
})
}
}
func TestStorageRemainingReportsVolumeRecordsAndTagResidue(t *testing.T) {
r := newStorageRig()
r.volumes.vols = []accountinventory.VolumeItem{
volume(freeVol, ec2.VolumeStateAvailable, ""),
volume("vol-0aaaaaaaaaaaaaaa2", ec2.VolumeStateDeleting, ""),
}
r.tags.ids = []string{"i-0aaaaaaaaaaaaaaaf", freeVol}
res, err := r.cleanup().StorageRemaining(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, StatusIncomplete, res.Status)
assert.Equal(t, ReasonRecordRemains, itemFor(res, freeVol).Reason)
assert.Equal(t, ReasonVolumeDeleting, itemFor(res, "vol-0aaaaaaaaaaaaaaa2").Reason)
assert.Equal(t, KindTag, itemFor(res, "i-0aaaaaaaaaaaaaaaf").Kind, "tags of a resource with no record are residue")
assert.Empty(t, r.deleter.deleted, "a re-read deletes nothing")
assert.Empty(t, r.tags.deleted, "a re-read clears no tag")
}
func TestStorageRemainingUnreadStoreIsIncompleteNeverClean(t *testing.T) {
for name, mutate := range map[string]func(*storageRig){
"volumes": func(r *storageRig) { r.volumes.failAt = -1 },
"instances": func(r *storageRig) { r.instances.out, r.instances.err = nil, errors.New("node silent") },
"teardown": func(r *storageRig) { r.teardown.err = errors.New("no responder") },
"tags": func(r *storageRig) { r.tags.scanErr = errors.New("tag index unreadable") },
} {
t.Run(name, func(t *testing.T) {
r := newStorageRig()
r.volumes.vols = nil
mutate(r)
res, err := r.cleanup().StorageRemaining(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, StatusIncomplete, res.Status)
assert.NotEmpty(t, res.IncompleteReasons)
})
}
}
@@ -0,0 +1,28 @@
package instance
import (
"context"
"fmt"
"time"
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/nats-io/nats.go"
)
// AccountTeardownOutstandingSubject serves the instances of an account whose
// teardown is not complete, from the shared record store.
const AccountTeardownOutstandingSubject = "ec2.AccountTeardownOutstanding"
var accountTeardownOutstandingTimeout = 15 * time.Second
// AccountTeardownOutstanding reads the account's unfinished instance teardowns.
// No responder, a timeout or an error reply is an error, never an empty list.
func AccountTeardownOutstanding(ctx context.Context, natsConn *nats.Conn, accountID string) ([]ec2instance.OutstandingInstance, error) {
out, err := natsmsg.NATSRequest[ec2instance.TeardownOutstandingOutput](ctx, natsConn, AccountTeardownOutstandingSubject,
&ec2instance.TeardownOutstandingInput{}, accountTeardownOutstandingTimeout, accountID)
if err != nil {
return nil, fmt.Errorf("read outstanding teardown: %w", err)
}
return out.Instances, nil
}
@@ -0,0 +1,88 @@
package instance
import (
"context"
"encoding/json"
"testing"
"time"
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func serveTeardownOutstanding(t *testing.T, nc *nats.Conn, reply []byte) {
t.Helper()
_, err := nc.Subscribe(AccountTeardownOutstandingSubject, func(msg *nats.Msg) { _ = msg.Respond(reply) })
require.NoError(t, err)
require.NoError(t, nc.Flush())
}
func TestAccountTeardownOutstanding_ReturnsTheServedInstances(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
reply, err := json.Marshal(ec2instance.TeardownOutstandingOutput{Instances: []ec2instance.OutstandingInstance{{InstanceID: "i-1"}}})
require.NoError(t, err)
serveTeardownOutstanding(t, nc, reply)
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, "i-1", got[0].InstanceID)
}
// A read that cannot be answered must never look like an empty list, because an
// empty list lets the storage cleanup believe no QEMU still holds a volume.
func TestAccountTeardownOutstanding_UnansweredReadIsAnError(t *testing.T) {
t.Parallel()
t.Run("no responder", func(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
require.Error(t, err)
assert.Nil(t, got)
assert.ErrorIs(t, err, nats.ErrNoResponders)
})
t.Run("error reply", func(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
serveTeardownOutstanding(t, nc, awserrors.GenerateErrorPayload(awserrors.ErrorServerInternal))
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
require.Error(t, err)
assert.Nil(t, got)
})
t.Run("unreadable reply", func(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
serveTeardownOutstanding(t, nc, []byte("not json"))
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
require.Error(t, err)
assert.Nil(t, got)
})
}
// Not parallel: it shortens the package timeout.
func TestAccountTeardownOutstanding_TimeoutIsAnError(t *testing.T) {
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe(AccountTeardownOutstandingSubject, func(*nats.Msg) {})
require.NoError(t, err)
require.NoError(t, nc.Flush())
previous := accountTeardownOutstandingTimeout
accountTeardownOutstandingTimeout = 200 * time.Millisecond
t.Cleanup(func() { accountTeardownOutstandingTimeout = previous })
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
require.Error(t, err)
assert.Nil(t, got)
}
@@ -11,6 +11,7 @@ import (
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/nats-io/nats.go"
)
@@ -65,6 +66,20 @@ func ModifyInstanceAttribute(ctx context.Context, input *ec2.ModifyInstanceAttri
slog.InfoContext(ctx, "ModifyInstanceAttribute: Processing request", "instance_id", *input.InstanceId)
// Termination protection on a running instance lives on its owner, so it
// goes there first. A nil value is not a value and keeps the queue path.
if input.DisableApiTermination != nil && input.DisableApiTermination.Value != nil {
cmd := ec2v1.EC2InstanceCommand{
ID: *input.InstanceId,
Attributes: ec2v1.EC2CommandAttributes{SetDisableApiTermination: true},
DisableApiTerminationData: &ec2v1.DisableApiTerminationData{Value: *input.DisableApiTermination.Value},
}
if _, err := routeInstanceMutation[ec2.ModifyInstanceAttributeOutput](ctx, natsConn, *input.InstanceId, accountID, cmd, "ec2.ModifyInstanceAttribute", input); err != nil {
return ec2.ModifyInstanceAttributeOutput{}, err
}
return ec2.ModifyInstanceAttributeOutput{}, nil
}
jsonData, err := json.Marshal(input)
if err != nil {
slog.ErrorContext(ctx, "ModifyInstanceAttribute: Failed to marshal request", "instance_id", *input.InstanceId, "err", err)
@@ -3,10 +3,13 @@ package instance
import (
"context"
"encoding/json"
"sync"
"sync/atomic"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
@@ -226,3 +229,53 @@ func TestModifyInstanceAttribute_ValidationFailure(t *testing.T) {
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
}
func TestModifyInstanceAttribute_DisableApiTerminationRouting(t *testing.T) {
const id = "i-0123456789abcdef0"
_, nc := startTestNATSServer(t)
var gotValues []bool
var mu sync.Mutex
_, err := nc.Subscribe(ec2v1.InstanceCommandSubject(id), func(msg *nats.Msg) {
var cmd ec2v1.EC2InstanceCommand
if json.Unmarshal(msg.Data, &cmd) == nil && cmd.Attributes.SetDisableApiTermination && cmd.DisableApiTerminationData != nil {
mu.Lock()
gotValues = append(gotValues, cmd.DisableApiTerminationData.Value)
mu.Unlock()
}
reply := nats.NewMsg(msg.Reply)
reply.Data = []byte(`{}`)
reply.Header.Set(ec2v1.HandledHeader, "1")
_ = msg.RespondMsg(reply)
})
require.NoError(t, err)
var queue atomic.Int32
_, err = nc.Subscribe("ec2.ModifyInstanceAttribute", func(msg *nats.Msg) {
queue.Add(1)
_ = msg.Respond([]byte(`{}`))
})
require.NoError(t, err)
for _, v := range []bool{true, false} {
_, err := ModifyInstanceAttribute(context.Background(), &ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String(id),
DisableApiTermination: &ec2.AttributeBooleanValue{Value: aws.Bool(v)},
}, nc, "acct")
require.NoError(t, err)
}
mu.Lock()
assert.Equal(t, []bool{true, false}, gotValues, "explicit true and false both reach the owner")
mu.Unlock()
assert.Zero(t, queue.Load())
// A nil value is not a value: queue path, no owner command.
_, err = ModifyInstanceAttribute(context.Background(), &ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String(id),
DisableApiTermination: &ec2.AttributeBooleanValue{},
}, nc, "acct")
require.NoError(t, err)
assert.Equal(t, int32(1), queue.Load())
mu.Lock()
assert.Len(t, gotValues, 2, "no owner command for a nil value")
mu.Unlock()
}

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