Compare commits

...

9 Commits

Author SHA1 Message Date
Ben Duncan 173ac7a936 test(e2e): attach and detach a volume on a stopped instance, as AWS allows (#1508) 2026-11-03 10:14:02 -06:00
Josh Siviour 1c39e29367 fix: stop on guest poweroff and keep stopped instances' volumes attached (#1507)
* test: pin guest poweroff handling and stopped-instance volume state

* test: gofmt the poweroff and stopped-volume tests

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

* test: separate the embedded mounter from the recorder fields

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

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

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

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

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

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

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

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

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

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

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

* test: order imports in stopped-volume daemon tests

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

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

* test: expect append mode on microvm file chardevs

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

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

* test: drop removed append argument from startQEMU calls

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A caller already gone before the publish starts sends nothing, so publish
returns its context error without calling the provider and without the
ambiguous classification; a disconnected connection stays retryable.
2026-11-03 16:38:19 +10:00
120 changed files with 7048 additions and 2798 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ subject shape are wire compatibility. Callers and receivers must use this
package rather than rebuild those details. `commands_test.go` preserves the
version-1 JSON shape and subject form.
This package owns shared command payloads and the drain-volume response. It
This package owns shared command payloads. It
does not define every action's reply body, authorize the request, store EC2
state or implement NATS transport. Those remain responsibilities of the EC2
domain and its runtime composition.
-29
View File
@@ -29,7 +29,6 @@ type EC2InstanceCommand struct {
Attributes EC2CommandAttributes `json:"attributes"`
AttachVolumeData *AttachVolumeData `json:"attach_volume_data,omitempty"`
DetachVolumeData *DetachVolumeData `json:"detach_volume_data,omitempty"`
DrainVolumeData *DrainVolumeData `json:"drain_volume_data,omitempty"`
AttachENIData *AttachENIData `json:"attach_eni_data,omitempty"`
DetachENIData *DetachENIData `json:"detach_eni_data,omitempty"`
IamProfileAssociationData *IamProfileAssociationData `json:"iam_profile_association_data,omitempty"`
@@ -45,7 +44,6 @@ type EC2CommandAttributes struct {
StartInstance bool `json:"start_instance"`
AttachVolume bool `json:"attach_volume"`
DetachVolume bool `json:"detach_volume"`
DrainVolume bool `json:"drain_volume,omitempty"`
RebootInstance bool `json:"reboot_instance"`
AttachENI bool `json:"attach_eni"`
DetachENI bool `json:"detach_eni"`
@@ -69,33 +67,6 @@ type DetachVolumeData struct {
Force bool `json:"force,omitempty"`
}
// DrainVolumeData carries parameters for a drain-volume command, which flushes
// the volume's in-flight writes to S3 before a snapshot reads them. The command
// is addressed to the instance the volume is attached to because the drain
// socket only exists on the node hosting it.
type DrainVolumeData struct {
VolumeID string `json:"volume_id"`
}
const (
// DrainVolumeStatusDrained is the ack a node returns once the volume's
// in-flight writes have reached S3.
DrainVolumeStatusDrained = "drained"
// DrainVolumeStatusNotRunning is the ack a node returns when it still holds
// the instance but the VM is not running, so no process is writing to the
// volume and its sealed checkpoint is already current. An attachment record
// outlives the writer — stop deliberately leaves boot volumes attached — so
// the caller cannot tell this from the record alone.
DrainVolumeStatusNotRunning = "not-running"
)
// DrainVolumeResponse is the reply to a drain-volume command.
type DrainVolumeResponse struct {
VolumeID string `json:"volume_id"`
Status string `json:"status"`
}
// AttachENIData carries parameters for an attach-network-interface command.
type AttachENIData struct {
NetworkInterfaceID string `json:"network_interface_id"`
-14
View File
@@ -16,7 +16,6 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
StartInstance: true,
AttachVolume: true,
DetachVolume: true,
DrainVolume: true,
RebootInstance: true,
AttachENI: true,
DetachENI: true,
@@ -28,7 +27,6 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
},
AttachVolumeData: &AttachVolumeData{VolumeID: "vol-1", Device: "/dev/sdf"},
DetachVolumeData: &DetachVolumeData{VolumeID: "vol-2", Device: "/dev/sdg", Force: true},
DrainVolumeData: &DrainVolumeData{VolumeID: "vol-3"},
AttachENIData: &AttachENIData{NetworkInterfaceID: "eni-1", DeviceIndex: 2},
DetachENIData: &DetachENIData{AttachmentID: "eni-attach-1", Force: true},
IamProfileAssociationData: &IamProfileAssociationData{InstanceProfileArn: "arn:aws:iam::123456789012:instance-profile/example"},
@@ -47,7 +45,6 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
"start_instance": true,
"attach_volume": true,
"detach_volume": true,
"drain_volume": true,
"reboot_instance": true,
"attach_eni": true,
"detach_eni": true,
@@ -59,7 +56,6 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
},
"attach_volume_data": {"volume_id": "vol-1", "device": "/dev/sdf"},
"detach_volume_data": {"volume_id": "vol-2", "device": "/dev/sdg", "force": true},
"drain_volume_data": {"volume_id": "vol-3"},
"attach_eni_data": {"network_interface_id": "eni-1", "device_index": 2},
"detach_eni_data": {"attachment_id": "eni-attach-1", "force": true},
"iam_profile_association_data": {"instance_profile_arn": "arn:aws:iam::123456789012:instance-profile/example"},
@@ -69,16 +65,6 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
}`, string(data))
}
func TestDrainVolumeResponseJSONContract(t *testing.T) {
data, err := json.Marshal(DrainVolumeResponse{
VolumeID: "vol-1",
Status: DrainVolumeStatusDrained,
})
require.NoError(t, err)
require.JSONEq(t, `{"volume_id":"vol-1","status":"drained"}`, string(data))
require.Equal(t, "not-running", DrainVolumeStatusNotRunning)
}
func TestInstanceCommandSubjects(t *testing.T) {
require.Equal(t, "ec2.cmd.i-0123456789abcdef0", InstanceCommandSubject("i-0123456789abcdef0"))
require.Equal(t, "ec2.cmd.*", InstanceCommandSubjectWildcard)
-13
View File
@@ -51,19 +51,6 @@ request/reply replies.
removed its local state and export. These routes and JSON shapes are deployed
legacy compatibility behaviour, not a generic EBS-provider interface.
## Fence route
`VolumeFencedSubject(node)` is `ebs.<node>.fenced`, or `ebs.fenced` with no
node for a single-node daemon. The Viperblock adapter publishes
`VolumeFencedEvent` on it after tearing down an export whose volume lease
moved to another node; the local daemon subscribes to stop the guest that
was using it. Node-addressed, like mount and unmount: the guest is local, and
no other node's daemon has anything to stop.
The event is sent as a request and repeated until the daemon replies with `VolumeFencedAck`, because the fence often runs while the node's NATS server is restarting and a single publish can reach no subscriber.
The daemon replies only when the event carries a reply subject, so a plain publish is still accepted.
Repeats are harmless, since fencing a guest that is already fenced changes nothing.
## Abandon route
`VolumeAbandonSubject(node)` is `ebs.<node>.abandon`. The daemon uses
@@ -9,9 +9,8 @@ import (
"github.com/stretchr/testify/require"
)
// TestVolumeAbandonSubject_BothForms pins the abandon route the same way the
// fenced route is pinned: node-addressed, with an empty-node fallback for a
// single-node daemon.
// TestVolumeAbandonSubject_BothForms pins the abandon route: node-addressed,
// with an empty-node fallback for a single-node daemon.
func TestVolumeAbandonSubject_BothForms(t *testing.T) {
assert.Equal(t, "ebs.node-a.abandon", viperblocklegacyv1.VolumeAbandonSubject("node-a"))
assert.Equal(t, "ebs.abandon", viperblocklegacyv1.VolumeAbandonSubject(""))
-29
View File
@@ -1,29 +0,0 @@
package viperblocklegacyv1
// VolumeFencedSubject is where a fenced volume is announced so the daemon on
// this node can stop the guest that was using it. Node-addressed: the guest is
// local, and every other node's daemon has nothing to do about it.
func VolumeFencedSubject(node string) string {
if node == "" {
return "ebs.fenced"
}
return "ebs." + node + ".fenced"
}
// VolumeFencedEvent announces that a node tore down a volume's export because
// the lease moved. Winner is who holds it now, for the operator reading the
// guest's stop reason.
type VolumeFencedEvent struct {
Volume string `json:"volume"`
Node string `json:"node"`
Winner string `json:"winner"`
Reason string `json:"reason"`
}
// VolumeFencedAck is the daemon's answer to a VolumeFencedEvent. Any answer
// ends the sender's retries; Instance names the guest stopped, if one was, and
// Error says why the daemon could not act on the event.
type VolumeFencedAck struct {
Instance string `json:"instance,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -1,54 +0,0 @@
package viperblocklegacyv1_test
import (
"encoding/json"
"testing"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestVolumeFencedSubject_IsNodeAddressed pins the routing. A fenced guest is on
// the node that lost the volume, so a subject without the node in it would reach
// daemons with nothing to stop and, on a queue group, miss the one that has.
func TestVolumeFencedSubject_IsNodeAddressed(t *testing.T) {
assert.Equal(t, "ebs.node-a.fenced", viperblocklegacyv1.VolumeFencedSubject("node-a"))
assert.Equal(t, "ebs.fenced", viperblocklegacyv1.VolumeFencedSubject(""),
"a single-node daemon has no node name, and still has to hear its own fences")
}
// TestVolumeFencedEvent_JSON pins the exact field spelling of the fenced
// event, published by viperblockd and consumed by the daemon.
func TestVolumeFencedEvent_JSON(t *testing.T) {
golden := `{"volume":"vol-1","node":"node-a","winner":"node-b","reason":"volume lease moved to node-b while this node had the volume open"}`
event := viperblocklegacyv1.VolumeFencedEvent{
Volume: "vol-1",
Node: "node-a",
Winner: "node-b",
Reason: "volume lease moved to node-b while this node had the volume open",
}
marshaled, err := json.Marshal(event)
require.NoError(t, err)
assert.JSONEq(t, golden, string(marshaled))
var decoded viperblocklegacyv1.VolumeFencedEvent
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
assert.Equal(t, event, decoded)
}
// TestVolumeFencedAck_JSON pins the daemon's answer, which is what ends
// viperblockd's retries of a fence notice.
func TestVolumeFencedAck_JSON(t *testing.T) {
golden := `{"instance":"i-1","error":"bad event"}`
ack := viperblocklegacyv1.VolumeFencedAck{Instance: "i-1", Error: "bad event"}
marshaled, err := json.Marshal(ack)
require.NoError(t, err)
assert.JSONEq(t, golden, string(marshaled))
empty, err := json.Marshal(viperblocklegacyv1.VolumeFencedAck{})
require.NoError(t, err)
assert.JSONEq(t, `{}`, string(empty), "an ack for a volume with no local guest carries nothing")
}
+3 -3
View File
@@ -112,14 +112,14 @@ When a daemon handles `RunInstances`:
1. **Resource Check**: Validates CPU/memory availability via `ResourceManager`
2. **Volume Generation**: Creates boot and EFI volumes via Viperblock
3. **Volume Mount**: Sends a mount request to the colocated Viperblock worker on `ebs.{node}.mount`, receives NBD URI
3. **Volume Mount**: Publishes each volume through the EBS provider contract (`PublishVolume` on `ebs.provider.v1.{node}.mount`), receives its export
4. **QEMU Launch**: Builds and executes QEMU command with NBD-backed disks
5. **QMP Monitoring**: Establishes QEMU Machine Protocol connection for VM management
6. **Command subscription**: Subscribes to `ec2.cmd.{instanceID}` for subsequent commands on this VM
```go
// Volume mounting via NATS (runtime/daemon/vm_adapters.go)
reply, err := ebsRequestWithTrace(ctx, a.nc, instance.AccountID, viperblocklegacyv1.MountSubject(a.node), ebsMountRequest, 30*time.Second)
// Volume mounting through the EBS provider contract (runtime/daemon/vm_adapters.go)
published, err := a.provider.PublishVolume(ebsprovider.WithAccountID(ctx, accountID), ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewLatestVersioned(), VolumeID: req.Name, NodeID: a.node})
// QEMU launch with NBD-backed drives in args (runtime/compute/vm/vm.go)
cmd := exec.Command(qemuArchitecture, args...) // qemu-system-x86_64 or qemu-system-aarch64
@@ -23,6 +23,7 @@ resources:
- [Overview](#overview)
- [Launch](#launch)
- [Manage](#manage)
- [Stopping and Data Volumes](#stopping-and-data-volumes)
- [Modify Instance Attributes](#modify-instance-attributes)
- [Instance Metadata Options](#instance-metadata-options)
- [Spot Instances](#spot-instances)
@@ -73,6 +74,31 @@ aws ec2 terminate-instances --instance-ids $INSTANCE_ID
aws ec2 reboot-instances --instance-ids $INSTANCE_ID
```
## Stopping and Data Volumes
A guest that powers itself off (`poweroff`, `shutdown -h`) goes `stopping` then `stopped`, the same as `stop-instances`.
It is not restarted automatically.
`InstanceInitiatedShutdownBehavior=terminate` is not honoured yet: a guest shutdown always stops the instance.
Data volumes stay `in-use` and attached to a stopped instance, and `start-instances` mounts them again.
While the instance is stopped, `attach-volume` of one of its data volumes to another instance returns `VolumeInUse`, and `delete-volume` is refused.
`detach-volume` works on a stopped instance, with or without `--instance-id`.
The volume becomes `available` and the next start does not attach it.
The root volume of a stopped instance can be detached too, as on AWS.
The internal EFI variable store is not a customer volume: it never appears in `describe-volumes` or `BlockDeviceMappings`, and detaching it is refused with `OperationNotPermitted`.
The instance then cannot start: `start-instances` returns `InvalidParameterValue` with "Instance does not have a volume attached at root (/dev/sda1)".
`attach-volume` works on a stopped instance too.
The volume must be `available` and in the instance's availability zone, otherwise the call returns `VolumeInUse` or `InvalidVolume.ZoneMismatch`.
The attachment is recorded on the stopped instance and the volume becomes `in-use` straight away.
Attaching at the root device name (for example `/dev/sda1`) makes the volume the root again, and the next `start-instances` boots from it.
An instance stopped by an earlier build had its data volumes released to `available` while its record still listed them.
On start, a listed data volume that was deleted or is now attached to another instance is dropped with a warning in the daemon log, and the start continues.
A listed volume that is still `available` and unattached is attached again.
A missing root volume fails the start with the same `InvalidParameterValue` answer.
## Modify Instance Attributes
Change instance type, user data, or termination protection. Instance type and user data require the instance to be **stopped** first.
@@ -135,6 +161,30 @@ aws ec2 get-console-output --instance-id $INSTANCE_ID \
--query 'Output' --output text | base64 -d
```
Like AWS, the call returns the most recent 64 KiB of the console.
While an instance is shutting down after a terminate request, the call reads the renamed file described below.
Once the instance has left the node's running set, which includes a stopped or terminated instance, the node no longer answers and the call returns `InvalidInstanceID.NotFound`.
AWS still lists a terminated instance for a while after termination, so a caller may expect this call to work then; it does not here, a known gap.
Whether the API should answer for a stopped instance is not settled here; only the file on the host is kept.
### Where console logs live and how big they get
The serial console of each instance is written on its host to `/run/spinifex/console-<instance-id>.log`.
`/run` is memory-backed, so these files are cleared when the host reboots, including any left by older releases.
The daemon keeps them bounded.
A file over 4 MiB is cut back in place to its newest 1 MiB, and the guest keeps writing while it happens.
The console is opened in append mode, so a reboot or auto-restart of the guest adds to the same file instead of starting over.
A live `console-<instance-id>.log` is deleted only when it has not changed for an hour and no QEMU process on the host has it open, so a stopped instance keeps its console for an hour and a quiet running guest keeps it indefinitely.
If the daemon cannot read the open files of every QEMU process on the host, it deletes nothing on that pass and logs a warning.
This also clears the file of an instance that was stopped and then terminated, or that was recovered on another node, within about an hour of its last write.
When an instance is terminated, including a failed first launch and the system instances behind load balancers, queues and EKS control planes, its file is renamed to `console-<instance-id>.log.terminated`.
The daemon removes a `.terminated` file an hour after its last write.
Known limits:
- The log shipper follows only `console-*.log`, so it stops following a file when it is renamed on terminate, and the last few seconds of output that it had not yet read can be lost.
- The instance password retrieval reads only the live file name, so it does not find a console renamed after terminate.
## Instance Types
List instance types available on the current host. The catalog is generated from the host CPU (Intel, AMD, or ARM) and includes burstable (t-family), general purpose (m-family), compute optimised (c-family), and memory optimised (r-family) types.
+12 -6
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
@@ -28,10 +29,11 @@ func SocketTempDir(t *testing.T) string {
// StartDrainSocket stands in for the NBD plugin's drain socket, at the path the
// plugin serves it on the node hosting volumeID ({dataDir}/viperblock/{volumeID}
// /snapshot.sock). Every connection is answered with ack ("OK\n" for a drain
// that reached S3, "ERR\n" for one that did not) and closed.
func StartDrainSocket(t *testing.T, dataDir, volumeID, ack string) {
// that reached S3, "ERR\n" for one that did not) and closed. connections
// reports how many drains have been asked for so far.
func StartDrainSocket(t *testing.T, dataDir, volumeID, ack string) (connections func() int) {
t.Helper()
serveDrainSocket(t, dataDir, volumeID, ack, nil, nil)
return serveDrainSocket(t, dataDir, volumeID, ack, nil, nil)
}
// StartBlockingDrainSocket stands in for a plugin whose flush is still running:
@@ -43,7 +45,7 @@ func StartBlockingDrainSocket(t *testing.T, dataDir, volumeID, ack string) (acce
acceptedCh := make(chan struct{})
releaseCh := make(chan struct{})
serveDrainSocket(t, dataDir, volumeID, ack, acceptedCh, releaseCh)
_ = serveDrainSocket(t, dataDir, volumeID, ack, acceptedCh, releaseCh)
var once sync.Once
t.Cleanup(func() { once.Do(func() { close(releaseCh) }) })
@@ -52,8 +54,9 @@ func StartBlockingDrainSocket(t *testing.T, dataDir, volumeID, ack string) (acce
}
// serveDrainSocket answers every connection with ack, optionally closing
// accepted on the first one and waiting for release before it replies.
func serveDrainSocket(t *testing.T, dataDir, volumeID, ack string, accepted chan struct{}, release <-chan struct{}) {
// accepted on the first one and waiting for release before it replies. The
// returned func counts the connections accepted so far.
func serveDrainSocket(t *testing.T, dataDir, volumeID, ack string, accepted chan struct{}, release <-chan struct{}) func() int {
t.Helper()
dir := filepath.Join(dataDir, "viperblock", volumeID)
@@ -63,6 +66,7 @@ func serveDrainSocket(t *testing.T, dataDir, volumeID, ack string, accepted chan
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
var count atomic.Int64
go func() {
var once sync.Once
for {
@@ -70,6 +74,7 @@ func serveDrainSocket(t *testing.T, dataDir, volumeID, ack string, accepted chan
if err != nil {
return // listener closed by the cleanup above
}
count.Add(1)
if accepted != nil {
once.Do(func() { close(accepted) })
}
@@ -80,4 +85,5 @@ func serveDrainSocket(t *testing.T, dataDir, volumeID, ack string, accepted chan
_ = conn.Close()
}
}()
return func() int { return int(count.Load()) }
}
+3
View File
@@ -159,6 +159,7 @@ case "${ROLE}" in
svc_start mulga-eks-state-report
svc_start mulga-eks-addon-sync
svc_start mulga-eks-node-gc
svc_optional mulga-eks-log-forward
svc_optional eks-pod-identity-webhook
;;
server-join)
@@ -178,12 +179,14 @@ case "${ROLE}" in
svc_start konnectivity-server
svc_start mulga-eks-state-report
svc_start mulga-eks-node-gc
svc_optional mulga-eks-log-forward
svc_optional eks-pod-identity-webhook
;;
agent)
log "configuring agent role"
svc_enable k3s-agent
svc_start k3s-agent
svc_optional mulga-eks-log-forward
;;
*)
die "SPINIFEX_K3S_ROLE missing or invalid: '${ROLE}'"
@@ -146,6 +146,15 @@ for INIT in openrc systemd; do
)"
check "server(${INIT}): enables node-gc" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-node-gc)")"
check "server(${INIT}): starts node-gc" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-node-gc)")"
check "server(${INIT}): enables log-forward" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-log-forward)")"
check "server(${INIT}): starts log-forward" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-log-forward)")"
check "server(${INIT}): starts log-forward before the webhook" "yes" "$(
awk -v f="$(start_cmd "${INIT}" mulga-eks-log-forward)" -v w="$(start_cmd "${INIT}" eks-pod-identity-webhook)" '
index($0, f) == 1 { fn = NR }
index($0, w) == 1 { wn = NR }
END { print (fn > 0 && wn > fn) ? "yes" : "no" }
' "${CALLS}"
)"
check "server(${INIT}): no k3s-agent" "no" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "server(${INIT}): self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
check "server(${INIT}): pod-identity-webhook is the last call" "$(start_cmd "${INIT}" eks-pod-identity-webhook)" "$(grep -v "$(disable_cmd "${INIT}" eks-node-role)" "${CALLS}" | tail -n 1)"
@@ -196,6 +205,15 @@ for INIT in openrc systemd; do
# published the cluster-identical artifacts, and a join re-publish would
# only race the bootstrap bus. This is deliberate, not an oversight.
check "server-join(${INIT}): pod-identity-webhook is the last call" "$(start_cmd "${INIT}" eks-pod-identity-webhook)" "$(grep -v "$(disable_cmd "${INIT}" eks-node-role)" "${CALLS}" | tail -n 1)"
check "server-join(${INIT}): enables log-forward" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-log-forward)")"
check "server-join(${INIT}): starts log-forward" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-log-forward)")"
check "server-join(${INIT}): starts log-forward before the webhook" "yes" "$(
awk -v f="$(start_cmd "${INIT}" mulga-eks-log-forward)" -v w="$(start_cmd "${INIT}" eks-pod-identity-webhook)" '
index($0, f) == 1 { fn = NR }
index($0, w) == 1 { wn = NR }
END { print (fn > 0 && wn > fn) ? "yes" : "no" }
' "${CALLS}"
)"
check "server-join(${INIT}): no k3s-first-boot" "yes" "$(absent_all "${CALLS}" "k3s-first-boot")"
check "server-join(${INIT}): no k3s-agent" "yes" "$(absent_all "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "server-join(${INIT}): self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
@@ -207,8 +225,39 @@ for INIT in openrc systemd; do
check "agent(${INIT}): exit 0" 0 "${rc}"
check "agent(${INIT}): role file" "agent" "$(cat "${ROLE_FILE}")"
check "agent(${INIT}): enables k3s-agent" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "agent(${INIT}): enables log-forward" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-log-forward)")"
check "agent(${INIT}): starts log-forward" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-log-forward)")"
check "agent(${INIT}): log-forward is enabled after k3s-agent" "yes" "$(
awk -v k="$(enable_cmd "${INIT}" k3s-agent)" -v f="$(enable_cmd "${INIT}" mulga-eks-log-forward)" '
index($0, k) == 1 { kn = NR }
index($0, f) == 1 { fn = NR }
END { print (kn > 0 && fn > kn) ? "yes" : "no" }
' "${CALLS}"
)"
check "agent(${INIT}): no server services" "yes" "$(absent_all "${CALLS}" "eks-token-webhook" "eks-pod-identity-webhook" "k3s-first-boot" "mulga-eks-node-gc" "$(enable_cmd "${INIT}" k3s)")"
# A broken log forwarder must never stop a node joining.
for FAILING in enable start; do
run_case "${INIT}" agent_logfwd_${FAILING}_fail
printf 'SPINIFEX_K3S_ROLE=agent\n' > "${EKS_NODE_ENVFILE}"
if [ "${FAILING}" = enable ]; then
STUB_FAIL="$(enable_cmd "${INIT}" mulga-eks-log-forward)"
export STUB_FAIL
STUB_FAIL="${STUB_FAIL#rc-update add }"; STUB_FAIL="${STUB_FAIL#systemctl enable }"
else
STUB_FAIL="$(start_cmd "${INIT}" mulga-eks-log-forward)"
export STUB_FAIL
STUB_FAIL="${STUB_FAIL#rc-service }"; STUB_FAIL="${STUB_FAIL#systemctl start --no-block }"
fi
invoke
unset STUB_FAIL
check "agent(${INIT}): log-forward ${FAILING} failure still exits 0" 0 "${rc}"
check "agent(${INIT}): log-forward ${FAILING} failure still enables k3s-agent" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "agent(${INIT}): log-forward ${FAILING} failure still starts k3s-agent" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" k3s-agent)")"
check "agent(${INIT}): log-forward ${FAILING} failure still self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
done
# --- agent (inferred from agent.env, no explicit role) ---
run_case "${INIT}" agent_inferred
printf 'K3S_URL=https://nlb:443\nK3S_TOKEN=abc\n' > "${EKS_NODE_AGENT_ENVFILE}"
+2
View File
@@ -45,6 +45,8 @@ scripts/images/eks-node/mulga-eks-addon-sync.initd:/etc/init.d/mulga-eks-addon-s
scripts/images/eks-node/mulga-eks-addon-sync.sh:/usr/local/sbin/mulga-eks-addon-sync \
scripts/images/eks-node/mulga-eks-node-gc.initd:/etc/init.d/mulga-eks-node-gc \
scripts/images/eks-node/mulga-eks-node-gc.sh:/usr/local/sbin/mulga-eks-node-gc \
scripts/images/eks-node/mulga-eks-log-forward.initd:/etc/init.d/mulga-eks-log-forward \
scripts/images/eks-node/mulga-eks-log-forward.sh:/usr/local/sbin/mulga-eks-log-forward \
scripts/images/eks-node/konnectivity-server.initd:/etc/init.d/konnectivity-server \
scripts/images/eks-node/konnectivity-server-prestart.sh:/usr/local/sbin/konnectivity-server-prestart \
scripts/images/eks-node/konnectivity/konnectivity-agent.yaml:/usr/share/spinifex-eks/konnectivity/konnectivity-agent.yaml \
+30
View File
@@ -0,0 +1,30 @@
#!/sbin/openrc-run
# mulga-eks-log-forward — copies k3s warnings and errors to the serial console
# so they reach the Elasticsearch sink. Best effort: nothing depends on it and
# a failure here never affects the node.
description="Forward k3s warnings and errors to the serial console"
command="/bin/sh"
command_background="yes"
supervisor="supervise-daemon"
respawn_delay=5
pidfile="/run/mulga-eks-log-forward.pid"
output_log="/dev/null"
error_log="/dev/null"
depend() {
after k3s k3s-agent
}
start_pre() {
role=$(cat /etc/spinifex-eks/role 2>/dev/null || true)
if [ "${role}" = "agent" ]; then
log=/var/log/k3s-agent.log
FORWARD_PREFIX="[k3s-agent]"
else
log=/var/log/k3s.log
FORWARD_PREFIX="[k3s]"
fi
export FORWARD_PREFIX
command_args="-c 'tail -n +1 -F ${log} 2>/dev/null | /usr/local/sbin/mulga-eks-log-forward'"
}
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
# mulga-eks-log-forward — copy the warnings and errors of a k3s log to the
# serial console, where the host picks them up for the Elasticsearch sink.
#
# Reads the tailed log on stdin. Lines that matter are redacted first, then
# cut to FORWARD_MAX_BYTES, then rate limited: at most FORWARD_RATE_PER_MIN a
# minute reach the console and the rest are reported as one "N lines dropped"
# line. Every console write has a timeout, so a stuck console loses lines
# instead of stalling the tail or k3s.
#
# Env: FORWARD_CONSOLE (default /dev/console), FORWARD_PREFIX, FORWARD_MAX_BYTES
# (512), FORWARD_RATE_PER_MIN (60), FORWARD_WRITE_TIMEOUT seconds (2) and
# FORWARD_CLOCK_CMD (default: seconds of uptime, read without forking).
CONSOLE=${FORWARD_CONSOLE:-/dev/console}
PREFIX=${FORWARD_PREFIX:-[k3s]}
MAX_BYTES=${FORWARD_MAX_BYTES:-512}
RATE=${FORWARD_RATE_PER_MIN:-60}
WRITE_TIMEOUT=${FORWARD_WRITE_TIMEOUT:-2}
CLOCK_CMD=${FORWARD_CLOCK_CMD:-}
now() {
if [ -n "${CLOCK_CMD}" ]; then
sh -c "${CLOCK_CMD}"
else
read -r up _ < /proc/uptime && echo "${up%%.*}"
fi
}
# emit TEXT: one console line, abandoned after WRITE_TIMEOUT. Returns non-zero
# when the console did not take it.
emit() {
timeout "${WRITE_TIMEOUT}" sh -c 'printf "%s\n" "$1" >> "$2"' sh "$1" "${CONSOLE}" 2>/dev/null
}
# Filter, redact and truncate in one awk pass so lines that do not matter cost
# no fork. Redaction runs before the cut so a secret straddling the limit
# cannot leave a readable prefix behind.
select_lines() {
LC_ALL=C awk -v max="${MAX_BYTES}" '
{
line = $0
gsub(/\r/, "", line)
keep = 0
if (line ~ /level=(warning|error)/) keep = 1
else if (line ~ /^[WE][0-9][0-9][0-9][0-9] /) keep = 1
else if (line ~ /NodeNotReady|PLEG|[Ll]ease|[Nn]ode status/) keep = 1
if (!keep) next
gsub(/[Bb]earer +[A-Za-z0-9._~+\/=-]+/, "Bearer [REDACTED]", line)
gsub(/K10[0-9a-f]+(::[A-Za-z0-9-]+:[^ "\047]+)?/, "[REDACTED]", line)
gsub(/eyJ[A-Za-z0-9_.-]+/, "[REDACTED]", line)
gsub(/(AKIA|ASIA)[A-Z0-9]+/, "[REDACTED]", line)
gsub(/X-Amz-Signature=[^& "\047]*/, "X-Amz-Signature=[REDACTED]", line)
gsub(/X-Amz-Credential=[^& "\047]*/, "X-Amz-Credential=[REDACTED]", line)
gsub(/X-Amz-Security-Token=[^& "\047]*/, "X-Amz-Security-Token=[REDACTED]", line)
gsub(/[^[:print:]\t]/, " ", line)
print substr(line, 1, max)
}'
}
win_start=0
sent=0
dropped=0
flush_dropped() {
[ "${dropped}" -gt 0 ] || return 0
emit "${PREFIX} ${dropped} lines dropped"
dropped=0
}
select_lines | {
while IFS= read -r line; do
t=$(now) || t=0
if [ "${win_start}" -eq 0 ] || [ $((t - win_start)) -ge 60 ]; then
flush_dropped
win_start=${t}
sent=0
[ "${win_start}" -eq 0 ] && win_start=1
fi
if [ "${sent}" -ge "${RATE}" ]; then
dropped=$((dropped + 1))
continue
fi
sent=$((sent + 1))
emit "${PREFIX} ${line}" || dropped=$((dropped + 1))
done
flush_dropped
}
exit 0
@@ -0,0 +1,190 @@
#!/bin/sh
# Self-contained POSIX test for mulga-eks-log-forward.sh: the line filter,
# redaction, truncation, rate cap and blocked-console behaviour. The script
# reads the tailed log on stdin and writes to FORWARD_CONSOLE, which the test
# points at a temp file (or a reader-less FIFO), so no root or tty is needed.
#
# Run: sh scripts/images/eks-node/mulga-eks-log-forward_test.sh
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SCRIPT="${SCRIPT_DIR}/mulga-eks-log-forward.sh"
WORK=$(mktemp -d)
trap 'rm -rf "${WORK}"' EXIT
PASS=0
FAIL=0
check() { # check <desc> <expected> <actual>
if [ "$2" = "$3" ]; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
echo "FAIL: $1"
echo " expected: [$2]"
echo " actual: [$3]"
fi
}
# forward <name>: feeds stdin to the script with a per-case console file and a
# fixed clock, leaving the forwarded text in ${OUT}.
forward() {
CASE="${WORK}/$1"
mkdir -p "${CASE}"
OUT="${CASE}/console"
: > "${OUT}"
FORWARD_CONSOLE="${OUT}" FORWARD_PREFIX="[k3s-agent]" \
FORWARD_CLOCK_CMD="echo 1000" sh "${SCRIPT}" 2>/dev/null || true
}
forwarded() { grep -c . "${OUT}" || true; }
# --- filter: lines that must pass ---
forward filter_pass <<'EOF'
time="2026-11-03T03:00:00Z" level=warning msg="slow response from apiserver"
time="2026-11-03T03:00:01Z" level=error msg="failed to renew"
W1103 03:00:02.123456 1234 reflector.go:561] watch of *v1.Pod ended
E1103 03:00:03.123456 1234 kubelet.go:2400] node not found
I1103 03:00:04.123456 1234 kubelet_node_status.go:73] Node status update failed
I1103 03:00:05.123456 1234 controller.go:193] Failed to update lease
I1103 03:00:06.123456 1234 kubelet.go:2200] PLEG is not healthy
time="2026-11-03T03:00:07Z" level=info msg="Event: NodeNotReady kubelet"
EOF
check "filter: all eight interesting lines pass" "8" "$(forwarded)"
check "filter: lines carry the prefix" "8" "$(grep -c '^\[k3s-agent\] ' "${OUT}" || true)"
# --- filter: lines that must be dropped ---
forward filter_drop <<'EOF'
time="2026-11-03T03:00:00Z" level=info msg="Starting k3s agent"
time="2026-11-03T03:00:01Z" level=debug msg="tick"
I1103 03:00:02.123456 1234 server.go:100] Version: v1.32.5
I1103 03:00:03.123456 1234 kubelet.go:1000] SyncLoop (UPDATE, "api")
EOF
check "filter: info and debug lines are dropped" "0" "$(forwarded)"
# --- redaction: one case per secret shape ---
redact() { # redact <name> <line> <secret-that-must-not-appear>
forward "redact_$1" <<EOF
time="2026-11-03T03:00:00Z" level=error msg="$2"
EOF
check "redact(${1}): line still forwarded" "1" "$(forwarded)"
if grep -qF "$3" "${OUT}"; then
check "redact(${1}): secret removed" "absent" "present"
else
check "redact(${1}): secret removed" "absent" "absent"
fi
check "redact(${1}): marker present" "1" "$(grep -c 'REDACTED' "${OUT}" || true)"
}
redact bearer "request failed Authorization: Bearer abc123SECRETvalue456" "abc123SECRETvalue456"
redact k3s_token "join failed token K1009f3c2d4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2::server:s3cr3tPassw0rd" "s3cr3tPassw0rd"
redact jwt "bad token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXNlY3JldA" "eyJhbGciOiJIUzI1NiJ9"
redact akia "credentials AKIAIOSFODNN7EXAMPLE rejected" "AKIAIOSFODNN7EXAMPLE"
redact asia "credentials ASIAIOSFODNN7EXAMPLE rejected" "ASIAIOSFODNN7EXAMPLE"
redact amz_signature "GET /obj?X-Amz-Signature=0123456789abcdef0123456789abcdef&X-Amz-Expires=60 failed" "0123456789abcdef0123456789abcdef"
redact amz_credential "GET /obj?X-Amz-Credential=ZZZsecretscope%2F20261103%2Fap%2Fs3%2Faws4_request failed" "ZZZsecretscope"
# --- truncation: text over 512 bytes is cut ---
LONG=$(awk 'BEGIN { for (i = 0; i < 2000; i++) printf "a"; }')
forward truncate <<EOF
time="2026-11-03T03:00:00Z" level=error msg="${LONG}"
EOF
check "truncate: still forwarded" "1" "$(forwarded)"
BODY=$(sed 's/^\[k3s-agent\] //' "${OUT}" | head -n 1)
check "truncate: body is exactly 512 bytes" "512" "$(printf '%s' "${BODY}" | wc -c | tr -d ' ')"
check "truncate: keeps the start of the line" "time=\"2026-11-03T03:00:00Z\"" "$(printf '%s' "${BODY}" | cut -c1-27)"
# --- redaction runs before the cut: a secret over the limit leaves no prefix ---
# The pad puts the 512-byte cut right after the bare marker (AKIA, eyJ, K10).
# A cut fragment that long matches no redaction pattern, so redacting after the
# cut would leave it in the output.
HEAD='time="2026-11-03T03:00:00Z" level=error msg="'
straddle() { # straddle <name> <secret> <bytes of the secret kept by the cut>
PAD=$(awk -v n=$((512 - ${#HEAD} - 1 - $3)) 'BEGIN { for (i = 0; i < n; i++) printf "x"; }')
forward "straddle_$1" <<EOF
${HEAD}${PAD} $2 tail"
EOF
check "straddle(${1}): line forwarded" "1" "$(forwarded)"
check "straddle(${1}): cut keeps the marker length" "$3" "$(sed 's/^\[k3s-agent\] //' "${OUT}" | head -n 1 | awk -v p="${PAD} " '{ print length($0) - index($0, p) - length(p) + 1 }')"
if grep -qF "$(printf '%s' "$2" | cut -c1-"$3")" "${OUT}"; then
check "straddle(${1}): no fragment survives the cut" "absent" "present"
else
check "straddle(${1}): no fragment survives the cut" "absent" "absent"
fi
}
straddle jwt "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.c2ln" 3
straddle akia "AKIAIOSFODNN7EXAMPLE" 4
straddle k3s_token "K1009f3c2d4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2::server:s3cr3tPassw0rd" 3
# --- rate cap: 60 a minute, then one summary of what was dropped ---
gen() { # gen <count>
awk -v n="$1" 'BEGIN { for (i = 0; i < n; i++) printf "time=\"t\" level=error msg=\"loop %d\"\n", i }'
}
gen 100 > "${WORK}/burst"
forward rate_cap < "${WORK}/burst"
check "rate cap: 60 lines forwarded plus one summary" "61" "$(forwarded)"
check "rate cap: one dropped summary" "1" "$(grep -c '40 lines dropped' "${OUT}" || true)"
check "rate cap: first line kept" "1" "$(grep -c 'loop 0"' "${OUT}" || true)"
check "rate cap: line past the cap is not forwarded" "0" "$(grep -c 'loop 60"' "${OUT}" || true)"
# --- rate cap: a new minute starts a new window and reports the old drops ---
# The clock command advances 60 s every 70 calls, so lines 0-69 share a window
# (10 dropped) and lines 70-74 land in the next one.
CASE="${WORK}/rate_window"
mkdir -p "${CASE}"
OUT="${CASE}/console"
: > "${OUT}"
echo 0 > "${CASE}/calls"
cat > "${CASE}/clock" <<EOF
#!/bin/sh
n=\$(cat "${CASE}/calls")
echo \$((n + 1)) > "${CASE}/calls"
echo \$((1000 + n / 70 * 60))
EOF
chmod +x "${CASE}/clock"
gen 75 | FORWARD_CONSOLE="${OUT}" FORWARD_PREFIX="[k3s-agent]" FORWARD_CLOCK_CMD="${CASE}/clock" sh "${SCRIPT}" 2>/dev/null || true
check "window: 60 + 5 forwarded plus one summary" "66" "$(forwarded)"
check "window: summary names the 10 dropped" "1" "$(grep -c '10 lines dropped' "${OUT}" || true)"
check "window: new window forwards its lines" "1" "$(grep -c 'loop 74"' "${OUT}" || true)"
# --- blocked console: lines are dropped, the forwarder neither stalls nor fails ---
CASE="${WORK}/blocked"
mkdir -p "${CASE}"
mkfifo "${CASE}/console"
gen 3 > "${CASE}/in"
(
FORWARD_CONSOLE="${CASE}/console" FORWARD_PREFIX="[k3s-agent]" FORWARD_CLOCK_CMD="echo 1000" \
FORWARD_WRITE_TIMEOUT=1 sh "${SCRIPT}" < "${CASE}/in"
) &
PID=$!
i=0
while kill -0 "${PID}" 2>/dev/null && [ "${i}" -lt 20 ]; do
sleep 1
i=$((i + 1))
done
if kill -0 "${PID}" 2>/dev/null; then
kill "${PID}" 2>/dev/null || true
check "blocked console: forwarder finished" "finished" "stalled"
else
check "blocked console: forwarder finished" "finished" "finished"
fi
rc=0
wait "${PID}" || rc=$?
check "blocked console: exit 0" "0" "${rc}"
# --- the service is wired in, and nothing depends on it ---
INITD="${SCRIPT_DIR}/mulga-eks-log-forward.initd"
check "initd ships" "yes" "$([ -f "${INITD}" ] && echo yes || echo no)"
check "manifest installs the initd" "1" "$(grep -c 'mulga-eks-log-forward.initd:/etc/init.d/mulga-eks-log-forward' "${SCRIPT_DIR}/manifest.conf" || true)"
check "manifest installs the script" "1" "$(grep -c 'mulga-eks-log-forward.sh:/usr/local/sbin/mulga-eks-log-forward' "${SCRIPT_DIR}/manifest.conf" || true)"
DEPS=0
for f in "${SCRIPT_DIR}"/*.initd; do
[ "${f}" = "${INITD}" ] && continue
if grep -E '^[[:space:]]*(need|use|want)[[:space:]].*mulga-eks-log-forward' "${f}" >/dev/null 2>&1; then
DEPS=$((DEPS + 1))
fi
done
check "nothing depends on the forwarder" "0" "${DEPS}"
if [ -f "${INITD}" ]; then
check "initd does not need anything it could block boot on" "0" "$(grep -cE '^[[:space:]]*need[[:space:]]' "${INITD}" || true)"
fi
echo "mulga-eks-log-forward_test: ${PASS} passed, ${FAIL} failed"
[ "${FAIL}" -eq 0 ]
+2 -2
View File
@@ -45,10 +45,10 @@ ln -sf /usr/local/bin/k3s /usr/local/bin/ctr
chmod 0755 /etc/init.d/eks-node-role /etc/init.d/k3s /etc/init.d/k3s-agent \
/etc/init.d/eks-token-webhook /etc/init.d/k3s-first-boot /etc/init.d/mulga-eks-state-report \
/etc/init.d/mulga-eks-addon-sync /etc/init.d/mulga-eks-node-gc /etc/init.d/konnectivity-server \
/etc/init.d/eks-pod-identity-webhook
/etc/init.d/eks-pod-identity-webhook /etc/init.d/mulga-eks-log-forward
chmod 0755 /usr/local/sbin/eks-node-role /usr/local/sbin/k3s-first-boot \
/usr/local/sbin/mulga-eks-state-report /usr/local/sbin/mulga-eks-addon-sync \
/usr/local/sbin/mulga-eks-node-gc
/usr/local/sbin/mulga-eks-node-gc /usr/local/sbin/mulga-eks-log-forward
# start_pre helpers shared with the systemd wrappers that will eventually
# replace these runscripts.
chmod 0755 /usr/local/sbin/k3s-prestart /usr/local/sbin/k3s-agent-prestart \
@@ -49,6 +49,12 @@ func AttachVolume(ctx context.Context, input *ec2.AttachVolumeInput, natsConn *n
device = *input.Device
}
// The shared record decides: a stopped instance has no QMP to plug into, so
// its record is edited and the volume claimed there instead.
if isStoppedInstance(ctx, instanceID, natsConn, accountID) {
return attachToStopped(ctx, natsConn, accountID, input)
}
command := ec2v1.EC2InstanceCommand{
ID: instanceID,
Attributes: ec2v1.EC2CommandAttributes{
@@ -97,6 +103,24 @@ func AttachVolume(ctx context.Context, input *ec2.AttachVolumeInput, natsConn *n
return output, nil
}
// attachToStoppedSubject is answered by any node and edits the stopped
// instance record in the shared bucket.
const attachToStoppedSubject = "ec2.AttachVolumeToStopped"
// attachToStopped asks the instance service to add the volume to the stopped
// instance's record; the volume is claimed in-use by the same call.
func attachToStopped(ctx context.Context, natsConn *nats.Conn, accountID string, input *ec2.AttachVolumeInput) (ec2.VolumeAttachment, error) {
attachment, err := natsmsg.NATSRequest[ec2.VolumeAttachment](ctx, natsConn, attachToStoppedSubject, input, 30*time.Second, accountID)
if err != nil {
slog.ErrorContext(ctx, "AttachVolume: stopped-instance attach failed", "volumeId", aws.StringValue(input.VolumeId), "instanceId", aws.StringValue(input.InstanceId), "err", err)
return ec2.VolumeAttachment{}, err
}
if attachment == nil {
return ec2.VolumeAttachment{}, nil
}
return *attachment, nil
}
// isStoppedInstance checks the shared KV (via ec2.DescribeStoppedInstances) to
// determine whether instanceID exists as a stopped instance.
func isStoppedInstance(ctx context.Context, instanceID string, natsConn *nats.Conn, accountID string) bool {
@@ -0,0 +1,55 @@
package volume
import (
"context"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The record decides the path: a stopped instance has no QMP to plug into, so
// the attach goes to the instance service and ec2.cmd.<id> is never used.
func TestAttachVolume_StoppedInstanceUsesTheRecordPath(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
log := &subjectLog{}
stoppedRecord := &ec2.DescribeInstancesOutput{Reservations: []*ec2.Reservation{{
Instances: []*ec2.Instance{{InstanceId: aws.String("i-stopped")}},
}}}
handlers := map[string]func(*nats.Msg){
"ec2.DescribeStoppedInstances": func(m *nats.Msg) { respondJSON(t, m, stoppedRecord) },
attachToStoppedSubject: func(m *nats.Msg) {
log.add(attachToStoppedSubject)
respondJSON(t, m, &ec2.VolumeAttachment{
VolumeId: aws.String("vol-data"), InstanceId: aws.String("i-stopped"),
Device: aws.String("/dev/sdf"), State: aws.String("attached"),
})
},
"ec2.cmd.i-stopped": func(m *nats.Msg) {
log.add("ec2.cmd.i-stopped")
_ = m.Respond(nil)
},
}
for subject, h := range handlers {
sub, err := nc.Subscribe(subject, h)
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
require.NoError(t, nc.Flush())
out, err := AttachVolume(context.Background(), &ec2.AttachVolumeInput{
VolumeId: aws.String("vol-data"),
InstanceId: aws.String("i-stopped"),
Device: aws.String("/dev/sdf"),
}, nc, "000000000001")
require.NoError(t, err)
assert.Equal(t, "attached", aws.StringValue(out.State))
assert.Equal(t, []string{attachToStoppedSubject}, log.get(),
"only the record path runs for a stopped instance")
}
@@ -74,6 +74,12 @@ func DetachVolume(ctx context.Context, input *ec2.DetachVolumeInput, natsConn *n
force := input.Force != nil && *input.Force
// The shared record decides: a stopped instance has no QMP to unplug from,
// so its record is edited first and the volume released after.
if isStoppedInstance(ctx, instanceID, natsConn, accountID) {
return detachFromStopped(ctx, natsConn, accountID, volumeID, instanceID, device)
}
command := ec2v1.EC2InstanceCommand{
ID: instanceID,
Attributes: ec2v1.EC2CommandAttributes{
@@ -131,6 +137,10 @@ func DetachVolume(ctx context.Context, input *ec2.DetachVolumeInput, natsConn *n
// running the instance and so cannot help when that host is the problem.
const forceDetachSubject = "ec2.ForceDetachVolume"
// detachFromStoppedSubject is answered by any node and edits the stopped
// instance record in the shared bucket.
const detachFromStoppedSubject = "ec2.DetachVolumeFromStopped"
// forceDetach releases a volume whose instance has no responder.
//
// A volume outlives the instance it was attached to whenever teardown failed
@@ -153,3 +163,27 @@ func forceDetach(ctx context.Context, natsConn *nats.Conn, accountID, volumeID,
}
return *attachment, nil
}
// detachFromStopped removes the volume from the stopped instance's record and
// then releases the volume, in that order, so a failed release leaves the
// record clean and the volume still held rather than the reverse.
func detachFromStopped(ctx context.Context, natsConn *nats.Conn, accountID, volumeID, instanceID, device string) (ec2.VolumeAttachment, error) {
in := &ec2.DetachVolumeInput{VolumeId: &volumeID, InstanceId: &instanceID}
if device != "" {
in.Device = &device
}
if _, err := natsmsg.NATSRequest[ec2.VolumeAttachment](ctx, natsConn, detachFromStoppedSubject, in, 30*time.Second, accountID); err != nil {
slog.ErrorContext(ctx, "DetachVolume: stopped-instance record edit failed", "volumeId", volumeID, "instanceId", instanceID, "err", err)
return ec2.VolumeAttachment{}, err
}
attachment, err := natsmsg.NATSRequest[ec2.VolumeAttachment](ctx, natsConn, forceDetachSubject,
&ec2.DetachVolumeInput{VolumeId: &volumeID, Force: aws.Bool(true)}, 30*time.Second, accountID)
if err != nil {
slog.ErrorContext(ctx, "DetachVolume: release after record edit failed", "volumeId", volumeID, "instanceId", instanceID, "err", err)
return ec2.VolumeAttachment{}, err
}
if attachment == nil {
return ec2.VolumeAttachment{}, nil
}
return *attachment, nil
}
@@ -0,0 +1,115 @@
package volume
import (
"context"
"encoding/json"
"sync"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type subjectLog struct {
mu sync.Mutex
seen []string
}
func (l *subjectLog) add(s string) {
l.mu.Lock()
defer l.mu.Unlock()
l.seen = append(l.seen, s)
}
func (l *subjectLog) get() []string {
l.mu.Lock()
defer l.mu.Unlock()
return append([]string(nil), l.seen...)
}
func respondJSON(t *testing.T, msg *nats.Msg, v any) {
t.Helper()
data, err := json.Marshal(v)
require.NoError(t, err)
require.NoError(t, msg.Respond(data))
}
// The record decides the path. A stopped instance has no QMP to unplug from, so
// its record is edited and the volume released, and ec2.cmd.<id> is never used.
func TestDetachVolume_StoppedInstanceEditsTheRecordThenReleasesTheVolume(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
log := &subjectLog{}
stoppedRecord := &ec2.DescribeInstancesOutput{Reservations: []*ec2.Reservation{{
Instances: []*ec2.Instance{{InstanceId: aws.String("i-stopped")}},
}}}
handlers := map[string]func(*nats.Msg){
"ec2.DescribeStoppedInstances": func(m *nats.Msg) { respondJSON(t, m, stoppedRecord) },
detachFromStoppedSubject: func(m *nats.Msg) {
log.add(detachFromStoppedSubject)
respondJSON(t, m, &ec2.VolumeAttachment{
VolumeId: aws.String("vol-data"), InstanceId: aws.String("i-stopped"), Device: aws.String("/dev/sdf"),
})
},
forceDetachSubject: func(m *nats.Msg) {
log.add(forceDetachSubject)
respondJSON(t, m, &ec2.VolumeAttachment{
VolumeId: aws.String("vol-data"), InstanceId: aws.String("i-stopped"), State: aws.String("detached"),
})
},
"ec2.cmd.i-stopped": func(m *nats.Msg) {
log.add("ec2.cmd.i-stopped")
_ = m.Respond(nil)
},
}
for subject, h := range handlers {
sub, err := nc.Subscribe(subject, h)
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
require.NoError(t, nc.Flush())
out, err := DetachVolume(context.Background(), &ec2.DetachVolumeInput{
VolumeId: aws.String("vol-data"),
InstanceId: aws.String("i-stopped"),
}, nc, "000000000001")
require.NoError(t, err)
assert.Equal(t, "detached", aws.StringValue(out.State))
assert.Equal(t, []string{detachFromStoppedSubject, forceDetachSubject}, log.get(),
"record first, then the volume; the host path is never used for a stopped instance")
}
// NoResponders alone must not be read as "stopped": a dead or partitioned node
// gives the same answer, and editing the record then would race a live QEMU.
func TestDetachVolume_NoRespondersAloneDoesNotMeanStopped(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
log := &subjectLog{}
for _, subject := range []string{detachFromStoppedSubject, forceDetachSubject} {
sub, err := nc.Subscribe(subject, func(m *nats.Msg) {
log.add(subject)
_ = m.Respond(nil)
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
sub, err := nc.Subscribe("ec2.DescribeStoppedInstances", func(m *nats.Msg) {
respondJSON(t, m, &ec2.DescribeInstancesOutput{})
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
require.NoError(t, nc.Flush())
_, err = DetachVolume(context.Background(), &ec2.DetachVolumeInput{
VolumeId: aws.String("vol-data"),
InstanceId: aws.String("i-silent-node"),
}, nc, "000000000001")
require.Error(t, err)
assert.Empty(t, log.get(), "no record edit and no control-plane release without a stopped record")
}
-140
View File
@@ -1,140 +0,0 @@
package image
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/volumestate"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const drainImageInstanceID = "i-image-drain-host"
// setupDrainImageService mirrors domains/ec2/snapshot's setupDrainService: a
// reachable (but rejecting) Predastore host so a drained volume falls through
// into the real snapshot path and fails fast, a data dir with no local drain
// socket, and a NATS connection to route the drain to the hosting node.
func setupDrainImageService(t *testing.T) (*ImageServiceImpl, *objectstore.MemoryObjectStore, *nats.Conn) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
t.Cleanup(srv.Close)
_, nc := testutil.StartTestNATS(t)
store := objectstore.NewMemoryObjectStore()
cfg := &config.Config{
DataDir: testutil.SocketTempDir(t),
Predastore: config.PredastoreConfig{
Host: srv.URL,
Bucket: testBucket,
},
}
svc := NewImageServiceImplWithConfig(cfg, store, nc)
return svc, store, nc
}
func seedImageVolumeAttachment(t *testing.T, store *objectstore.MemoryObjectStore, volumeID, state, instanceID string) {
t.Helper()
require.NoError(t, volumestate.Write(context.Background(), store, testBucket, volumeID, volumestate.Record{
State: state,
AttachedInstance: instanceID,
}))
}
func drainImageResponder(t *testing.T, nc *nats.Conn, instanceID string, reply []byte) chan *nats.Msg {
t.Helper()
got := make(chan *nats.Msg, 4)
sub, err := nc.Subscribe("ec2.cmd."+instanceID, func(msg *nats.Msg) {
got <- msg
_ = msg.Respond(reply)
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
return got
}
func drainedImageAck(t *testing.T, volumeID string) []byte {
t.Helper()
data, err := json.Marshal(ec2v1.DrainVolumeResponse{VolumeID: volumeID, Status: ec2v1.DrainVolumeStatusDrained})
require.NoError(t, err)
return data
}
func awaitImageDrainCommand(t *testing.T, got chan *nats.Msg) ec2v1.EC2InstanceCommand {
t.Helper()
select {
case msg := <-got:
var command ec2v1.EC2InstanceCommand
require.NoError(t, json.Unmarshal(msg.Data, &command))
return command
case <-time.After(2 * time.Second):
t.Fatal("the node hosting the volume never received a drain command")
return ec2v1.EC2InstanceCommand{}
}
}
// CreateImageFromInstance's IsRunning path must drain the volume's hosting node
// before it reads the live checkpoint — the same guarantee ec2.CreateSnapshot
// has. Without this, a checkpoint predating a guest-triggered GPT rewrite
// (e.g. growpart on first boot) can be captured silently.
func TestSnapshotRunningVolume_DrainsAttachedVolume(t *testing.T) {
svc, store, nc := setupDrainImageService(t)
createTestVolumeConfig(t, store, "vol-image-drain", 10)
seedImageVolumeAttachment(t, store, "vol-image-drain", "in-use", drainImageInstanceID)
got := drainImageResponder(t, nc, drainImageInstanceID, drainedImageAck(t, "vol-image-drain"))
err := svc.snapshotRunningVolume("vol-image-drain", "snap-image-drain", testAccountID)
// The rejecting Predastore stub fails the snapshot itself; what matters
// here is that the drain reached the hosting node first.
require.Error(t, err)
command := awaitImageDrainCommand(t, got)
assert.True(t, command.Attributes.DrainVolume)
assert.Equal(t, drainImageInstanceID, command.ID)
require.NotNil(t, command.DrainVolumeData)
assert.Equal(t, "vol-image-drain", command.DrainVolumeData.VolumeID)
}
// An attached volume whose host reports the drain failed must fail
// CreateImage rather than register an AMI built from a stale checkpoint.
func TestSnapshotRunningVolume_UndrainableAttachedVolumeFails(t *testing.T) {
svc, store, nc := setupDrainImageService(t)
createTestVolumeConfig(t, store, "vol-image-nodrain", 10)
seedImageVolumeAttachment(t, store, "vol-image-nodrain", "in-use", drainImageInstanceID)
drainImageResponder(t, nc, drainImageInstanceID,
[]byte(`{"Code":"`+awserrors.ErrorServerInternal+`","Message":"drain failed"}`))
err := svc.snapshotRunningVolume("vol-image-nodrain", "snap-image-nodrain", testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
// An unattached (stopped-instance) volume takes the no-drain path: the
// checkpoint Close() left behind is already current.
func TestSnapshotRunningVolume_AvailableSkipsDrain(t *testing.T) {
svc, store, _ := setupDrainImageService(t)
createTestVolumeConfig(t, store, "vol-image-available", 10)
seedImageVolumeAttachment(t, store, "vol-image-available", "available", "")
svc.natsConn = nil
// No responder exists and the socket is never dialled: proceeding straight
// to the (rejecting) snapshot path proves the drain was skipped, not stuck.
err := svc.snapshotRunningVolume("vol-image-available", "snap-image-available", testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
@@ -2,16 +2,20 @@ package image
import (
"context"
"sync/atomic"
"testing"
"time"
"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/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -96,15 +100,15 @@ func TestGetVolumeMetadata_Provider_UnknownVolumeIsNotFound(t *testing.T) {
assert.Equal(t, awserrors.ErrorInvalidVolumeNotFound, err.Error())
}
// TestSnapshotStoppedVolume_Provider_CreatesThroughProvider locks that a
// stopped-volume snapshot under a provider is created via provider.CreateSnapshot
// TestSnapshotVolume_Provider_CreatesThroughProvider locks that a
// root-volume snapshot under a provider is created via provider.CreateSnapshot
// and never falls through to building an embedded engine — the Predastore host
// is a closed port, so any fallback would fail with connection refused.
func TestSnapshotStoppedVolume_Provider_CreatesThroughProvider(t *testing.T) {
func TestSnapshotVolume_Provider_CreatesThroughProvider(t *testing.T) {
svc, _, provider := setupProviderSnapshotImageService(t)
doc := seedProviderVolume(t, svc, provider, "vol-stopped001", 8)
err := svc.snapshotStoppedVolume(doc.VolumeID, "snap-stopped001", testAccountID)
err := svc.snapshotVolume(doc.VolumeID, "snap-stopped001", testAccountID)
require.NoError(t, err, "the provider path must not fall through to the embedded engine against the closed Predastore port")
// CreateSnapshot is idempotent on (SnapshotID, VolumeID): re-issuing it
@@ -118,22 +122,22 @@ func TestSnapshotStoppedVolume_Provider_CreatesThroughProvider(t *testing.T) {
assert.Equal(t, doc.VolumeID, snap.SourceVolumeID)
}
// TestSnapshotRunningVolume_Provider_PassesProviderHandleAsVolumeHandle locks
// TestSnapshotVolume_Provider_PassesProviderHandleAsVolumeHandle locks
// that the ebsmetadata document's ProviderHandle is threaded through as
// CreateSnapshotRequest.VolumeHandle. A wrong handle must be rejected by the
// provider (MemoryProvider treats an empty VolumeHandle as "skip the check",
// so only a mismatched non-empty value proves the real value is forwarded).
func TestSnapshotRunningVolume_Provider_PassesProviderHandleAsVolumeHandle(t *testing.T) {
func TestSnapshotVolume_Provider_PassesProviderHandleAsVolumeHandle(t *testing.T) {
svc, _, provider := setupProviderSnapshotImageService(t)
doc := seedProviderVolume(t, svc, provider, "vol-running001", 8)
err := svc.snapshotRunningVolume(doc.VolumeID, "snap-running001", testAccountID)
err := svc.snapshotVolume(doc.VolumeID, "snap-running001", testAccountID)
require.NoError(t, err, "the correct provider handle must let CreateSnapshot succeed")
doc.ProviderHandle = "wrong-handle"
require.NoError(t, svc.MetadataStore().PutVolume(context.Background(), doc))
err = svc.snapshotRunningVolume(doc.VolumeID, "snap-running002", testAccountID)
err = svc.snapshotVolume(doc.VolumeID, "snap-running002", testAccountID)
require.Error(t, err, "a mismatched provider handle must be rejected, proving the value came from the document")
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
@@ -222,3 +226,98 @@ func TestCreateImageFromInstance_SetsEnaSupportFromInstance(t *testing.T) {
require.NotNil(t, ami.EnaSupport)
assert.True(t, *ami.EnaSupport)
}
// createUnavailableProvider refuses every CreateSnapshot as retry-later.
type createUnavailableProvider struct {
ebsprovider.EBSProvider
}
func (p *createUnavailableProvider) CreateSnapshot(context.Context, ebsprovider.CreateSnapshotRequest) (*ebsprovider.Snapshot, error) {
return nil, &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnavailable, Message: "held by node-2"}
}
// TestSnapshotVolume_Provider_UnavailableIsRetryLater locks that the
// CreateImage snapshot surfaces a provider's retry-later as ServiceUnavailable.
func TestSnapshotVolume_Provider_UnavailableIsRetryLater(t *testing.T) {
svc, _, provider := setupProviderSnapshotImageService(t)
doc := seedProviderVolume(t, svc, provider, "vol-stopped-busy", 8)
svc.SetEBSProvider(&createUnavailableProvider{EBSProvider: provider})
err := svc.snapshotVolume(doc.VolumeID, "snap-stopped-busy", testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServiceUnavailable, err.Error())
}
// createInUseProvider refuses every CreateSnapshot as volume in use.
type createInUseProvider struct {
ebsprovider.EBSProvider
}
func (p *createInUseProvider) CreateSnapshot(context.Context, ebsprovider.CreateSnapshotRequest) (*ebsprovider.Snapshot, error) {
return nil, &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeVolumeInUse, Message: "published"}
}
// TestSnapshotVolume_Provider_VolumeInUseIsIncorrectState locks that the
// CreateImage snapshot tells the caller to detach a volume the provider refuses as in use.
func TestSnapshotVolume_Provider_VolumeInUseIsIncorrectState(t *testing.T) {
svc, _, provider := setupProviderSnapshotImageService(t)
doc := seedProviderVolume(t, svc, provider, "vol-attached-inuse", 8)
svc.SetEBSProvider(&createInUseProvider{EBSProvider: provider})
err := svc.snapshotVolume(doc.VolumeID, "snap-attached-inuse", testAccountID)
code, message, ok := awserrors.ResolveErrorDetail(err)
require.True(t, ok, "unregistered error: %v", err)
assert.Equal(t, awserrors.ErrorIncorrectState, code)
assert.Contains(t, message, doc.VolumeID)
assert.Contains(t, message, "Detach")
}
// countingSnapshotProvider counts the CreateSnapshot calls it forwards.
type countingSnapshotProvider struct {
ebsprovider.EBSProvider
creates atomic.Int32
}
func (p *countingSnapshotProvider) CreateSnapshot(ctx context.Context, req ebsprovider.CreateSnapshotRequest) (*ebsprovider.Snapshot, error) {
p.creates.Add(1)
return p.EBSProvider.CreateSnapshot(ctx, req)
}
// An image of a running instance is snapshotted by the provider, which drains
// a buffered volume itself, so CreateImage neither dials the drain socket nor
// asks the instance's host to drain.
func TestCreateImageFromInstance_RunningInstanceLeavesTheDrainToTheProvider(t *testing.T) {
const instanceID = "i-running-drain"
svc, _, provider := setupProviderSnapshotImageService(t)
_, nc := testutil.StartTestNATS(t)
svc.natsConn = nc
svc.config.DataDir = testutil.SocketTempDir(t)
counting := &countingSnapshotProvider{EBSProvider: provider}
svc.SetEBSProvider(counting)
doc := seedProviderVolume(t, svc, provider, "vol-running-drain", 8)
doc.State, doc.AttachedInstance = "in-use", instanceID
require.NoError(t, svc.MetadataStore().PutVolume(context.Background(), doc))
drains := testutil.StartDrainSocket(t, svc.config.DataDir, doc.VolumeID, "OK\n")
var commands atomic.Int32
sub, err := nc.Subscribe(ec2v1.InstanceCommandSubject(instanceID), func(msg *nats.Msg) {
commands.Add(1)
_ = msg.Respond([]byte(`{"Code":"InternalError","Message":"no drain socket"}`))
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
out, err := svc.CreateImageFromInstance(CreateImageParams{
Input: &ec2.CreateImageInput{InstanceId: aws.String(instanceID), Name: aws.String("running-ami")},
RootVolumeID: doc.VolumeID,
IsRunning: true,
}, testAccountID)
require.NoError(t, err)
require.NotNil(t, out.ImageId)
assert.Equal(t, int32(1), counting.creates.Load(), "the provider must take the snapshot")
assert.Zero(t, drains(), "CreateImage must not dial the drain socket")
assert.Zero(t, commands.Load(), "CreateImage must not ask the instance's host to drain")
}
+13 -52
View File
@@ -38,7 +38,7 @@ type CreateImageParams struct {
Input *ec2.CreateImageInput
RootVolumeID string
SourceImageID string
IsRunning bool // true = use live checkpoint (instance still running), false = use numbered checkpoint from Close
IsRunning bool // logged only: the provider's CreateSnapshot drains a live volume itself
}
// ImageServiceImpl handles AMI image operations with S3 storage.
@@ -69,9 +69,7 @@ func (s *ImageServiceImpl) MetadataStore() *ebsmetadata.Store {
return s.metadata
}
// NewImageServiceImpl creates a new daemon-side image service. natsConn is used
// to drain a running instance's volume (routed to the node hosting it) before
// CreateImageFromInstance reads its live checkpoint.
// NewImageServiceImpl creates a new daemon-side image service.
func NewImageServiceImpl(cfg *config.Config, natsConn *nats.Conn) *ImageServiceImpl {
store := objectstore.NewS3ObjectStoreFromConfig(
cfg.Predastore.Host,
@@ -99,8 +97,7 @@ func NewImageServiceImplWithStore(store objectstore.ObjectStore, bucketName stri
}
// NewImageServiceImplWithConfig creates an image service with an explicit
// config and NATS connection (for testing the running-volume drain path,
// which needs config.DataDir/Predastore and a NATS connection to route to).
// config, object store and NATS connection (for testing).
func NewImageServiceImplWithConfig(cfg *config.Config, store objectstore.ObjectStore, natsConn *nats.Conn) *ImageServiceImpl {
return &ImageServiceImpl{
config: cfg,
@@ -447,15 +444,9 @@ func (s *ImageServiceImpl) CreateImageFromInstance(params CreateImageParams, acc
"rootVolumeId", params.RootVolumeID, "amiId", amiID, "snapshotId", snapshotID,
"isRunning", params.IsRunning)
// Step 1: Snapshot root volume (live via NATS or offline from S3)
var snapshotErr error
if params.IsRunning {
snapshotErr = s.snapshotRunningVolume(params.RootVolumeID, snapshotID, accountID)
} else {
snapshotErr = s.snapshotStoppedVolume(params.RootVolumeID, snapshotID, accountID)
}
if snapshotErr != nil {
return nil, snapshotErr
// Step 1: Snapshot root volume
if err := s.snapshotVolume(params.RootVolumeID, snapshotID, accountID); err != nil {
return nil, err
}
// Step 2: Read source volume config for size
@@ -521,51 +512,21 @@ func (s *ImageServiceImpl) CreateImageFromInstance(params CreateImageParams, acc
}, nil
}
// snapshotRunningVolume creates a snapshot of a running instance's volume by
// draining it to the node hosting it (so any writes buffered there reach S3),
// then reading the live checkpoint written by nbdkit on every NBD Flush. This
// shares domains/ec2/snapshot's DrainVolume with ec2.CreateSnapshot so the two
// live-snapshot paths cannot diverge on when a drain is required.
func (s *ImageServiceImpl) snapshotRunningVolume(volumeID, snapshotID, accountID string) error {
// snapshotVolume snapshots the root volume through the EBS provider, whether
// or not the instance is running. A provider that buffers writes drains the
// volume inside its own CreateSnapshot, so no drain is issued here.
func (s *ImageServiceImpl) snapshotVolume(volumeID, snapshotID, accountID string) error {
if s.config == nil {
return errors.New(awserrors.ErrorServerInternal)
}
volMeta, err := s.getVolumeMetadata(context.Background(), accountID, volumeID)
if err != nil {
slog.Error("snapshotRunningVolume: failed to read volume metadata", "volumeId", volumeID, "err", err)
slog.Error("snapshotVolume: failed to read volume metadata", "volumeId", volumeID, "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Flush the writes still buffered by whichever node serves the volume, so
// the snapshot below is current rather than an arbitrarily stale one
// predating e.g. a guest-triggered GPT rewrite (growpart on first boot). An
// attached volume that cannot be drained fails here rather than silently
// producing an image from a stale checkpoint.
if err := ec2snapshot.DrainVolume(context.Background(), s.config, s.store, s.natsConn, volumeID, volMeta.State, volMeta.AttachedInstance, accountID); err != nil {
slog.Error("snapshotRunningVolume: drain failed", "volumeId", volumeID, "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
return s.createSnapshot(context.Background(), volMeta, snapshotID, "snapshotRunningVolume")
}
// snapshotStoppedVolume creates a snapshot of a detached volume. No drain: the
// volume is stopped/detached, so nothing is buffered on a serving node.
// viperblockd's handleCreateSnapshot prefers a live mounted engine over opening
// a second one, so it handles the mounted case itself.
func (s *ImageServiceImpl) snapshotStoppedVolume(volumeID, snapshotID, accountID string) error {
if s.config == nil {
return errors.New(awserrors.ErrorServerInternal)
}
volMeta, err := s.getVolumeMetadata(context.Background(), accountID, volumeID)
if err != nil {
slog.Error("snapshotStoppedVolume: failed to read volume metadata", "volumeId", volumeID, "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
return s.createSnapshot(context.Background(), volMeta, snapshotID, "snapshotStoppedVolume")
return s.createSnapshot(context.Background(), volMeta, snapshotID, "snapshotVolume")
}
// createSnapshot delegates snapshot creation for volMeta to the EBS provider.
@@ -580,7 +541,7 @@ func (s *ImageServiceImpl) createSnapshot(ctx context.Context, volMeta ebsmetada
})
if err != nil || created == nil {
slog.ErrorContext(ctx, "provider CreateSnapshot failed", "op", op, "volumeId", volMeta.VolumeID, "snapshotId", snapshotID, "err", err)
return errors.New(awserrors.ErrorServerInternal)
return ec2snapshot.ProviderCreateError(volMeta.VolumeID, err)
}
slog.InfoContext(ctx, "snapshot created", "op", op, "volumeId", volMeta.VolumeID, "snapshotId", snapshotID)
return nil
@@ -2551,56 +2551,19 @@ func TestResetImageAttribute_MissingParameters(t *testing.T) {
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
}
func TestSnapshotRunningVolume_VolumeConfigMissing(t *testing.T) {
func TestSnapshotVolume_VolumeConfigMissing(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := &ImageServiceImpl{
config: &config.Config{},
store: store,
bucketName: testBucket,
}
err := svc.snapshotRunningVolume("vol-missing", "snap-r1", testAccountID)
err := svc.snapshotVolume("vol-missing", "snap-s1", testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
func TestSnapshotRunningVolume_BackendInitFails(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
store := objectstore.NewMemoryObjectStore()
svc := &ImageServiceImpl{
config: &config.Config{
Predastore: config.PredastoreConfig{
Host: srv.URL,
Region: "us-east-1",
Bucket: testBucket,
},
},
store: store,
bucketName: testBucket,
}
createTestVolumeConfig(t, store, "vol-running1", 10)
err := svc.snapshotRunningVolume("vol-running1", "snap-r2", testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
func TestSnapshotStoppedVolume_VolumeConfigMissing(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := &ImageServiceImpl{
config: &config.Config{},
store: store,
bucketName: testBucket,
}
err := svc.snapshotStoppedVolume("vol-missing", "snap-s1", testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
func TestSnapshotStoppedVolume_BackendInitFails(t *testing.T) {
func TestSnapshotVolume_BackendInitFails(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
@@ -2620,7 +2583,7 @@ func TestSnapshotStoppedVolume_BackendInitFails(t *testing.T) {
}
createTestVolumeConfig(t, store, "vol-stopped1", 10)
err := svc.snapshotStoppedVolume("vol-stopped1", "snap-s2", testAccountID)
err := svc.snapshotVolume("vol-stopped1", "snap-s2", testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
@@ -2646,60 +2609,7 @@ func imageS3MockServer(t *testing.T) *httptest.Server {
}))
}
func TestSnapshotRunningVolume_EncryptionKeyLoadError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
store := objectstore.NewMemoryObjectStore()
svc := &ImageServiceImpl{
config: &config.Config{
Predastore: config.PredastoreConfig{
Host: srv.URL,
Region: "us-east-1",
Bucket: testBucket,
},
Viperblock: config.ViperblockConfig{
EncryptionKeyFile: "/nonexistent-image-run-key.bin",
},
},
store: store,
bucketName: testBucket,
}
createTestVolumeConfig(t, store, "vol-running-enckey", 10)
err := svc.snapshotRunningVolume("vol-running-enckey", "snap-run-enckey", testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
func TestSnapshotRunningVolume_LoadStateFails(t *testing.T) {
srv := imageS3MockServer(t)
defer srv.Close()
store := objectstore.NewMemoryObjectStore()
svc := &ImageServiceImpl{
config: &config.Config{
Predastore: config.PredastoreConfig{
Host: srv.URL,
Region: "us-east-1",
Bucket: testBucket,
AccessKey: "test-key",
SecretKey: "test-secret",
},
},
store: store,
bucketName: testBucket,
}
createTestVolumeConfig(t, store, "vol-running-lsf", 10)
err := svc.snapshotRunningVolume("vol-running-lsf", "snap-run-lsf", testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
func TestSnapshotStoppedVolume_EncryptionKeyLoadError(t *testing.T) {
func TestSnapshotVolume_EncryptionKeyLoadError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
@@ -2722,12 +2632,12 @@ func TestSnapshotStoppedVolume_EncryptionKeyLoadError(t *testing.T) {
}
createTestVolumeConfig(t, store, "vol-stopped-enckey", 10)
err := svc.snapshotStoppedVolume("vol-stopped-enckey", "snap-stop-enckey", testAccountID)
err := svc.snapshotVolume("vol-stopped-enckey", "snap-stop-enckey", testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
func TestSnapshotStoppedVolume_LoadStateFails(t *testing.T) {
func TestSnapshotVolume_LoadStateFails(t *testing.T) {
srv := imageS3MockServer(t)
defer srv.Close()
@@ -2747,7 +2657,7 @@ func TestSnapshotStoppedVolume_LoadStateFails(t *testing.T) {
}
createTestVolumeConfig(t, store, "vol-stopped-lsf", 10)
err := svc.snapshotStoppedVolume("vol-stopped-lsf", "snap-stop-lsf", testAccountID)
err := svc.snapshotVolume("vol-stopped-lsf", "snap-stop-lsf", testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
@@ -2700,6 +2700,12 @@ func (s *InstanceServiceImpl) launchClaimedInstance(ctx context.Context, instanc
// failure of this launch.
instance.FirstLaunch = false
if err := s.reconcileStartVolumes(ctx, instance); err != nil {
slog.ErrorContext(ctx, "launchClaimedInstance: volumes cannot be reconciled", "instanceId", instance.ID, "err", err)
undo()
return err
}
instanceType, ok := s.resourceMgr.InstanceTypes()[instance.InstanceType]
if !ok {
slog.ErrorContext(ctx, "launchClaimedInstance: instance type not available on this node",
@@ -3674,3 +3680,153 @@ func (s *InstanceServiceImpl) DescribeInstanceStatus(ctx context.Context, input
slog.InfoContext(ctx, "DescribeInstanceStatus completed", "count", len(statuses))
return &ec2.DescribeInstanceStatusOutput{InstanceStatuses: statuses}, nil
}
// DetachVolumeFromStopped removes a data volume from a stopped instance's
// record, so a later start no longer attaches it. It does not touch the
// volume's own state; the caller releases that once the record is clean. The
// device the volume held comes back on the attachment.
func (s *InstanceServiceImpl) DetachVolumeFromStopped(ctx context.Context, input *ec2.DetachVolumeInput, accountID string) (*ec2.VolumeAttachment, error) {
if input == nil || aws.StringValue(input.InstanceId) == "" || aws.StringValue(input.VolumeId) == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
if s.stoppedStore == nil {
slog.ErrorContext(ctx, "DetachVolumeFromStopped: stopped store not available")
return nil, errors.New(awserrors.ErrorServerInternal)
}
instanceID, volumeID := *input.InstanceId, *input.VolumeId
stored, err := s.stoppedStore.LoadStoppedInstance(instanceID)
if err != nil {
slog.ErrorContext(ctx, "DetachVolumeFromStopped: failed to load stopped instance", "instanceId", instanceID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
if stored == nil || !IsInstanceVisible(accountID, stored.AccountID) {
return nil, errors.New(awserrors.ErrorInvalidInstanceIDNotFound)
}
req, ok := findVolumeRequest(stored, volumeID)
if !ok {
return nil, errors.New(awserrors.ErrorIncorrectState)
}
if req.EFI {
return nil, errors.New(awserrors.ErrorOperationNotPermitted)
}
var removed bool
_, err = s.stoppedStore.UpdateStoppedInstance(instanceID, func(v *vm.VM) {
removed = removeVolumeFromRecord(v, volumeID)
})
if err != nil {
if errors.Is(err, kvstore.ErrNotFound) {
slog.WarnContext(ctx, "DetachVolumeFromStopped: instance claimed concurrently", "instanceId", instanceID)
return nil, errors.New(awserrors.ErrorIncorrectInstanceState)
}
slog.ErrorContext(ctx, "DetachVolumeFromStopped: failed to update stopped instance", "instanceId", instanceID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
if !removed {
return nil, errors.New(awserrors.ErrorIncorrectState)
}
return &ec2.VolumeAttachment{
VolumeId: aws.String(volumeID),
InstanceId: aws.String(instanceID),
Device: aws.String(req.DeviceName),
State: aws.String(ec2.VolumeAttachmentStateDetaching),
}, nil
}
// AttachVolumeToStopped adds an available volume to a stopped instance's
// record, so the next start mounts it. The volume is claimed in-use first and
// released again if the record edit fails, so a failure never leaves the record
// naming a volume another instance could take.
func (s *InstanceServiceImpl) AttachVolumeToStopped(ctx context.Context, input *ec2.AttachVolumeInput, accountID string) (*ec2.VolumeAttachment, error) {
if input == nil || aws.StringValue(input.InstanceId) == "" || aws.StringValue(input.VolumeId) == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
if s.stoppedStore == nil || s.metadata == nil {
slog.ErrorContext(ctx, "AttachVolumeToStopped: stores not available")
return nil, errors.New(awserrors.ErrorServerInternal)
}
instanceID, volumeID := *input.InstanceId, *input.VolumeId
stored, err := s.stoppedStore.LoadStoppedInstance(instanceID)
if err != nil {
slog.ErrorContext(ctx, "AttachVolumeToStopped: failed to load stopped instance", "instanceId", instanceID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
if stored == nil || !IsInstanceVisible(accountID, stored.AccountID) {
return nil, errors.New(awserrors.ErrorInvalidInstanceIDNotFound)
}
meta, err := s.metadata.GetVolume(ctx, accountID, volumeID)
if err != nil {
if objectstore.IsNoSuchKeyError(err) {
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
}
slog.ErrorContext(ctx, "AttachVolumeToStopped: failed to read volume", "volumeId", volumeID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
if meta.TenantID != accountID {
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
}
if meta.State != "available" {
return nil, errors.New(awserrors.ErrorVolumeInUse)
}
if meta.AvailabilityZone != "" && s.config != nil && s.config.AZ != "" && meta.AvailabilityZone != s.config.AZ {
return nil, errors.New(awserrors.ErrorInvalidVolumeZoneMismatch)
}
device, boot, err := stoppedAttachDevice(stored, aws.StringValue(input.Device))
if err != nil {
return nil, err
}
meta.State = "in-use"
meta.AttachedInstance = instanceID
meta.DeviceName = device
meta.AttachedAt = time.Now()
if err := s.metadata.PutVolume(ctx, meta); err != nil {
slog.ErrorContext(ctx, "AttachVolumeToStopped: failed to claim volume", "volumeId", volumeID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
var conflict error
_, err = s.stoppedStore.UpdateStoppedInstance(instanceID, func(v *vm.VM) {
// The device was chosen against a snapshot, so it is checked again
// against the record the edit is applied to.
if _, _, cerr := stoppedAttachDevice(v, device); cerr != nil {
conflict = cerr
return
}
addVolumeToRecord(v, volumeID, device, boot)
})
if err == nil && conflict != nil {
err = conflict
}
if err != nil {
meta.State = "available"
meta.AttachedInstance = ""
meta.DeviceName = ""
meta.AttachedAt = time.Time{}
if rerr := s.metadata.PutVolume(ctx, meta); rerr != nil {
slog.ErrorContext(ctx, "AttachVolumeToStopped: failed to release volume after a failed record edit", "volumeId", volumeID, "err", rerr)
}
if conflict != nil {
return nil, conflict
}
if errors.Is(err, kvstore.ErrNotFound) {
slog.WarnContext(ctx, "AttachVolumeToStopped: instance claimed concurrently", "instanceId", instanceID)
return nil, errors.New(awserrors.ErrorIncorrectInstanceState)
}
slog.ErrorContext(ctx, "AttachVolumeToStopped: failed to update stopped instance", "instanceId", instanceID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
return &ec2.VolumeAttachment{
VolumeId: aws.String(volumeID),
InstanceId: aws.String(instanceID),
Device: aws.String(device),
State: aws.String(ec2.VolumeAttachmentStateAttaching),
}, nil
}
@@ -0,0 +1,54 @@
//test:in-package — it swaps the manager's volume mounter, an unexported dependency.
package instance
import (
"context"
"errors"
"sync"
"testing"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mountRecorder notes which volumes a launch asked to mount and then fails the
// launch, which is the earliest point that shows what the start would attach.
type mountRecorder struct {
refusingMounter
mu sync.Mutex
mounted []string
}
func (r *mountRecorder) Mount(_ context.Context, v *vm.VM) error {
r.mu.Lock()
defer r.mu.Unlock()
for _, req := range v.EBSRequests.Requests {
r.mounted = append(r.mounted, req.Name)
}
return errors.New("stop after recording")
}
func (r *mountRecorder) MountOne(context.Context, string, *viperblocklegacyv1.EBSRequest) error {
return nil
}
// A volume detached while the instance was stopped must not come back when the
// instance is started: the start path reads the same record the detach edited.
func TestStartStoppedInstance_DoesNotReattachAVolumeDetachedWhileStopped(t *testing.T) {
id := "i-detach-then-start"
stopped := stoppedWithVolumes("acc", id)
stopped.InstanceType = "t3.micro"
f := newStartFixture(t, stopped)
rec := &mountRecorder{}
f.svc.vmMgr.SetDeps(vm.Deps{VolumeMounter: rec})
_, err := f.svc.DetachVolumeFromStopped(context.Background(), stoppedDetachInput(id, "vol-data"), "acc")
require.NoError(t, err)
require.Error(t, f.start(t, id), "the recorder fails the launch after the mount")
assert.Equal(t, []string{"vol-root", "vol-other"}, rec.mounted,
"the detached volume is not part of the start")
}
@@ -0,0 +1,86 @@
package instance
import (
"context"
"fmt"
"log/slog"
"slices"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
ebsmetadata "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
)
// reconcileStartVolumes drops the data volumes a claimed instance can no longer
// have, and fails the start when its boot volume is gone.
func (s *InstanceServiceImpl) reconcileStartVolumes(ctx context.Context, instance *vm.VM) error {
if s.metadata == nil {
return nil
}
instance.EBSRequests.Mu.Lock()
requests := slices.Clone(instance.EBSRequests.Requests)
instance.EBSRequests.Mu.Unlock()
if !slices.ContainsFunc(requests, func(r viperblocklegacyv1.EBSRequest) bool { return r.Boot }) {
return noRootVolumeError(instance)
}
var drop []string
for _, req := range requests {
meta, err := s.metadata.GetVolume(ctx, instance.AccountID, req.Name)
missing := err != nil && objectstore.IsNoSuchKeyError(err)
if err != nil && !missing {
return fmt.Errorf("read volume %s: %w", req.Name, err)
}
if req.Boot {
if missing {
slog.ErrorContext(ctx, "start: root volume no longer exists", "instanceId", instance.ID, "volumeId", req.Name)
return noRootVolumeError(instance)
}
continue
}
if req.EFI {
continue
}
if missing {
slog.WarnContext(ctx, "start: dropping deleted data volume", "instanceId", instance.ID, "volumeId", req.Name)
drop = append(drop, req.Name)
continue
}
if meta.AttachedInstance != "" && meta.AttachedInstance != instance.ID {
slog.WarnContext(ctx, "start: dropping data volume attached to another instance",
"instanceId", instance.ID, "volumeId", req.Name, "attachedTo", meta.AttachedInstance)
drop = append(drop, req.Name)
}
}
if len(drop) == 0 {
return nil
}
instance.EBSRequests.Mu.Lock()
instance.EBSRequests.Requests = slices.DeleteFunc(instance.EBSRequests.Requests, func(r viperblocklegacyv1.EBSRequest) bool {
return slices.Contains(drop, r.Name)
})
instance.EBSRequests.Mu.Unlock()
if instance.Instance != nil {
instance.Instance.BlockDeviceMappings = slices.DeleteFunc(instance.Instance.BlockDeviceMappings, func(m *ec2.InstanceBlockDeviceMapping) bool {
return m != nil && m.Ebs != nil && slices.Contains(drop, aws.StringValue(m.Ebs.VolumeId))
})
}
return nil
}
// noRootVolumeError is what AWS answers when an instance is started with
// nothing attached at its root device name.
func noRootVolumeError(instance *vm.VM) error {
device := ebsmetadata.RootDeviceName
if instance.Instance != nil && aws.StringValue(instance.Instance.RootDeviceName) != "" {
device = aws.StringValue(instance.Instance.RootDeviceName)
}
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "Instance does not have a volume attached at root (%s)", device)
}
@@ -0,0 +1,125 @@
package instance
import (
"context"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const startVolumesAccount = "000000000011"
func startVolumesService(t *testing.T, volumes ...ebsmetadata.Volume) *InstanceServiceImpl {
t.Helper()
store := objectstore.NewMemoryObjectStore()
meta := ebsmetadata.NewStore(store, testRootBucket)
for _, v := range volumes {
v.TenantID = startVolumesAccount
require.NoError(t, meta.PutVolume(context.Background(), v))
}
return &InstanceServiceImpl{metadata: meta}
}
func startingInstance(id string, names ...string) *vm.VM {
v := &vm.VM{ID: id, AccountID: startVolumesAccount, Status: vm.StateStopped, Instance: &ec2.Instance{InstanceId: aws.String(id)}}
for i, n := range names {
boot := i == 0
device := "/dev/sd" + string(rune('f'+i))
if boot {
device = "/dev/sda1"
}
v.EBSRequests.Requests = append(v.EBSRequests.Requests, viperblocklegacyv1.EBSRequest{Name: n, Boot: boot, DeviceName: device})
v.Instance.BlockDeviceMappings = append(v.Instance.BlockDeviceMappings, &ec2.InstanceBlockDeviceMapping{
DeviceName: aws.String(device), Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String(n)},
})
}
return v
}
func requestedNames(v *vm.VM) []string {
var out []string
for _, r := range v.EBSRequests.Requests {
out = append(out, r.Name)
}
return out
}
func mappedNames(v *vm.VM) []string {
var out []string
for _, m := range v.Instance.BlockDeviceMappings {
out = append(out, aws.StringValue(m.Ebs.VolumeId))
}
return out
}
// An instance stopped on an older build had its data volumes released, and they
// may since have been attached elsewhere or deleted; starting must not take a
// volume that is no longer its own.
func TestReconcileStartVolumes_DropsVolumesThatAreNoLongerTheInstances(t *testing.T) {
svc := startVolumesService(t,
ebsmetadata.Volume{VolumeID: "vol-root", State: "in-use", AttachedInstance: "i-old"},
ebsmetadata.Volume{VolumeID: "vol-elsewhere", State: "in-use", AttachedInstance: "i-someone-else"},
ebsmetadata.Volume{VolumeID: "vol-free", State: "available"},
ebsmetadata.Volume{VolumeID: "vol-mine", State: "in-use", AttachedInstance: "i-old"},
)
inst := startingInstance("i-old", "vol-root", "vol-elsewhere", "vol-gone", "vol-free", "vol-mine")
require.NoError(t, svc.reconcileStartVolumes(context.Background(), inst))
want := []string{"vol-root", "vol-free", "vol-mine"}
assert.Equal(t, want, requestedNames(inst),
"attached-elsewhere and deleted volumes are dropped; free and own ones stay")
assert.Equal(t, want, mappedNames(inst), "the API view stops listing them too")
}
func TestReconcileStartVolumes_MissingBootVolumeFailsTheStart(t *testing.T) {
svc := startVolumesService(t, ebsmetadata.Volume{VolumeID: "vol-data", State: "available"})
inst := startingInstance("i-old", "vol-root", "vol-data")
inst.Instance.RootDeviceName = aws.String("/dev/sda1")
err := svc.reconcileStartVolumes(context.Background(), inst)
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInvalidParameterValue), "got %v", err)
assert.Contains(t, err.Error(), "Instance does not have a volume attached at root (/dev/sda1)")
}
// A root volume detached while the instance was stopped leaves no boot request
// in the record; AWS refuses the start with the same answer.
func TestReconcileStartVolumes_NoRootVolumeAttachedFailsTheStart(t *testing.T) {
svc := startVolumesService(t, ebsmetadata.Volume{VolumeID: "vol-data", State: "in-use", AttachedInstance: "i-old"})
inst := startingInstance("i-old", "vol-root", "vol-data")
require.True(t, removeVolumeFromRecord(inst, "vol-root"))
inst.Instance.RootDeviceName = aws.String("/dev/sda1")
err := svc.reconcileStartVolumes(context.Background(), inst)
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInvalidParameterValue), "got %v", err)
assert.Contains(t, err.Error(), "Instance does not have a volume attached at root (/dev/sda1)")
assert.Equal(t, []string{"vol-data"}, requestedNames(inst), "a refused start changes nothing")
}
// An instance stopped on an older build had its data volumes released to
// available with no attachment; those are still the instance's own and a
// start must bring them back.
func TestReconcileStartVolumes_ReattachesStillAvailableUnattachedVolume(t *testing.T) {
svc := startVolumesService(t,
ebsmetadata.Volume{VolumeID: "vol-root", State: "in-use", AttachedInstance: "i-old"},
ebsmetadata.Volume{VolumeID: "vol-released", State: "available"},
)
inst := startingInstance("i-old", "vol-root", "vol-released")
require.NoError(t, svc.reconcileStartVolumes(context.Background(), inst))
assert.Equal(t, []string{"vol-root", "vol-released"}, requestedNames(inst))
assert.Equal(t, []string{"vol-root", "vol-released"}, mappedNames(inst))
}
@@ -0,0 +1,180 @@
//test:in-package — it swaps the manager's volume mounter, an unexported dependency.
package instance
import (
"context"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
ebsmetadata "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
vmmock "github.com/mulgadc/spinifex/spinifex/runtime/compute/vm/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const stoppedAttachAZ = "ap-southeast-2a"
type stoppedAttachFixture struct {
svc *InstanceServiceImpl
stored *vm.VM
id string
}
func newStoppedAttachFixture(t *testing.T, volumes ...ebsmetadata.Volume) *stoppedAttachFixture {
t.Helper()
id := "i-stopped-attach"
svc := startVolumesService(t, volumes...)
stored := startingInstance(id, "vol-root", "vol-data")
svc.config = &config.Config{AZ: stoppedAttachAZ}
svc.vmMgr = mgrWith(map[string]*vm.VM{})
svc.stoppedStore = &vmmock.StateStore{Stopped: map[string]*vm.VM{id: stored}}
return &stoppedAttachFixture{svc: svc, stored: stored, id: id}
}
func (f *stoppedAttachFixture) attach(volumeID, device string) (*ec2.VolumeAttachment, error) {
in := &ec2.AttachVolumeInput{InstanceId: aws.String(f.id), VolumeId: aws.String(volumeID)}
if device != "" {
in.Device = aws.String(device)
}
return f.svc.AttachVolumeToStopped(context.Background(), in, startVolumesAccount)
}
func (f *stoppedAttachFixture) volume(t *testing.T, id string) ebsmetadata.Volume {
t.Helper()
v, err := f.svc.metadata.GetVolume(context.Background(), startVolumesAccount, id)
require.NoError(t, err)
return v
}
func availableVolume(id string) ebsmetadata.Volume {
return ebsmetadata.Volume{VolumeID: id, State: "available", AvailabilityZone: stoppedAttachAZ}
}
// AWS lets a volume be attached to a stopped instance; the record then names it
// so the next start mounts it, and the volume is held in-use meanwhile.
func TestAttachVolumeToStopped_AddsTheVolumeAndClaimsIt(t *testing.T) {
f := newStoppedAttachFixture(t, availableVolume("vol-new"))
out, err := f.attach("vol-new", "")
require.NoError(t, err)
assert.Equal(t, "/dev/sdf", aws.StringValue(out.Device), "the first free data device")
assert.Equal(t, []string{"vol-root", "vol-data", "vol-new"}, requestedNames(f.stored))
assert.Equal(t, []string{"vol-root", "vol-data", "vol-new"}, mappedNames(f.stored))
assert.Equal(t, vm.StateStopped, f.stored.Status)
got := f.volume(t, "vol-new")
assert.Equal(t, "in-use", got.State)
assert.Equal(t, f.id, got.AttachedInstance)
assert.Equal(t, "/dev/sdf", got.DeviceName)
for _, r := range f.stored.EBSRequests.Requests {
if r.Name == "vol-new" {
assert.False(t, r.Boot, "a data device is not the root")
}
}
}
func TestAttachVolumeToStopped_Refusals(t *testing.T) {
tests := []struct {
name string
volumes []ebsmetadata.Volume
volume string
device string
want string
}{
{"volume in use elsewhere", []ebsmetadata.Volume{{VolumeID: "vol-busy", State: "in-use", AttachedInstance: "i-other", AvailabilityZone: stoppedAttachAZ}}, "vol-busy", "", awserrors.ErrorVolumeInUse},
{"wrong availability zone", []ebsmetadata.Volume{{VolumeID: "vol-far", State: "available", AvailabilityZone: "ap-southeast-2b"}}, "vol-far", "", awserrors.ErrorInvalidVolumeZoneMismatch},
{"no such volume", nil, "vol-missing", "", awserrors.ErrorInvalidVolumeNotFound},
{"device already taken", []ebsmetadata.Volume{availableVolume("vol-new")}, "vol-new", "/dev/sdg", awserrors.ErrorInvalidParameterValue},
{"root device already has a volume", []ebsmetadata.Volume{availableVolume("vol-new")}, "vol-new", "/dev/sda1", awserrors.ErrorInvalidParameterValue},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newStoppedAttachFixture(t, tt.volumes...)
_, err := f.attach(tt.volume, tt.device)
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, tt.want), "got %v, want %s", err, tt.want)
assert.Equal(t, []string{"vol-root", "vol-data"}, requestedNames(f.stored), "a refused attach changes nothing")
for _, v := range tt.volumes {
got := f.volume(t, v.VolumeID)
assert.Equal(t, v.State, got.State, "a refused attach leaves the volume as it was")
assert.Equal(t, v.AttachedInstance, got.AttachedInstance)
}
})
}
}
func TestAttachVolumeToStopped_OtherAccountsInstanceIsNotFound(t *testing.T) {
f := newStoppedAttachFixture(t, availableVolume("vol-new"))
_, err := f.svc.AttachVolumeToStopped(context.Background(),
&ec2.AttachVolumeInput{InstanceId: aws.String(f.id), VolumeId: aws.String("vol-new")}, "000000000099")
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInvalidInstanceIDNotFound))
assert.Equal(t, []string{"vol-root", "vol-data"}, requestedNames(f.stored))
}
// A record edit that cannot be applied must give the volume back, otherwise it
// stays in-use against an instance that does not name it.
func TestAttachVolumeToStopped_FailedRecordEditReleasesTheVolume(t *testing.T) {
f := newStoppedAttachFixture(t, availableVolume("vol-new"))
f.svc.stoppedStore = &vmmock.StateStore{Stopped: map[string]*vm.VM{f.id: f.stored}, UpdateStoppedErr: assert.AnError}
_, err := f.attach("vol-new", "")
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorServerInternal))
got := f.volume(t, "vol-new")
assert.Equal(t, "available", got.State)
assert.Empty(t, got.AttachedInstance)
}
// Detaching the root volume of a stopped instance makes it unstartable; the way
// back is attaching a volume at the root device name, which makes it the boot
// volume again so the start finds a root.
func TestAttachVolumeToStopped_RootDeviceNameRestoresTheRootAndTheStartWorks(t *testing.T) {
id := "i-root-roundtrip"
stopped := stoppedWithVolumes(startVolumesAccount, id)
stopped.InstanceType = "t3.micro"
stopped.Instance.RootDeviceName = aws.String("/dev/sda1")
f := newStartFixture(t, stopped)
rec := &mountRecorder{}
f.svc.vmMgr.SetDeps(vm.Deps{VolumeMounter: rec})
f.svc.config = &config.Config{AZ: stoppedAttachAZ}
meta := startVolumesService(t,
ebsmetadata.Volume{VolumeID: "vol-root", State: "in-use", AttachedInstance: id, AvailabilityZone: stoppedAttachAZ},
ebsmetadata.Volume{VolumeID: "vol-data", State: "in-use", AttachedInstance: id, AvailabilityZone: stoppedAttachAZ},
ebsmetadata.Volume{VolumeID: "vol-other", State: "in-use", AttachedInstance: id, AvailabilityZone: stoppedAttachAZ},
)
f.svc.metadata = meta.metadata
stopped.AccountID = startVolumesAccount
_, err := f.svc.DetachVolumeFromStopped(context.Background(), stoppedDetachInput(id, "vol-root"), startVolumesAccount)
require.NoError(t, err)
vol, err := f.svc.metadata.GetVolume(context.Background(), startVolumesAccount, "vol-root")
require.NoError(t, err)
vol.State, vol.AttachedInstance = "available", ""
require.NoError(t, f.svc.metadata.PutVolume(context.Background(), vol))
out, err := f.svc.AttachVolumeToStopped(context.Background(),
&ec2.AttachVolumeInput{InstanceId: aws.String(id), VolumeId: aws.String("vol-root"), Device: aws.String("/dev/sda1")}, startVolumesAccount)
require.NoError(t, err)
assert.Equal(t, "/dev/sda1", aws.StringValue(out.Device))
var boot bool
for _, r := range stopped.EBSRequests.Requests {
if r.Name == "vol-root" {
boot = r.Boot
}
}
assert.True(t, boot, "the root device name makes it the boot volume")
_, err = f.svc.StartStoppedInstance(context.Background(), &StartStoppedInstanceInput{InstanceID: id}, startVolumesAccount)
require.Error(t, err, "the recorder fails the launch after the mount")
assert.Contains(t, rec.mounted, "vol-root", "the start now mounts the restored root")
}
@@ -0,0 +1,112 @@
package instance
import (
"errors"
"fmt"
"slices"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
ebsmetadata "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
)
func findVolumeRequest(v *vm.VM, volumeID string) (viperblocklegacyv1.EBSRequest, bool) {
v.EBSRequests.Mu.Lock()
defer v.EBSRequests.Mu.Unlock()
i := slices.IndexFunc(v.EBSRequests.Requests, func(r viperblocklegacyv1.EBSRequest) bool { return r.Name == volumeID })
if i < 0 {
return viperblocklegacyv1.EBSRequest{}, false
}
return v.EBSRequests.Requests[i], true
}
// removeVolumeFromRecord drops the volume from the mount list and the API view
// of the record, and reports whether it was a data volume that was there.
func removeVolumeFromRecord(v *vm.VM, volumeID string) bool {
v.EBSRequests.Mu.Lock()
defer v.EBSRequests.Mu.Unlock()
i := slices.IndexFunc(v.EBSRequests.Requests, func(r viperblocklegacyv1.EBSRequest) bool { return r.Name == volumeID })
if i < 0 || v.EBSRequests.Requests[i].EFI {
return false
}
v.EBSRequests.Requests = slices.Delete(v.EBSRequests.Requests, i, i+1)
if v.Instance != nil {
v.Instance.BlockDeviceMappings = slices.DeleteFunc(v.Instance.BlockDeviceMappings, func(m *ec2.InstanceBlockDeviceMapping) bool {
return m != nil && m.Ebs != nil && aws.StringValue(m.Ebs.VolumeId) == volumeID
})
}
return true
}
// stoppedAttachDevice picks the device a volume takes on a stopped instance and
// reports whether it is the root. An empty request takes the next free
// /dev/sd[f-p]; a named device must be free, and the root device name is free
// only while no boot volume is attached.
func stoppedAttachDevice(v *vm.VM, requested string) (string, bool, error) {
root := ebsmetadata.RootDeviceName
if v.Instance != nil && aws.StringValue(v.Instance.RootDeviceName) != "" {
root = aws.StringValue(v.Instance.RootDeviceName)
}
v.EBSRequests.Mu.Lock()
defer v.EBSRequests.Mu.Unlock()
used := make(map[string]bool, len(v.EBSRequests.Requests))
hasBoot := false
for _, r := range v.EBSRequests.Requests {
if r.DeviceName != "" {
used[r.DeviceName] = true
}
hasBoot = hasBoot || r.Boot
}
if v.Instance != nil {
for _, m := range v.Instance.BlockDeviceMappings {
if m != nil && m.DeviceName != nil {
used[*m.DeviceName] = true
}
}
}
if requested == "" {
for c := 'f'; c <= 'p'; c++ {
if dev := fmt.Sprintf("/dev/sd%c", c); !used[dev] {
return dev, false, nil
}
}
return "", false, errors.New(awserrors.ErrorAttachmentLimitExceeded)
}
if used[requested] {
return "", false, errors.New(awserrors.ErrorInvalidParameterValue)
}
boot := requested == root
if boot && hasBoot {
return "", false, errors.New(awserrors.ErrorInvalidParameterValue)
}
return requested, boot, nil
}
// addVolumeToRecord adds the volume to the mount list and the API view of the
// record. The root device name makes it the boot volume.
func addVolumeToRecord(v *vm.VM, volumeID, device string, boot bool) {
v.EBSRequests.Mu.Lock()
defer v.EBSRequests.Mu.Unlock()
v.EBSRequests.Requests = append(v.EBSRequests.Requests, viperblocklegacyv1.EBSRequest{
Name: volumeID,
DeviceName: device,
Boot: boot,
})
if v.Instance == nil {
return
}
mapping := &ec2.InstanceBlockDeviceMapping{}
mapping.SetDeviceName(device)
mapping.Ebs = &ec2.EbsInstanceBlockDevice{}
mapping.Ebs.SetVolumeId(volumeID)
mapping.Ebs.SetAttachTime(time.Now())
mapping.Ebs.SetDeleteOnTermination(false)
mapping.Ebs.SetStatus("attached")
v.Instance.BlockDeviceMappings = append(v.Instance.BlockDeviceMappings, mapping)
}
@@ -0,0 +1,139 @@
package instance
import (
"context"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
vmmock "github.com/mulgadc/spinifex/spinifex/runtime/compute/vm/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func stoppedWithVolumes(owner, id string) *vm.VM {
v := &vm.VM{
ID: id, AccountID: owner, Status: vm.StateStopped,
Instance: &ec2.Instance{
InstanceId: aws.String(id),
BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{
{DeviceName: aws.String("/dev/sda1"), Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-root")}},
{DeviceName: aws.String("/dev/sdf"), Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-data")}},
{DeviceName: aws.String("/dev/sdg"), Ebs: &ec2.EbsInstanceBlockDevice{VolumeId: aws.String("vol-other")}},
},
},
}
v.EBSRequests.Requests = []viperblocklegacyv1.EBSRequest{
{Name: "vol-root", Boot: true, DeviceName: "/dev/sda1"},
{Name: "vol-data", DeviceName: "/dev/sdf"},
{Name: "vol-other", DeviceName: "/dev/sdg"},
}
return v
}
func stoppedDetachInput(instanceID, volumeID string) *ec2.DetachVolumeInput {
return &ec2.DetachVolumeInput{InstanceId: aws.String(instanceID), VolumeId: aws.String(volumeID)}
}
func volumeNames(v *vm.VM) []string {
var out []string
for _, r := range v.EBSRequests.Requests {
out = append(out, r.Name)
}
return out
}
// AWS lets a stopped instance's data volume be detached; the record must stop
// claiming it so a later start does not re-attach it.
func TestDetachVolumeFromStopped_RemovesOnlyThatVolume(t *testing.T) {
owner := awsidentifiers.GlobalAccountID
id := "i-stopped-detach"
stored := stoppedWithVolumes(owner, id)
store := &vmmock.StateStore{Stopped: map[string]*vm.VM{id: stored}}
svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{}), stoppedStore: store}
out, err := svc.DetachVolumeFromStopped(context.Background(), stoppedDetachInput(id, "vol-data"), owner)
require.NoError(t, err)
assert.Equal(t, "/dev/sdf", aws.StringValue(out.Device))
assert.Equal(t, id, aws.StringValue(out.InstanceId))
assert.Equal(t, []string{"vol-root", "vol-other"}, volumeNames(stored))
var mapped []string
for _, m := range stored.Instance.BlockDeviceMappings {
mapped = append(mapped, aws.StringValue(m.Ebs.VolumeId))
}
assert.Equal(t, []string{"vol-root", "vol-other"}, mapped)
assert.Equal(t, vm.StateStopped, stored.Status, "the instance stays stopped")
}
func TestDetachVolumeFromStopped_Refusals(t *testing.T) {
owner := awsidentifiers.GlobalAccountID
tests := []struct {
name string
caller string
volume string
stopped bool
want string
}{
{"volume not on the instance", owner, "vol-elsewhere", true, awserrors.ErrorIncorrectState},
{"another account", "000000000099", "vol-data", true, awserrors.ErrorInvalidInstanceIDNotFound},
{"no such stopped instance", owner, "vol-data", false, awserrors.ErrorInvalidInstanceIDNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id := "i-stopped-refuse"
stored := stoppedWithVolumes(owner, id)
store := &vmmock.StateStore{Stopped: map[string]*vm.VM{}}
if tt.stopped {
store.Stopped[id] = stored
}
svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{}), stoppedStore: store}
_, err := svc.DetachVolumeFromStopped(context.Background(), stoppedDetachInput(id, tt.volume), tt.caller)
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, tt.want), "got %v, want %s", err, tt.want)
assert.Equal(t, []string{"vol-root", "vol-data", "vol-other"}, volumeNames(stored),
"a refused detach changes nothing")
})
}
}
// AWS lets the root volume of a stopped instance be detached; the instance then
// cannot start until a volume is attached at its root device name again.
func TestDetachVolumeFromStopped_DetachesTheRootVolume(t *testing.T) {
owner := awsidentifiers.GlobalAccountID
id := "i-stopped-root"
stored := stoppedWithVolumes(owner, id)
store := &vmmock.StateStore{Stopped: map[string]*vm.VM{id: stored}}
svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{}), stoppedStore: store}
out, err := svc.DetachVolumeFromStopped(context.Background(), stoppedDetachInput(id, "vol-root"), owner)
require.NoError(t, err)
assert.Equal(t, "/dev/sda1", aws.StringValue(out.Device))
assert.Equal(t, []string{"vol-data", "vol-other"}, volumeNames(stored))
assert.Equal(t, vm.StateStopped, stored.Status)
}
// The EFI variable store is Spinifex-internal and never shown to the customer,
// so it cannot be detached even by its name.
func TestDetachVolumeFromStopped_RefusesTheInternalEFIStore(t *testing.T) {
owner := awsidentifiers.GlobalAccountID
id := "i-stopped-efi"
stored := stoppedWithVolumes(owner, id)
stored.EBSRequests.Requests = append(stored.EBSRequests.Requests,
viperblocklegacyv1.EBSRequest{Name: "vol-efi", EFI: true})
store := &vmmock.StateStore{Stopped: map[string]*vm.VM{id: stored}}
svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{}), stoppedStore: store}
_, err := svc.DetachVolumeFromStopped(context.Background(), stoppedDetachInput(id, "vol-efi"), owner)
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorOperationNotPermitted), "got %v", err)
assert.Equal(t, []string{"vol-root", "vol-data", "vol-other", "vol-efi"}, volumeNames(stored))
}
@@ -0,0 +1,185 @@
// Package uefistore holds the types that describe an instance's UEFI variable
// store and the bucket of store records that carry its deletion and write-back
// obligations past the life of the instance record.
package uefistore
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go/jetstream"
)
const (
// BucketName holds one store record per instance, keyed by instance ID.
BucketName = "spinifex-uefi-stores"
// BucketVersion is the bucket's schema stamp.
BucketVersion = 1
// RecordSchemaVersion is the version every record is written at.
RecordSchemaVersion uint16 = 1
// StateLive is a store whose objects belong to a running or stopped instance.
StateLive = "live"
// StateDeleting is a store whose objects are to be deleted, and then the record.
StateDeleting = "deleting"
objectRoot = "uefi/"
)
// ErrInvalidRecord is returned for a store record that breaks its invariants,
// whether it is about to be written or was just read.
var ErrInvalidRecord = errors.New("uefistore: invalid store record")
// FirmwareIdentity names the firmware a variable store was written under. A
// store only boots where Arch and VarsSize match the node's firmware, because
// pflash needs a byte-exact region; CodeSHA256 is recorded, not enforced.
type FirmwareIdentity struct {
Arch string `json:"arch"`
VarsSize int64 `json:"vars_size"`
CodeSHA256 string `json:"code_sha256"`
}
// UEFIStoreRecord is the durable owner of one instance's variable store
// objects. It outlives the instance record, so an obligation recorded here is
// not lost when a terminated record expires.
type UEFIStoreRecord struct {
SchemaVersion uint16 `json:"schema_version"`
InstanceID string `json:"instance_id"`
AccountID string `json:"account_id"`
State string `json:"state"`
WriteBack *UEFIWriteBack `json:"write_back,omitempty"`
}
// UEFIWriteBack is a write-back that failed and is still owed. Key is the
// object every retry puts to, so retries leave no extra objects; SeededFrom is
// the pointer Node seeded from, which a retry must still find on the record.
type UEFIWriteBack struct {
Node string `json:"node"`
Key string `json:"key"`
SeededFrom string `json:"seeded_from"`
Since time.Time `json:"since"`
}
// KeyPrefix is the object-store prefix every store object of ownerID (an
// instance or image ID) is written under.
func KeyPrefix(ownerID string) string {
return objectRoot + ownerID + "/"
}
// ownsKey reports whether key names one object directly under ownerID's prefix.
func ownsKey(ownerID, key string) bool {
name, ok := strings.CutPrefix(key, KeyPrefix(ownerID))
return ok && name != "" && !strings.Contains(name, "/")
}
// validate checks rec against key. A write-back key outside the instance's own
// prefix is refused because a superseded obligation deletes that key.
func (rec *UEFIStoreRecord) validate(key string) error {
switch {
case rec.SchemaVersion > RecordSchemaVersion:
return fmt.Errorf("%w: %s is at schema_version %d, newer than this node understands (%d)",
ErrInvalidRecord, key, rec.SchemaVersion, RecordSchemaVersion)
case rec.SchemaVersion != RecordSchemaVersion:
return fmt.Errorf("%w: %s has schema_version %d", ErrInvalidRecord, key, rec.SchemaVersion)
case !strings.HasPrefix(rec.InstanceID, "i-"):
return fmt.Errorf("%w: instance_id %q is not an instance ID", ErrInvalidRecord, rec.InstanceID)
case rec.InstanceID != key:
return fmt.Errorf("%w: instance_id %s stored at key %s", ErrInvalidRecord, rec.InstanceID, key)
case rec.State != StateLive && rec.State != StateDeleting:
return fmt.Errorf("%w: %s has state %q", ErrInvalidRecord, key, rec.State)
}
if wb := rec.WriteBack; wb != nil {
switch {
case wb.Node == "":
return fmt.Errorf("%w: %s write-back names no node", ErrInvalidRecord, key)
case wb.Since.IsZero():
return fmt.Errorf("%w: %s write-back has no since", ErrInvalidRecord, key)
case !ownsKey(rec.InstanceID, wb.Key):
return fmt.Errorf("%w: %s write-back key %q is not under %s", ErrInvalidRecord, key, wb.Key, KeyPrefix(rec.InstanceID))
case !ownsKey(rec.InstanceID, wb.SeededFrom):
return fmt.Errorf("%w: %s write-back seeded_from %q is not under %s", ErrInvalidRecord, key, wb.SeededFrom, KeyPrefix(rec.InstanceID))
}
}
return nil
}
func bucketConfig() kvstore.Config {
return kvstore.Config{
Name: BucketName,
Description: "Spinifex UEFI variable store records (deletion and write-back obligations)",
History: 1,
// Left false: a lost bucket has lost obligations, and an empty
// recreated one would report every store as having none.
RecreateIfMissing: false,
OnOpen: func(ctx context.Context, kv jetstream.KeyValue) error {
return migrate.DefaultRegistry.RunKV(ctx, BucketName, kv, BucketVersion)
},
Missing: "UEFI store KV bucket not initialized",
}
}
// Store reads and writes store records. Every change is guarded by the
// revision the caller read, so a lost race is reported rather than retried.
type Store struct {
kv *kvstore.Store[UEFIStoreRecord]
}
// NewStore returns a Store over js. The bucket is created on Open or first use.
func NewStore(js jetstream.JetStream) *Store {
return &Store{kv: kvstore.New[UEFIStoreRecord](js, bucketConfig())}
}
// Open creates the bucket if it is missing and stamps its schema version.
func (s *Store) Open(ctx context.Context) error {
_, err := s.kv.KV(ctx)
return err
}
// Get returns instanceID's record and the revision it was read at. A missing
// record is kvstore.ErrNotFound; one that breaks its invariants is
// ErrInvalidRecord, so a newer or corrupt record is never acted on.
func (s *Store) Get(ctx context.Context, instanceID string) (*UEFIStoreRecord, uint64, error) {
rec, rev, err := s.kv.Get(ctx, instanceID)
if err != nil {
return nil, 0, err
}
if err := rec.validate(instanceID); err != nil {
return nil, 0, err
}
return rec, rev, nil
}
// Create writes a new record, which must be live: a store is recorded before
// its first object is written. An existing record is kvstore.ErrExists. Every
// write stamps rec with RecordSchemaVersion.
func (s *Store) Create(ctx context.Context, rec *UEFIStoreRecord) (uint64, error) {
rec.SchemaVersion = RecordSchemaVersion
if rec.State != StateLive {
return 0, fmt.Errorf("%w: %s created in state %q, not %q", ErrInvalidRecord, rec.InstanceID, rec.State, StateLive)
}
if err := rec.validate(rec.InstanceID); err != nil {
return 0, err
}
return s.kv.Create(ctx, rec.InstanceID, rec)
}
// CompareAndSet replaces the record only if it is still at rev, returning
// kvstore.ErrConflict when it is not, and the revision written otherwise.
func (s *Store) CompareAndSet(ctx context.Context, rec *UEFIStoreRecord, rev uint64) (uint64, error) {
rec.SchemaVersion = RecordSchemaVersion
if err := rec.validate(rec.InstanceID); err != nil {
return 0, err
}
return s.kv.CompareAndSet(ctx, rec.InstanceID, rec, rev)
}
// CompareAndDelete removes instanceID's record only if it is still at rev,
// returning kvstore.ErrConflict when it is not.
func (s *Store) CompareAndDelete(ctx context.Context, instanceID string, rev uint64) error {
return s.kv.CompareAndDelete(ctx, instanceID, rev)
}
@@ -0,0 +1,285 @@
package uefistore_test
import (
"context"
"encoding/json"
"fmt"
"sync"
"testing"
"time"
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const instanceID = "i-0123456789abcdef0"
func openStore(t *testing.T) (*uefistore.Store, jetstream.JetStream) {
t.Helper()
_, _, js := testutil.StartTestJetStream(t)
s := uefistore.NewStore(js)
require.NoError(t, s.Open(t.Context()))
return s, js
}
func liveRecord() *uefistore.UEFIStoreRecord {
return &uefistore.UEFIStoreRecord{InstanceID: instanceID, AccountID: "111122223333", State: uefistore.StateLive}
}
func writeBack() *uefistore.UEFIWriteBack {
return &uefistore.UEFIWriteBack{
Node: "node-2",
Key: uefistore.KeyPrefix(instanceID) + "b",
SeededFrom: uefistore.KeyPrefix(instanceID) + "a",
Since: time.Date(2026, 11, 3, 1, 2, 3, 0, time.UTC),
}
}
func rawPut(t *testing.T, js jetstream.JetStream, key, body string) {
t.Helper()
kv, err := js.KeyValue(t.Context(), uefistore.BucketName)
require.NoError(t, err)
_, err = kv.Put(t.Context(), key, []byte(body))
require.NoError(t, err)
}
// The records carry obligations past the 1 h terminated-record TTL, so the
// bucket must not expire them, and it is stamped so a newer schema is refused.
func TestOpen_CreatesBucketWithNoTTLAndStampsVersion(t *testing.T) {
_, js := openStore(t)
kv, err := js.KeyValue(t.Context(), uefistore.BucketName)
require.NoError(t, err)
status, err := kv.Status(t.Context())
require.NoError(t, err)
assert.Equal(t, int64(1), status.History())
assert.Zero(t, status.TTL(), "a TTL would expire deletion obligations before they are settled")
v, err := kvutil.ReadVersion(t.Context(), kv)
require.NoError(t, err)
assert.Equal(t, uefistore.BucketVersion, v)
}
func TestCreate_RoundTripsAndStampsSchemaVersion(t *testing.T) {
s, _ := openStore(t)
rec := liveRecord()
rec.WriteBack = writeBack()
rev, err := s.Create(t.Context(), rec)
require.NoError(t, err)
got, gotRev, err := s.Get(t.Context(), instanceID)
require.NoError(t, err)
assert.Equal(t, rev, gotRev)
assert.Equal(t, uefistore.RecordSchemaVersion, got.SchemaVersion)
assert.Equal(t, rec, got)
}
// The wire names are what every release reads, so they are pinned.
func TestRecord_WireShape(t *testing.T) {
s, js := openStore(t)
rec := liveRecord()
rec.WriteBack = writeBack()
_, err := s.Create(t.Context(), rec)
require.NoError(t, err)
kv, err := js.KeyValue(t.Context(), uefistore.BucketName)
require.NoError(t, err)
entry, err := kv.Get(t.Context(), instanceID)
require.NoError(t, err)
assert.JSONEq(t, `{
"schema_version": 1,
"instance_id": "i-0123456789abcdef0",
"account_id": "111122223333",
"state": "live",
"write_back": {
"node": "node-2",
"key": "uefi/i-0123456789abcdef0/b",
"seeded_from": "uefi/i-0123456789abcdef0/a",
"since": "2026-11-03T01:02:03Z"
}
}`, string(entry.Value()))
}
func TestGet_MissingIsNotFound(t *testing.T) {
s, _ := openStore(t)
_, _, err := s.Get(t.Context(), instanceID)
require.ErrorIs(t, err, kvstore.ErrNotFound)
}
func TestCreate_ExistingRecordIsErrExists(t *testing.T) {
s, _ := openStore(t)
_, err := s.Create(t.Context(), liveRecord())
require.NoError(t, err)
dup := liveRecord()
dup.AccountID = "999999999999"
_, err = s.Create(t.Context(), dup)
require.ErrorIs(t, err, kvstore.ErrExists)
got, _, err := s.Get(t.Context(), instanceID)
require.NoError(t, err)
assert.Equal(t, "111122223333", got.AccountID, "the second create must not overwrite the first")
}
// A store is recorded live before its first object, so a record that starts
// out deleting has no object it could be describing.
func TestCreate_RefusesDeleting(t *testing.T) {
s, _ := openStore(t)
rec := liveRecord()
rec.State = uefistore.StateDeleting
_, err := s.Create(t.Context(), rec)
require.ErrorIs(t, err, uefistore.ErrInvalidRecord)
_, _, err = s.Get(t.Context(), instanceID)
require.ErrorIs(t, err, kvstore.ErrNotFound)
}
func TestCompareAndSet_StaleRevisionConflictsAndWritesNothing(t *testing.T) {
s, _ := openStore(t)
first, err := s.Create(t.Context(), liveRecord())
require.NoError(t, err)
moved := liveRecord()
moved.WriteBack = writeBack()
second, err := s.CompareAndSet(t.Context(), moved, first)
require.NoError(t, err)
assert.Greater(t, second, first)
stale := liveRecord()
stale.State = uefistore.StateDeleting
_, err = s.CompareAndSet(t.Context(), stale, first)
require.ErrorIs(t, err, kvstore.ErrConflict)
got, rev, err := s.Get(t.Context(), instanceID)
require.NoError(t, err)
assert.Equal(t, second, rev)
assert.Equal(t, uefistore.StateLive, got.State)
assert.Equal(t, writeBack(), got.WriteBack)
}
// Two actors that read the same revision race to change it; exactly one may
// win, so neither can overwrite an obligation the other recorded.
func TestCompareAndSet_ConcurrentWritersFromOneReadHaveOneWinner(t *testing.T) {
s, _ := openStore(t)
_, err := s.Create(t.Context(), liveRecord())
require.NoError(t, err)
_, rev, err := s.Get(t.Context(), instanceID)
require.NoError(t, err)
const writers = 8
var (
wg sync.WaitGroup
mu sync.Mutex
wins []string
)
for i := range writers {
wg.Go(func() {
rec := liveRecord()
rec.WriteBack = writeBack()
rec.WriteBack.Node = fmt.Sprintf("node-%d", i)
if _, err := s.CompareAndSet(context.Background(), rec, rev); err == nil {
mu.Lock()
wins = append(wins, rec.WriteBack.Node)
mu.Unlock()
} else {
assert.ErrorIs(t, err, kvstore.ErrConflict)
}
})
}
wg.Wait()
require.Len(t, wins, 1)
got, _, err := s.Get(t.Context(), instanceID)
require.NoError(t, err)
assert.Equal(t, wins[0], got.WriteBack.Node)
}
func TestCompareAndDelete_StaleRevisionKeepsRecord(t *testing.T) {
s, _ := openStore(t)
first, err := s.Create(t.Context(), liveRecord())
require.NoError(t, err)
deleting := liveRecord()
deleting.State = uefistore.StateDeleting
second, err := s.CompareAndSet(t.Context(), deleting, first)
require.NoError(t, err)
require.ErrorIs(t, s.CompareAndDelete(t.Context(), instanceID, first), kvstore.ErrConflict)
_, _, err = s.Get(t.Context(), instanceID)
require.NoError(t, err, "a delete guarded on a stale revision must leave the record")
require.NoError(t, s.CompareAndDelete(t.Context(), instanceID, second))
_, _, err = s.Get(t.Context(), instanceID)
require.ErrorIs(t, err, kvstore.ErrNotFound)
}
// A superseded obligation deletes its recorded key, so a key outside the
// instance's own prefix must never reach the bucket.
func TestCompareAndSet_RefusesInvalidRecords(t *testing.T) {
cases := map[string]func(*uefistore.UEFIStoreRecord){
"unknown state": func(r *uefistore.UEFIStoreRecord) { r.State = "gone" },
"not an instance ID": func(r *uefistore.UEFIStoreRecord) { r.InstanceID = "ami-0123456789abcdef0" },
"write-back with no node": func(r *uefistore.UEFIStoreRecord) { r.WriteBack.Node = "" },
"write-back with no since": func(r *uefistore.UEFIStoreRecord) { r.WriteBack.Since = time.Time{} },
"write-back key under another instance": func(r *uefistore.UEFIStoreRecord) {
r.WriteBack.Key = uefistore.KeyPrefix("i-0fedcba9876543210") + "b"
},
"write-back key under an image": func(r *uefistore.UEFIStoreRecord) {
r.WriteBack.Key = uefistore.KeyPrefix("ami-0123456789abcdef0") + "b"
},
"write-back key is the prefix itself": func(r *uefistore.UEFIStoreRecord) {
r.WriteBack.Key = uefistore.KeyPrefix(instanceID)
},
"write-back key nested below the prefix": func(r *uefistore.UEFIStoreRecord) {
r.WriteBack.Key = uefistore.KeyPrefix(instanceID) + "x/y"
},
"write-back seeded from another instance": func(r *uefistore.UEFIStoreRecord) {
r.WriteBack.SeededFrom = uefistore.KeyPrefix("i-0fedcba9876543210") + "a"
},
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
s, _ := openStore(t)
rev, err := s.Create(t.Context(), liveRecord())
require.NoError(t, err)
rec := liveRecord()
rec.WriteBack = writeBack()
mutate(rec)
_, err = s.CompareAndSet(t.Context(), rec, rev)
require.ErrorIs(t, err, uefistore.ErrInvalidRecord)
got, gotRev, err := s.Get(t.Context(), instanceID)
require.NoError(t, err)
assert.Equal(t, rev, gotRev, "an invalid record must not be written")
assert.Nil(t, got.WriteBack)
})
}
}
// A record a newer release wrote, or one that does not describe its own key,
// is reported rather than decoded into something a reaper would act on.
func TestGet_RefusesRecordsItCannotTrust(t *testing.T) {
cases := map[string]string{
"newer schema": `{"schema_version":2,"instance_id":"i-0123456789abcdef0","state":"live"}`,
"no schema": `{"instance_id":"i-0123456789abcdef0","state":"live"}`,
"another instance": `{"schema_version":1,"instance_id":"i-0fedcba9876543210","state":"deleting"}`,
"unknown state": `{"schema_version":1,"instance_id":"i-0123456789abcdef0","state":"purged"}`,
"foreign write-back": `{"schema_version":1,"instance_id":"i-0123456789abcdef0","state":"live","write_back":{"node":"n","key":"uefi/i-0fedcba9876543210/b","seeded_from":"uefi/i-0123456789abcdef0/a","since":"2026-11-03T00:00:00Z"}}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
s, js := openStore(t)
require.True(t, json.Valid([]byte(body)))
rawPut(t, js, instanceID, body)
_, _, err := s.Get(t.Context(), instanceID)
require.ErrorIs(t, err, uefistore.ErrInvalidRecord)
})
}
}
+53 -302
View File
@@ -2,12 +2,8 @@ package snapshot
import (
"context"
"encoding/json"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
@@ -15,8 +11,6 @@ import (
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/volumestate"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/nats-io/nats.go"
@@ -24,321 +18,78 @@ import (
"github.com/stretchr/testify/require"
)
const drainInstanceID = "i-drain-host"
const attachedInstanceID = "i-attached-host"
// setupDrainService returns a snapshot service configured like a daemon that
// answers ec2.CreateSnapshot: a reachable Predastore host (so drains are not
// skipped as metadata-only), a data dir with no drain socket in it, and a NATS
// connection. The Predastore stub rejects everything, so a snapshot that gets
// past the drain fails fast rather than hanging on a dead address.
func setupDrainService(t *testing.T) (*SnapshotServiceImpl, *objectstore.MemoryObjectStore, *nats.Conn) {
// countingSnapshotProvider counts the CreateSnapshot calls it forwards.
type countingSnapshotProvider struct {
ebsprovider.EBSProvider
creates atomic.Int32
}
func (p *countingSnapshotProvider) CreateSnapshot(ctx context.Context, req ebsprovider.CreateSnapshotRequest) (*ebsprovider.Snapshot, error) {
p.creates.Add(1)
return p.EBSProvider.CreateSnapshot(ctx, req)
}
// setupAttachedSnapshot is a viperblockd node answering CreateSnapshot for a
// volume attached to a running instance, with a memory provider behind it.
func setupAttachedSnapshot(t *testing.T, volumeID string) (*SnapshotServiceImpl, *countingSnapshotProvider, *nats.Conn) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
t.Cleanup(srv.Close)
_, nc := testutil.StartTestNATS(t)
store := objectstore.NewMemoryObjectStore()
cfg := &config.Config{
DataDir: testutil.SocketTempDir(t),
Predastore: config.PredastoreConfig{
Host: srv.URL,
Bucket: "test-bucket",
},
DataDir: testutil.SocketTempDir(t),
EBS: config.EBSConfig{Provider: config.EBSProviderViperblockd},
Predastore: config.PredastoreConfig{Host: "127.0.0.1:1", Bucket: "test-bucket"},
}
return NewSnapshotServiceImplWithStore(cfg, store, nc), store, nc
}
svc := NewSnapshotServiceImplWithStore(cfg, objectstore.NewMemoryObjectStore(), nc)
provider := &countingSnapshotProvider{EBSProvider: ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{})}
svc.SetEBSProvider(provider)
// seedVolumeAttachment writes the control-plane state.json that decides whether
// a snapshot must drain.
func seedVolumeAttachment(t *testing.T, store *objectstore.MemoryObjectStore, volumeID, state, instanceID string) {
t.Helper()
require.NoError(t, volumestate.Write(context.Background(), store, "test-bucket", volumeID, volumestate.Record{
State: state,
AttachedInstance: instanceID,
}))
}
// drainResponder answers the per-instance command subject the way the node
// hosting the instance does, and hands each request it received to the test.
func drainResponder(t *testing.T, nc *nats.Conn, instanceID string, reply []byte) chan *nats.Msg {
t.Helper()
got := make(chan *nats.Msg, 4)
sub, err := nc.Subscribe("ec2.cmd."+instanceID, func(msg *nats.Msg) {
got <- msg
_ = msg.Respond(reply)
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
return got
}
// drainedAck is the reply of a node whose drain reached S3.
func drainedAck(t *testing.T, volumeID string) []byte {
t.Helper()
data, err := json.Marshal(ec2v1.DrainVolumeResponse{VolumeID: volumeID, Status: ec2v1.DrainVolumeStatusDrained})
require.NoError(t, err)
return data
}
// requireReturnsWithin fails the test if fn has not returned successfully by
// limit, so a drain that blocks on a socket it should not have dialed reports
// as a failure rather than as a slow test.
func requireReturnsWithin(t *testing.T, limit time.Duration, fn func() error) {
t.Helper()
done := make(chan error, 1)
go func() { done <- fn() }()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(limit):
t.Fatalf("drain did not return within %s", limit)
}
}
// awaitDrainCommand returns the command the hosting node received, failing the
// test if none arrives.
func awaitDrainCommand(t *testing.T, got chan *nats.Msg) ec2v1.EC2InstanceCommand {
t.Helper()
select {
case msg := <-got:
var command ec2v1.EC2InstanceCommand
require.NoError(t, json.Unmarshal(msg.Data, &command))
assert.Equal(t, testAccountID, msg.Header.Get(natsmsg.AccountIDHeader))
return command
case <-time.After(2 * time.Second):
t.Fatal("the node hosting the volume never received a drain command")
return ec2v1.EC2InstanceCommand{}
}
}
// An attached volume is drained on the node hosting its instance, which is the
// only node with the socket — the node answering CreateSnapshot usually is not.
func TestDrainVolume_AttachedRoutesToHostingNode(t *testing.T) {
svc, store, nc := setupDrainService(t)
seedVolumeAttachment(t, store, "vol-attached", "in-use", drainInstanceID)
got := drainResponder(t, nc, drainInstanceID, drainedAck(t, "vol-attached"))
require.NoError(t, svc.drainVolume(context.Background(), "vol-attached", "", "", testAccountID))
command := awaitDrainCommand(t, got)
assert.True(t, command.Attributes.DrainVolume)
assert.Equal(t, drainInstanceID, command.ID)
require.NotNil(t, command.DrainVolumeData)
assert.Equal(t, "vol-attached", command.DrainVolumeData.VolumeID)
}
// No responder on the command subject means no node is running the instance,
// which is what a stopped instance looks like: stop tears down both the plugin
// and the subscription but deliberately leaves a boot volume attached. Failing
// here would make every stopped instance's root volume unsnapshottable.
func TestDrainVolume_AttachedToStoppedInstanceTakesStoppedPath(t *testing.T) {
svc, store, _ := setupDrainService(t)
seedVolumeAttachment(t, store, "vol-no-host", "in-use", drainInstanceID)
require.NoError(t, svc.drainVolume(context.Background(), "vol-no-host", "", "", testAccountID))
}
// A host that still holds the instance but reports it not running has nothing
// to flush, so the sealed checkpoint stands. This is the host-drain stop, which
// unmounts the volumes but keeps the subscription.
func TestDrainVolume_NotRunningAckTakesStoppedPath(t *testing.T) {
svc, store, nc := setupDrainService(t)
seedVolumeAttachment(t, store, "vol-not-running", "in-use", drainInstanceID)
ack, err := json.Marshal(ec2v1.DrainVolumeResponse{
VolumeID: "vol-not-running", Status: ec2v1.DrainVolumeStatusNotRunning,
})
require.NoError(t, err)
got := drainResponder(t, nc, drainInstanceID, ack)
require.NoError(t, svc.drainVolume(context.Background(), "vol-not-running", "", "", testAccountID))
assert.True(t, awaitDrainCommand(t, got).Attributes.DrainVolume)
}
// A hosting node that reports the drain failed (wedged plugin, socket gone
// mid-restart) fails the snapshot rather than falling through.
func TestDrainVolume_AttachedWithFailedAckFails(t *testing.T) {
svc, store, nc := setupDrainService(t)
seedVolumeAttachment(t, store, "vol-drain-err", "in-use", drainInstanceID)
drainResponder(t, nc, drainInstanceID,
[]byte(`{"Code":"`+awserrors.ErrorServerInternal+`","Message":"drain failed"}`))
err := svc.drainVolume(context.Background(), "vol-drain-err", "", "", testAccountID)
require.Error(t, err)
assert.Contains(t, err.Error(), "drain failed")
}
// An ack that is not the drained ack is not evidence the writes reached S3.
func TestDrainVolume_AttachedWithUnexpectedAckFails(t *testing.T) {
svc, store, nc := setupDrainService(t)
seedVolumeAttachment(t, store, "vol-odd-ack", "in-use", drainInstanceID)
drainResponder(t, nc, drainInstanceID, []byte(`{}`))
err := svc.drainVolume(context.Background(), "vol-odd-ack", "", "", testAccountID)
require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected ack")
}
// Nothing writes to an unattached volume, so the checkpoint its Close() left
// behind is current and no drain is attempted — not even locally. Dialing the
// socket is not a probe: it makes the plugin run a full flush, so an available
// volume must be decided from the record without touching it.
func TestDrainVolume_AvailableDoesNotDialLocalSocket(t *testing.T) {
svc, store, _ := setupDrainService(t)
seedVolumeAttachment(t, store, "vol-available", "available", "")
// A socket that never answers: a dial would park here until the read
// deadline, so returning promptly is what proves none was made.
testutil.StartBlockingDrainSocket(t, svc.config.DataDir, "vol-available", "OK\n")
// A nil connection proves no command was issued: routing one would error.
svc.natsConn = nil
requireReturnsWithin(t, 2*time.Second, func() error {
return svc.drainVolume(context.Background(), "vol-available", "", "", testAccountID)
})
}
// A volume recorded as in-use but with no instance (drifted state) has no node
// to address, so it takes the stopped path rather than failing every snapshot.
func TestDrainVolume_InUseWithoutInstanceTakesStoppedPath(t *testing.T) {
svc, store, _ := setupDrainService(t)
seedVolumeAttachment(t, store, "vol-no-instance", "in-use", "")
svc.natsConn = nil
require.NoError(t, svc.drainVolume(context.Background(), "vol-no-instance", "", "", testAccountID))
}
// When the volume is served by this node the socket is local, so the drain
// short-circuits without a hop. This is every single-node deployment.
func TestDrainVolume_LocalSocketShortCircuits(t *testing.T) {
svc, store, _ := setupDrainService(t)
seedVolumeAttachment(t, store, "vol-local", "in-use", drainInstanceID)
testutil.StartDrainSocket(t, svc.config.DataDir, "vol-local", "OK\n")
// A nil connection proves the local ack was enough: no responder exists for
// drainInstanceID, so a routed command would fail.
svc.natsConn = nil
require.NoError(t, svc.drainVolume(context.Background(), "vol-local", "", "", testAccountID))
}
// A socket that answers without acking means this node does serve the volume
// and the flush failed. Routing that onward would come back to this same node
// and re-run the drain that has already failed, so it fails here instead.
func TestDrainVolume_LocalSocketErrorAckFailsWithoutRouting(t *testing.T) {
svc, store, nc := setupDrainService(t)
seedVolumeAttachment(t, store, "vol-local-err", "in-use", drainInstanceID)
testutil.StartDrainSocket(t, svc.config.DataDir, "vol-local-err", "ERR\n")
got := drainResponder(t, nc, drainInstanceID, drainedAck(t, "vol-local-err"))
err := svc.drainVolume(context.Background(), "vol-local-err", "", "", testAccountID)
require.Error(t, err)
assert.Empty(t, got, "a local drain that failed must not be re-issued to the host node")
}
// state.json is the control-plane-owned attachment record; the copy inside
// config.json is rewritten by the live NBD plugin from its stale in-memory
// state, so a volume reading "available" there can still be attached.
func TestDrainVolume_StateJSONOverridesConfigJSON(t *testing.T) {
svc, store, nc := setupDrainService(t)
seedVolumeAttachment(t, store, "vol-stale-config", "in-use", drainInstanceID)
got := drainResponder(t, nc, drainInstanceID, drainedAck(t, "vol-stale-config"))
// "available" mirrors the stale State a live NBD plugin leaves in
// config.json; drainVolume must not trust it (see the test's own doc above).
require.NoError(t, svc.drainVolume(context.Background(), "vol-stale-config", "available", "", testAccountID))
assert.True(t, awaitDrainCommand(t, got).Attributes.DrainVolume)
}
// Volumes predating the state.json split have no state object, so the state
// embedded in config.json is all there is to decide from.
func TestDrainVolume_FallsBackToConfigJSONWhenNoStateObject(t *testing.T) {
svc, _, nc := setupDrainService(t)
got := drainResponder(t, nc, drainInstanceID, drainedAck(t, "vol-legacy"))
// The state/instance embedded in a legacy config.json, with no state.json to overlay it.
require.NoError(t, svc.drainVolume(context.Background(), "vol-legacy", "in-use", drainInstanceID, testAccountID))
assert.True(t, awaitDrainCommand(t, got).Attributes.DrainVolume)
}
// Without Predastore the snapshot is metadata-only and never reads a live
// checkpoint, so there is nothing for a drain to make current.
func TestDrainVolume_MetadataOnlySkipsDrain(t *testing.T) {
svc, store, _ := setupDrainService(t)
seedVolumeAttachment(t, store, "vol-meta-only", "in-use", drainInstanceID)
svc.config.Predastore.Host = ""
svc.natsConn = nil
require.NoError(t, svc.drainVolume(context.Background(), "vol-meta-only", "", "", testAccountID))
}
// putProviderVolume seeds both the control-plane document CreateSnapshot's
// provider branch reads for attachment state, and the provider's own record,
// mirroring what CreateVolume's provider branch leaves behind.
func putProviderVolume(t *testing.T, svc *SnapshotServiceImpl, provider ebsprovider.EBSProvider, volumeID, state, instanceID string) {
t.Helper()
require.NoError(t, svc.metadata.PutVolume(context.Background(), ebsmetadata.Volume{
VolumeID: volumeID, TenantID: testAccountID, CapacityGiB: 4,
State: state, AttachedInstance: instanceID, AvailabilityZone: "us-east-1a",
ProviderHandle: "memory://volume/" + volumeID,
}))
_, err := provider.CreateVolume(context.Background(), ebsprovider.CreateVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID,
CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 4 * 1024 * 1024 * 1024},
AvailabilityZone: "us-east-1a",
})
require.NoError(t, err)
require.NoError(t, svc.metadata.PutVolume(context.Background(), ebsmetadata.Volume{
VolumeID: volumeID, TenantID: testAccountID, CapacityGiB: 4,
State: "in-use", AttachedInstance: attachedInstanceID, AvailabilityZone: "us-east-1a",
}))
return svc, provider, nc
}
// The provider branch used to skip the drain entirely, silently snapshotting a
// stale checkpoint of an attached, actively-written volume. It must drain the
// same way the legacy branch does.
func TestCreateSnapshot_Provider_DrainsAttachedVolume(t *testing.T) {
svc, _, nc := setupDrainService(t)
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{})
svc.SetEBSProvider(provider)
putProviderVolume(t, svc, provider, "vol-provider-drain", "in-use", drainInstanceID)
got := drainResponder(t, nc, drainInstanceID, drainedAck(t, "vol-provider-drain"))
// The provider drains a buffered volume inside its own CreateSnapshot, so the
// domain must not dial the drain socket itself: that would drain twice.
func TestCreateSnapshot_AttachedVolumeOpensNoDrainSocket(t *testing.T) {
svc, provider, _ := setupAttachedSnapshot(t, "vol-attached-local")
drains := testutil.StartDrainSocket(t, svc.config.DataDir, "vol-attached-local", "OK\n")
_, err := svc.CreateSnapshot(context.Background(),
&ec2.CreateSnapshotInput{VolumeId: aws.String("vol-provider-drain")}, testAccountID)
&ec2.CreateSnapshotInput{VolumeId: aws.String("vol-attached-local")}, testAccountID)
require.NoError(t, err)
command := awaitDrainCommand(t, got)
assert.True(t, command.Attributes.DrainVolume)
assert.Equal(t, "vol-provider-drain", command.DrainVolumeData.VolumeID)
assert.Zero(t, drains(), "the domain must leave the drain to the provider")
assert.Equal(t, int32(1), provider.creates.Load())
}
// A provider-branch snapshot whose drain fails must fail closed rather than
// return a snapshot of stale data: the provider must never be asked to
// snapshot, and no snapshot metadata may be left behind.
func TestCreateSnapshot_Provider_UndrainableAttachedVolumeFails(t *testing.T) {
svc, _, nc := setupDrainService(t)
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{})
svc.SetEBSProvider(provider)
putProviderVolume(t, svc, provider, "vol-provider-nodrain", "in-use", drainInstanceID)
got := drainResponder(t, nc, drainInstanceID,
[]byte(`{"Code":"`+awserrors.ErrorServerInternal+`","Message":"drain failed"}`))
_, err := svc.CreateSnapshot(context.Background(),
&ec2.CreateSnapshotInput{VolumeId: aws.String("vol-provider-nodrain")}, testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
assert.True(t, awaitDrainCommand(t, got).Attributes.DrainVolume)
out, err := svc.DescribeSnapshots(context.Background(), &ec2.DescribeSnapshotsInput{}, testAccountID)
// With no drain socket on this node, an attached volume is snapshotted by the
// provider without asking the instance's host for a drain.
func TestCreateSnapshot_AttachedVolumeSendsItsHostNoDrain(t *testing.T) {
svc, provider, nc := setupAttachedSnapshot(t, "vol-attached-remote")
var commands atomic.Int32
sub, err := nc.Subscribe(ec2v1.InstanceCommandSubject(attachedInstanceID), func(msg *nats.Msg) {
commands.Add(1)
_ = msg.Respond([]byte(`{"Code":"InternalError","Message":"no drain socket"}`))
})
require.NoError(t, err)
assert.Empty(t, out.Snapshots, "a failed drain must not leave a snapshot behind")
t.Cleanup(func() { _ = sub.Unsubscribe() })
snap, err := svc.CreateSnapshot(context.Background(),
&ec2.CreateSnapshotInput{VolumeId: aws.String("vol-attached-remote")}, testAccountID)
require.NoError(t, err)
assert.Equal(t, "vol-attached-remote", aws.StringValue(snap.VolumeId))
assert.Equal(t, int32(1), provider.creates.Load())
assert.Zero(t, commands.Load(), "the instance's host must not be asked to drain")
}
+17 -185
View File
@@ -5,10 +5,7 @@ import (
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/tagmirror"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"log/slog"
"net"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -18,10 +15,8 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/bluebottle/pkg/safecast"
"github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/volumestate"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
@@ -209,19 +204,12 @@ func (s *SnapshotServiceImpl) CreateSnapshot(ctx context.Context, input *ec2.Cre
return nil, errors.New(awserrors.ErrorIncorrectState)
}
// Flush the writes buffered by whichever node serves the volume, so the
// provider reads a current checkpoint. An attached volume that cannot be
// drained fails here rather than silently snapshotting stale data.
if err := s.drainVolume(ctx, volumeID, volume.State, volume.AttachedInstance, accountID); err != nil {
slog.ErrorContext(ctx, "CreateSnapshot: drain failed", "volumeId", volumeID, "snapshotId", snapshotID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
created, err := s.provider.CreateSnapshot(ctx, ebsprovider.CreateSnapshotRequest{
Versioned: ebsprovider.NewVersioned(), SnapshotID: snapshotID, VolumeID: volumeID, VolumeHandle: volume.ProviderHandle,
})
if err != nil || created == nil {
return nil, errors.New(awserrors.ErrorServerInternal)
slog.ErrorContext(ctx, "CreateSnapshot: provider CreateSnapshot failed", "volumeId", volumeID, "snapshotId", snapshotID, "err", err)
return nil, ProviderCreateError(volumeID, err)
}
now := time.Now()
snapshotCfg := ebsmetadata.Snapshot{
@@ -259,177 +247,6 @@ func (s *SnapshotServiceImpl) CreateSnapshot(ctx context.Context, input *ec2.Cre
return snapshotConfigToEC2(snapshotCfg), nil
}
const (
// drainDialTimeout bounds the connect to the local drain socket. The socket
// is either being served on this node or it is not, so a slow connect means
// a wedged plugin rather than a busy one.
drainDialTimeout = time.Second
// drainAckTimeout bounds DrainToBackend on the hosting node: it flushes the
// WAL and the live checkpoint to S3, so it scales with the dirty set rather
// than with a fixed unit of work.
drainAckTimeout = 30 * time.Second
// drainRequestTimeout bounds the ec2.cmd round-trip. It exceeds
// drainAckTimeout so a slow drain surfaces as the hosting node's error
// instead of an opaque NATS timeout here.
drainRequestTimeout = 35 * time.Second
)
// ErrNoDrainSocket reports that this node has no drain socket for the volume,
// which is how a node learns it does not serve it. It is deliberately distinct
// from a socket that answered badly: only the former is worth routing onward,
// and conflating the two makes a node re-run a drain that has already failed.
var ErrNoDrainSocket = errors.New("no drain socket on this node")
// DrainVolumeSocket asks the NBD plugin serving volumeID on this node to flush
// its WAL chunks and live checkpoint to S3, then waits for the ack. The plugin
// creates the socket under {dataDir}/viperblock/{volumeID}/, so this only
// succeeds on the node hosting the volume; the daemon calls it when a drain
// command is routed to it.
func DrainVolumeSocket(dataDir, volumeID string) error {
sockPath := filepath.Join(dataDir, "viperblock", volumeID, "snapshot.sock")
conn, err := net.DialTimeout("unix", sockPath, drainDialTimeout)
if err != nil {
return fmt.Errorf("%w: dial %s: %w", ErrNoDrainSocket, sockPath, err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(drainAckTimeout))
buf := make([]byte, 8)
n, err := conn.Read(buf)
if err != nil {
return fmt.Errorf("read drain ack for %s: %w", volumeID, err)
}
if !strings.HasPrefix(string(buf[:n]), "OK") {
return fmt.Errorf("drain of %s did not ack OK: %q", volumeID, string(buf[:n]))
}
return nil
}
// drainVolume flushes the volume's in-flight writes to S3 before the snapshot
// reads the live checkpoint from there.
func (s *SnapshotServiceImpl) drainVolume(ctx context.Context, volumeID string, fallbackState, fallbackInstance string, accountID string) error {
return DrainVolume(ctx, s.config, s.store, s.natsConn, volumeID, fallbackState, fallbackInstance, accountID)
}
// DrainVolume flushes the volume's in-flight writes to S3 before a live
// checkpoint is read from there. Shared by ec2.CreateSnapshot and
// ec2.CreateImage's running-instance path (domains/ec2/image) so the two
// live-snapshot call sites cannot diverge on when a drain is required.
//
// The drain socket lives on the node serving the volume, but both callers are
// queue-grouped across every node, so the node answering the request usually
// is not that node. Whether a drain is required is therefore decided from the
// volume's attachment record, never from whether the socket happens to be
// local: an attached volume is being written to right now, and reading its
// checkpoint without draining silently captures an older one.
func DrainVolume(ctx context.Context, cfg *config.Config, store objectstore.ObjectStore, natsConn *nats.Conn, volumeID string, fallbackState, fallbackInstance string, accountID string) error {
// A metadata-only snapshot never reads the live checkpoint, so there is
// nothing for a drain to make current.
if cfg == nil || cfg.Predastore.Host == "" {
return nil
}
state, instanceID, err := volumeAttachment(ctx, store, cfg.Predastore.Bucket, volumeID, fallbackState, fallbackInstance)
if err != nil {
return err
}
// Nothing is writing to an unattached volume: the checkpoint its Close()
// left behind is the current one. Decide this before touching the socket —
// dialing it is not a probe, it makes the plugin run a full flush.
if state != "in-use" || instanceID == "" {
slog.InfoContext(ctx, "DrainVolume: volume not attached, snapshotting the checkpoint left by Close (stopped instance path)",
"volumeId", volumeID, "state", state, "attachedInstance", instanceID)
return nil
}
// Fast path: the volume is served by this node, so no hop is needed. This is
// every single-node deployment, and the node hosting the instance on a
// cluster.
localErr := DrainVolumeSocket(cfg.DataDir, volumeID)
if localErr == nil {
return nil
}
// A socket that answered but did not ack means this node does serve the
// volume and the flush itself failed. Routing that onward would land back on
// this same node and repeat the drain that has already failed.
if !errors.Is(localErr, ErrNoDrainSocket) {
return fmt.Errorf("drain volume %s on this node: %w", volumeID, localErr)
}
slog.InfoContext(ctx, "DrainVolume: volume attached, routing drain to the node hosting it",
"volumeId", volumeID, "instanceId", instanceID)
return drainOnHostNode(ctx, natsConn, volumeID, instanceID, accountID)
}
// volumeAttachment returns the volume's authoritative state and attached
// instance. state.json is control-plane-owned and authoritative; the fallback
// pair (passed in from the caller's own volume record) is rewritten by the
// live NBD plugin from its stale in-memory state and is only used for volumes
// predating the split.
func volumeAttachment(ctx context.Context, store objectstore.ObjectStore, bucket, volumeID string, fallbackState, fallbackInstance string) (state, instanceID string, err error) {
rec, found, err := volumestate.Read(ctx, store, bucket, volumeID)
if err != nil {
return "", "", fmt.Errorf("read volume state for %s: %w", volumeID, err)
}
if found {
return rec.State, rec.AttachedInstance, nil
}
return fallbackState, fallbackInstance, nil
}
// drainOnHostNode issues the drain on the node hosting instanceID. Only that
// node subscribes ec2.cmd.{instanceID}, so the command is self-routing and no
// volume-to-node resolution is needed here.
//
// An attachment record is not proof of a live writer: stop deliberately leaves
// a boot volume attached (runtime/daemon/vm_adapters.go's volumeMounterAdapter.Unmount)
// while tearing down both the NBD plugin and this subscription. The two
// not-running signals below are therefore the stopped-instance path, not a
// failure — treating them as one would make every stopped instance's root
// volume permanently unsnapshottable.
func drainOnHostNode(ctx context.Context, natsConn *nats.Conn, volumeID, instanceID, accountID string) error {
command := ec2v1.EC2InstanceCommand{
ID: instanceID,
Attributes: ec2v1.EC2CommandAttributes{DrainVolume: true},
DrainVolumeData: &ec2v1.DrainVolumeData{VolumeID: volumeID},
}
resp, err := natsmsg.NATSRequest[ec2v1.DrainVolumeResponse](ctx, natsConn,
ec2v1.InstanceCommandSubject(instanceID), command, drainRequestTimeout, accountID)
if err != nil {
// No subscriber at all: the instance runs nowhere in the cluster, so it
// was stopped (stop migrates it to shared KV before unsubscribing) and
// its volumes were sealed by Close. Warn rather than Info because the
// same shape appears if the hosting node has lost NATS while its VM
// keeps writing, which yields a stale snapshot as it did before routing
// existed. A live host that answers and fails is still a hard error.
if errors.Is(err, nats.ErrNoResponders) {
slog.WarnContext(ctx, "drainVolume: no node hosts the instance, treating the volume as stopped and snapshotting its sealed checkpoint",
"volumeId", volumeID, "instanceId", instanceID)
return nil
}
return fmt.Errorf("drain volume %s on the node hosting %s: %w", volumeID, instanceID, err)
}
switch resp.Status {
case ec2v1.DrainVolumeStatusDrained:
return nil
// The host still holds the instance but it is not running: nothing is
// writing, so there is nothing to flush. This is a host-drain stop, which
// keeps the VM (and this subscription) in place after unmounting.
case ec2v1.DrainVolumeStatusNotRunning:
slog.InfoContext(ctx, "drainVolume: instance is not running on its host, snapshotting the volume's sealed checkpoint",
"volumeId", volumeID, "instanceId", instanceID)
return nil
default:
return fmt.Errorf("drain volume %s on the node hosting %s: unexpected ack %q", volumeID, instanceID, resp.Status)
}
}
// describeSnapshotsValidFilters defines the set of filter names accepted by DescribeSnapshots.
var describeSnapshotsValidFilters = map[string]bool{
"snapshot-id": true,
@@ -740,6 +557,21 @@ func unreadableRecordError(ctx context.Context, snapshotID, accountID, kind stri
"Cannot confirm that snapshot '%s' is not in use: %d %s record(s) could not be read", snapshotID, len(unreadable), kind)
}
// ProviderCreateError maps a provider CreateSnapshot failure of volumeID to
// the code a client can act on: retry-later for a provider that cannot answer
// now, IncorrectState for one that needs the volume detached, else internal.
func ProviderCreateError(volumeID string, err error) error {
switch {
case errors.Is(err, ebsprovider.ErrUnavailable):
return errors.New(awserrors.ErrorServiceUnavailable)
case errors.Is(err, ebsprovider.ErrVolumeInUse):
return awserrors.Errorf(awserrors.ErrorIncorrectState,
"The volume '%s' is in use. Detach it before creating a snapshot.", volumeID)
default:
return errors.New(awserrors.ErrorServerInternal)
}
}
// providerDeleteError maps a provider DeleteSnapshot failure to the code a
// client can act on. A transport failure is retryable: the delete is idempotent.
func providerDeleteError(snapshotID string, err error) error {
@@ -1519,3 +1519,55 @@ func TestDropEmptySnapshotRefsDoesNotDropAConcurrentAdd(t *testing.T) {
assert.True(t, has, "iteration %d: the concurrent add was dropped by the empty-key delete", i)
}
}
// createFailingProvider fails only CreateSnapshot, with err.
type createFailingProvider struct {
ebsprovider.EBSProvider
err error
}
func (p *createFailingProvider) CreateSnapshot(context.Context, ebsprovider.CreateSnapshotRequest) (*ebsprovider.Snapshot, error) {
return nil, p.err
}
// A provider that cannot snapshot a volume while it is attached refuses it
// as in use, which the client can act on by detaching the volume first.
func TestCreateSnapshot_ProviderVolumeInUseIsIncorrectState(t *testing.T) {
svc, store := setupTestSnapshotService(t)
createTestVolume(t, svc, store, "vol-create-inuse", 10)
svc.SetEBSProvider(&createFailingProvider{EBSProvider: svc.provider,
err: &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeVolumeInUse, Message: "published"}})
_, err := svc.CreateSnapshot(context.Background(), &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-create-inuse")}, testAccountID)
code, message := clientErrorDetail(t, err)
assert.Equal(t, awserrors.ErrorIncorrectState, code)
assert.Contains(t, message, "vol-create-inuse")
assert.Contains(t, message, "Detach")
}
// A provider that cannot answer now, such as one refusing a volume another
// node holds, is retry-later for the client; any other failure is internal.
func TestCreateSnapshot_ProviderFailureMapsToSpecificError(t *testing.T) {
cases := []struct {
name string
err error
wantCode string
}{
{"provider unavailable", &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnavailable, Message: "held by node-2"}, awserrors.ErrorServiceUnavailable},
{"internal failure", &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeInternal, Message: "boom"}, awserrors.ErrorServerInternal},
{"transport failure", errors.New("connection reset"), awserrors.ErrorServerInternal},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
svc, store := setupTestSnapshotService(t)
createTestVolume(t, svc, store, "vol-create-fail", 10)
svc.SetEBSProvider(&createFailingProvider{EBSProvider: svc.provider, err: tc.err})
_, err := svc.CreateSnapshot(context.Background(), &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-create-fail")}, testAccountID)
require.Error(t, err)
assert.Equal(t, tc.wantCode, err.Error())
})
}
}
@@ -1725,6 +1725,28 @@ func TestDeleteVolume_VolumeAttachedButAvailable(t *testing.T) {
assert.Equal(t, awserrors.ErrorVolumeInUse, err.Error())
}
// A stopped instance keeps its volumes in use, so deleting one is refused
// until it is detached; AWS answers the same.
func TestDeleteVolume_VolumeHeldByStoppedInstanceIsRefused(t *testing.T) {
kv := setupTestVolumeKV(t)
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
svc.snapshotKV = kv
createVolumeInStoreWithMeta(t, svc, store, "vol-held", ebsmetadata.Volume{
VolumeID: "vol-held",
CapacityGiB: 10,
State: "in-use",
AttachedInstance: "i-stopped",
})
_, err := svc.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{
VolumeId: aws.String("vol-held"),
}, testVolAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorVolumeInUse, err.Error())
}
func TestDeleteVolume_EmptyStateUnattachedDeletable(t *testing.T) {
kv := setupTestVolumeKV(t)
store := objectstore.NewMemoryObjectStore()
@@ -1,78 +0,0 @@
// Package volumestate reads and writes the control-plane-owned attachment
// state for an EBS volume, persisted to a per-volume state.json object.
//
// It is kept out of config.json because config.json is rewritten by the live
// nbdkit VB on every SaveState (clobbering any State the control plane wrote
// there) and is a sealed object for encrypted volumes (a second writer reuses
// the AES-GCM nonce). state.json is plaintext, viperblock never touches it, so
// the control plane is its single writer. Readers outside the volume service
// (the snapshot path) need the same record, which is why it lives here.
package volumestate
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
)
// Record is the control-plane-owned attachment state of a volume.
type Record struct {
State string `json:"state"`
AttachedInstance string `json:"attachedInstance"`
DeviceName string `json:"deviceName"`
AttachedAt time.Time `json:"attachedAt"`
}
// Key is the S3 key for a volume's control-plane state object.
func Key(volumeID string) string { return volumeID + "/state.json" }
// Write persists the attachment state, replacing any previous record.
func Write(ctx context.Context, store objectstore.ObjectStore, bucket, volumeID string, rec Record) error {
data, err := json.Marshal(rec)
if err != nil {
return fmt.Errorf("marshal volume state: %w", err)
}
_, err = store.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(Key(volumeID)),
Body: bytes.NewReader(data),
})
if err != nil {
return fmt.Errorf("failed to write volume state to S3: %w", err)
}
return nil
}
// Read returns the attachment state. found=false with a nil error means the
// object is absent (a volume predating the state.json split), in which case the
// caller falls back to the State embedded in config.json.
func Read(ctx context.Context, store objectstore.ObjectStore, bucket, volumeID string) (Record, bool, error) {
getResult, err := store.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(Key(volumeID)),
})
if err != nil {
if objectstore.IsNoSuchKeyError(err) {
return Record{}, false, nil
}
return Record{}, false, fmt.Errorf("failed to get volume state: %w", err)
}
defer getResult.Body.Close()
body, err := io.ReadAll(getResult.Body)
if err != nil {
return Record{}, false, fmt.Errorf("failed to read volume state body: %w", err)
}
var rec Record
if err := json.Unmarshal(body, &rec); err != nil {
return Record{}, false, fmt.Errorf("failed to unmarshal volume state: %w", err)
}
return rec, true, nil
}
+8 -2
View File
@@ -597,8 +597,14 @@ func (s *IAMServiceImpl) DeleteUser(accountID string, input *iam.DeleteUserInput
if len(user.AttachedPolicies) > 0 {
return nil, errDetachPoliciesFirst
}
if len(user.Groups) > 0 {
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
for _, groupName := range user.Groups {
if _, err := s.getGroup(ctx, accountID, groupName); err == nil {
return nil, awserrors.Errorf(awserrors.ErrorIAMDeleteConflict, "Cannot delete entity, must remove users from group first.")
} else if code, _ := awserrors.ResolveErrorCode(err); code != awserrors.ErrorIAMNoSuchEntity {
return nil, fmt.Errorf("get group: %w", err)
}
slog.Warn("DeleteUser: ignoring group name with no group record",
"accountID", accountID, "userName", userName, "groupName", groupName)
}
if len(user.InlinePolicies) > 0 {
return nil, errDeletePoliciesFirst
+34
View File
@@ -3180,3 +3180,37 @@ func TestDeleteUser_RevocationFailureKeepsDelete(t *testing.T) {
_, err = svc.GetUser(testAccountID, &iam.GetUserInput{UserName: aws.String("leaver")})
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorIAMNoSuchEntity))
}
func TestDeleteUser_DanglingGroupName(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
createTestUser(t, svc, "danglinggroup")
createTestGroup(t, svc, "gone")
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
UserName: aws.String("danglinggroup"), GroupName: aws.String("gone"),
})
require.NoError(t, err)
require.NoError(t, svc.groupsBucket.Delete(t.Context(), testAccountID+".gone"))
_, err = svc.DeleteUser(testAccountID, &iam.DeleteUserInput{UserName: aws.String("danglinggroup")})
require.NoError(t, err)
_, err = svc.GetUser(testAccountID, &iam.GetUserInput{UserName: aws.String("danglinggroup")})
require.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestDeleteUser_LiveGroupStillConflicts(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
createTestUser(t, svc, "ingroup")
createTestGroup(t, svc, "live")
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
UserName: aws.String("ingroup"), GroupName: aws.String("live"),
})
require.NoError(t, err)
_, err = svc.DeleteUser(testAccountID, &iam.DeleteUserInput{UserName: aws.String("ingroup")})
require.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMDeleteConflict)
}
@@ -48,10 +48,10 @@ func ExtractTraceContext(ctx context.Context, msg *nats.Msg) context.Context {
// front door to whichever service ends up doing the work.
const AttrAccountID = "aws.account_id"
// accountAttrs returns the account attribute for accountID, or nothing. Work
// AccountAttrs returns the account attribute for accountID, or nothing. Work
// with no caller — a sweep, a reconciler, a node-to-node probe — is left
// unattributed rather than being credited to whoever happens to be admin.
func accountAttrs(accountID string) []attribute.KeyValue {
func AccountAttrs(accountID string) []attribute.KeyValue {
if accountID == "" {
return nil
}
@@ -66,7 +66,7 @@ func StartConsumerSpan(msg *nats.Msg) (context.Context, trace.Span) {
attrs := append([]attribute.KeyValue{
attribute.String("messaging.system", "nats"),
attribute.String("messaging.destination.name", msg.Subject),
}, accountAttrs(AccountIDFromMsg(msg))...)
}, AccountAttrs(AccountIDFromMsg(msg))...)
return otel.Tracer(natsTracerName).Start(ctx, "NATS "+msg.Subject,
trace.WithSpanKind(trace.SpanKindConsumer),
@@ -78,7 +78,7 @@ func startProducerSpan(ctx context.Context, subject, accountID string) (context.
attrs := append([]attribute.KeyValue{
attribute.String("messaging.system", "nats"),
attribute.String("messaging.destination.name", subject),
}, accountAttrs(accountID)...)
}, AccountAttrs(accountID)...)
return otel.Tracer(natsTracerName).Start(ctx, "NATS "+subject,
trace.WithSpanKind(trace.SpanKindClient),
@@ -162,6 +162,24 @@ func (r *iamUserReaper) Delete(_ context.Context, accountID string, resource Res
}
}
// AWS refuses DeleteUser while the user is in a group.
groups, err := r.svc.ListGroupsForUser(accountID, &iam.ListGroupsForUserInput{UserName: aws.String(resource.ID)})
if err != nil && !isAlreadyGone(err) {
return err
}
if groups != nil {
for _, group := range groups.Groups {
if group == nil || group.GroupName == nil {
continue
}
if _, err := r.svc.RemoveUserFromGroup(accountID, &iam.RemoveUserFromGroupInput{
UserName: aws.String(resource.ID), GroupName: group.GroupName,
}); err != nil && !isAlreadyGone(err) {
return err
}
}
}
_, err = r.svc.DeleteUser(accountID, &iam.DeleteUserInput{UserName: aws.String(resource.ID)})
return ignoreAlreadyGone(err)
}
@@ -249,8 +267,46 @@ func (r *iamGroupReaper) List(_ context.Context, accountID string) ([]Resource,
return found, nil
}
// Delete strips the group's policies first: AWS refuses to delete a group that
// still has any. Its members were removed with their users, earlier in the stage.
func (r *iamGroupReaper) Delete(_ context.Context, accountID string, resource Resource, _ bool) error {
_, err := r.svc.DeleteGroup(accountID, &iam.DeleteGroupInput{GroupName: aws.String(resource.ID)})
attached, err := r.svc.ListAttachedGroupPolicies(accountID, &iam.ListAttachedGroupPoliciesInput{
GroupName: aws.String(resource.ID),
})
if err != nil && !isAlreadyGone(err) {
return err
}
if attached != nil {
for _, policy := range attached.AttachedPolicies {
if policy == nil || policy.PolicyArn == nil {
continue
}
if _, err := r.svc.DetachGroupPolicy(accountID, &iam.DetachGroupPolicyInput{
GroupName: aws.String(resource.ID), PolicyArn: policy.PolicyArn,
}); err != nil && !isAlreadyGone(err) {
return err
}
}
}
inline, err := r.svc.ListGroupPolicies(accountID, &iam.ListGroupPoliciesInput{GroupName: aws.String(resource.ID)})
if err != nil && !isAlreadyGone(err) {
return err
}
if inline != nil {
for _, name := range inline.PolicyNames {
if name == nil {
continue
}
if _, err := r.svc.DeleteGroupPolicy(accountID, &iam.DeleteGroupPolicyInput{
GroupName: aws.String(resource.ID), PolicyName: name,
}); err != nil && !isAlreadyGone(err) {
return err
}
}
}
_, err = r.svc.DeleteGroup(accountID, &iam.DeleteGroupInput{GroupName: aws.String(resource.ID)})
return ignoreAlreadyGone(err)
}
@@ -0,0 +1,95 @@
package accountlifecycle
//test:in-package — drives the unexported identity reapers against the real IAM
// service, so the DeleteConflict refusals are the real ones and not a fake's.
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/internal/testkit"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const groupTestAccount = "000000000042"
const groupTestPolicyDoc = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListAllMyBuckets","Resource":"*"}]}`
func newRealIAM(t *testing.T) *iamdomain.IAMServiceImpl {
t.Helper()
_, nc, _ := testutil.StartTestJetStream(t)
masterKey, err := iamdomain.GenerateMasterKey()
require.NoError(t, err)
svc, err := iamdomain.NewIAMServiceImpl(t.Context(), nc, masterKey)
require.NoError(t, err)
return svc
}
// drainIdentity runs the identity-stage reapers in registration order for a
// few passes, as a stage does, and returns what each reaper still lists and
// the delete errors of the last pass.
func drainIdentity(t *testing.T, svc iamdomain.IAMService) (remaining []Resource, lastErrs []error) {
t.Helper()
for range 3 {
lastErrs = nil
for _, reaper := range IAMReapers(svc) {
if reaper.Stage() != StageIdentity {
continue
}
found, err := reaper.List(testCtx(t), groupTestAccount)
require.NoError(t, err)
for _, resource := range found {
if err := reaper.Delete(testCtx(t), groupTestAccount, resource, false); err != nil {
lastErrs = append(lastErrs, err)
}
}
}
}
for _, reaper := range IAMReapers(svc) {
if reaper.Stage() != StageIdentity {
continue
}
found, err := reaper.List(testCtx(t), groupTestAccount)
require.NoError(t, err)
remaining = append(remaining, found...)
}
return remaining, lastErrs
}
func seedGroupAccount(t *testing.T, svc *iamdomain.IAMServiceImpl) string {
t.Helper()
_, err := svc.CreateUser(groupTestAccount, &iam.CreateUserInput{UserName: aws.String("u")})
require.NoError(t, err)
_, err = svc.CreateGroup(groupTestAccount, &iam.CreateGroupInput{GroupName: aws.String("g")})
require.NoError(t, err)
_, err = svc.AddUserToGroup(groupTestAccount, &iam.AddUserToGroupInput{UserName: aws.String("u"), GroupName: aws.String("g")})
require.NoError(t, err)
policy, err := svc.CreatePolicy(groupTestAccount, &iam.CreatePolicyInput{
PolicyName: aws.String("p"), PolicyDocument: aws.String(groupTestPolicyDoc),
})
require.NoError(t, err)
_, err = svc.AttachGroupPolicy(groupTestAccount, &iam.AttachGroupPolicyInput{
GroupName: aws.String("g"), PolicyArn: policy.Policy.Arn,
})
require.NoError(t, err)
_, err = svc.PutGroupPolicy(groupTestAccount, &iam.PutGroupPolicyInput{
GroupName: aws.String("g"), PolicyName: aws.String("inline"), PolicyDocument: aws.String(groupTestPolicyDoc),
})
require.NoError(t, err)
return *policy.Policy.Arn
}
// A user in a group, a group carrying an attached and an inline policy, and the
// customer policy attached to it all drain; none of them may strand the account.
func TestIdentityStageDrainsGroupsMembersAndGroupPolicies(t *testing.T) {
svc := newRealIAM(t)
seedGroupAccount(t, svc)
remaining, lastErrs := drainIdentity(t, svc)
assert.Empty(t, remaining)
assert.Empty(t, lastErrs)
}
@@ -34,6 +34,9 @@ type fakeIAM struct {
profileRoles map[string][]string
attachedUser []string
inlineUser []string
userGroups []string
attachedGroup []string
inlineGroup []string
attachedRole []string
inlineRole []string
@@ -99,6 +102,48 @@ func (f *fakeIAM) DeleteAccessKey(_ string, _ *iam.DeleteAccessKeyInput) (*iam.D
return &iam.DeleteAccessKeyOutput{}, nil
}
func (f *fakeIAM) ListGroupsForUser(_ string, _ *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error) {
f.record("ListGroupsForUser")
out := &iam.ListGroupsForUserOutput{}
for _, name := range f.userGroups {
out.Groups = append(out.Groups, &iam.Group{GroupName: aws.String(name)})
}
return out, nil
}
func (f *fakeIAM) RemoveUserFromGroup(_ string, _ *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) {
f.record("RemoveUserFromGroup")
return &iam.RemoveUserFromGroupOutput{}, nil
}
func (f *fakeIAM) ListAttachedGroupPolicies(_ string, _ *iam.ListAttachedGroupPoliciesInput) (*iam.ListAttachedGroupPoliciesOutput, error) {
f.record("ListAttachedGroupPolicies")
out := &iam.ListAttachedGroupPoliciesOutput{}
for _, arn := range f.attachedGroup {
out.AttachedPolicies = append(out.AttachedPolicies, &iam.AttachedPolicy{PolicyArn: aws.String(arn)})
}
return out, nil
}
func (f *fakeIAM) DetachGroupPolicy(_ string, _ *iam.DetachGroupPolicyInput) (*iam.DetachGroupPolicyOutput, error) {
f.record("DetachGroupPolicy")
return &iam.DetachGroupPolicyOutput{}, nil
}
func (f *fakeIAM) ListGroupPolicies(_ string, _ *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) {
f.record("ListGroupPolicies")
out := &iam.ListGroupPoliciesOutput{}
for _, name := range f.inlineGroup {
out.PolicyNames = append(out.PolicyNames, aws.String(name))
}
return out, nil
}
func (f *fakeIAM) DeleteGroupPolicy(_ string, _ *iam.DeleteGroupPolicyInput) (*iam.DeleteGroupPolicyOutput, error) {
f.record("DeleteGroupPolicy")
return &iam.DeleteGroupPolicyOutput{}, nil
}
func (f *fakeIAM) ListAttachedUserPolicies(_ string, _ *iam.ListAttachedUserPoliciesInput) (*iam.ListAttachedUserPoliciesOutput, error) {
f.record("ListAttachedUserPolicies")
out := &iam.ListAttachedUserPoliciesOutput{}
@@ -337,10 +382,41 @@ func TestUserReaperStripsPoliciesBeforeDeleting(t *testing.T) {
assert.Equal(t, []string{
"ListAttachedUserPolicies", "DetachUserPolicy",
"ListUserPolicies", "DeleteUserPolicy",
"ListGroupsForUser", "DeleteUser",
}, svc.calls)
}
func TestUserReaperRemovesGroupMembershipsBeforeDeleting(t *testing.T) {
svc := newFakeIAM()
svc.userGroups = []string{"g1", "g2"}
err := (&iamUserReaper{svc: svc}).Delete(testCtx(t), "000000000042",
Resource{Kind: "iam-user", ID: "admin"}, false)
require.NoError(t, err)
assert.Equal(t, []string{
"ListAttachedUserPolicies", "ListUserPolicies",
"ListGroupsForUser", "RemoveUserFromGroup", "RemoveUserFromGroup",
"DeleteUser",
}, svc.calls)
}
func TestGroupReaperStripsPoliciesBeforeDeleting(t *testing.T) {
svc := newFakeIAM()
svc.attachedGroup = []string{"arn:aws:iam::000000000042:policy/p"}
svc.inlineGroup = []string{"inline-1"}
err := (&iamGroupReaper{svc: svc}).Delete(testCtx(t), "000000000042",
Resource{Kind: "iam-group", ID: "g"}, false)
require.NoError(t, err)
assert.Equal(t, []string{
"ListAttachedGroupPolicies", "DetachGroupPolicy",
"ListGroupPolicies", "DeleteGroupPolicy",
"DeleteGroup",
}, svc.calls)
}
func TestRoleReaperStripsPoliciesBeforeDeleting(t *testing.T) {
svc := newFakeIAM()
svc.attachedRole = []string{"arn:aws:iam::000000000042:policy/task"}
+1
View File
@@ -23,6 +23,7 @@ Single source of truth for the current schema version of every config file Spini
| `spinifex-cluster-state` | `1` | `daemon.ClusterStateBucketVersion` |
| `spinifex-cluster-config` | `1` | `daemon.ClusterConfigBucketVersion` |
| `spinifex-terminated-instances` | `3` | `daemon.TerminatedInstanceBucketVersion` |
| `spinifex-uefi-stores` | `1` | `uefistore.BucketVersion` |
## Registered migrations
@@ -145,6 +145,16 @@ type SuiteConfig struct {
// the provider, standing in for a crash. Nil skips the dead-export case,
// for a provider whose export is not a separate process.
KillExport func(t *testing.T, published *ebsprovider.PublishedVolume)
// QueueOnOtherNode returns a client answered only on the queue subjects, by
// a node other than NodeID: what a request meets when the volume's owner
// does not answer. Nil skips the cases that need it.
QueueOnOtherNode func(t *testing.T) ebsprovider.EBSProvider
// RevokeExport makes the provider revoke a live export as it does when the
// volume is lost to another node, and returns the FenceNotice it sent; see
// CaptureFenceNotice. Nil skips the case, for a provider that never revokes.
RevokeExport func(t *testing.T, published *ebsprovider.PublishedVolume) ebsprovider.FenceNotice
}
func (c SuiteConfig) node() string {
@@ -210,9 +220,9 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
t.Run("ContractVersions", func(t *testing.T) {
t.Run("listed versions are ascending and include the legacy version", func(t *testing.T) {
require.NotEmpty(t, contractVersions, "a caller cannot choose a version for a provider that lists none")
require.NotEmpty(t, contractVersions, "a provider must list the contract versions it serves")
assert.Contains(t, contractVersions, ebsprovider.LegacySchemaVersion,
"dropping the legacy version strands every caller still in an N/N-1 rollout")
"callers send version-neutral requests at the legacy version, so a provider must serve it")
for i := 1; i < len(contractVersions); i++ {
assert.Lessf(t, contractVersions[i-1], contractVersions[i], "contract versions %v are not strictly ascending", contractVersions)
}
@@ -630,6 +640,39 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
_, err := provider.CreateSnapshot(context.Background(), ebsprovider.CreateSnapshotRequest{Versioned: ebsprovider.NewVersioned()})
require.ErrorIs(t, err, ebsprovider.ErrInvalidArgument)
})
// Only the owner can drain what it buffers, so a node answering in its
// place must refuse rather than snapshot a checkpoint the owner has not
// flushed. The owner's own snapshot then succeeds.
t.Run("refused on the queue subject while another node holds the volume", func(t *testing.T) {
if !capabilities.OwnerRouting || !capabilities.Exclusion.SingleWriter() {
t.Skip("no owner routing behind cluster exclusion, so no node answers in place of an owner")
}
if cfg.QueueOnOtherNode == nil {
t.Skip("no queue-only client on another node configured")
}
provider := newProvider(t)
queue := newTrackingProvider(t, cfg.QueueOnOtherNode(t))
ctx := context.Background()
volumeID := cfg.id("vol-snap-held")
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
for _, version := range contractVersions {
snapshotID := cfg.id(fmt.Sprintf("snap-held-queue-v%d", version))
_, err = queue.CreateSnapshot(ctx, ebsprovider.CreateSnapshotRequest{Versioned: ebsprovider.Versioned{SchemaVersion: version}, SnapshotID: snapshotID, VolumeID: volumeID})
require.ErrorIs(t, err, ebsprovider.ErrUnavailable, "v%d: a node not holding the volume must refuse as retry-later", version)
if capabilities.SnapshotEnumeration {
assert.NotContains(t, drainSnapshotIDs(t, provider, 0), snapshotID)
}
}
snap, err := provider.CreateSnapshot(ctx, ebsprovider.CreateSnapshotRequest{Versioned: ebsprovider.NewVersioned(), SnapshotID: cfg.id("snap-held-owner"), VolumeID: volumeID})
require.NoError(t, err, "the holder answers for its own volume")
assert.Equal(t, ebsprovider.SnapshotStateCompleted, snap.State)
})
})
t.Run("DeleteSnapshot", func(t *testing.T) {
@@ -950,6 +993,24 @@ func RunSuiteWithConfig(t *testing.T, newRawProvider func(t *testing.T) ebsprovi
nbdInfoRetry(t, dialURI(t, publishedNBDURI(t, second)))
})
t.Run("revoking a live export sends a fence notice to its node", func(t *testing.T) {
if cfg.RevokeExport == nil {
t.Skip("this provider never revokes a live export")
}
provider := newProvider(t)
ctx := context.Background()
volumeID := cfg.id("vol-pub-revoked")
_, err := provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
published, err := provider.PublishVolume(ctx, ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: cfg.node()})
require.NoError(t, err)
notice := cfg.RevokeExport(t, published)
assert.Equal(t, volumeID, notice.VolumeID, "the daemon stops the guest on the volume the notice names")
assert.Equal(t, published.NodeID, notice.NodeID, "the notice must name the node whose export was revoked")
assert.True(t, ebsprovider.SupportsSchemaVersion(notice.SchemaVersion), "a daemon refuses a notice in a version it cannot read")
})
t.Run("not_found on absent volume", func(t *testing.T) {
provider := newProvider(t)
_, err := provider.PublishVolume(context.Background(), ebsprovider.PublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: cfg.id("vol-never-existed"), NodeID: cfg.node()})
@@ -75,6 +75,28 @@ func TestMemoryProviderNamePrefixConformance(t *testing.T) {
}
}
// TestMemoryProviderFenceNoticeConformance runs the suite with the revocation
// case enabled, so the contract's fence notice is held to the same check here
// as on viperblockd.
func TestMemoryProviderFenceNoticeConformance(t *testing.T) {
_, conn := testutil.StartTestNATS(t)
shared := ebsprovider.NewMemoryProvider(conformance.ReferenceCapabilities)
shared.NotifyFencesOn(conn)
conformance.RunSuiteWithConfig(t, func(t *testing.T) ebsprovider.EBSProvider {
t.Helper()
return shared
}, conformance.SuiteConfig{
RevokeExport: func(t *testing.T, published *ebsprovider.PublishedVolume) ebsprovider.FenceNotice {
t.Helper()
return conformance.CaptureFenceNotice(t, conn, published.NodeID, func() error {
_, err := shared.RevokePublication(t.Context(), published.VolumeID, "node-elsewhere")
return err
})
},
})
}
// TestNATSProviderConformance runs the suite against a NATSProvider backed
// by natsserve.Serve, the production-shaped neutral server, rather than a
// test-only twin: this exercises the transport this package's implementors actually run in production, not a hand-written stand-in for it.
@@ -0,0 +1,60 @@
package conformance
import (
"encoding/json"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/require"
)
// fenceNoticeWait bounds how long CaptureFenceNotice waits for the notice.
const fenceNoticeWait = 10 * time.Second
// CaptureFenceNotice answers nodeID's fence notices on conn as a daemon with no
// guest on the volume does, starts revoke, and returns the first notice heard.
// revoke runs on its own goroutine, since a sender may retry until answered.
func CaptureFenceNotice(t *testing.T, conn *nats.Conn, nodeID string, revoke func() error) ebsprovider.FenceNotice {
t.Helper()
subject, err := ebsprovider.FenceNoticeSubject(nodeID)
require.NoError(t, err)
heard := make(chan ebsprovider.FenceNotice, 1)
sub, err := conn.Subscribe(subject, func(msg *nats.Msg) {
var notice ebsprovider.FenceNotice
ack := ebsprovider.FenceNoticeAck{Versioned: ebsprovider.NewVersioned()}
if err := json.Unmarshal(msg.Data, &notice); err != nil {
ack.Error = &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeInvalidArgument, Message: err.Error()}
} else {
ack.Versioned = ebsprovider.ReplyVersioned(notice.SchemaVersion)
select {
case heard <- notice:
default:
}
}
if payload, err := json.Marshal(ack); err == nil {
_ = msg.Respond(payload)
}
})
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
require.NoError(t, conn.Flush())
revoked := make(chan error, 1)
go func() { revoked <- revoke() }()
timeout := time.After(fenceNoticeWait)
for {
select {
case notice := <-heard:
return notice
case err := <-revoked:
require.NoError(t, err, "revoking the export")
revoked = nil
case <-timeout:
t.Fatalf("no fence notice on %s within %s of revoking the export; the daemon would leave its guest running on a revoked disk", subject, fenceNoticeWait)
}
}
}
+53
View File
@@ -0,0 +1,53 @@
package ebsprovider
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/nats-io/nats.go"
)
// NotifyFenced sends notice once and returns the instance the daemon stopped.
// An ErrInvalidArgument error is terminal: the notice can never be sent. A
// *ProviderError (internal for an undecodable ack) was heard; others were not.
func NotifyFenced(ctx context.Context, conn *nats.Conn, notice FenceNotice, timeout time.Duration) (string, error) {
if notice.VolumeID == "" {
return "", fmt.Errorf("%w: fence notice names no volume", ErrInvalidArgument)
}
subject, err := FenceNoticeSubject(notice.NodeID)
if err != nil {
return "", err
}
if conn == nil {
return "", fmt.Errorf("%w: no connection to send the fence notice on", ErrInvalidArgument)
}
reply, err := requestReply(ctx, conn, subject, timeout, notice)
if err != nil {
return "", err
}
var ack FenceNoticeAck
if err := json.Unmarshal(reply, &ack); err != nil {
// The daemon answered, so repeating the notice would only repeat this.
return "", &ProviderError{Code: ErrorCodeInternal, Message: fmt.Sprintf("decode %s ack: %v", subject, err)}
}
if ack.Error != nil {
return ack.InstanceID, ack.Error
}
return ack.InstanceID, nil
}
// FenceNoticeRetryable reports whether a NotifyFenced error leaves the notice
// to be sent again: it was not heard, or the daemon refused it as unavailable.
func FenceNoticeRetryable(err error) bool {
if err == nil || errors.Is(err, ErrInvalidArgument) {
return false
}
if _, refused := errors.AsType[*ProviderError](err); refused {
return errors.Is(err, ErrUnavailable)
}
return true
}
+205
View File
@@ -0,0 +1,205 @@
package ebsprovider
import (
"context"
"encoding/json"
"sync/atomic"
"testing"
"time"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// answerFenceNotices replies to node-a's fence notices with reply.
func answerFenceNotices(t *testing.T, conn *nats.Conn, reply []byte) {
t.Helper()
subject, err := FenceNoticeSubject("node-a")
require.NoError(t, err)
sub, err := conn.Subscribe(subject, func(msg *nats.Msg) { _ = msg.Respond(reply) })
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
require.NoError(t, conn.Flush())
}
func fenceNoticeFor(volumeID string) FenceNotice {
return FenceNotice{Versioned: NewVersioned(), VolumeID: volumeID, NodeID: "node-a", Winner: "node-b"}
}
// TestNotifyFenced_SeparatesHeardFromUnheard pins what a sender's retry turns
// on: an answer of any kind ends the notice, and only silence repeats it.
func TestNotifyFenced_SeparatesHeardFromUnheard(t *testing.T) {
t.Run("acknowledged", func(t *testing.T) {
_, conn := testutil.StartTestNATS(t)
ack, err := json.Marshal(FenceNoticeAck{Versioned: NewVersioned(), InstanceID: "i-stopped"})
require.NoError(t, err)
answerFenceNotices(t, conn, ack)
instanceID, err := NotifyFenced(t.Context(), conn, fenceNoticeFor("vol-1"), time.Second)
require.NoError(t, err)
assert.Equal(t, "i-stopped", instanceID)
})
t.Run("refused", func(t *testing.T) {
_, conn := testutil.StartTestNATS(t)
ack, err := json.Marshal(FenceNoticeAck{Versioned: NewVersioned(), Error: &ProviderError{Code: ErrorCodeInvalidArgument, Message: "no"}})
require.NoError(t, err)
answerFenceNotices(t, conn, ack)
_, err = NotifyFenced(t.Context(), conn, fenceNoticeFor("vol-1"), time.Second)
var refused *ProviderError
require.ErrorAs(t, err, &refused, "a refusal was heard, and repeating it would only be refused again")
assert.ErrorIs(t, err, ErrInvalidArgument)
assert.False(t, FenceNoticeRetryable(err))
})
t.Run("refused as unavailable", func(t *testing.T) {
_, conn := testutil.StartTestNATS(t)
ack, err := json.Marshal(FenceNoticeAck{Versioned: NewVersioned(), Error: &ProviderError{Code: ErrorCodeUnavailable, Message: "later"}})
require.NoError(t, err)
answerFenceNotices(t, conn, ack)
_, err = NotifyFenced(t.Context(), conn, fenceNoticeFor("vol-1"), time.Second)
require.ErrorIs(t, err, ErrUnavailable)
assert.True(t, FenceNoticeRetryable(err), "a daemon that cannot act yet may act on the next notice")
})
t.Run("undecodable answer", func(t *testing.T) {
_, conn := testutil.StartTestNATS(t)
answerFenceNotices(t, conn, []byte("{"))
_, err := NotifyFenced(t.Context(), conn, fenceNoticeFor("vol-1"), time.Second)
var refused *ProviderError
require.ErrorAs(t, err, &refused, "the daemon answered, so the notice was heard")
assert.Equal(t, ErrorCodeInternal, refused.Code)
assert.False(t, FenceNoticeRetryable(err))
})
t.Run("nobody listening", func(t *testing.T) {
_, conn := testutil.StartTestNATS(t)
_, err := NotifyFenced(t.Context(), conn, fenceNoticeFor("vol-1"), time.Second)
require.Error(t, err)
var refused *ProviderError
assert.NotErrorAs(t, err, &refused, "an unheard notice must read as unheard, or the sender stops retrying")
assert.True(t, FenceNoticeRetryable(err))
})
t.Run("connection closed", func(t *testing.T) {
_, conn := testutil.StartTestNATS(t)
conn.Close()
_, err := NotifyFenced(t.Context(), conn, fenceNoticeFor("vol-1"), time.Second)
require.ErrorIs(t, err, nats.ErrConnectionClosed)
assert.True(t, FenceNoticeRetryable(err), "a connection that comes back can still carry the notice")
})
}
// TestNotifyFenced_AttributesTheNoticeToItsAccount holds the notice to the
// contract's request path: the daemon's span names the account it acts for.
func TestNotifyFenced_AttributesTheNoticeToItsAccount(t *testing.T) {
_, conn := testutil.StartTestNATS(t)
subject, err := FenceNoticeSubject("node-a")
require.NoError(t, err)
accounts := make(chan string, 1)
ack, err := json.Marshal(FenceNoticeAck{Versioned: NewVersioned()})
require.NoError(t, err)
sub, err := conn.Subscribe(subject, func(msg *nats.Msg) {
accounts <- msg.Header.Get(AccountIDHeader)
_ = msg.Respond(ack)
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
require.NoError(t, conn.Flush())
_, err = NotifyFenced(WithAccountID(t.Context(), "123456789012"), conn, fenceNoticeFor("vol-1"), time.Second)
require.NoError(t, err)
assert.Equal(t, "123456789012", <-accounts)
}
// TestNotifyFenced_RefusesAnUnaddressableNotice keeps a notice that can never
// be sent from reading as unheard, which a retrying sender would repeat forever.
func TestNotifyFenced_RefusesAnUnaddressableNotice(t *testing.T) {
_, conn := testutil.StartTestNATS(t)
cases := map[string]struct {
conn *nats.Conn
notice FenceNotice
}{
"no volume": {conn, FenceNotice{Versioned: NewVersioned(), NodeID: "node-a"}},
"no node": {conn, FenceNotice{Versioned: NewVersioned(), VolumeID: "vol-1"}},
"bad node": {conn, FenceNotice{Versioned: NewVersioned(), VolumeID: "vol-1", NodeID: "node.a"}},
"no conn": {nil, fenceNoticeFor("vol-1")},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
_, err := NotifyFenced(context.Background(), tc.conn, tc.notice, time.Second)
require.ErrorIs(t, err, ErrInvalidArgument)
assert.False(t, FenceNoticeRetryable(err), "a notice that can never be sent must not be retried")
})
}
}
// TestMemoryProviderRevokePublication_RetriesAnUnheardNotice keeps the memory
// provider to the fence's delivery rule: a notice nobody answered is sent
// again until one does, not dropped after the first try.
func TestMemoryProviderRevokePublication_RetriesAnUnheardNotice(t *testing.T) {
_, conn := testutil.StartTestNATS(t)
ctx := t.Context()
provider := NewMemoryProvider(Capabilities{})
provider.fenceNoticeTimeout = 100 * time.Millisecond
provider.fenceNoticeEvery = 10 * time.Millisecond
provider.NotifyFencesOn(conn)
_, err := provider.CreateVolume(ctx, CreateVolumeRequest{Versioned: NewVersioned(), VolumeID: "vol-1", CapacityRange: CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, PublishVolumeRequest{Versioned: NewVersioned(), VolumeID: "vol-1", NodeID: "node-a"})
require.NoError(t, err)
subject, err := FenceNoticeSubject("node-a")
require.NoError(t, err)
ack, err := json.Marshal(FenceNoticeAck{Versioned: NewVersioned(), InstanceID: "i-stopped"})
require.NoError(t, err)
var heard atomic.Int32
sub, err := conn.Subscribe(subject, func(msg *nats.Msg) {
if heard.Add(1) == 1 {
return // the first notice is lost, as on a daemon still reconnecting
}
_ = msg.Respond(ack)
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
require.NoError(t, conn.Flush())
stopped, err := provider.RevokePublication(ctx, "vol-1", "node-b")
require.NoError(t, err, "a lost first notice must be sent again")
assert.Equal(t, "i-stopped", stopped)
assert.Equal(t, int32(2), heard.Load())
}
func TestMemoryProviderRevokePublication(t *testing.T) {
_, conn := testutil.StartTestNATS(t)
ctx := context.Background()
provider := NewMemoryProvider(Capabilities{})
_, err := provider.CreateVolume(ctx, CreateVolumeRequest{Versioned: NewVersioned(), VolumeID: "vol-1", CapacityRange: CapacityRange{RequiredBytes: 1 << 30}})
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, PublishVolumeRequest{Versioned: NewVersioned(), VolumeID: "vol-1", NodeID: "node-a"})
require.NoError(t, err)
_, err = provider.RevokePublication(ctx, "vol-1", "node-b")
require.ErrorIs(t, err, ErrUnavailable, "with nowhere to announce it, a revocation would leave the guest unstopped")
stillPublished, err := provider.PublishVolume(ctx, PublishVolumeRequest{Versioned: NewVersioned(), VolumeID: "vol-1", NodeID: "node-b"})
require.ErrorIs(t, err, ErrVolumeInUse, "a refused revocation must leave the publication in place")
assert.Nil(t, stillPublished)
ack, err := json.Marshal(FenceNoticeAck{Versioned: NewVersioned()})
require.NoError(t, err)
answerFenceNotices(t, conn, ack)
provider.NotifyFencesOn(conn)
_, err = provider.RevokePublication(ctx, "vol-1", "node-b")
require.NoError(t, err)
_, err = provider.PublishVolume(ctx, PublishVolumeRequest{Versioned: NewVersioned(), VolumeID: "vol-1", NodeID: "node-b"})
require.NoError(t, err, "a revoked publication no longer holds the volume")
_, err = provider.RevokePublication(ctx, "vol-never", "node-b")
require.ErrorIs(t, err, ErrNotFound)
}
+70 -3
View File
@@ -2,12 +2,15 @@ package ebsprovider
import (
"bytes"
"cmp"
"context"
"fmt"
"maps"
"slices"
"sync"
"time"
"github.com/nats-io/nats.go"
)
// MemoryProvider is a deterministic, concurrency-safe Provider used by
@@ -19,6 +22,10 @@ type MemoryProvider struct {
volumes map[string]*memoryVolume
snapshots map[string]*Snapshot
now func() time.Time
fenceConn *nats.Conn
// Zero takes the defaults; tests shorten them.
fenceNoticeTimeout time.Duration
fenceNoticeEvery time.Duration
}
type memoryVolume struct {
@@ -406,13 +413,73 @@ func (m *MemoryProvider) UnpublishVolume(_ context.Context, req UnpublishVolumeR
if volume == nil || volume.published == nil || volume.published.NodeID != req.NodeID {
return result, nil
}
volume.published = nil
volume.readOnly = false
volume.volume.State = VolumeStateAvailable
volume.unpublish()
result.TornDown = req.SchemaVersion >= NoSealSchemaVersion
return result, nil
}
// NotifyFencesOn sets the connection RevokePublication announces fences on.
func (m *MemoryProvider) NotifyFencesOn(conn *nats.Conn) {
m.mu.Lock()
defer m.mu.Unlock()
m.fenceConn = conn
}
// unpublish drops the volume's publication. The caller holds the provider lock.
func (v *memoryVolume) unpublish() {
v.published = nil
v.readOnly = false
v.volume.State = VolumeStateAvailable
}
// Defaults for RevokePublication's notice: each attempt's wait for an answer,
// and the pause before an unanswered notice is sent again.
const (
memoryFenceNoticeTimeout = 2 * time.Second
memoryFenceNoticeEvery = time.Second
)
// RevokePublication tears volumeID's publication down as a provider that lost
// the volume to winner does, then sends a FenceNotice to the node it was
// published on until heard or ctx ends. It returns the instance stopped there.
func (m *MemoryProvider) RevokePublication(ctx context.Context, volumeID, winner string) (string, error) {
m.mu.Lock()
conn := m.fenceConn
volume := m.volumes[volumeID]
timeout := cmp.Or(m.fenceNoticeTimeout, memoryFenceNoticeTimeout)
every := cmp.Or(m.fenceNoticeEvery, memoryFenceNoticeEvery)
if conn == nil {
m.mu.Unlock()
return "", fmt.Errorf("%w: no connection to announce the fence on", ErrUnavailable)
}
if volume == nil || volume.published == nil {
m.mu.Unlock()
return "", fmt.Errorf("%w: volume %s is not published", ErrNotFound, volumeID)
}
nodeID := volume.published.NodeID
volume.unpublish()
m.mu.Unlock()
notice := FenceNotice{
Versioned: NewVersioned(),
VolumeID: volumeID,
NodeID: nodeID,
Winner: winner,
Reason: "volume revoked in favour of " + winner,
}
for {
instanceID, err := NotifyFenced(ctx, conn, notice, timeout)
if !FenceNoticeRetryable(err) || conn.IsClosed() {
return instanceID, err
}
select {
case <-ctx.Done():
return "", fmt.Errorf("fence notice for %s never acknowledged: %w: %w", volumeID, ctx.Err(), err)
case <-time.After(every):
}
}
}
// HoldsUnsealedWrites answers that none are held: every write lands in this
// process's map, which is the backend, before it is acknowledged.
func (m *MemoryProvider) HoldsUnsealedWrites(_ context.Context, nodeID string, volumeIDs []string) ([]string, error) {
+94 -52
View File
@@ -35,8 +35,9 @@ const (
const defaultRequestTimeout = 30 * time.Second
// dataMovementTimeout bounds the subjects whose duration is a function of the
// volume rather than of the control plane. Matches unmountSealTimeout, this
// codebase's existing budget for an EBS operation the caller cannot bound.
// volume rather than of the control plane. It must exceed viperblockd's 120s
// kill grace plus a seal allowance, or an unpublish that uses the whole grace
// could never be seen to succeed.
const dataMovementTimeout = 180 * time.Second
// dataMovementVerbs names every operation that references or moves real data,
@@ -88,11 +89,10 @@ func SnapshotCompletionSubject(snapshotID string) (string, error) {
// Last tokens of the per-node subjects: ebs.provider.v1.<node>.<verb>.
const (
publishVerb = "mount"
unpublishVerb = "unmount"
unsealedVerb = "unsealed"
nodeCapabilitiesVerb = "capabilities"
fenceNoticeVerb = "fenced"
publishVerb = "mount"
unpublishVerb = "unmount"
unsealedVerb = "unsealed"
fenceNoticeVerb = "fenced"
)
// nodeSubjectWildcard is the subject matching verb on every node, for a server
@@ -101,10 +101,9 @@ func nodeSubjectWildcard(verb string) string { return subjectPrefix + "*." + ver
// Wildcards over every node for each per-node subject.
var (
PublishSubjectWildcard = nodeSubjectWildcard(publishVerb)
UnpublishSubjectWildcard = nodeSubjectWildcard(unpublishVerb)
UnsealedWritesSubjectWildcard = nodeSubjectWildcard(unsealedVerb)
NodeCapabilitiesSubjectWildcard = nodeSubjectWildcard(nodeCapabilitiesVerb)
PublishSubjectWildcard = nodeSubjectWildcard(publishVerb)
UnpublishSubjectWildcard = nodeSubjectWildcard(unpublishVerb)
UnsealedWritesSubjectWildcard = nodeSubjectWildcard(unsealedVerb)
)
func nodeSubject(nodeID, verb string) (string, error) {
@@ -133,13 +132,6 @@ func UnsealedWritesSubject(nodeID string) (string, error) {
return nodeSubject(nodeID, unsealedVerb)
}
// NodeCapabilitiesSubject returns nodeID's per-node GetCapabilities subject
// (ebs.provider.v1.<node>.capabilities). Unlike CapabilitiesSubject, which
// any node's provider answers, only nodeID's provider answers it.
func NodeCapabilitiesSubject(nodeID string) (string, error) {
return nodeSubject(nodeID, nodeCapabilitiesVerb)
}
// FenceNoticeSubject returns nodeID's fence-notice subject
// (ebs.provider.v1.<node>.fenced), on which a provider that revoked a live
// export there sends a FenceNotice for that node's daemon to acknowledge.
@@ -218,6 +210,38 @@ func validateSubjectToken(value string) error {
return nil
}
// A publish answered without an error but without a usable export. The
// provider may have started the export anyway, so a caller must not assume
// nothing is left to tear down.
var (
ErrNoPublishedVolume = errors.New("returned no published volume")
ErrInvalidExport = errors.New("returned an invalid export")
)
// ErrUnreadableReply is a reply that arrived but could not be decoded, or
// carried a schema version this build does not serve. The provider did
// receive the request, so whatever it asked for may have happened.
var ErrUnreadableReply = errors.New("provider reply could not be read")
// AccountIDHeader carries the account a contract request acts for. It is the
// header the control plane's other NATS hops use, so one name attributes a
// request from the gateway through to the provider.
const AccountIDHeader = "X-Account-ID"
type accountIDKey struct{}
// WithAccountID returns ctx carrying the account the contract requests made
// under it act for. An empty accountID leaves them unattributed.
func WithAccountID(ctx context.Context, accountID string) context.Context {
return context.WithValue(ctx, accountIDKey{}, accountID)
}
// AccountIDFromContext returns the account WithAccountID put on ctx, or "".
func AccountIDFromContext(ctx context.Context) string {
accountID, _ := ctx.Value(accountIDKey{}).(string)
return accountID
}
// NATSProvider drives a provider daemon through the versioned ebs.* contract.
type NATSProvider struct {
conn *nats.Conn
@@ -239,26 +263,11 @@ func NewNATSProvider(conn *nats.Conn, requestTimeout time.Duration) *NATSProvide
}
func (p *NATSProvider) GetCapabilities(ctx context.Context, req GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) {
return p.getCapabilities(ctx, CapabilitiesSubject, req)
}
// GetNodeCapabilities asks nodeID's own provider what it supports, over
// NodeCapabilitiesSubject. A provider that predates that subject gets no
// responder, which a caller must count as no evidence.
func (p *NATSProvider) GetNodeCapabilities(ctx context.Context, nodeID string, req GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) {
subject, err := NodeCapabilitiesSubject(nodeID)
if err != nil {
return nil, err
}
return p.getCapabilities(ctx, subject, req)
}
func (p *NATSProvider) getCapabilities(ctx context.Context, subject string, req GetCapabilitiesRequest) (*GetCapabilitiesResponse, error) {
if err := checkVersion(req.SchemaVersion); err != nil {
return nil, err
}
var response GetCapabilitiesResponse
if err := p.request(ctx, subject, req, &response); err != nil {
if err := p.request(ctx, CapabilitiesSubject, req, &response); err != nil {
return nil, err
}
if err := responseError(response.SchemaVersion, response.Error); err != nil {
@@ -373,7 +382,7 @@ func (p *NATSProvider) CreateSnapshot(ctx context.Context, req CreateSnapshotReq
if err != nil {
return nil, err
}
if p.conn == nil || !p.conn.IsConnected() {
if !p.connected() {
return nil, nats.ErrConnectionClosed
}
@@ -413,7 +422,7 @@ func (p *NATSProvider) CreateSnapshot(ctx context.Context, req CreateSnapshotReq
RecordResponseError(waitSpan, msg.Data)
var completed CreateSnapshotResponse
if err := json.Unmarshal(msg.Data, &completed); err != nil {
return nil, fmt.Errorf("decode snapshot completion: %w", err)
return nil, fmt.Errorf("decode snapshot completion: %w: %w", ErrUnreadableReply, err)
}
if err := responseError(completed.SchemaVersion, completed.Error); err != nil {
return nil, err
@@ -496,11 +505,11 @@ func (p *NATSProvider) PublishVolume(ctx context.Context, req PublishVolumeReque
return nil, err
}
if response.Published == nil {
return nil, fmt.Errorf("%s returned no published volume", subject)
return nil, fmt.Errorf("%s %w", subject, ErrNoPublishedVolume)
}
published := PublishedVolumeFromV1(response.Published)
if err := published.Export.Validate(); err != nil {
return nil, fmt.Errorf("%s returned an invalid export: %w", subject, err)
return nil, fmt.Errorf("%s %w: %w", subject, ErrInvalidExport, err)
}
return published, nil
}
@@ -512,10 +521,10 @@ func (p *NATSProvider) PublishVolume(ctx context.Context, req PublishVolumeReque
return nil, err
}
if response.Published == nil {
return nil, fmt.Errorf("%s returned no published volume", subject)
return nil, fmt.Errorf("%s %w", subject, ErrNoPublishedVolume)
}
if err := response.Published.Export.Validate(); err != nil {
return nil, fmt.Errorf("%s returned an invalid export: %w", subject, err)
return nil, fmt.Errorf("%s %w: %w", subject, ErrInvalidExport, err)
}
return response.Published, nil
}
@@ -557,31 +566,64 @@ func (p *NATSProvider) HoldsUnsealedWrites(ctx context.Context, nodeID string, v
return response.Held, nil
}
// connected reports whether a request can be sent now. A reconnecting
// connection would buffer it and send it on reconnect, after its caller may
// have given up, so the provider would act with nobody waiting on the result.
func connected(conn *nats.Conn) bool {
return conn != nil && conn.IsConnected()
}
func (p *NATSProvider) connected() bool { return connected(p.conn) }
func (p *NATSProvider) request(ctx context.Context, subject string, input, output any) error {
if p.conn == nil || !p.conn.IsConnected() {
return nats.ErrConnectionClosed
reply, err := requestReply(ctx, p.conn, subject, p.timeoutFor(subject), input)
if err != nil {
return err
}
if err := json.Unmarshal(reply, output); err != nil {
return fmt.Errorf("decode %s response: %w: %w", subject, ErrUnreadableReply, err)
}
return nil
}
// requestReply sends input on subject as ctx's account, waits up to timeout
// and returns the raw reply. Its client span records a transport error, an
// unreadable reply, or the error the reply carries; decoding is the caller's.
func requestReply(ctx context.Context, conn *nats.Conn, subject string, timeout time.Duration, input any) ([]byte, error) {
if !connected(conn) {
return nil, nats.ErrConnectionClosed
}
payload, err := json.Marshal(input)
if err != nil {
return fmt.Errorf("encode %s request: %w", subject, err)
return nil, fmt.Errorf("encode %s request: %w", subject, err)
}
request := &nats.Msg{Subject: subject, Data: payload, Header: nats.Header{}}
ctx, span := startClientSpan(ctx, subject, request.Header)
accountID := AccountIDFromContext(ctx)
if accountID != "" {
request.Header.Set(AccountIDHeader, accountID)
}
ctx, span := startClientSpan(ctx, subject, accountID, request.Header)
defer span.End()
requestCtx, cancel := context.WithTimeout(ctx, p.timeoutFor(subject))
requestCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
msg, err := p.conn.RequestMsgWithContext(requestCtx, request)
msg, err := conn.RequestMsgWithContext(requestCtx, request)
if err != nil {
RecordSpanError(span, err)
return fmt.Errorf("request %s: %w", subject, err)
}
if err := json.Unmarshal(msg.Data, output); err != nil {
RecordSpanError(span, err)
return fmt.Errorf("decode %s response: %w", subject, err)
return nil, fmt.Errorf("request %s: %w", subject, err)
}
RecordResponseError(span, msg.Data)
return msg.Data, nil
}
// ValidateNodeID reports whether nodeID can address this node's contract
// subjects. A node that fails it has no fence-notice subject, so a revoked
// export there would leave its guest running.
func ValidateNodeID(nodeID string) error {
if err := validateSubjectToken(nodeID); err != nil {
return fmt.Errorf("node ID cannot address EBS provider subjects: %w", err)
}
return nil
}
@@ -598,7 +640,7 @@ func (p *NATSProvider) requestOwnerFirst(ctx context.Context, ownerSubject, queu
func responseError(version uint16, providerErr *ProviderError) error {
if err := checkVersion(version); err != nil {
return err
return fmt.Errorf("%w: %w", ErrUnreadableReply, err)
}
if providerErr != nil {
return providerErr
@@ -0,0 +1,58 @@
package ebsprovider
import (
"context"
"testing"
"time"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// A request on a connection that is down fails at once rather than buffering:
// one flushed on reconnect would run after its caller gave up on it.
func TestNATSProviderRequestRefusesWhileDisconnected(t *testing.T) {
connections := map[string]func(t *testing.T) *nats.Conn{
"Reconnecting": func(t *testing.T) *nats.Conn {
ns := startNATS(t)
nc, err := nats.Connect(ns.ClientURL(), nats.MaxReconnects(-1), nats.ReconnectWait(time.Hour))
require.NoError(t, err)
t.Cleanup(nc.Close)
ns.Shutdown()
require.Eventually(t, func() bool { return !nc.IsConnected() }, 5*time.Second, 5*time.Millisecond)
require.False(t, nc.IsClosed(), "the connection must still be trying to reconnect")
return nc
},
"Closed": func(t *testing.T) *nats.Conn {
nc, err := nats.Connect(startNATS(t).ClientURL())
require.NoError(t, err)
nc.Close()
return nc
},
}
for name, connect := range connections {
t.Run(name, func(t *testing.T) {
provider := NewNATSProvider(connect(t), 5*time.Second)
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
_, err := provider.UnpublishVolume(ctx, UnpublishVolumeRequest{
Versioned: NewLatestVersioned(), VolumeID: "vol-1", NodeID: "node-down",
})
require.ErrorIs(t, err, nats.ErrConnectionClosed)
assert.NotErrorIs(t, err, context.DeadlineExceeded, "the request must be refused, not left waiting")
})
}
}
func startNATS(t *testing.T) *server.Server {
t.Helper()
ns, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: -1, NoLog: true, NoSigs: true})
require.NoError(t, err)
go ns.Start()
require.True(t, ns.ReadyForConnections(5*time.Second))
t.Cleanup(ns.Shutdown)
return ns
}
+3 -3
View File
@@ -475,6 +475,7 @@ func TestNATSProviderRejectsEmptySuccessResponse(t *testing.T) {
respondWith(t, conn, subject, PublishVolumeResponse{Versioned: empty})
_, err = provider.PublishVolume(ctx, PublishVolumeRequest{Versioned: NewLatestVersioned(), VolumeID: "vol-empty", NodeID: "node-empty"})
require.EqualError(t, err, subject+" returned no published volume")
require.ErrorIs(t, err, ErrNoPublishedVolume)
})
t.Run("CreateSnapshot", func(t *testing.T) {
@@ -512,6 +513,7 @@ func TestNATSProviderRejectsInvalidExport(t *testing.T) {
})
published, err := provider.PublishVolume(t.Context(), PublishVolumeRequest{Versioned: NewLatestVersioned(), VolumeID: "vol-1", NodeID: tt.node})
require.EqualError(t, err, subject+" returned an invalid export: "+tt.wantErr)
require.ErrorIs(t, err, ErrInvalidExport)
assert.Nil(t, published)
})
}
@@ -718,8 +720,6 @@ func TestTimeoutForSizesEachSubjectToItsWork(t *testing.T) {
require.NoError(t, err)
unsealed, err := UnsealedWritesSubject("node-1")
require.NoError(t, err)
nodeCapabilities, err := NodeCapabilitiesSubject("node-1")
require.NoError(t, err)
// A delete costs one round trip per object, so its duration is a function
// of the volume. On prod a 100 GiB volume's delete took 52 s against a 30 s
@@ -740,7 +740,7 @@ func TestTimeoutForSizesEachSubjectToItsWork(t *testing.T) {
fast := []string{
CapabilitiesSubject, GetVolumeSubject, ListVolumesSubject,
ListSnapshotsSubject, ownerDescribe,
publish, unsealed, nodeCapabilities,
publish, unsealed,
}
for _, subject := range fast {
assert.Equal(t, callerTimeout, p.timeoutFor(subject),
@@ -126,8 +126,6 @@ func wireVerbs(t *testing.T) []wireVerb {
require.NoError(t, err)
unsealedSubject, err := ebsprovider.UnsealedWritesSubject("node-1")
require.NoError(t, err)
nodeCapabilitiesSubject, err := ebsprovider.NodeCapabilitiesSubject("node-1")
require.NoError(t, err)
return []wireVerb{
{name: "capabilities", subject: ebsprovider.CapabilitiesSubject,
@@ -156,8 +154,6 @@ func wireVerbs(t *testing.T) []wireVerb {
request: ebsprovider.UnpublishVolumeRequest{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-1", NodeID: "node-1"}},
{name: "unsealed", subject: unsealedSubject,
request: ebsprovider.HoldsUnsealedWritesRequest{Versioned: ebsprovider.NewVersioned(), NodeID: "node-1", VolumeIDs: []string{"vol-1"}}},
{name: "node.capabilities", subject: nodeCapabilitiesSubject,
request: ebsprovider.GetCapabilitiesRequest{Versioned: ebsprovider.NewVersioned()}},
}
}
@@ -737,22 +733,6 @@ func TestServeNodeIDScopesMountSubjects(t *testing.T) {
require.ErrorIs(t, err, nats.ErrNoResponders, "another node's mount subject must go unanswered")
}
// TestGetNodeCapabilitiesIsAnsweredOnlyByThatNode is why the per-node subject
// exists: the shared one is answered by whichever node's provider is fastest,
// which says nothing about the provider on the node a caller will mount on.
func TestGetNodeCapabilitiesIsAnsweredOnlyByThatNode(t *testing.T) {
conn := serveProvider(t, ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{}), natsserve.Options{NodeID: "node-1"})
client := ebsprovider.NewNATSProvider(conn, requestTimeout)
request := ebsprovider.GetCapabilitiesRequest{Versioned: ebsprovider.NewVersioned()}
resp, err := client.GetNodeCapabilities(t.Context(), "node-1", request)
require.NoError(t, err)
assert.Equal(t, ebsprovider.SupportedSchemaVersions(), resp.ContractVersions)
_, err = client.GetNodeCapabilities(t.Context(), "node-2", request)
require.ErrorIs(t, err, nats.ErrNoResponders, "another node's capabilities subject must go unanswered")
}
// TestServeRejectsUnroutableNodeID covers the subject-build failure path: an
// unsubscribable node ID must fail Serve outright rather than leave a server
// running with the shared subjects subscribed and no mount subject at all.
+3 -4
View File
@@ -36,9 +36,9 @@ type Options struct {
// across workers does not apply.
NoQueueGroup bool
// NodeID scopes the per-node subjects (publish, unpublish, unsealed writes
// and per-node capabilities) to this node, as a production daemon serves
// them. Left empty, Serve subscribes their wildcards and answers for any node.
// NodeID scopes the per-node subjects (publish, unpublish and unsealed
// writes) to this node, as a production daemon serves them. Left empty,
// Serve subscribes their wildcards and answers for any node.
NodeID string
}
@@ -135,7 +135,6 @@ func Serve(ctx context.Context, nc *nats.Conn, provider ebsprovider.EBSProvider,
{ebsprovider.PublishSubjectWildcard, ebsprovider.PublishSubject, handlePublishVolume(provider)},
{ebsprovider.UnpublishSubjectWildcard, ebsprovider.UnpublishSubject, handleUnpublishVolume(provider)},
{ebsprovider.UnsealedWritesSubjectWildcard, ebsprovider.UnsealedWritesSubject, handleHoldsUnsealedWrites(provider)},
{ebsprovider.NodeCapabilitiesSubjectWildcard, ebsprovider.NodeCapabilitiesSubject, handleCapabilities(provider)},
}
for _, step := range nodeSteps {
subject := step.wildcard
@@ -81,3 +81,34 @@ func TestServerSpan_SurvivesAnUntracedCaller(t *testing.T) {
assert.Equal(t, "ebs.volume.list", serverSpan.Name())
assert.False(t, serverSpan.Parent().IsValid(), "an untraced caller must not leave a dangling parent")
}
// TestServerSpan_CarriesTheCallersAccount attributes provider work to the
// account the request acts for, on both halves of the hop, and leaves an
// unattributed request unattributed.
func TestServerSpan_CarriesTheCallersAccount(t *testing.T) {
recorder := testutil.RecordSpans(t)
client := serveMemoryProvider(t)
ctx := ebsprovider.WithAccountID(t.Context(), "000000000042")
_, err := client.ListVolumes(ctx, ebsprovider.ListVolumesRequest{Versioned: ebsprovider.NewVersioned()})
require.NoError(t, err)
clientSpan, serverSpan := spansByKind(t, recorder)
assert.Equal(t, "000000000042", spanAccount(clientSpan))
assert.Equal(t, "000000000042", spanAccount(serverSpan))
recorder = testutil.RecordSpans(t)
_, err = client.ListVolumes(t.Context(), ebsprovider.ListVolumesRequest{Versioned: ebsprovider.NewVersioned()})
require.NoError(t, err)
clientSpan, serverSpan = spansByKind(t, recorder)
assert.Empty(t, spanAccount(clientSpan))
assert.Empty(t, spanAccount(serverSpan))
}
func spanAccount(span sdktrace.ReadOnlySpan) string {
for _, attr := range span.Attributes() {
if attr.Key == "aws.account_id" {
return attr.Value.AsString()
}
}
return ""
}
+5 -6
View File
@@ -40,14 +40,13 @@ type Versioned struct {
SchemaVersion uint16 `json:"schema_version"`
}
// NewVersioned returns the oldest representation guaranteed to interoperate
// during an N/N-1 rollout. Callers may use NewLatestVersioned only after a
// capability exchange proves the target understands SchemaVersion.
// NewVersioned returns LegacySchemaVersion, the oldest representation a
// provider serves, for a request whose shape is the same at every version.
func NewVersioned() Versioned { return Versioned{SchemaVersion: LegacySchemaVersion} }
// NewLatestVersioned returns the newest representation this build understands.
// It must not be used merely because this binary is new: a separately released
// peer may still be serving LegacySchemaVersion.
// NewLatestVersioned returns SchemaVersion. A cluster runs one contract
// version, which every provider in it serves, so a request that needs a field
// only the newest version carries is sent at it without asking first.
func NewLatestVersioned() Versioned { return Versioned{SchemaVersion: SchemaVersion} }
// SupportsSchemaVersion reports whether this build can safely decode version.
+21 -11
View File
@@ -3,6 +3,7 @@ package ebsprovider
import (
"context"
"encoding/json"
"fmt"
"maps"
"slices"
"strings"
@@ -30,6 +31,9 @@ const (
attrNodeID = "ebs.provider.node_id"
attrOwnerRouted = "ebs.provider.owner_routed"
attrErrorCode = "ebs.provider.error_code"
// attrAccountID matches the key the gateway and the control plane's own
// NATS spans use, so one field follows a request to the provider.
attrAccountID = "aws.account_id"
)
// VerbUnknown is the verb reported for a subject that is not part of the
@@ -115,8 +119,6 @@ func parseSubject(subject string) subjectTarget {
return subjectTarget{verb: verbVolumeUnpublish, nodeID: nodeID}
case unsealedVerb:
return subjectTarget{verb: verbUnsealedWrites, nodeID: nodeID}
case nodeCapabilitiesVerb:
return subjectTarget{verb: verbCapabilities, nodeID: nodeID}
case fenceNoticeVerb:
return subjectTarget{verb: verbFenceNotice, nodeID: nodeID}
default:
@@ -124,11 +126,16 @@ func parseSubject(subject string) subjectTarget {
}
}
func (t subjectTarget) attributes(subject string) []attribute.KeyValue {
// attributes describes the target; an empty accountID is left off, as work
// with no caller must not be credited to anyone.
func (t subjectTarget) attributes(subject, accountID string) []attribute.KeyValue {
attrs := []attribute.KeyValue{
attribute.String("messaging.system", "nats"),
attribute.String(attrSubject, subject),
}
if accountID != "" {
attrs = append(attrs, attribute.String(attrAccountID, accountID))
}
if t.volumeID != "" {
attrs = append(attrs, attribute.String(attrVolumeID, t.volumeID))
}
@@ -156,11 +163,11 @@ func (c headerCarrier) Keys() []string {
// startClientSpan opens a span for an outbound request and injects the trace
// context into hdr so the serving provider joins the same trace.
func startClientSpan(ctx context.Context, subject string, hdr nats.Header) (context.Context, trace.Span) {
func startClientSpan(ctx context.Context, subject, accountID string, hdr nats.Header) (context.Context, trace.Span) {
target := parseSubject(subject)
ctx, span := otel.Tracer(tracerName).Start(ctx, spanNamePrefix+target.verb,
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(target.attributes(subject)...))
trace.WithAttributes(target.attributes(subject, accountID)...))
otel.GetTextMapPropagator().Inject(ctx, headerCarrier(hdr))
return ctx, span
}
@@ -178,16 +185,18 @@ func startCompletionSpan(ctx context.Context, verb, completionSubject string) (c
}
// StartServerSpan joins the trace carried in msg's headers and opens a span
// for the verb its subject names. Callers must End the returned span and
// should pass the returned context into the work the handler does.
// for the verb its subject names, attributed to the account the request
// carries. Callers must End the span and pass the context into the handler.
func StartServerSpan(ctx context.Context, msg *nats.Msg) (context.Context, trace.Span) {
target := parseSubject(msg.Subject)
var accountID string
if msg.Header != nil {
ctx = otel.GetTextMapPropagator().Extract(ctx, headerCarrier(msg.Header))
accountID = msg.Header.Get(AccountIDHeader)
}
return otel.Tracer(tracerName).Start(ctx, spanNamePrefix+target.verb,
trace.WithSpanKind(trace.SpanKindServer),
trace.WithAttributes(target.attributes(msg.Subject)...))
trace.WithAttributes(target.attributes(msg.Subject, accountID)...))
}
// RecordSpanError marks span failed for a transport or handler error.
@@ -210,9 +219,9 @@ func RecordProviderError(span trace.Span, providerErr *ProviderError) {
span.SetStatus(codes.Error, providerErr.Message)
}
// RecordResponseError tags span with the error an encoded response carries.
// The response types share no accessor for it, so this re-reads the one field
// rather than making every verb implement one; it runs only when recording.
// RecordResponseError tags span with the error an encoded response carries, or
// fails it for a response that is not readable. The response types share no
// accessor, so this re-reads the one field; it runs only when recording.
func RecordResponseError(span trace.Span, data []byte) {
if !span.IsRecording() {
return
@@ -221,6 +230,7 @@ func RecordResponseError(span trace.Span, data []byte) {
Error *ProviderError `json:"error"`
}
if err := json.Unmarshal(data, &envelope); err != nil {
RecordSpanError(span, fmt.Errorf("%w: %w", ErrUnreadableReply, err))
return
}
RecordProviderError(span, envelope.Error)
-3
View File
@@ -32,8 +32,6 @@ func TestSubjectVerb_StripsIDsFromEveryDynamicSubject(t *testing.T) {
require.NoError(t, err)
unsealed, err := UnsealedWritesSubject(nodeID)
require.NoError(t, err)
nodeCapabilities, err := NodeCapabilitiesSubject(nodeID)
require.NoError(t, err)
fenced, err := FenceNoticeSubject(nodeID)
require.NoError(t, err)
@@ -54,7 +52,6 @@ func TestSubjectVerb_StripsIDsFromEveryDynamicSubject(t *testing.T) {
publish: verbVolumePublish,
unpublish: verbVolumeUnpublish,
unsealed: verbUnsealedWrites,
nodeCapabilities: verbCapabilities,
fenced: verbFenceNotice,
}
@@ -17,6 +17,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/providers/ebs/conformance"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
testpredastore "github.com/mulgadc/spinifex/tests/fixtures/predastore"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/require"
)
@@ -53,24 +54,49 @@ func requireExportTools(t *testing.T) string {
return pluginPath
}
// queueOnlyNode stands a node up whose provider subjects are served on a NATS
// of its own, with the cluster's lease, dirty and liveness state, so its
// client reaches it on the queue subjects alone, as when an owner is silent.
func queueOnlyNode(t *testing.T, node *Config, clusterNC *nats.Conn) ebsprovider.EBSProvider {
t.Helper()
dirty, err := newVolumeDirty(t.Context(), clusterNC, node.leaseOwner())
require.NoError(t, err)
node.dirty = dirty
node.liveness = newHeartbeatLiveness(clusterNC)
_, privateURL := setupEmbeddedNATS(t)
return ebsprovider.NewNATSProvider(startProviderSubjects(t, node, privateURL), 60*time.Second)
}
// providerHarness is what inProcessProvider stands up: a client for the
// cluster, both node names, and the hooks the conformance suite takes.
type providerHarness struct {
newProvider func(t *testing.T) ebsprovider.EBSProvider
nodeID string
otherNodeID string
killExport func(*testing.T, *ebsprovider.PublishedVolume)
revokeExport func(*testing.T, *ebsprovider.PublishedVolume) ebsprovider.FenceNotice
queueOnOtherNode func(t *testing.T) ebsprovider.EBSProvider
}
// inProcessProvider stands two nodes of the provider handlers up over one
// embedded NATS and one predastore, and returns a client for them plus both
// node names. Everything is torn down with the test.
// embedded NATS and one predastore. Everything is torn down with the test.
//
// Two nodes rather than one because the exclusion this provider advertises is
// cluster-scoped, and a single node cannot exercise it: the second publish
// would go to a subject nobody serves and time out instead of being refused.
// Both share the NATS the volume leases live in and the bucket the volumes
// live in, so a second opener is refused by the lease, not by the harness.
// The last return kills a published export's nbdkit behind the handlers' back.
func inProcessProvider(t *testing.T) (func(t *testing.T) ebsprovider.EBSProvider, string, string, func(*testing.T, *ebsprovider.PublishedVolume)) {
// The hooks kill a published export's nbdkit behind the handlers' back, and
// revoke one as a lost lease does.
func inProcessProvider(t *testing.T) providerHarness {
t.Helper()
pluginPath := requireExportTools(t)
fixture := testpredastore.Start(t)
_, natsURL := setupEmbeddedNATS(t)
newNode := func(name string) *Config {
newNode := func(t *testing.T, name string) *Config {
cfg := setupTestConfig(t, natsURL)
cfg.S3Host = "https://" + fixture.Host
cfg.Bucket = testpredastore.DefaultBucket
@@ -88,8 +114,8 @@ func inProcessProvider(t *testing.T) (func(t *testing.T) ebsprovider.EBSProvider
return cfg
}
first := newNode("node-1")
second := newNode("node-2")
first := newNode(t, "node-1")
second := newNode(t, "node-2")
nc := startProviderSubjects(t, first, natsURL)
startProviderSubjects(t, second, natsURL)
@@ -100,21 +126,42 @@ func inProcessProvider(t *testing.T) (func(t *testing.T) ebsprovider.EBSProvider
_, err = js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: clusterv1.ClusterStateBucket})
require.NoError(t, err)
killExport := func(t *testing.T, published *ebsprovider.PublishedVolume) {
holder := func(t *testing.T, published *ebsprovider.PublishedVolume) (*Config, MountedVolume) {
t.Helper()
for _, node := range []*Config{first, second} {
if mv, ok := findMountedVolume(node, published.VolumeID); ok && node.NodeName == published.NodeID {
require.NoError(t, hostprocess.ForceKillProcess(mv.PID, 5*time.Second))
return
return node, mv
}
}
t.Fatalf("no mounted entry for %s on %s", published.VolumeID, published.NodeID)
return nil, MountedVolume{}
}
return func(t *testing.T) ebsprovider.EBSProvider {
t.Helper()
return ebsprovider.NewNATSProvider(nc, 60*time.Second)
}, first.NodeName, second.NodeName, killExport
return providerHarness{
newProvider: func(t *testing.T) ebsprovider.EBSProvider {
t.Helper()
return ebsprovider.NewNATSProvider(nc, 60*time.Second)
},
nodeID: first.NodeName,
otherNodeID: second.NodeName,
killExport: func(t *testing.T, published *ebsprovider.PublishedVolume) {
t.Helper()
_, mv := holder(t, published)
require.NoError(t, hostprocess.ForceKillProcess(mv.PID, 5*time.Second))
},
revokeExport: func(t *testing.T, published *ebsprovider.PublishedVolume) ebsprovider.FenceNotice {
t.Helper()
node, _ := holder(t, published)
return conformance.CaptureFenceNotice(t, nc, published.NodeID, func() error {
node.onVolumeLeaseLost(t.Context(), published.VolumeID, leaseLostStalled)
return nil
})
},
queueOnOtherNode: func(t *testing.T) ebsprovider.EBSProvider {
t.Helper()
return queueOnlyNode(t, newNode(t, "node-3"), nc)
},
}
}
// runPrefix names one run's volumes so it cannot meet an earlier run's
@@ -126,12 +173,14 @@ func runPrefix() string {
// TestViperblockdConformance judges the provider handlers by the same suite
// MemoryProvider and qemunbdd answer to.
func TestViperblockdConformance(t *testing.T) {
newProvider, nodeID, otherNodeID, killExport := inProcessProvider(t)
conformance.RunSuiteWithConfig(t, newProvider, conformance.SuiteConfig{
NamePrefix: runPrefix(),
NodeID: nodeID,
OtherNodeID: otherNodeID,
KillExport: killExport,
h := inProcessProvider(t)
conformance.RunSuiteWithConfig(t, h.newProvider, conformance.SuiteConfig{
NamePrefix: runPrefix(),
NodeID: h.nodeID,
OtherNodeID: h.otherNodeID,
KillExport: h.killExport,
RevokeExport: h.revokeExport,
QueueOnOtherNode: h.queueOnOtherNode,
})
}
@@ -139,9 +188,9 @@ func TestViperblockdConformance(t *testing.T) {
// cannot: whether the nbdkit export viperblockd publishes is usable by libnbd,
// which knows nothing about this codebase.
func TestViperblockdNBDClient(t *testing.T) {
newProvider, nodeID, _, _ := inProcessProvider(t)
conformance.RunNBDClientSuiteWithConfig(t, newProvider, conformance.NBDClientConfig{
NodeID: nodeID,
h := inProcessProvider(t)
conformance.RunNBDClientSuiteWithConfig(t, h.newProvider, conformance.NBDClientConfig{
NodeID: h.nodeID,
VolumePrefix: "vol-" + runPrefix() + "nbd",
})
}
@@ -12,6 +12,7 @@ import (
"testing"
"time"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/internal/testkit/deadpid"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/nbd"
@@ -176,23 +177,6 @@ func TestProviderHandlers_PublishVolume_RepublishRepliesInRequestVersion(t *test
}
}
// TestProviderHandlers_NodeCapabilitiesListsServedVersions pins the per-node
// subject to the same contract versions this build serves.
func TestProviderHandlers_NodeCapabilitiesListsServedVersions(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
nc := startProviderSubjects(t, cfg, natsURL)
subject, err := ebsprovider.NodeCapabilitiesSubject(cfg.NodeName)
require.NoError(t, err)
msg := requestProvider(t, nc, subject, marshalRequest(t, map[string]any{"schema_version": ebsprovider.SchemaVersion}))
var resp ebsprovider.GetCapabilitiesResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
require.Nil(t, resp.Error)
assert.Equal(t, ebsprovider.SupportedSchemaVersions(), resp.ContractVersions)
}
// TestProviderHandlers_UnpublishNoSealAboveServedVersionRefused covers a caller
// newer than this build: a no_seal it cannot vouch for is refused, and the
// export is neither torn down nor sealed.
@@ -252,6 +236,9 @@ func TestPublishError_Codes(t *testing.T) {
assert.Equal(t, tc.want, got.Code)
})
}
// Callers still recognise held-elsewhere by its text, so the message keeps it.
heldElsewhere := publishError(ebsprovider.NoSealSchemaVersion, "vol-publisherror01", leaseHeld)
assert.Contains(t, heldElsewhere.Message, ec2v1.VolumeHeldElsewhereText)
assert.True(t, ebsprovider.ErrorCodeUnavailable.Retryable(), "a backing store that is not ready must read as retry-later")
}
@@ -53,12 +53,12 @@ var providerObjectStoreFactory = func(cfg *Config) objectstore.ObjectStore {
// viperblock engine construction out of the EC2 control-plane handlers and
// into the storage daemon that owns BaseDir and the mounted-volume registry.
//
// PublishVolume/UnpublishVolume are node-addressed (ebsprovider.PublishSubject
// / UnpublishSubject already route to one node), so they are only registered
// when cfg.NodeName is set; there is no queue-group fallback the way the
// legacy ebs.mount/ebs.unmount subjects have.
// It refuses a cfg.NodeName that cannot address the node-addressed subjects.
// Exported so harnesses can put a real provider behind the control plane.
func RegisterProviderSubjects(cfg *Config, nc *nats.Conn) error {
if err := cfg.validateNodeName(); err != nil {
return err
}
// The lease store has to exist before any subject is served: a handler
// that reaches an engine open without one refuses, and refusing every
// publish is a worse failure than not starting.
@@ -114,11 +114,6 @@ func RegisterProviderSubjects(cfg *Config, nc *nats.Conn) error {
return fmt.Errorf("subscribe to %s: %w", snapshotCreateWildcard, err)
}
if cfg.NodeName == "" {
slog.Warn("ebs.provider: NodeName is empty, this node cannot serve PublishVolume/UnpublishVolume attachments")
return nil
}
publishSubject, err := ebsprovider.PublishSubject(cfg.NodeName)
if err != nil {
return fmt.Errorf("build publish subject: %w", err)
@@ -149,16 +144,6 @@ func RegisterProviderSubjects(cfg *Config, nc *nats.Conn) error {
return fmt.Errorf("subscribe to %s: %w", unsealedSubject, err)
}
// The queue subject is answered by any node's provider; this one only by
// this node's, so a daemon can learn what its own provider serves.
nodeCapabilitiesSubject, err := ebsprovider.NodeCapabilitiesSubject(cfg.NodeName)
if err != nil {
return fmt.Errorf("build node capabilities subject: %w", err)
}
if _, err := nc.Subscribe(nodeCapabilitiesSubject, tracedProviderHandler(handleProviderCapabilities)); err != nil {
return fmt.Errorf("subscribe to %s: %w", nodeCapabilitiesSubject, err)
}
return nil
}
@@ -1141,13 +1126,9 @@ func handleCreateSnapshot(ctx context.Context, cfg *Config, nc *nats.Conn, msg *
},
})
// The caller is responsible for draining the volume (flushing guest
// writes to the live checkpoint) before requesting a snapshot; that
// coordination goes through the guest over ec2.cmd.<instanceID> and
// stays a control-plane concern this provider boundary does not reach.
// WithoutCancel keeps the trace context while dropping the deadline: the
// work has to outlive the request it was accepted by, and it still has to
// be attributable to it.
// The drain runs in the background with the snapshot, so only the accept
// reply is freed of it; a caller waiting on completion still waits for it.
// WithoutCancel keeps the trace context but drops the request's deadline.
go completeCreateSnapshot(context.WithoutCancel(ctx), cfg, nc, req, operationID, completionSubject)
}
@@ -1188,7 +1169,7 @@ func snapshotErrorCode(err error) ebsprovider.ErrorCode {
return ebsprovider.ErrorCodeNotFound
case errors.Is(err, errSnapshotExistsElsewhere):
return ebsprovider.ErrorCodeAlreadyExists
case errors.Is(err, errVolumeLeaseHeld), errors.Is(err, errNoVolumeLeaseStore):
case errors.Is(err, errVolumeLeaseHeld), errors.Is(err, errNoVolumeLeaseStore), errors.Is(err, errExportSettling):
// Exclusive access could not be established. The holder gives the
// volume up on detach, so this is worth repeating rather than the
// permanent failure an internal error reads as.
@@ -1216,26 +1197,36 @@ func checkSnapshotIDFree(vb *viperblock.VB, volumeID, snapshotID string) error {
}
// snapshotVolumeEngine creates snapshotID off volumeID's live checkpoint,
// preferring an already-mounted VB over opening a second engine on the same
// volume. Draining the volume so the checkpoint is current is the caller's
// responsibility (see handleCreateSnapshot).
//
// The unmounted branch opens through openVolumeVB, so it holds the volume's
// cluster-wide lease for as long as the engine is open: a snapshot taken
// beside a live writer on another node is two engines on one volume.
// preferring an already-mounted VB, drained first so the checkpoint is
// current, over opening a second engine on the same volume.
func snapshotVolumeEngine(ctx context.Context, cfg *Config, volumeID, snapshotID string) (*ebsprovider.Snapshot, error) {
if mv, ok := findMountedVolume(cfg, volumeID); ok && mv.VB != nil {
if err := drainMountedExport(ctx, cfg, volumeID); err != nil {
return nil, err
}
return createSnapshotWithVB(mv.VB, volumeID, snapshotID)
}
vb, lease, err := openVolumeVB(ctx, cfg, volumeID)
// The lease refuses a volume another node holds. It is reentrant here, so
// an export this node runs but has not registered is excluded or drained
// separately, and the engine opens only once that is settled.
lease, err := cfg.acquireVolumeLease(ctx, volumeID)
if err != nil {
return nil, err
}
defer func() {
vb.Detach()
cfg.releaseVolumeLease(ctx, lease)
}()
defer cfg.releaseVolumeLease(ctx, lease)
lock, err := excludeLocalWriter(ctx, cfg, volumeID)
if err != nil {
return nil, err
}
defer releaseWriterLock(ctx, lock, volumeID)
vb, err := openLeasedVolumeVB(ctx, cfg, volumeID, lease)
if err != nil {
return nil, err
}
defer vb.Detach()
return createSnapshotWithVB(vb, volumeID, snapshotID)
}
@@ -1813,8 +1804,8 @@ func unmountVolume(ctx context.Context, cfg *Config, volumeName string) (viperbl
// handlePublishVolume serves ebs.provider.v1.<node>.mount, the provider-neutral
// front for the same nbdkit mount path ebs.mount uses. It is node-addressed
// (RegisterProviderSubjects only subscribes it when cfg.NodeName is set), so
// unlike the legacy handler there is no queue-group fallback to reason about.
// (subscribed on cfg.NodeName's subject), so unlike the legacy handler there
// is no queue-group fallback to reason about.
func handlePublishVolume(ctx context.Context, cfg *Config, nc *nats.Conn, msg *nats.Msg) {
var req ebsprovider.PublishVolumeRequest
err := json.Unmarshal(msg.Data, &req)
@@ -1929,7 +1920,7 @@ func tearDownPublication(ctx context.Context, cfg *Config, req ebsprovider.Unpub
if err != nil {
// Nothing mounted here is already the state unpublish asks for, so a
// retry after a request that completed server-side converges rather
// than erroring, matching the legacy path's unmountResponseError.
// than erroring.
if unmountResp.NotFound {
return unmountResp.Reaped, nil
}
@@ -154,7 +154,6 @@ func providerSubjectCases() []providerSubjectCase {
"node_id": "test-node",
"volume_ids": []string{"vol-testunsealed01"},
}},
{"node_capabilities", "ebs.provider.v1.test-node.capabilities", map[string]any{}},
}
}
@@ -1007,9 +1006,8 @@ func TestProviderHandlers_UnpublishVolume_RemovesMountedVolume(t *testing.T) {
}
// TestProviderHandlers_UnpublishVolume_AbsentIsIdempotent covers a volume this
// node never mounted. The legacy path treats that as a successful seal (see
// unmountResponseError), and so must this one: a retry after a request that
// timed out client-side but completed server-side has to converge.
// node never mounted, which counts as a successful seal: a retry after a
// request that timed out client-side but completed server-side must converge.
func TestProviderHandlers_UnpublishVolume_AbsentIsIdempotent(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
cfg := setupTestConfig(t, natsURL)
@@ -1029,29 +1027,29 @@ func TestProviderHandlers_UnpublishVolume_AbsentIsIdempotent(t *testing.T) {
assert.Nil(t, resp.Error, "unpublishing a volume this node never mounted must succeed")
}
// TestProviderHandlers_PublishUnpublish_RequireNodeName covers
// RegisterProviderSubjects' NodeName gate: PublishVolume/UnpublishVolume must
// not be registered when cfg.NodeName is empty (PublishSubject has no node to
// address), while every other ebs.provider.v1.* subject still is.
func TestProviderHandlers_PublishUnpublish_RequireNodeName(t *testing.T) {
// TestProviderHandlers_RefuseAnUnaddressableNodeName keeps viperblockd from
// serving where the node-addressed subjects, the fence notice among them, have
// no subject: a fence there would leave its guest running.
func TestProviderHandlers_RefuseAnUnaddressableNodeName(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
cfgNoNode := setupTestConfig(t, natsURL)
cfgNoNode.NodeName = ""
ncNoNode := startProviderSubjects(t, cfgNoNode, natsURL)
for _, node := range []string{"", "node.a", "node-*"} {
t.Run(node, func(t *testing.T) {
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
t.Cleanup(nc.Close)
_, natsURL2 := setupEmbeddedNATS(t)
cfgNamed := setupTestConfig(t, natsURL2)
ncNamed := startProviderSubjects(t, cfgNamed, natsURL2)
register := setupTestConfig(t, natsURL)
register.NodeName = node
err = RegisterProviderSubjects(register, nc)
require.ErrorIs(t, err, ebsprovider.ErrInvalidArgument)
assert.Contains(t, err.Error(), "viperblockd node name")
assert.Equal(t, ncNamed.NumSubscriptions()-4, ncNoNode.NumSubscriptions(),
"an empty NodeName must skip exactly the publish, unpublish, unsealed-writes and node capabilities subscriptions")
// The other provider subjects must still work without a node name.
body := marshalRequest(t, map[string]any{"schema_version": ebsprovider.SchemaVersion})
msg := requestProvider(t, ncNoNode, ebsprovider.CapabilitiesSubject, body)
var resp ebsprovider.GetCapabilitiesResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
assert.Nil(t, resp.Error)
run := setupTestConfig(t, natsURL)
run.NodeName = node
require.ErrorIs(t, run.Run(nc), ebsprovider.ErrInvalidArgument)
assert.Zero(t, nc.NumSubscriptions(), "a refused start must serve nothing")
})
}
}
// TestProviderHandlers_CreateVolume_SeedValidation covers the guards that must
@@ -0,0 +1,129 @@
package viperblock
import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"net"
"os"
"path/filepath"
"strings"
"time"
otelsetup "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/viperblock/viperblock"
)
const (
// exportDrainDialTimeout bounds the connect to the plugin's drain socket.
// The socket is local, so a slow connect is a wedged plugin.
exportDrainDialTimeout = time.Second
// exportDrainAckTimeout bounds the plugin's flush of its WAL and live
// checkpoint to the backend, which scales with the dirty set.
exportDrainAckTimeout = 30 * time.Second
// maxUnixSocketPath is sun_path's size. The plugin cannot listen on a
// longer path, so a drain socket there never exists.
maxUnixSocketPath = 108
)
// errExportDrainFailed is a drain the plugin was asked for and did not
// confirm, so the live checkpoint cannot be shown to be current.
var errExportDrainFailed = errors.New("export drain failed")
// errExportSettling is an export whose client is connecting or disconnecting:
// it holds the volume lock without serving the drain socket, so its writes are
// neither drainable nor yet sealed. It clears once the client settles.
var errExportSettling = errors.New("export is between connections")
// drainMountedExport asks the nbdkit plugin serving volumeID on this node to
// flush its WAL and live checkpoint to the backend before a snapshot reads
// them. The plugin serves the socket only while a client is connected.
func drainMountedExport(ctx context.Context, cfg *Config, volumeID string) error {
sockPath := filepath.Join(cfg.BaseDir, volumeID, "snapshot.sock")
if len(sockPath) >= maxUnixSocketPath {
return requireNoConnectedClient(ctx, cfg, volumeID, "")
}
start := time.Now()
conn, err := net.DialTimeout("unix", sockPath, exportDrainDialTimeout)
if errors.Is(err, fs.ErrNotExist) {
return requireNoConnectedClient(ctx, cfg, volumeID, sockPath)
}
if err != nil {
return fmt.Errorf("%w: %s: %w", errExportDrainFailed, volumeID, err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(exportDrainAckTimeout))
buf := make([]byte, 8)
n, err := conn.Read(buf)
if err != nil {
return fmt.Errorf("%w: %s: read ack: %w", errExportDrainFailed, volumeID, err)
}
if !strings.HasPrefix(string(buf[:n]), "OK") {
return fmt.Errorf("%w: %s: ack %q", errExportDrainFailed, volumeID, string(buf[:n]))
}
slog.InfoContext(ctx, "ebs.provider.snapshot.create: drained export", "volume", volumeID, "drain_ms", otelsetup.Millis(time.Since(start)))
return nil
}
// requireNoConnectedClient proves an export serving no drain socket has nothing
// buffered. sockPath is empty when the socket path is too long for the plugin
// to listen on, so a held lock there never clears.
func requireNoConnectedClient(ctx context.Context, cfg *Config, volumeID, sockPath string) error {
lock, err := viperblock.AcquireVolumeLock(cfg.BaseDir, volumeID)
if errors.Is(err, viperblock.ErrVolumeLocked) {
if sockPath == "" {
return fmt.Errorf("%w: %s: client connected with no drain socket", errExportDrainFailed, volumeID)
}
slog.WarnContext(ctx, "ebs.provider.snapshot.create: volume locked with no drain socket, refusing as retry-later",
"volume", volumeID, "socket", sockPath)
return fmt.Errorf("%w: %s", errExportSettling, volumeID)
}
if err != nil {
return fmt.Errorf("%w: %s: %w", errExportDrainFailed, volumeID, err)
}
if err := viperblock.ReleaseVolumeLock(lock); err != nil {
return fmt.Errorf("%w: %s: %w", errExportDrainFailed, volumeID, err)
}
return requireLastClientSealed(cfg, volumeID)
}
// requireLastClientSealed refuses an export whose last client left WAL behind
// without a seal receipt newer than it. The plugin releases the lock after a
// failed seal too, so a free lock alone does not show the writes reached the backend.
func requireLastClientSealed(cfg *Config, volumeID string) error {
if isAuxVolume(volumeID) || !volumeHoldsLocalWAL(cfg, volumeID) || sealReceiptCoversWAL(cfg, volumeID) {
return nil
}
return fmt.Errorf("%w: %s: last client left unsealed WAL", errExportDrainFailed, volumeID)
}
// excludeLocalWriter proves no export on this node holds writes the backend
// lacks before a snapshot opens its own engine. The returned lock keeps a new
// writer out until the snapshot is read; it is nil when a live export was drained.
func excludeLocalWriter(ctx context.Context, cfg *Config, volumeID string) (*os.File, error) {
lock, err := viperblock.AcquireVolumeLock(cfg.BaseDir, volumeID)
if errors.Is(err, viperblock.ErrVolumeLocked) {
return nil, drainMountedExport(ctx, cfg, volumeID)
}
if err != nil {
return nil, fmt.Errorf("%w: %s: %w", errExportDrainFailed, volumeID, err)
}
if err := requireLastClientSealed(cfg, volumeID); err != nil {
releaseWriterLock(ctx, lock, volumeID)
return nil, err
}
return lock, nil
}
// releaseWriterLock gives up a lock excludeLocalWriter took. A failed release
// leaves only the kernel to drop it at exit, so it is logged, not returned.
func releaseWriterLock(ctx context.Context, lock *os.File, volumeID string) {
if err := viperblock.ReleaseVolumeLock(lock); err != nil {
slog.WarnContext(ctx, "ebs.provider.snapshot.create: failed to release volume lock", "volume", volumeID, "err", err)
}
}
@@ -0,0 +1,450 @@
package viperblock
import (
"encoding/json"
"io/fs"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/viperblock/viperblock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mountedSnapshotFixture is a node with volumeID mounted over a real engine,
// whose base dir is where testutil's drain socket stand-in listens.
func mountedSnapshotFixture(t *testing.T, volumeID string) (cfg *Config, dataDir string, vb *viperblock.VB) {
t.Helper()
dataDir = testutil.SocketTempDir(t)
cfg, vb = mountedAt(t, volumeID, filepath.Join(dataDir, "viperblock"))
return cfg, dataDir, vb
}
// mountedAt is a node with volumeID mounted over a real engine under baseDir.
func mountedAt(t *testing.T, volumeID, baseDir string) (*Config, *viperblock.VB) {
t.Helper()
vb := createTestVBWithState(t, volumeID)
t.Cleanup(func() {
vb.StopChunkUploader()
vb.StopWALSyncer()
})
cfg := &Config{
NodeName: "node-a",
BaseDir: baseDir,
MountedVolumes: []MountedVolume{{Name: volumeID, VB: vb}},
}
require.NoError(t, os.MkdirAll(cfg.BaseDir, 0o750))
return cfg, vb
}
// leaveUnsealedWAL stands in for a client whose disconnect seal failed: the
// plugin keeps the volume's local WAL and writes no receipt.
func leaveUnsealedWAL(t *testing.T, cfg *Config, volumeID string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(cfg.BaseDir, volumeID, "wal", "chunks"), 0o750))
}
func requireNoSnapshot(t *testing.T, vb *viperblock.VB, snapshotID string) {
t.Helper()
exists, err := snapshotConfigExists(vb, snapshotID)
require.NoError(t, err)
assert.False(t, exists, "a snapshot that could not be shown current must not be written")
}
// The snapshot reads the checkpoint the plugin flushes, so it must not start
// until the plugin has acknowledged the drain.
func TestSnapshotVolumeEngine_MountedVolumeIsDrainedBeforeTheSnapshot(t *testing.T) {
const volumeID, snapshotID = "vol-drainfirst", "snap-drainfirst00001"
cfg, dataDir, vb := mountedSnapshotFixture(t, volumeID)
accepted, release := testutil.StartBlockingDrainSocket(t, dataDir, volumeID, "OK\n")
type result struct {
snapshot *ebsprovider.Snapshot
err error
}
done := make(chan result, 1)
go func() {
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
done <- result{snapshot, err}
}()
select {
case <-accepted:
case <-time.After(5 * time.Second):
t.Fatal("a mounted volume's snapshot never asked the plugin to drain")
}
requireNoSnapshot(t, vb, snapshotID)
release()
select {
case got := <-done:
require.NoError(t, got.err)
assert.Equal(t, ebsprovider.SnapshotStateCompleted, got.snapshot.State)
case <-time.After(10 * time.Second):
t.Fatal("snapshot did not complete after the drain acked")
}
}
// The control plane issues no drain of its own, so this one is the only flush
// an attached volume's snapshot gets, and it must happen exactly once.
func TestSnapshotVolumeEngine_MountedVolumeIsDrainedOnce(t *testing.T) {
const volumeID, snapshotID = "vol-drainonce", "snap-drainonce000001"
cfg, dataDir, _ := mountedSnapshotFixture(t, volumeID)
drains := testutil.StartDrainSocket(t, dataDir, volumeID, "OK\n")
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.NoError(t, err)
assert.Equal(t, ebsprovider.SnapshotStateCompleted, snapshot.State)
assert.Equal(t, 1, drains())
}
// A plugin that answers without confirming the flush leaves the checkpoint
// unproven, so the snapshot fails rather than capturing older data.
func TestSnapshotVolumeEngine_RefusedDrainFailsTheSnapshot(t *testing.T) {
const volumeID, snapshotID = "vol-drainerr", "snap-drainerr0000001"
cfg, dataDir, vb := mountedSnapshotFixture(t, volumeID)
testutil.StartDrainSocket(t, dataDir, volumeID, "ERR\n")
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.ErrorIs(t, err, errExportDrainFailed)
assert.Nil(t, snapshot)
assert.Equal(t, ebsprovider.ErrorCodeInternal, snapshotErrorCode(err))
requireNoSnapshot(t, vb, snapshotID)
}
// A socket file nobody listens on is a plugin that died mid-connection, whose
// writes were never drained.
func TestSnapshotVolumeEngine_StaleDrainSocketFailsTheSnapshot(t *testing.T) {
const volumeID, snapshotID = "vol-drainstale", "snap-drainstale00001"
cfg, _, vb := mountedSnapshotFixture(t, volumeID)
sockDir := filepath.Join(cfg.BaseDir, volumeID)
require.NoError(t, os.MkdirAll(sockDir, 0o750))
ln, err := net.Listen("unix", filepath.Join(sockDir, "snapshot.sock"))
require.NoError(t, err)
ln.(*net.UnixListener).SetUnlinkOnClose(false)
require.NoError(t, ln.Close())
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.ErrorIs(t, err, errExportDrainFailed)
assert.Nil(t, snapshot)
requireNoSnapshot(t, vb, snapshotID)
}
// The plugin serves the socket only while a client is connected, and drains
// on disconnect. An export with no client has nothing buffered to flush.
func TestSnapshotVolumeEngine_ExportWithoutAClientNeedsNoDrain(t *testing.T) {
const volumeID, snapshotID = "vol-drainidle", "snap-drainidle000001"
cfg, _, _ := mountedSnapshotFixture(t, volumeID)
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.NoError(t, err)
assert.Equal(t, ebsprovider.SnapshotStateCompleted, snapshot.State)
}
// A client between connections holds the volume lock without serving the
// socket: its writes are neither drainable nor sealed yet, which clears soon.
func TestSnapshotVolumeEngine_ExportBetweenConnectionsIsRetryLater(t *testing.T) {
const volumeID, snapshotID = "vol-drainsettle", "snap-drainsettle0001"
cfg, _, vb := mountedSnapshotFixture(t, volumeID)
lock, err := viperblock.AcquireVolumeLock(cfg.BaseDir, volumeID)
require.NoError(t, err)
t.Cleanup(func() { _ = viperblock.ReleaseVolumeLock(lock) })
logs := captureLogs(t)
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.ErrorIs(t, err, errExportSettling)
assert.Nil(t, snapshot)
assert.Equal(t, ebsprovider.ErrorCodeUnavailable, snapshotErrorCode(err))
requireNoSnapshot(t, vb, snapshotID)
// A plugin that never serves its socket refuses every retry, so each
// refusal is logged with the socket an operator would look for.
sockPath := filepath.Join(cfg.BaseDir, volumeID, "snapshot.sock")
assert.True(t, slices.ContainsFunc(strings.Split(logs.String(), "\n"), func(line string) bool {
return strings.Contains(line, "level=WARN") && strings.Contains(line, "volume="+volumeID) &&
strings.Contains(line, "socket="+sockPath)
}), "the refusal must be logged at WARN with the volume and socket: %s", logs.String())
}
// The plugin releases the lock even when its disconnect seal fails, so a free
// lock with WAL left behind and no receipt is writes that never reached the
// backend, and the checkpoint a snapshot would read predates them.
func TestSnapshotVolumeEngine_UnsealedLastClientFailsTheSnapshot(t *testing.T) {
const volumeID, snapshotID = "vol-drainunsealed", "snap-drainunsealed01"
cfg, _, vb := mountedSnapshotFixture(t, volumeID)
leaveUnsealedWAL(t, cfg, volumeID)
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.ErrorIs(t, err, errExportDrainFailed)
assert.Nil(t, snapshot)
assert.Equal(t, ebsprovider.ErrorCodeInternal, snapshotErrorCode(err))
requireNoSnapshot(t, vb, snapshotID)
}
// A seal receipt is the plugin's proof the last client's writes reached the
// backend, so local WAL beside it does not block the snapshot.
func TestSnapshotVolumeEngine_SealedLastClientProceeds(t *testing.T) {
const volumeID, snapshotID = "vol-drainsealed", "snap-drainsealed0001"
cfg, _, _ := mountedSnapshotFixture(t, volumeID)
leaveUnsealedWAL(t, cfg, volumeID)
receipt, err := json.Marshal(sealReceipt{Volume: volumeID, PID: 1, SealedAt: time.Now()})
require.NoError(t, err)
require.NoError(t, os.WriteFile(sealReceiptPath(cfg.BaseDir, volumeID), receipt, 0o600))
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.NoError(t, err)
assert.Equal(t, ebsprovider.SnapshotStateCompleted, snapshot.State)
assert.FileExists(t, sealReceiptPath(cfg.BaseDir, volumeID), "the receipt belongs to the unmount it attests to")
}
// A socket path the plugin could not have listened on never appears, so a
// connected client there can never be drained: that is permanent, not retry.
func TestSnapshotVolumeEngine_ConnectedClientWithUnservableSocketFails(t *testing.T) {
const volumeID, snapshotID = "vol-drainlongpath", "snap-drainlongpath01"
baseDir := filepath.Join(t.TempDir(), strings.Repeat("d", maxUnixSocketPath))
cfg, vb := mountedAt(t, volumeID, baseDir)
lock, err := viperblock.AcquireVolumeLock(cfg.BaseDir, volumeID)
require.NoError(t, err)
t.Cleanup(func() { _ = viperblock.ReleaseVolumeLock(lock) })
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.ErrorIs(t, err, errExportDrainFailed)
assert.NotErrorIs(t, err, errExportSettling)
assert.Nil(t, snapshot)
assert.Equal(t, ebsprovider.ErrorCodeInternal, snapshotErrorCode(err))
requireNoSnapshot(t, vb, snapshotID)
}
// A plugin that closes the connection without an ack has not confirmed the
// flush, so the snapshot fails.
func TestSnapshotVolumeEngine_DrainClosedWithoutAckFailsTheSnapshot(t *testing.T) {
const volumeID, snapshotID = "vol-drainnoack", "snap-drainnoack00001"
cfg, dataDir, vb := mountedSnapshotFixture(t, volumeID)
testutil.StartDrainSocket(t, dataDir, volumeID, "")
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.ErrorIs(t, err, errExportDrainFailed)
assert.Nil(t, snapshot)
requireNoSnapshot(t, vb, snapshotID)
}
// lockProbingS3Host is a backend that refuses every request, recording for
// each whether a new writer could have taken volumeID's lock at that moment.
type lockProbingS3Host struct {
url string
requests atomic.Int32
lockable atomic.Int32
}
func startLockProbingS3Host(t *testing.T, baseDir, volumeID string) *lockProbingS3Host {
t.Helper()
probe := &lockProbingS3Host{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
probe.requests.Add(1)
if lock, err := viperblock.AcquireVolumeLock(baseDir, volumeID); err == nil {
probe.lockable.Add(1)
_ = viperblock.ReleaseVolumeLock(lock)
}
w.WriteHeader(http.StatusBadRequest)
}))
t.Cleanup(srv.Close)
probe.url = srv.URL
return probe
}
// unmountedSnapshotFixture is the volume's own host with nothing registered
// for it, so a snapshot takes the open-an-engine branch.
func unmountedSnapshotFixture(t *testing.T, volumeID string) (*Config, string, *lockProbingS3Host) {
t.Helper()
_, natsURL := setupEmbeddedNATS(t)
dataDir := testutil.SocketTempDir(t)
baseDir := filepath.Join(dataDir, "viperblock")
require.NoError(t, os.MkdirAll(baseDir, 0o750))
backend := startLockProbingS3Host(t, baseDir, volumeID)
cfg := &Config{
NodeName: "node-a",
BaseDir: baseDir,
S3Host: backend.url,
Bucket: "test-bucket",
Region: "us-east-1",
AccessKey: "test-access-key",
SecretKey: "test-secret-key",
loadStateRetryAttempts: 1,
}
installTestVolumeLeases(t, cfg, natsURL)
return cfg, dataDir, backend
}
// holdLockAsExport stands in for an nbdkit this node runs but never
// registered, as a failed rebuild in recovery leaves behind.
func holdLockAsExport(t *testing.T, cfg *Config, volumeID string) {
t.Helper()
lock, err := viperblock.AcquireVolumeLock(cfg.BaseDir, volumeID)
require.NoError(t, err)
t.Cleanup(func() { _ = viperblock.ReleaseVolumeLock(lock) })
}
// An unregistered export still takes guest writes, so the snapshot must have
// it drain before an engine reads the backend's checkpoint.
func TestSnapshotVolumeEngine_UnregisteredExportIsDrainedBeforeTheOpen(t *testing.T) {
const volumeID, snapshotID = "vol-strayserving", "snap-strayserving001"
cfg, dataDir, backend := unmountedSnapshotFixture(t, volumeID)
holdLockAsExport(t, cfg, volumeID)
accepted, release := testutil.StartBlockingDrainSocket(t, dataDir, volumeID, "OK\n")
done := make(chan error, 1)
go func() {
_, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
done <- err
}()
select {
case <-accepted:
case <-time.After(5 * time.Second):
t.Fatal("an unregistered export was never asked to drain")
}
assert.Zero(t, backend.requests.Load(), "the engine must not read the backend before the drain acks")
release()
select {
case err := <-done:
require.Error(t, err, "the refusing backend fails the open")
assert.NotErrorIs(t, err, errExportDrainFailed)
case <-time.After(10 * time.Second):
t.Fatal("snapshot did not proceed after the drain acked")
}
assert.Positive(t, backend.requests.Load(), "a drained export lets the engine open")
}
// An unregistered export with no socket cannot be drained, so the snapshot is
// refused as retry-later before any engine reads the backend.
func TestSnapshotVolumeEngine_UnregisteredExportWithoutSocketIsRetryLater(t *testing.T) {
const volumeID, snapshotID = "vol-straysettle", "snap-straysettle0001"
cfg, _, backend := unmountedSnapshotFixture(t, volumeID)
holdLockAsExport(t, cfg, volumeID)
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.ErrorIs(t, err, errExportSettling)
assert.Nil(t, snapshot)
assert.Equal(t, ebsprovider.ErrorCodeUnavailable, snapshotErrorCode(err))
assert.Zero(t, backend.requests.Load(), "a refused snapshot must not open an engine")
}
// A departed export that left unsealed WAL holds writes the backend lacks.
func TestSnapshotVolumeEngine_UnmountedUnsealedWALFailsBeforeTheOpen(t *testing.T) {
const volumeID, snapshotID = "vol-strayunsealed", "snap-strayunsealed01"
cfg, _, backend := unmountedSnapshotFixture(t, volumeID)
leaveUnsealedWAL(t, cfg, volumeID)
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.ErrorIs(t, err, errExportDrainFailed)
assert.Nil(t, snapshot)
assert.Zero(t, backend.requests.Load(), "a refused snapshot must not open an engine")
}
// With no export to drain, the volume lock is held across the open so a
// writer cannot start between the check and the read.
func TestSnapshotVolumeEngine_UnmountedOpenHoldsTheVolumeLock(t *testing.T) {
const volumeID, snapshotID = "vol-strayidle", "snap-strayidle000001"
cfg, _, backend := unmountedSnapshotFixture(t, volumeID)
_, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.Error(t, err, "the refusing backend fails the open")
assert.NotErrorIs(t, err, errExportDrainFailed)
assert.NotErrorIs(t, err, errExportSettling)
require.Positive(t, backend.requests.Load(), "a node with no export and no WAL opens the engine")
assert.Zero(t, backend.lockable.Load(), "a writer could take the volume lock while the engine was reading")
lock, err := viperblock.AcquireVolumeLock(cfg.BaseDir, volumeID)
require.NoError(t, err, "the snapshot must give the lock up when it is done")
require.NoError(t, viperblock.ReleaseVolumeLock(lock))
}
// writeSealReceiptAt leaves the receipt the plugin writes after a seal at sealedAt.
func writeSealReceiptAt(t *testing.T, cfg *Config, volumeID string, sealedAt time.Time) {
t.Helper()
receipt, err := json.Marshal(sealReceipt{Volume: volumeID, PID: 1, SealedAt: sealedAt})
require.NoError(t, err)
require.NoError(t, os.WriteFile(sealReceiptPath(cfg.BaseDir, volumeID), receipt, 0o600))
}
// touchWAL dates every entry of the volume's local WAL to at.
func touchWAL(t *testing.T, cfg *Config, volumeID string, at time.Time) {
t.Helper()
dir, err := localWALDir(cfg, volumeID)
require.NoError(t, err)
require.NoError(t, filepath.WalkDir(dir, func(path string, _ fs.DirEntry, err error) error {
if err != nil {
return err
}
return os.Chtimes(path, at, at)
}))
}
// A receipt is proof only for the WAL that existed when it was written. WAL a
// later client wrote and failed to seal is newer than it, so it is refused.
func TestSnapshotVolumeEngine_SealReceiptOlderThanTheWALFails(t *testing.T) {
const volumeID, snapshotID = "vol-drainstaleseal", "snap-drainstaleseal1"
cfg, _, vb := mountedSnapshotFixture(t, volumeID)
sealedAt := time.Now().Add(-time.Minute)
writeSealReceiptAt(t, cfg, volumeID, sealedAt)
leaveUnsealedWAL(t, cfg, volumeID)
require.NoError(t, os.WriteFile(filepath.Join(cfg.BaseDir, volumeID, "wal", "chunks", "wal.00000002.bin"), []byte("w"), 0o600))
touchWAL(t, cfg, volumeID, sealedAt.Add(time.Second))
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.ErrorIs(t, err, errExportDrainFailed)
assert.Nil(t, snapshot)
requireNoSnapshot(t, vb, snapshotID)
}
// A receipt written after every WAL change attests to all of it.
func TestSnapshotVolumeEngine_SealReceiptNewerThanTheWALProceeds(t *testing.T) {
const volumeID, snapshotID = "vol-drainfreshseal", "snap-drainfreshseal1"
cfg, _, _ := mountedSnapshotFixture(t, volumeID)
leaveUnsealedWAL(t, cfg, volumeID)
walChanged := time.Now().Add(-time.Minute)
touchWAL(t, cfg, volumeID, walChanged)
writeSealReceiptAt(t, cfg, volumeID, walChanged.Add(time.Second))
snapshot, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.NoError(t, err)
assert.Equal(t, ebsprovider.SnapshotStateCompleted, snapshot.State)
}
// The plugin dates its receipt to the millisecond, after the seal's own WAL
// changes, so a WAL change inside the receipt's millisecond is not newer.
func TestSnapshotVolumeEngine_SealReceiptInTheWALsMillisecondProceeds(t *testing.T) {
const volumeID, snapshotID = "vol-drainsamemilli", "snap-drainsamemilli1"
cfg, _, _ := mountedSnapshotFixture(t, volumeID)
leaveUnsealedWAL(t, cfg, volumeID)
sealedAt := time.Now().Add(-time.Minute).Truncate(time.Millisecond)
touchWAL(t, cfg, volumeID, sealedAt.Add(400*time.Microsecond))
writeSealReceiptAt(t, cfg, volumeID, sealedAt)
_, err := snapshotVolumeEngine(t.Context(), cfg, volumeID, snapshotID)
require.NoError(t, err)
}
+118 -16
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/mulgadc/spinifex/spinifex/foundation/netaddr"
"io/fs"
"log/slog"
"net/http"
"os"
@@ -23,6 +24,7 @@ import (
"github.com/mulgadc/bluebottle/pkg/masterkey"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
"github.com/mulgadc/viperblock/viperblock"
"github.com/mulgadc/viperblock/viperblock/backends/s3"
@@ -459,12 +461,22 @@ func openVolumeVB(ctx context.Context, cfg *Config, volumeName string) (*viperbl
if err != nil {
return nil, nil, err
}
vb, err := openLeasedVolumeVB(ctx, cfg, volumeName, lease)
if err != nil {
cfg.releaseVolumeLease(ctx, lease)
return nil, nil, err
}
return vb, lease, nil
}
// openLeasedVolumeVB is openVolumeVB under a lease the caller already holds,
// for a caller with a check to make between the lease and the open. The lease
// stays the caller's to release, on failure too.
func openLeasedVolumeVB(ctx context.Context, cfg *Config, volumeName string, lease *volumeLease) (*viperblock.VB, error) {
vbconfig, s3cfg := volumeVBConfig(cfg, volumeName)
vb, err := viperblock.New(&vbconfig, "s3", s3cfg)
if err != nil {
cfg.releaseVolumeLease(ctx, lease)
return nil, nil, fmt.Errorf("new viperblock: %w", err)
return nil, fmt.Errorf("new viperblock: %w", err)
}
// New starts the chunk uploader and WAL syncer, so a failure below must
@@ -473,22 +485,21 @@ func openVolumeVB(ctx context.Context, cfg *Config, volumeName string) (*viperbl
defer func() {
if !opened {
vb.Detach()
cfg.releaseVolumeLease(ctx, lease)
}
}()
if err := vb.Backend.InitCtx(ctx); err != nil {
return nil, nil, fmt.Errorf("backend init: %w", err)
return nil, fmt.Errorf("backend init: %w", err)
}
if err := loadStateWithRetry(ctx, cfg, vb, volumeName); err != nil {
if errors.Is(err, viperblock.ErrStateNotFound) {
cfg.clearVolumeDirty(ctx, volumeName, lease.generation)
}
return nil, nil, fmt.Errorf("load state: %w", err)
return nil, fmt.Errorf("load state: %w", err)
}
opened = true
return vb, lease, nil
return vb, nil
}
// isAuxVolume reports whether a volume is an -efi auxiliary volume. Auxiliary
@@ -546,6 +557,55 @@ func volumeNeedsSeal(volumeName, baseDir string) bool {
return err == nil
}
// volumeHoldsLocalWAL reports whether an nbdkit plugin has opened the
// volume's WAL here and not removed it. A successful seal removes it; an
// export no client has connected to yet never created it.
func volumeHoldsLocalWAL(cfg *Config, volumeName string) bool {
dir, err := localWALDir(cfg, volumeName)
if err != nil {
return false
}
_, err = os.Stat(dir)
return err == nil
}
// localWALDir is where the nbdkit plugin keeps volumeName's WAL on this node.
func localWALDir(cfg *Config, volumeName string) (string, error) {
walBase := cfg.WALBaseDir
if walBase == "" {
walBase = cfg.BaseDir
}
dir, err := localVolumeDir(walBase, volumeName)
if err != nil {
return "", err
}
return filepath.Join(dir, "wal"), nil
}
// latestWALChange is the newest modification time under the volume's local
// WAL, the directory itself included, so a file created or removed counts too.
func latestWALChange(cfg *Config, volumeName string) (time.Time, error) {
dir, err := localWALDir(cfg, volumeName)
if err != nil {
return time.Time{}, err
}
var latest time.Time
err = filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
if info.ModTime().After(latest) {
latest = info.ModTime()
}
return nil
})
return latest, err
}
// sealReceiptSuffix names the file the nbdkit plugin leaves at
// baseDir/<volume>.sealed after a successful seal.
const sealReceiptSuffix = ".sealed"
@@ -584,19 +644,53 @@ func consumeSealReceipt(baseDir, volume string) bool {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
slog.Warn("failed to remove seal receipt", "volume", volume, "path", path, "err", err)
}
_, ok := validSealReceipt(data, volume, path)
return ok
}
var receipt sealReceipt
if err := json.Unmarshal(data, &receipt); err != nil {
slog.Warn("malformed seal receipt", "volume", volume, "path", path, "err", err)
// sealReceiptCoversWAL reports whether the volume's seal receipt postdates
// every change to its local WAL, leaving the receipt for the unmount it
// attests to. An older receipt is an earlier client's, not the last one's.
func sealReceiptCoversWAL(cfg *Config, volume string) bool {
path := sealReceiptPath(cfg.BaseDir, volume)
data, err := os.ReadFile(path)
if err != nil {
return false
}
if receipt.Volume != volume || receipt.SealedAt.IsZero() {
slog.Warn("seal receipt missing required fields", "volume", volume, "path", path, "receipt", receipt)
receipt, ok := validSealReceipt(data, volume, path)
if !ok {
return false
}
latest, err := latestWALChange(cfg, volume)
if err != nil {
slog.Warn("could not read local WAL to date its seal receipt", "volume", volume, "err", err)
return false
}
// The plugin writes SealedAt to the millisecond, after the seal's own WAL
// changes, so a WAL change in the receipt's millisecond is the seal's.
if receipt.SealedAt.Before(latest.Truncate(time.Millisecond)) {
slog.Warn("seal receipt predates the local WAL, treating the WAL as unsealed",
"volume", volume, "sealed_at", receipt.SealedAt, "wal_changed_at", latest)
return false
}
return true
}
// validSealReceipt decodes data, reporting whether it is a receipt the plugin
// wrote for volume.
func validSealReceipt(data []byte, volume, path string) (sealReceipt, bool) {
var receipt sealReceipt
if err := json.Unmarshal(data, &receipt); err != nil {
slog.Warn("malformed seal receipt", "volume", volume, "path", path, "err", err)
return sealReceipt{}, false
}
if receipt.Volume != volume || receipt.SealedAt.IsZero() {
slog.Warn("seal receipt missing required fields", "volume", volume, "path", path, "receipt", receipt)
return sealReceipt{}, false
}
return receipt, true
}
// clearStaleSealReceipt removes any seal receipt left by a previous mount of
// this volume. Staleness is handled here, at mount, rather than by matching
// PIDs at unmount: once this runs, any receipt found at the next unmount can
@@ -733,6 +827,9 @@ func (cfg *Config) RecoverMountedVolumes(ctx context.Context, nc *nats.Conn) {
// readiness: without the round trip a caller can act on the signal and still
// have its request arrive before the interest does.
func (cfg *Config) Run(nc *nats.Conn) error {
if err := cfg.validateNodeName(); err != nil {
return err
}
if err := cfg.LoadEncryptionKey(); err != nil {
return err
}
@@ -746,11 +843,7 @@ func (cfg *Config) Run(nc *nats.Conn) error {
cfg.RecoverMountedVolumes(context.Background(), nc)
if cfg.NodeName != "" {
slog.Info("Waiting for EBS events", "node", cfg.NodeName)
} else {
slog.Info("Waiting for EBS events (single-node mode)")
}
slog.Info("Waiting for EBS events", "node", cfg.NodeName)
if err := RegisterVolumeLifecycleSubjects(cfg, nc); err != nil {
return err
@@ -768,6 +861,15 @@ func (cfg *Config) Run(nc *nats.Conn) error {
return nil
}
// validateNodeName refuses a node name the contract cannot address. Without
// one the fence notice has no subject, and a fenced guest would keep running.
func (cfg *Config) validateNodeName() error {
if err := ebsprovider.ValidateNodeID(cfg.NodeName); err != nil {
return fmt.Errorf("viperblockd node name %q (cluster config node): %w", cfg.NodeName, err)
}
return nil
}
// Shutdown flushes each mounted volume's WAL and reaps idle nbdkit processes.
// It snapshots MountedVolumes and clears it while holding the lock, then
// flushes/kills outside the lock since VB.Close does heavy I/O.
@@ -839,42 +839,6 @@ func TestIntegration_ServiceGracefulShutdown(t *testing.T) {
t.Log("Service is running and responsive")
}
// TestIntegration_GenericTopicRouting tests that NodeName="" uses generic ebs.unmount with queue group.
func TestIntegration_GenericTopicRouting(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ns, natsURL := setupEmbeddedNATS(t)
defer ns.Shutdown()
cfg := setupTestConfig(t, natsURL)
cfg.NodeName = "" // Single-node / generic mode
cfg.MountedVolumes = []MountedVolume{
{Name: "vol-generic", PID: deadpid.PID},
}
startTestService(t, cfg)
nc, err := nats.Connect(natsURL)
assert.NoError(t, err)
defer nc.Close()
// Send to generic topic (not ebs.{node}.unmount)
request := viperblocklegacyv1.EBSRequest{Name: "vol-generic"}
requestData, _ := json.Marshal(request)
msg, err := nc.Request("ebs.unmount", requestData, 3*time.Second)
assert.NoError(t, err)
var response viperblocklegacyv1.EBSUnMountResponse
assert.NoError(t, json.Unmarshal(msg.Data, &response))
assert.Equal(t, "vol-generic", response.Volume)
assert.False(t, response.Mounted)
assert.Empty(t, response.Error)
}
// TestIntegration_MountAuxiliaryVolumeSuffix tests that volumes with -cloudinit suffix
// reach the mount handler and trigger the auxiliary volume code path.
func TestIntegration_MountAuxiliaryVolumeSuffix(t *testing.T) {
@@ -9,8 +9,8 @@ import (
"os"
"time"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
"github.com/nats-io/nats.go/jetstream"
)
@@ -319,46 +319,40 @@ func (cfg *Config) fenceNoticeRetry() time.Duration {
}
// publishVolumeFenced tells the local daemon a guest is running against a disk
// that is gone, until the daemon acknowledges. It gives up on a closed
// connection, or once the volume is exported here again by a newer mount.
// that is gone, until the daemon acknowledges. It gives up on a notice that can
// never be acted on, a closed connection, or the volume being exported again.
func (cfg *Config) publishVolumeFenced(ctx context.Context, volumeName, winner string) {
if cfg.nc == nil {
return
}
event := viperblocklegacyv1.VolumeFencedEvent{
Volume: volumeName,
Node: cfg.leaseOwner(),
Winner: winner,
Reason: fmt.Sprintf("volume lease moved to %s while this node had the volume open", winner),
notice := ebsprovider.FenceNotice{
Versioned: ebsprovider.NewVersioned(),
VolumeID: volumeName,
NodeID: cfg.NodeName,
Winner: winner,
Reason: fmt.Sprintf("volume lease moved to %s while this node had the volume open", winner),
}
payload, err := json.Marshal(event)
if err != nil {
slog.ErrorContext(ctx, "fence: marshal fenced event", "volume", volumeName, "err", err)
return
}
subject := viperblocklegacyv1.VolumeFencedSubject(cfg.NodeName)
began := time.Now()
for attempt := 1; ; attempt++ {
reqCtx, cancel := context.WithTimeout(ctx, fenceNoticeTimeout)
reply, err := cfg.nc.RequestWithContext(reqCtx, subject, payload)
cancel()
instanceID, err := ebsprovider.NotifyFenced(ctx, cfg.nc, notice, fenceNoticeTimeout)
if err == nil {
var ack viperblocklegacyv1.VolumeFencedAck
if jerr := json.Unmarshal(reply.Data, &ack); jerr == nil && ack.Error != "" {
slog.ErrorContext(ctx, "fence: the daemon could not act on the fenced volume",
"volume", volumeName, "subject", subject, "err", ack.Error)
} else if attempt > 1 {
if attempt > 1 {
slog.WarnContext(ctx, "fence: the daemon acknowledged the fenced volume after retrying",
"volume", volumeName, "instance", ack.Instance, "attempts", attempt,
"volume", volumeName, "instance", instanceID, "attempts", attempt,
"unacknowledged_ms", otelsetup.Millis(time.Since(began)))
}
return
}
if !ebsprovider.FenceNoticeRetryable(err) {
slog.ErrorContext(ctx, "fence: the daemon cannot act on the fenced volume, the guest is left with dead I/O",
"volume", volumeName, "node", notice.NodeID, "attempts", attempt, "err", err)
return
}
if cfg.nc.IsClosed() {
slog.ErrorContext(ctx, "fence: connection closed before the daemon acknowledged the fenced volume, the guest may be left with dead I/O",
"volume", volumeName, "subject", subject, "attempts", attempt)
"volume", volumeName, "node", notice.NodeID, "attempts", attempt)
return
}
if _, again := cfg.lookupMountedVolume(volumeName); again {
@@ -368,7 +362,7 @@ func (cfg *Config) publishVolumeFenced(ctx context.Context, volumeName, winner s
}
if attempt == 1 || attempt%fenceNoticeLogEveryTries == 0 {
slog.ErrorContext(ctx, "fence: the daemon has not acknowledged the fenced volume, retrying; until it does the guest keeps running with dead I/O",
"volume", volumeName, "subject", subject, "attempts", attempt,
"volume", volumeName, "node", notice.NodeID, "attempts", attempt,
"unacknowledged_ms", otelsetup.Millis(time.Since(began)), "err", err)
}
@@ -11,7 +11,7 @@ import (
"testing"
"time"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -133,16 +133,16 @@ func TestS4_FenceHolderReadIsBounded(t *testing.T) {
// fenceResponder answers fence notices as the daemon does and records them.
type fenceResponder struct {
mu sync.Mutex
events []viperblocklegacyv1.VolumeFencedEvent
events []ebsprovider.FenceNotice
}
func (r *fenceResponder) handle(msg *nats.Msg) {
var event viperblocklegacyv1.VolumeFencedEvent
_ = json.Unmarshal(msg.Data, &event)
var notice ebsprovider.FenceNotice
_ = json.Unmarshal(msg.Data, &notice)
r.mu.Lock()
r.events = append(r.events, event)
r.events = append(r.events, notice)
r.mu.Unlock()
payload, _ := json.Marshal(viperblocklegacyv1.VolumeFencedAck{Instance: "i-fenced"})
payload, _ := json.Marshal(ebsprovider.FenceNoticeAck{Versioned: ebsprovider.ReplyVersioned(notice.SchemaVersion), InstanceID: "i-fenced"})
_ = msg.Respond(payload)
}
@@ -185,7 +185,9 @@ func TestS4_FenceNoticeSurvivesAMissedDelivery(t *testing.T) {
require.NoError(t, err)
t.Cleanup(daemonConn.Close)
responder := &fenceResponder{}
_, err = daemonConn.Subscribe(viperblocklegacyv1.VolumeFencedSubject("node-a"), responder.handle)
subject, err := ebsprovider.FenceNoticeSubject("node-a")
require.NoError(t, err)
_, err = daemonConn.Subscribe(subject, responder.handle)
require.NoError(t, err)
require.NoError(t, daemonConn.Flush())
@@ -197,7 +199,10 @@ func TestS4_FenceNoticeSurvivesAMissedDelivery(t *testing.T) {
require.GreaterOrEqualf(t, responder.count(), 1,
"ADR-0007:S4: a daemon that subscribed after the first notice never heard the fence, so its guest runs on with no disk. "+
"Fix: retry publishVolumeFenced (volume_fence.go) until the daemon acknowledges")
assert.Equal(t, volumeName, responder.events[0].Volume)
first := responder.events[0]
assert.Equal(t, volumeName, first.VolumeID)
assert.Equal(t, "node-a", first.NodeID)
assert.True(t, ebsprovider.SupportsSchemaVersion(first.SchemaVersion), "the daemon refuses a notice in a version it cannot read")
seen := responder.count()
time.Sleep(5 * cfg.fenceNoticeEvery)
@@ -252,3 +257,93 @@ func TestS4_FenceNoticeStopsWithTheConnection(t *testing.T) {
t.Fatal("a fence notice kept retrying on a closed connection")
}
}
// TestFenceNotice_UnavailableDaemonIsAskedAgain keeps a refusal that says
// "not now" from ending delivery: the guest is still running on a dead disk.
func TestFenceNotice_UnavailableDaemonIsAskedAgain(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
const volumeName = "vol-fencenoticeunavailable"
cfg, nc := noticeConfig(t, natsURL, volumeName)
subject, err := ebsprovider.FenceNoticeSubject("node-a")
require.NoError(t, err)
unavailable, err := json.Marshal(ebsprovider.FenceNoticeAck{
Versioned: ebsprovider.NewVersioned(),
Error: &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnavailable, Message: "not yet"},
})
require.NoError(t, err)
ack, err := json.Marshal(ebsprovider.FenceNoticeAck{Versioned: ebsprovider.NewVersioned(), InstanceID: "i-fenced"})
require.NoError(t, err)
var heard atomic.Int32
_, err = nc.Subscribe(subject, func(msg *nats.Msg) {
if heard.Add(1) == 1 {
_ = msg.Respond(unavailable)
return
}
_ = msg.Respond(ack)
})
require.NoError(t, err)
require.NoError(t, nc.Flush())
done := make(chan struct{})
go func() { defer close(done); cfg.onVolumeLeaseLost(context.Background(), volumeName, leaseLostStalled) }()
select {
case <-done:
case <-time.After(fenceNoticeTimeout + 2*time.Second):
t.Fatal("a fence notice was still being sent after the daemon acknowledged it")
}
assert.Equal(t, int32(2), heard.Load(), "ADR-0007:S4: a daemon that was unavailable was never asked again")
}
// TestFenceNotice_UnsendableNoticeIsNotRetried stops a notice that can never
// be addressed from spinning until shutdown on a goroutine nobody watches.
func TestFenceNotice_UnsendableNoticeIsNotRetried(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
const volumeName = "vol-fencenoticeunsendable"
cfg, _ := noticeConfig(t, natsURL, volumeName)
cfg.NodeName = ""
cfg.MountedVolumes = nil // gone, as after the teardown, so only the error can end the retry
done := make(chan struct{})
go func() { defer close(done); cfg.publishVolumeFenced(t.Context(), volumeName, "node-b") }()
select {
case <-done:
case <-time.After(fenceNoticeTimeout + 2*time.Second):
t.Fatal("a fence notice with no subject to send it on kept retrying")
}
}
// TestFenceNotice_RefusedNoticeIsNotRepeated keeps a notice the daemon heard and
// could not act on from being resent forever: a refusal is an answer.
func TestFenceNotice_RefusedNoticeIsNotRepeated(t *testing.T) {
_, natsURL := setupEmbeddedNATS(t)
const volumeName = "vol-fencenoticerefused"
cfg, nc := noticeConfig(t, natsURL, volumeName)
subject, err := ebsprovider.FenceNoticeSubject("node-a")
require.NoError(t, err)
var heard atomic.Int32
refusal, err := json.Marshal(ebsprovider.FenceNoticeAck{
Versioned: ebsprovider.NewVersioned(),
Error: &ebsprovider.ProviderError{Code: ebsprovider.ErrorCodeUnsupportedVersion, Message: "no"},
})
require.NoError(t, err)
_, err = nc.Subscribe(subject, func(msg *nats.Msg) {
heard.Add(1)
_ = msg.Respond(refusal)
})
require.NoError(t, err)
require.NoError(t, nc.Flush())
done := make(chan struct{})
go func() { defer close(done); cfg.onVolumeLeaseLost(context.Background(), volumeName, leaseLostStalled) }()
select {
case <-done:
case <-time.After(fenceNoticeTimeout + 2*time.Second):
t.Fatal("a fence notice kept retrying after the daemon refused it")
}
assert.Equal(t, int32(1), heard.Load())
}
@@ -12,6 +12,7 @@ import (
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -171,15 +172,25 @@ func TestVolumeFence_CompletesOnceTheWriterFinallyExits(t *testing.T) {
const volumeName = "vol-fencekilllate"
cfg := fencedConfig(t, natsURL, "node-a", volumeName)
cfg, nc := noticeConfig(t, natsURL, volumeName)
cfg.MountedVolumes[0].PID = 0 // rejected by ForceKillProcess, so the kill cannot succeed
// Alive for the guard and one watch pass, gone on the next: the fence has to
// see the failure persist and then resolve.
var probes atomic.Int32
cfg.processAlive = func(int) bool { return probes.Add(1) <= 2 }
cfg.fenceWatchEvery = 10 * time.Millisecond
responder := &fenceResponder{}
subject, err := ebsprovider.FenceNoticeSubject("node-a")
require.NoError(t, err)
_, err = nc.Subscribe(subject, responder.handle)
require.NoError(t, err)
require.NoError(t, nc.Flush())
cfg.fenceVolume(t.Context(), volumeName, leaseLostToPeer)
require.Equalf(t, 1, responder.count(),
"ADR-0007:S4: a fence that completed late never told the daemon, so its guest runs on with no disk. "+
"Fix: publishVolumeFenced once awaitFencedWriterExit sees the writer gone (volume_fence.go)")
assert.Equal(t, volumeName, responder.events[0].VolumeID)
cfg.mu.Lock()
mounted := len(cfg.MountedVolumes)
@@ -0,0 +1,228 @@
package vm
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
hostprocess "github.com/mulgadc/spinifex/spinifex/runtime/host/process"
)
const (
// consoleTrimAbove is the size past which a console log is cut back.
consoleTrimAbove = 4 << 20
// consoleKeepBytes is how much of the newest output survives a cut.
consoleKeepBytes = 1 << 20
// consoleRemoveAfter is how long a retired console file lingers so a late
// GetConsoleOutput still sees it.
consoleRemoveAfter = time.Hour
// consoleIdleAfter is how long a live console file must sit unchanged, with
// no QEMU on this node holding it open, before it is removed.
consoleIdleAfter = time.Hour
)
// consoleTerminatedSuffix marks the console file of an instance that is gone
// for good. The log shipper only follows console-*.log, so a renamed file is
// no longer shipped, and the reaper removes it once it is old enough.
const consoleTerminatedSuffix = ".terminated"
// RetiredConsolePath is where the console file at path moves when its instance
// is terminated.
func RetiredConsolePath(path string) string { return path + consoleTerminatedSuffix }
// retireConsoleLog renames an ended instance's console file so the reaper can
// age it out. The file is final by now: QEMU is down or being force-finalized.
func retireConsoleLog(path string) {
if path == "" {
return
}
if err := os.Rename(path, RetiredConsolePath(path)); err != nil && !os.IsNotExist(err) {
slog.Warn("vm: retire console log failed", "path", path, "err", err)
}
}
// ConsoleLogReaper caps the serial console files under dir and removes the
// retired ones. A live console-<id>.log goes only after an idle hour with no
// QEMU on this node holding it open, decided from /proc alone and never from
// instance state.
type ConsoleLogReaper struct {
dir string
procRoot string
now func() time.Time
}
var _ Reaper = (*ConsoleLogReaper)(nil)
// NewConsoleLogReaper builds a reaper over the console files in dir.
func NewConsoleLogReaper(dir string) *ConsoleLogReaper {
return &ConsoleLogReaper{dir: dir, procRoot: qemuProcRoot, now: time.Now}
}
func (r *ConsoleLogReaper) Class() string { return "console-log" }
func (r *ConsoleLogReaper) Scope() ReaperScope { return ScopeNodeLocal }
// NewLocalConsoleLogReaper builds the reaper over this node's runtime directory.
func (m *Manager) NewLocalConsoleLogReaper() *ConsoleLogReaper {
return NewConsoleLogReaper(hostprocess.RuntimeDir())
}
// Sweep removes idle live console files nothing holds open, cuts oversized ones
// back to their newest consoleKeepBytes, and removes retired ones that have sat
// for over an hour.
func (r *ConsoleLogReaper) Sweep(context.Context) (int, error) {
reaped := 0
live, err := filepath.Glob(filepath.Join(r.dir, "console-*.log"))
if err != nil {
return 0, err
}
now := r.now()
warned := false
for _, p := range live {
info, err := os.Stat(p)
if err != nil || !info.Mode().IsRegular() {
continue
}
if now.Sub(info.ModTime()) > consoleIdleAfter && r.removeIfUnheld(p, &warned) {
reaped++
continue
}
if info.Size() <= consoleTrimAbove {
continue
}
if err := trimConsoleLog(p); err != nil {
slog.Warn("vm/gc: trim console log failed", "path", p, "err", err)
continue
}
reaped++
}
retired, err := filepath.Glob(filepath.Join(r.dir, "console-*.log"+consoleTerminatedSuffix))
if err != nil {
return reaped, err
}
for _, p := range retired {
info, err := os.Stat(p)
if err != nil || !info.Mode().IsRegular() || now.Sub(info.ModTime()) <= consoleRemoveAfter {
continue
}
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
slog.Warn("vm/gc: remove retired console log failed", "path", p, "err", err)
continue
}
reaped++
}
return reaped, nil
}
// removeIfUnheld removes path when no QEMU process on this node has it open. If
// the process table cannot be read in full it removes nothing, since a guest
// that is merely quiet must keep its log.
func (r *ConsoleLogReaper) removeIfUnheld(path string, warned *bool) bool {
held, err := qemuOpenFiles(r.procRoot, r.dir)
if err != nil {
if !*warned {
slog.Warn("vm/gc: idle console logs kept, cannot tell whether QEMU holds them", "err", err)
*warned = true
}
return false
}
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
}
if held[path] {
return false
}
if err := os.Remove(path); err != nil {
if !os.IsNotExist(err) {
slog.Warn("vm/gc: remove idle console log failed", "path", path, "err", err)
}
return false
}
return true
}
// qemuOpenFiles returns the files every QEMU process under procRoot has open.
// Any unreadable descriptor table is an error, never a partial answer. The scan
// is complete only while the daemon keeps CAP_SYS_ADMIN under ProtectProc=invisible,
// so an instance pidfile naming a live pid the scan missed is an error too.
func qemuOpenFiles(procRoot, runtimeDir string) (map[string]bool, error) {
pids, err := scanLiveQEMUPIDs(procRoot)
if err != nil {
return nil, err
}
if err := checkPidFilesVisible(runtimeDir, pids); err != nil {
return nil, err
}
held := make(map[string]bool)
for _, pid := range pids {
fdDir := filepath.Join(procRoot, strconv.Itoa(pid), "fd")
fds, err := os.ReadDir(fdDir)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
for _, fd := range fds {
target, err := os.Readlink(filepath.Join(fdDir, fd.Name()))
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
held[target] = true
}
}
return held, nil
}
// checkPidFilesVisible fails when an instance pidfile names a process that is
// alive but absent from seen. Signal 0 answers even when /proc hides the pid.
func checkPidFilesVisible(runtimeDir string, seen []int) error {
visible := make(map[int]bool, len(seen))
for _, pid := range seen {
visible[pid] = true
}
for pid, id := range pidFileOwners(runtimeDir) {
if !strings.HasPrefix(id, "i-") || visible[pid] {
continue
}
if err := syscall.Kill(pid, 0); err == nil || err == syscall.EPERM {
return fmt.Errorf("pidfile for %s names live pid %d that the process scan cannot see", id, pid)
}
}
return nil
}
// trimConsoleLog rewrites the file in place as its newest consoleKeepBytes. The
// guest's writer keeps its descriptor (O_APPEND), so output written while the
// copy runs may be lost but the writer is never blocked.
func trimConsoleLog(path string) error {
f, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
if info.Size() <= consoleTrimAbove {
return nil
}
tail := make([]byte, consoleKeepBytes)
if _, err := f.ReadAt(tail, info.Size()-consoleKeepBytes); err != nil && err != io.EOF {
return err
}
if _, err := f.WriteAt(tail, 0); err != nil {
return err
}
return f.Truncate(consoleKeepBytes)
}
@@ -0,0 +1,298 @@
package vm
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// consoleBody returns n bytes of 16-byte numbered lines, so any slice of it
// identifies which part of the original it came from.
func consoleBody(n int) []byte {
var b bytes.Buffer
for i := 0; b.Len() < n; i++ {
fmt.Fprintf(&b, "l%014d\n", i)
}
return b.Bytes()[:n]
}
func newConsoleReaper(t *testing.T, now time.Time) (*ConsoleLogReaper, string) {
t.Helper()
dir := t.TempDir()
r := NewConsoleLogReaper(dir)
r.procRoot = t.TempDir()
r.now = func() time.Time { return now }
return r, dir
}
func writeConsole(t *testing.T, dir, id string, body []byte, mtime time.Time) string {
t.Helper()
p := filepath.Join(dir, "console-"+id+".log")
require.NoError(t, os.WriteFile(p, body, 0o644))
require.NoError(t, os.Chtimes(p, mtime, mtime))
return p
}
func TestConsoleLogReaper_TrimsToNewestMiB(t *testing.T) {
now := time.Now()
r, dir := newConsoleReaper(t, now)
body := consoleBody(5 << 20)
p := writeConsole(t, dir, "i-big", body, now)
reaped, err := r.Sweep(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, reaped)
got, err := os.ReadFile(p)
require.NoError(t, err)
gotSize := len(got)
require.Equal(t, consoleKeepBytes, gotSize, "a trim keeps exactly the newest MiB")
assert.True(t, bytes.Equal(body[len(body)-consoleKeepBytes:], got), "the newest MiB must survive, in order")
}
func TestConsoleLogReaper_LeavesFilesAtOrBelowTheLimit(t *testing.T) {
now := time.Now()
r, dir := newConsoleReaper(t, now)
small := consoleBody(3 << 20)
limit := consoleBody(consoleTrimAbove)
ps := writeConsole(t, dir, "i-small", small, now)
pl := writeConsole(t, dir, "i-limit", limit, now)
reaped, err := r.Sweep(context.Background())
require.NoError(t, err)
assert.Zero(t, reaped)
for p, want := range map[string][]byte{ps: small, pl: limit} {
got, err := os.ReadFile(p)
require.NoError(t, err)
assert.True(t, bytes.Equal(want, got), "%s must not change", filepath.Base(p))
}
}
// QEMU writes through a descriptor it keeps open, so the trim must leave the
// file usable for that writer: no hole of NULs, and later output at the end.
func TestConsoleLogReaper_AppendWriterKeepsWritingAfterTrim(t *testing.T) {
now := time.Now()
r, dir := newConsoleReaper(t, now)
p := filepath.Join(dir, "console-i-live.log")
w, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
require.NoError(t, err)
defer w.Close()
_, err = w.Write(consoleBody(5 << 20))
require.NoError(t, err)
_, err = r.Sweep(context.Background())
require.NoError(t, err)
marker := []byte("after-trim-marker\n")
_, err = w.Write(marker)
require.NoError(t, err)
got, err := os.ReadFile(p)
require.NoError(t, err)
assert.Zero(t, bytes.Count(got, []byte{0}), "no NUL hole from a stale write offset")
assert.True(t, bytes.HasSuffix(got, marker), "the writer's next line lands at the end")
assert.LessOrEqual(t, len(got), consoleKeepBytes+len(marker))
}
func TestConsoleLogReaper_RemovesOnlyAgedRetiredFiles(t *testing.T) {
now := time.Now()
r, dir := newConsoleReaper(t, now)
old := now.Add(-consoleRemoveAfter - time.Minute)
recent := now.Add(-consoleRemoveAfter + time.Minute)
retiredOld := writeConsole(t, dir, "i-gone-old", []byte("x\n"), old)
retiredNew := writeConsole(t, dir, "i-gone-new", []byte("x\n"), recent)
require.NoError(t, os.Rename(retiredOld, RetiredConsolePath(retiredOld)))
require.NoError(t, os.Rename(retiredNew, RetiredConsolePath(retiredNew)))
require.NoError(t, os.Chtimes(RetiredConsolePath(retiredOld), old, old))
require.NoError(t, os.Chtimes(RetiredConsolePath(retiredNew), recent, recent))
// A live file that is not yet idle for an hour is kept.
stopped := writeConsole(t, dir, "i-stopped", []byte("boot\n"), recent)
other := filepath.Join(dir, "serial-i-x.sock.log.terminated")
require.NoError(t, os.WriteFile(other, []byte("x"), 0o644))
require.NoError(t, os.Chtimes(other, old, old))
reaped, err := r.Sweep(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, reaped, "only the retired file older than an hour")
_, err = os.Stat(RetiredConsolePath(retiredOld))
assert.ErrorIs(t, err, os.ErrNotExist, "retired over an hour ago: removed")
_, err = os.Stat(RetiredConsolePath(retiredNew))
assert.NoError(t, err, "retired recently: kept for a late GetConsoleOutput")
_, err = os.Stat(stopped)
assert.NoError(t, err, "a live file idle for under an hour is kept")
_, err = os.Stat(other)
assert.NoError(t, err, "files that are not console logs are never touched")
}
func TestRetireConsoleLog(t *testing.T) {
dir := t.TempDir()
p := writeConsole(t, dir, "i-end", []byte("last words\n"), time.Now())
retireConsoleLog(p)
_, err := os.Stat(p)
assert.ErrorIs(t, err, os.ErrNotExist, "the live name no longer matches the shipper's glob")
got, err := os.ReadFile(RetiredConsolePath(p))
require.NoError(t, err)
assert.Equal(t, "last words\n", string(got))
assert.NotPanics(t, func() { retireConsoleLog(p) }, "a repeat finalize is a no-op")
assert.NotPanics(t, func() { retireConsoleLog("") }, "an instance that never got a console path is a no-op")
}
func TestConsoleLogReaper_TrimsOnlyLiveFiles(t *testing.T) {
now := time.Now()
r, dir := newConsoleReaper(t, now)
big := writeConsole(t, dir, "i-big", consoleBody(5<<20), now)
retired := writeConsole(t, dir, "i-gone", consoleBody(5<<20), now)
require.NoError(t, os.Rename(retired, RetiredConsolePath(retired)))
reaped, err := r.Sweep(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, reaped)
info, err := os.Stat(big)
require.NoError(t, err)
assert.Equal(t, int64(consoleKeepBytes), info.Size())
info, err = os.Stat(RetiredConsolePath(retired))
require.NoError(t, err)
assert.Equal(t, int64(5<<20), info.Size(), "a retired file is final and is only aged out")
}
func TestExecute_ConsoleAlwaysAppends(t *testing.T) {
for _, tc := range []struct {
name string
cfg Config
want string
}{
{"socket first boot", Config{SerialSocket: "/s", ConsoleLogPath: "/c"}, "logfile=/c,logappend=on"},
{"microvm first boot", Config{MachineType: "microvm", ConsoleLogPath: "/c"}, "file,id=console0,path=/c,append=on"},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := minimalConfig()
cfg.MachineType, cfg.SerialSocket = tc.cfg.MachineType, tc.cfg.SerialSocket
cfg.ConsoleLogPath = tc.cfg.ConsoleLogPath
cmd, err := cfg.Execute()
require.NoError(t, err)
var chardev string
for i, a := range cmd.Args {
if a == "-chardev" && i+1 < len(cmd.Args) {
chardev = cmd.Args[i+1]
}
}
assert.Contains(t, chardev, tc.want, "a truncating open would let an in-place trim leave a NUL hole")
})
}
}
// fakeQEMUProc fabricates procRoot/<pid> with a QEMU comm and the given open files.
func fakeQEMUProc(t *testing.T, procRoot string, pid int, open ...string) {
t.Helper()
pdir := filepath.Join(procRoot, strconv.Itoa(pid))
require.NoError(t, os.MkdirAll(filepath.Join(pdir, "fd"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(pdir, "comm"), []byte("qemu-system-x86\n"), 0o644))
for i, f := range open {
require.NoError(t, os.Symlink(f, filepath.Join(pdir, "fd", strconv.Itoa(i+3))))
}
}
func TestConsoleLogReaper_RemovesIdleLiveFileNothingHolds(t *testing.T) {
now := time.Now()
r, dir := newConsoleReaper(t, now)
idle := writeConsole(t, dir, "i-stopped-then-terminated", []byte("boot\n"), now.Add(-consoleIdleAfter-time.Minute))
fresh := writeConsole(t, dir, "i-fresh", []byte("boot\n"), now.Add(-consoleIdleAfter+time.Minute))
fakeQEMUProc(t, r.procRoot, 4242)
reaped, err := r.Sweep(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, reaped)
_, err = os.Stat(idle)
assert.ErrorIs(t, err, os.ErrNotExist, "idle over an hour and unopened: removed")
_, err = os.Stat(fresh)
assert.NoError(t, err, "written within the hour: kept")
}
func TestConsoleLogReaper_KeepsIdleFileHeldByRealProcess(t *testing.T) {
now := time.Now()
r, dir := newConsoleReaper(t, now)
held := writeConsole(t, dir, "i-quiet-guest", []byte("boot\n"), now.Add(-48*time.Hour))
// A real child holds the file open, as QEMU holds its chardev log.
cmd := exec.Command("sh", "-c", "exec 3<"+held+"; echo ready; read _")
stdin, err := cmd.StdinPipe()
require.NoError(t, err)
out, err := cmd.StdoutPipe()
require.NoError(t, err)
require.NoError(t, cmd.Start())
t.Cleanup(func() { _ = stdin.Close(); _ = cmd.Wait() })
line := make([]byte, 6)
_, err = io.ReadFull(out, line)
require.NoError(t, err)
// Present the child to the reaper as a QEMU process using its real fd table.
r.procRoot = t.TempDir()
pdir := filepath.Join(r.procRoot, strconv.Itoa(cmd.Process.Pid))
require.NoError(t, os.MkdirAll(pdir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(pdir, "comm"), []byte("qemu-system-x86\n"), 0o644))
require.NoError(t, os.Symlink(fmt.Sprintf("/proc/%d/fd", cmd.Process.Pid), filepath.Join(pdir, "fd")))
reaped, err := r.Sweep(context.Background())
require.NoError(t, err)
assert.Zero(t, reaped)
_, err = os.Stat(held)
assert.NoError(t, err, "a quiet running guest keeps its log")
}
func TestConsoleLogReaper_KeepsIdleFileWhenProcessTableUnreadable(t *testing.T) {
now := time.Now()
r, dir := newConsoleReaper(t, now)
idle := writeConsole(t, dir, "i-unprovable", []byte("boot\n"), now.Add(-48*time.Hour))
pdir := filepath.Join(r.procRoot, "4242")
require.NoError(t, os.MkdirAll(pdir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(pdir, "comm"), []byte("qemu-system-x86\n"), 0o644))
// A QEMU whose fd table cannot be read: a dangling fd entry that is a file, not a directory.
require.NoError(t, os.WriteFile(filepath.Join(pdir, "fd"), nil, 0o644))
reaped, err := r.Sweep(context.Background())
require.NoError(t, err)
assert.Zero(t, reaped)
_, err = os.Stat(idle)
assert.NoError(t, err, "cannot prove it is unused: kept")
}
func TestConsoleLogReaper_KeepsIdleFileWhenPidFileNamesLivePidTheScanMissed(t *testing.T) {
now := time.Now()
r, dir := newConsoleReaper(t, now)
idle := writeConsole(t, dir, "i-quiet", []byte("boot\n"), now.Add(-48*time.Hour))
// This test process is alive but is no QEMU, so the fabricated scan never lists it.
require.NoError(t, os.WriteFile(filepath.Join(dir, "i-quiet.pid"), []byte(strconv.Itoa(os.Getpid())), 0o644))
reaped, err := r.Sweep(context.Background())
require.NoError(t, err)
assert.Zero(t, reaped)
_, err = os.Stat(idle)
assert.NoError(t, err, "a live pid the scan cannot see means the scan is incomplete: nothing removed")
}
func TestConsoleLogReaper_IgnoresPidFileOfDeadProcess(t *testing.T) {
now := time.Now()
r, dir := newConsoleReaper(t, now)
idle := writeConsole(t, dir, "i-gone", []byte("boot\n"), now.Add(-48*time.Hour))
cmd := exec.Command("true")
require.NoError(t, cmd.Run())
require.NoError(t, os.WriteFile(filepath.Join(dir, "i-gone.pid"), []byte(strconv.Itoa(cmd.Process.Pid)), 0o644))
reaped, err := r.Sweep(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, reaped)
_, err = os.Stat(idle)
assert.ErrorIs(t, err, os.ErrNotExist, "a pidfile for a dead process does not block removal")
}
@@ -0,0 +1,85 @@
package vm
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func consoleFixture(t *testing.T, id string) (*VM, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "console-"+id+".log")
require.NoError(t, os.WriteFile(path, []byte("last output\n"), 0o644))
return &VM{
ID: id,
Status: StateRunning,
InstanceType: "t3.micro",
Instance: &ec2.Instance{},
Config: Config{ConsoleLogPath: path},
}, path
}
func requireRetired(t *testing.T, path string) {
t.Helper()
_, err := os.Stat(path)
require.ErrorIs(t, err, os.ErrNotExist, "the shipper's console-*.log glob must no longer match")
got, err := os.ReadFile(RetiredConsolePath(path))
require.NoError(t, err)
assert.Equal(t, "last output\n", string(got), "the content survives the rename")
}
func requireLive(t *testing.T, path string) {
t.Helper()
_, err := os.Stat(path)
require.NoError(t, err)
_, err = os.Stat(RetiredConsolePath(path))
require.ErrorIs(t, err, os.ErrNotExist)
}
func TestTerminate_RetiresConsoleLog(t *testing.T) {
m, _, _, _, _ := shutdownTestManager(t)
instance, path := consoleFixture(t, "i-term-console")
m.Insert(instance)
require.NoError(t, m.Terminate(instance.ID))
requireRetired(t, path)
}
func TestMarkFailed_RetiresConsoleLog(t *testing.T) {
m, _, _, _, _ := shutdownTestManager(t)
instance, path := consoleFixture(t, "i-failed-console")
instance.FirstLaunch = true
instance.Status = StatePending
m.Insert(instance)
m.MarkFailed(context.Background(), instance, "launch failed")
m.goroutineWg.Wait()
requireRetired(t, path)
}
func TestStop_KeepsConsoleLog(t *testing.T) {
m, _, _, _, _ := shutdownTestManager(t)
instance, path := consoleFixture(t, "i-stop-console")
m.Insert(instance)
require.NoError(t, m.Stop(instance.ID))
requireLive(t, path)
}
func TestStopAll_DrainKeepsConsoleLog(t *testing.T) {
m, _, _, _, _ := shutdownTestManager(t)
instance, path := consoleFixture(t, "i-drain-console")
m.Insert(instance)
require.NoError(t, m.StopAll())
requireLive(t, path)
}
@@ -107,7 +107,7 @@ func TestVMMarshal_LegacyHostfwdNotWrittenBack(t *testing.T) {
assert.Contains(t, raw, "hostfwd_port_map")
}
// The other twelve command booleans had nowhere to go because nothing read them
// The other eleven command booleans had nowhere to go because nothing read them
// back. A record must carry none of them, so they are gone rather than unread.
func TestVMMarshal_CommandBooleansAreNotPersisted(t *testing.T) {
legacy, err := json.Marshal(map[string]any{
@@ -115,7 +115,6 @@ func TestVMMarshal_CommandBooleansAreNotPersisted(t *testing.T) {
"attributes": ec2v1.EC2CommandAttributes{
AttachVolume: true,
DetachVolume: true,
DrainVolume: true,
StartInstance: true,
RebootInstance: true,
AttachENI: true,
@@ -137,7 +136,7 @@ func TestVMMarshal_CommandBooleansAreNotPersisted(t *testing.T) {
require.NoError(t, err)
for _, field := range []string{
"attach_volume", "detach_volume", "drain_volume", "start_instance",
"attach_volume", "detach_volume", "start_instance",
"reboot_instance", "attach_eni", "detach_eni",
"associate_iam_instance_profile", "set_spot_lineage", "set_instance_tags",
"remove_instance_tags", "set_instance_monitoring",
+4 -5
View File
@@ -32,11 +32,10 @@ var ErrVolumeNotDetachable = errors.New("volume cannot be detached")
// supplies a device name that does not match the recorded attachment.
var ErrVolumeDeviceMismatch = errors.New("volume device name mismatch")
// ErrMountAmbiguous is returned by VolumeMounter.MountOne when the
// backend acknowledged the mount but returned an empty URI. The mount may
// or may not have started serving NBD; AttachVolume must invoke UnmountOne
// defensively so a half-started backend mount is not orphaned.
var ErrMountAmbiguous = errors.New("ebs.mount succeeded with empty URI")
// ErrMountAmbiguous is returned by VolumeMounter.Mount and MountOne when a sent
// publish got no usable export back (bad reply, timeout, cancelled caller), so
// the provider may have started one; it is unpublished so it is not orphaned.
var ErrMountAmbiguous = errors.New("publish reply carried no usable export")
// ErrENINotAttached is returned by HotUnplugENI when the supplied ENI id
// is not present in the instance's ENIRequests.AttachedByENIID map.
@@ -0,0 +1,27 @@
package vm
import "log/slog"
// HandleQEMUExit decides what a finished QEMU process means once startup was
// confirmed. A wait error is a crash. A clean exit after the heartbeat saw the
// guest power itself off is a customer stop, kept in the stopped bucket so
// StartInstances can bring it back. Any other clean exit is left to the
// heartbeat's crash detection.
func (m *Manager) HandleQEMUExit(instance *VM, waitErr error) {
if waitErr != nil {
if m.deps.CrashHandler != nil {
m.deps.CrashHandler(instance, waitErr)
}
return
}
if !instance.guestPoweroff.Load() {
slog.Info("VM process exited cleanly", "instance", instance.ID)
return
}
slog.Info("Guest powered itself off, stopping instance", "instance", instance.ID)
m.UpdateState(instance.ID, func(v *VM) { v.DesiredState = DesiredStopped })
if err := m.Stop(instance.ID); err != nil {
slog.Error("Stop after guest poweroff failed", "instance", instance.ID, "err", err)
}
}
@@ -0,0 +1,155 @@
package vm
import (
"errors"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/qmp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func guestPoweroffManager(t *testing.T) (*Manager, *fakeStateStore, *recordedTransitions, *[]string) {
t.Helper()
m, store, _, _, rt := shutdownTestManager(t)
var (
mu sync.Mutex
down []string
)
m.SetDeps(Deps{
NodeID: "test-node",
StateStore: store,
VolumeMounter: &fakeVolumeMounter{},
InstanceCleaner: &recordingInstanceCleaner{},
TransitionState: rt.apply,
ShutdownSignal: func() bool { return false },
CrashHandler: m.HandleCrash,
Hooks: ManagerHooks{
OnInstanceDown: func(id string) {
mu.Lock()
down = append(down, id)
mu.Unlock()
},
},
})
return m, store, rt, &down
}
func TestQMPHeartbeatPoll_MarksGuestPoweroff(t *testing.T) {
for name, tc := range map[string]struct {
status string
running bool
want bool
}{
"guest shut down": {qmpStatusShutdown, false, true},
"guest running": {"running", true, false},
} {
t.Run(name, func(t *testing.T) {
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
if cmd.Execute == "query-status" {
return qmpStatusResponse(tc.status, tc.running)
}
return nil
})
defer cancel()
m := NewManager()
instance := &VM{ID: "i-1", Status: StateRunning, QMPClient: qmpClient}
m.Insert(instance)
require.True(t, m.qmpHeartbeatPoll(instance))
assert.Equal(t, tc.want, instance.guestPoweroff.Load())
})
}
}
// A guest that powered itself off is a stop: stopping then stopped, released
// to the stopped bucket so StartInstances can bring it back, and never a crash.
func TestHandleQEMUExit_GuestPoweroffStopsInstance(t *testing.T) {
m, store, rt, down := guestPoweroffManager(t)
scheduled := countScheduledRestarts(t)
instance := &VM{
ID: "i-poweroff",
Status: StateRunning,
InstanceType: "t3.micro",
Instance: &ec2.Instance{},
}
instance.guestPoweroff.Store(true)
m.Insert(instance)
started := time.Now()
m.HandleQEMUExit(instance, nil)
assert.Less(t, time.Since(started), qmpHeartbeatInterval,
"the stop must not wait for the next heartbeat")
assert.Equal(t, []InstanceState{StateStopping, StateStopped}, rt.targets(instance.ID),
"a guest poweroff goes stopping then stopped and never through error")
assert.Zero(t, scheduled.Load(), "a guest poweroff must not be restarted")
assert.Equal(t, []string{instance.ID}, *down)
stored, err := store.LoadStoppedInstance(instance.ID)
require.NoError(t, err)
require.NotNil(t, stored, "the instance must be startable from the stopped bucket")
assert.Equal(t, StateStopped, stored.Status)
}
// Without the guest-poweroff mark a clean exit is not ours to call a stop.
func TestHandleQEMUExit_CleanExitWithoutMarkIsLeftToCrashRecovery(t *testing.T) {
m, _, rt, _ := guestPoweroffManager(t)
instance := &VM{ID: "i-clean", Status: StateRunning, InstanceType: "t3.micro", Instance: &ec2.Instance{}}
m.Insert(instance)
m.HandleQEMUExit(instance, nil)
assert.Empty(t, rt.targets(instance.ID), "no stop without the mark")
assert.Equal(t, StateRunning, m.Status(instance))
}
// A wait error is a crash even when the mark is set: the mark only vouches for
// the quit we sent, not for a process that died some other way.
func TestHandleQEMUExit_WaitErrorIsACrashEvenWithMark(t *testing.T) {
m, _, rt, _ := guestPoweroffManager(t)
countScheduledRestarts(t)
instance := &VM{ID: "i-killed", Status: StateRunning, InstanceType: "t3.micro", Instance: &ec2.Instance{}}
instance.guestPoweroff.Store(true)
m.Insert(instance)
m.HandleQEMUExit(instance, errors.New("signal: killed"))
assert.Contains(t, rt.targets(instance.ID), StateError)
assert.NotContains(t, rt.targets(instance.ID), StateStopped)
}
// One heartbeat poll is enough to see the poweroff and end QEMU, and the exit
// then stops the instance. It never goes through the failure count that
// precedes crash recovery, which takes three polls to reach. The interval is a
// constant, so "within one interval" is a single poll with no waiting.
func TestGuestPoweroff_ReachesStoppedFromOnePollWithoutCrashCounting(t *testing.T) {
m, _, rt, _ := guestPoweroffManager(t)
scheduled := countScheduledRestarts(t)
qmpClient, cancel := newMockQMPClient(t, func(cmd qmp.QMPCommand) map[string]any {
if cmd.Execute == "query-status" {
return qmpStatusResponse(qmpStatusShutdown, false)
}
return nil
})
defer cancel()
instance := &VM{
ID: "i-one-poll", Status: StateRunning, InstanceType: "t3.micro",
Instance: &ec2.Instance{}, QMPClient: qmpClient,
}
m.Insert(instance)
require.True(t, m.qmpHeartbeatPoll(instance), "the poll keeps running until QEMU exits")
m.HandleQEMUExit(instance, nil)
assert.Equal(t, []InstanceState{StateStopping, StateStopped}, rt.targets(instance.ID))
assert.Zero(t, instance.Health.QMPConsecutiveFailures, "a powered-off guest is not a failed heartbeat")
assert.Zero(t, scheduled.Load(), "no crash restart")
}
+6 -12
View File
@@ -176,7 +176,7 @@ func (m *Manager) launch(ctx context.Context, instance *VM) (err error) {
}
_, qemuSpan := otel.Tracer(vmTracerName).Start(ctx, "vm.launch.start_qemu")
qemuErr := m.startQEMU(instance, false)
qemuErr := m.startQEMU(instance)
endSpanWithError(qemuSpan, qemuErr)
if qemuErr != nil {
slog.ErrorContext(ctx, "Failed to launch instance", "err", qemuErr)
@@ -282,9 +282,9 @@ func qemuStderrLevel(line string) slog.Level {
}
// startQEMU launches the QEMU process for instance and waits for startup to
// confirm. appendConsole keeps the console log of the process it replaces, so a
// reboot does not erase what the guest printed before it.
func (m *Manager) startQEMU(instance *VM, appendConsole bool) error {
// confirm. The console log is opened in append mode, so a replacement process
// keeps what the guest printed before it.
func (m *Manager) startQEMU(instance *VM) error {
pidFile, err := hostprocess.GeneratePidFile(instance.ID)
if err != nil {
slog.Error("Failed to generate PID file", "err", err)
@@ -504,7 +504,6 @@ func (m *Manager) startQEMU(instance *VM, appendConsole bool) error {
}
launchCfg = &composed
}
launchCfg.ConsoleLogAppend = appendConsole
// An NBD export is waited on until it accepts connections; a block export
// is local to the host and only has to exist. An invalid request has no
@@ -607,13 +606,7 @@ func (m *Manager) startQEMU(instance *VM, appendConsole bool) error {
slog.Info("Replaced VM process exited", "instance", instance.ID, "pid", cmd.Process.Pid, "err", waitErr)
return
}
if waitErr != nil {
if m.deps.CrashHandler != nil {
m.deps.CrashHandler(instance, waitErr)
}
} else {
slog.Info("VM process exited cleanly", "instance", instance.ID)
}
m.HandleQEMUExit(instance, waitErr)
}()
pid := <-processChan
@@ -968,6 +961,7 @@ func (m *Manager) qmpHeartbeatPoll(instance *VM) bool {
if err == nil && qmpStatus.Status == qmpStatusShutdown &&
m.Status(instance) == StateRunning {
slog.InfoContext(ctx, "Guest powered itself off, ending QEMU", "instance", instance.ID)
instance.guestPoweroff.Store(true)
if _, quitErr := sendQMPCommand(ctx, instance.QMPClient,
qmp.QMPCommand{Execute: "quit"}, instance.ID); quitErr != nil {
slog.WarnContext(ctx, "QMP quit failed after guest shutdown",
+15 -15
View File
@@ -1042,7 +1042,7 @@ func TestStartQEMU_DirectBoot_PrimaryTapError(t *testing.T) {
ENIMac: "02:aa:bb:cc:dd:01",
}
err := m.startQEMU(instance, false)
err := m.startQEMU(instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "setup tap device")
@@ -1070,7 +1070,7 @@ func TestStartQEMU_DirectBoot_BridgeEnsureError(t *testing.T) {
ENIMac: "02:aa:bb:cc:dd:04",
}
err := m.startQEMU(instance, false)
err := m.startQEMU(instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "ensure IMDS bridge")
@@ -1105,7 +1105,7 @@ func TestStartQEMU_DirectBoot_ExtraENITapError(t *testing.T) {
},
}
err := m.startQEMU(instance, false)
err := m.startQEMU(instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "extra ENI")
@@ -1126,7 +1126,7 @@ func TestStartQEMU_DirectBoot_MgmtTapError(t *testing.T) {
MgmtMAC: "02:aa:bb:cc:dd:ff",
}
err := m.startQEMU(instance, false)
err := m.startQEMU(instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "mgmt tap")
@@ -1152,7 +1152,7 @@ func TestStartQEMU_DiskBoot_MgmtTapCreated(t *testing.T) {
}
instance.EBSRequests.Requests = []EBSRequest{{Name: "vol-root", NBDURI: "nbd:unix:/tmp/root.sock", Boot: true}}
err := m.startQEMU(instance, false)
err := m.startQEMU(instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "mgmt tap")
@@ -1174,7 +1174,7 @@ func TestStartQEMU_DirectBoot_InstanceTypeNotFound(t *testing.T) {
DirectBoot: true,
}
err := m.startQEMU(instance, false)
err := m.startQEMU(instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "x9.enormous")
@@ -1190,7 +1190,7 @@ func TestStartQEMU_BlockDeviceMissing(t *testing.T) {
instance := directBootInstance("i-blk-missing",
EBSRequest{Name: "vol-blk", BlockDevice: missing, BlockSizeBytes: 1 << 20})
err := m.startQEMU(instance, false)
err := m.startQEMU(instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "block device not present for vol-blk")
@@ -2089,12 +2089,12 @@ func TestStartQEMU_DirectBoot_LaunchesMountedDataVolumeWithoutSavingIt(t *testin
second := nbdListener(t)
instance := directBootInstance("i-db-vol", EBSRequest{Name: "vol-data-a", NBDURI: first})
require.NoError(t, m.startQEMU(instance, false))
require.NoError(t, m.startQEMU(instance))
// A later launch from the same saved base, after the volume was remounted
// on a different endpoint.
instance.EBSRequests.Requests[0].NBDURI = second
require.NoError(t, m.startQEMU(instance, false))
require.NoError(t, m.startQEMU(instance))
got := launches()
require.Len(t, got, 2)
@@ -2136,7 +2136,7 @@ func TestStartQEMU_DirectBoot_LaunchesTheEndpointItProbed(t *testing.T) {
instance.EBSRequests.Mu.Unlock()
return orig(uri, timeout)
}
require.NoError(t, m.startQEMU(instance, false))
require.NoError(t, m.startQEMU(instance))
assert.Equal(t, []string{probedEP}, probed)
got := launches()
@@ -2151,7 +2151,7 @@ func TestStartQEMU_DirectBoot_NoVolumesLaunchesTheBase(t *testing.T) {
launches := withFakeQEMU(t)
instance := directBootInstance("i-db-novol")
require.NoError(t, m.startQEMU(instance, false))
require.NoError(t, m.startQEMU(instance))
got := launches()
require.Len(t, got, 1)
@@ -2176,7 +2176,7 @@ func TestStartQEMU_DirectBoot_InvalidDataRequestIsNotLaunched(t *testing.T) {
launches := withFakeQEMU(t)
instance := directBootInstance("i-db-bad", tt.req)
err := m.startQEMU(instance, false)
err := m.startQEMU(instance)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
@@ -2197,7 +2197,7 @@ func TestStartQEMU_DiskBoot_DataVolumeStaysOnThePCIPath(t *testing.T) {
{Name: "vol-data-a", NBDURI: ep},
}
require.NoError(t, os.WriteFile(strings.TrimPrefix(instance.EBSRequests.Requests[0].NBDURI, "nbd:unix:"), nil, 0o600))
require.NoError(t, m.startQEMU(instance, false))
require.NoError(t, m.startQEMU(instance))
got := launches()
require.Len(t, got, 1)
@@ -2252,7 +2252,7 @@ func TestStartQEMU_DiskBoot_BootVolumeIsABlockdevNode(t *testing.T) {
req := tt.req(t)
instance := &VM{ID: "i-boot-blockdev", InstanceType: "t3.nano"}
instance.EBSRequests.Requests = []EBSRequest{req}
require.NoError(t, m.startQEMU(instance, false))
require.NoError(t, m.startQEMU(instance))
got := launches()
require.Len(t, got, 1)
@@ -2283,7 +2283,7 @@ func TestStartQEMU_DiskBoot_NoBootVolumeIsNotLaunched(t *testing.T) {
instance := &VM{ID: "i-no-boot", InstanceType: "t3.nano"}
instance.EBSRequests.Requests = tt.requests
err := m.startQEMU(instance, false)
err := m.startQEMU(instance)
require.Error(t, err)
assert.Contains(t, err.Error(), "no boot volume among the instance's EBS requests")
+1 -1
View File
@@ -52,7 +52,7 @@ const (
// startRelaunchedQEMU starts the replacement process. A var so tests can stand
// in for QEMU.
var startRelaunchedQEMU = func(m *Manager, instance *VM) error {
return m.startQEMU(instance, true)
return m.startQEMU(instance)
}
// errRelaunchFailed marks a reboot that ended the old QEMU and could not start
+7 -13
View File
@@ -465,21 +465,18 @@ func TestThreadStatFields(t *testing.T) {
assert.Empty(t, threadStatFields("garbage"))
}
func TestExecute_ConsoleLogAppendOnlyWhenAsked(t *testing.T) {
func TestExecute_ConsoleLogAppendOnEveryBoot(t *testing.T) {
for _, tc := range []struct {
name string
cfg Config
want string
absent string
name string
cfg Config
want string
}{
{"socket fresh", Config{SerialSocket: "/s", ConsoleLogPath: "/c"}, "logfile=/c", "logappend"},
{"socket reboot", Config{SerialSocket: "/s", ConsoleLogPath: "/c", ConsoleLogAppend: true}, "logfile=/c,logappend=on", ""},
{"file reboot", Config{MachineType: "microvm", ConsoleLogPath: "/c", ConsoleLogAppend: true}, "file,id=console0,path=/c,append=on", ""},
{"socket", Config{SerialSocket: "/s", ConsoleLogPath: "/c"}, "logfile=/c,logappend=on"},
{"file", Config{MachineType: "microvm", ConsoleLogPath: "/c"}, "file,id=console0,path=/c,append=on"},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := minimalConfig()
cfg.MachineType, cfg.SerialSocket = tc.cfg.MachineType, tc.cfg.SerialSocket
cfg.ConsoleLogPath, cfg.ConsoleLogAppend = tc.cfg.ConsoleLogPath, tc.cfg.ConsoleLogAppend
cfg.MachineType, cfg.SerialSocket, cfg.ConsoleLogPath = tc.cfg.MachineType, tc.cfg.SerialSocket, tc.cfg.ConsoleLogPath
cmd, err := cfg.Execute()
require.NoError(t, err)
var chardev string
@@ -489,9 +486,6 @@ func TestExecute_ConsoleLogAppendOnlyWhenAsked(t *testing.T) {
}
}
assert.Contains(t, chardev, tc.want)
if tc.absent != "" {
assert.NotContains(t, chardev, tc.absent)
}
})
}
}
+6
View File
@@ -106,6 +106,10 @@ type InstanceStatus struct {
EBSRequests []EBSRequest `json:"ebs_requests,omitempty"`
ENIAvailableSlots []int `json:"eni_available_slots,omitempty"`
ENIAttachedByID map[string]int `json:"eni_attached_by_id,omitempty"`
// UEFIVarStore is optional, so records written without it still decode
// and the bucket schema does not change.
UEFIVarStore *UEFIVarStoreRef `json:"uefi_var_store,omitempty"`
}
// Record projects a VM onto the persisted record. QMPClient and the request
@@ -166,6 +170,7 @@ func (v *VM) Record() *InstanceRecord {
EBSRequests: v.EBSRequests.Requests,
ENIAvailableSlots: v.ENIRequests.AvailableSlots,
ENIAttachedByID: v.ENIRequests.AttachedByENIID,
UEFIVarStore: v.UEFIVarStore,
},
}
}
@@ -222,6 +227,7 @@ func VMFromRecord(r *InstanceRecord) *VM {
TerminatedAt: r.Status.TerminatedAt,
ShuttingDownAt: r.Status.ShuttingDownAt,
FirstLaunch: r.Status.FirstLaunch,
UEFIVarStore: r.Status.UEFIVarStore,
}
v.EBSRequests.Requests = r.Status.EBSRequests
v.ENIRequests.AvailableSlots = r.Status.ENIAvailableSlots
@@ -9,6 +9,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/gpu"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/qmp"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
@@ -114,6 +115,37 @@ func TestRecord_RoundTripThroughJSON(t *testing.T) {
assertVMFieldsEqual(t, original, vm.VMFromRecord(&decoded))
}
// The pointer is optional: a record written before it existed decodes with
// none, and a record without one writes no key, so the bucket schema holds.
func TestRecord_UEFIVarStoreIsOptional(t *testing.T) {
var decoded vm.InstanceRecord
require.NoError(t, json.Unmarshal([]byte(`{"metadata":{"name":"i-old"},"spec":{"config":{}},"status":{"status":"stopped"}}`), &decoded))
assert.Nil(t, decoded.Status.UEFIVarStore)
encoded, err := json.Marshal((&vm.VM{ID: "i-bios"}).Record())
require.NoError(t, err)
assert.NotContains(t, string(encoded), "uefi_var_store")
}
// The pointer's wire names are what the record carries across releases, so
// they are pinned rather than left to follow the Go field names.
func TestRecord_UEFIVarStoreWireShape(t *testing.T) {
encoded, err := json.Marshal(populatedVM().Record())
require.NoError(t, err)
var wire struct {
Status struct {
UEFIVarStore map[string]json.RawMessage `json:"uefi_var_store"`
} `json:"status"`
}
require.NoError(t, json.Unmarshal(encoded, &wire))
ref := wire.Status.UEFIVarStore
assert.JSONEq(t, `"uefi/i-round-trip/0d6f3c1e-5b7a-4f43-9a8e-2c1d4e5f6a7b"`, string(ref["key"]))
assert.JSONEq(t, `{"arch":"arm64","vars_size":67108864,"code_sha256":"ab12"}`, string(ref["firmware"]))
assert.JSONEq(t, `"node-3"`, string(ref["writer_node"]))
assert.JSONEq(t, `"2026-08-28T04:00:00Z"`, string(ref["written_at"]))
}
// The mutexes are dropped on the way out, so the rebuilt instance must arrive
// with usable zero-value locks rather than ones somebody else left held.
func TestVMFromRecord_RequestWrappersAreUsable(t *testing.T) {
@@ -206,6 +238,12 @@ func populatedVM() *vm.VM {
CapacityReservationId: "cr-1",
InstanceLifecycle: "spot",
SpotInstanceRequestId: "sir-1",
UEFIVarStore: &vm.UEFIVarStoreRef{
Key: "uefi/i-round-trip/0d6f3c1e-5b7a-4f43-9a8e-2c1d4e5f6a7b",
Firmware: uefistore.FirmwareIdentity{Arch: "arm64", VarsSize: 67108864, CodeSHA256: "ab12"},
WriterNode: "node-3",
WrittenAt: time.Date(2026, 8, 28, 4, 0, 0, 0, time.UTC),
},
Status: vm.StateRunning,
Instance: &ec2.Instance{InstanceId: aws.String("i-round-trip")},
+2
View File
@@ -389,6 +389,8 @@ func (m *Manager) finalizeTerminated(instance *VM) error {
instance.TerminatedAt = time.Now()
}
retireConsoleLog(instance.Config.ConsoleLogPath)
if m.deps.StateStore != nil {
if err := m.deps.StateStore.WriteTerminatedInstance(instance.ID, instance); err != nil {
slog.Error("Failed to write terminated instance to KV, keeping in local state for retry",
+26 -19
View File
@@ -19,6 +19,7 @@ import (
"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/domains/ec2/instance/uefistore"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/gpu"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/qmp"
"github.com/mulgadc/spinifex/spinifex/runtime/host/hostos"
@@ -136,6 +137,10 @@ type VM struct {
// claiming the flag serialises reboots of one instance.
rebooting atomic.Bool `json:"-"`
// guestPoweroff is set once the heartbeat has seen the guest power itself off
// and asked QEMU to quit, so the exit that follows is a stop, not a crash.
guestPoweroff atomic.Bool `json:"-"`
// relaunchMu is held while a reboot swaps this guest's QEMU process, so a
// stop or terminate never unmounts volumes under a process being started.
relaunchMu sync.Mutex `json:"-"`
@@ -308,6 +313,20 @@ type VM struct {
// tolerated by migrate.
InstanceLifecycle string `json:"instance_lifecycle,omitempty"`
SpotInstanceRequestId string `json:"spot_instance_request_id,omitempty"`
// UEFIVarStore points at the object holding this instance's UEFI variable
// store. Status, so written only by the node; nil for a BIOS instance.
UEFIVarStore *UEFIVarStoreRef `json:"uefi_var_store,omitempty"`
}
// UEFIVarStoreRef names the store object an instance launches from, with the
// firmware it was written under, so a launch can check it without reading the
// object's metadata.
type UEFIVarStoreRef struct {
Key string `json:"key"`
Firmware uefistore.FirmwareIdentity `json:"firmware"`
WriterNode string `json:"writer_node"`
WrittenAt time.Time `json:"written_at"`
}
// UnmarshalJSON folds the fields of legacy records that mixed a request with an
@@ -417,13 +436,10 @@ type Config struct {
NoGraphic bool `json:"no_graphic"`
MachineType string `json:"machine_type"`
ConsoleLogPath string `json:"console_log_path,omitempty"`
// ConsoleLogAppend keeps the existing console log rather than truncating
// it, for a reboot that replaces the process.
ConsoleLogAppend bool `json:"-"`
SerialSocket string `json:"serial_socket,omitempty"`
CPUType string `json:"cpu_type"`
CPUCount int `json:"cpu_count"`
Memory int `json:"memory"`
SerialSocket string `json:"serial_socket,omitempty"`
CPUType string `json:"cpu_type"`
CPUCount int `json:"cpu_count"`
Memory int `json:"memory"`
Drives []Drive `json:"drives"`
IOThreads []IOThread `json:"io_threads,omitempty"`
@@ -537,11 +553,11 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
// -serial chardev:console0 wires it to the existing device (not a new one).
// Do NOT use -device isa-serial here — that adds a second device (ttyS1)
// while the kernel talks to ttyS0.
args = append(args, "-chardev", fmt.Sprintf("file,id=console0,path=%s%s", cfg.ConsoleLogPath, appendOpt(",append", cfg.ConsoleLogAppend)))
args = append(args, "-chardev", fmt.Sprintf("file,id=console0,path=%s,append=on", cfg.ConsoleLogPath))
args = append(args, "-serial", "chardev:console0")
} else if cfg.SerialSocket != "" && cfg.ConsoleLogPath != "" {
chardevOpts := fmt.Sprintf("socket,id=console0,path=%s,server=on,wait=off,logfile=%s%s",
cfg.SerialSocket, cfg.ConsoleLogPath, appendOpt(",logappend", cfg.ConsoleLogAppend))
chardevOpts := fmt.Sprintf("socket,id=console0,path=%s,server=on,wait=off,logfile=%s,logappend=on",
cfg.SerialSocket, cfg.ConsoleLogPath)
args = append(args, "-chardev", chardevOpts)
args = append(args, "-serial", "chardev:console0")
}
@@ -678,12 +694,3 @@ func (cfg *Config) Execute() (*exec.Cmd, error) {
return cmd, nil
}
// appendOpt renders a chardev append option, or nothing when off, so a
// fresh launch keeps the command line it always had.
func appendOpt(key string, on bool) string {
if !on {
return ""
}
return key + "=on"
}
+2 -2
View File
@@ -463,7 +463,7 @@ func TestExecute_MicrovmFileChardev(t *testing.T) {
args := cmd.Args[1:]
chardev := argValue(args, "-chardev")
assert.Equal(t, "file,id=console0,path=/var/log/console.log", chardev)
assert.Equal(t, "file,id=console0,path=/var/log/console.log,append=on", chardev)
// -serial chardev:console0 attaches to the isa-serial=on device (ttyS0).
assert.Equal(t, "chardev:console0", argValue(args, "-serial"))
// No explicit isa-serial device addition (would create ttyS1, not ttyS0).
@@ -699,6 +699,6 @@ func TestExecute_MicrovmWithPCIEFileChardev(t *testing.T) {
require.NoError(t, err)
args := cmd.Args[1:]
assert.Equal(t, "file,id=console0,path=/var/log/console.log", argValue(args, "-chardev"))
assert.Equal(t, "file,id=console0,path=/var/log/console.log,append=on", argValue(args, "-chardev"))
assert.Equal(t, "chardev:console0", argValue(args, "-serial"))
}
@@ -5,8 +5,8 @@ import (
)
// VolumeMounter mounts and unmounts the EBS volumes attached to a VM. The
// real implementation routes ebs.mount / ebs.unmount NATS requests; the
// abstraction keeps NATS out of the manager.
// real implementation publishes and unpublishes them through the EBS provider
// over NATS; the abstraction keeps NATS out of the manager.
//
// Every method takes the caller's context so the ebs.* span joins the trace
// that asked for the work rather than rooting one of its own. Shutdown and
@@ -14,9 +14,9 @@ import (
// honest answer rather than a missing one.
type VolumeMounter interface {
// Mount mounts every attached volume in v.EBSRequests.Requests, recording
// the resolved NBDURI back onto each request entry.
// the resolved export back onto each request entry.
Mount(ctx context.Context, v *VM) error
// Unmount sends ebs.unmount for each attached volume, which drives the
// Unmount unmounts each attached volume, which drives the
// synchronous block-map seal. Every volume is attempted; the seal
// failures are aggregated and returned, and mean the data is not durable.
Unmount(ctx context.Context, v *VM) error
@@ -31,12 +31,12 @@ type VolumeMounter interface {
// anywhere.
Abandon(ctx context.Context, v *VM, reason string) error
// MountOne sends ebs.mount for a single request and writes the resolved
// NBDURI back into req.NBDURI on success. Used by hot-attach. accountID
// MountOne mounts a single request and writes the resolved export back
// into req on success. Used by hot-attach. accountID
// names the owner, which a lone request carries no instance to supply.
MountOne(ctx context.Context, accountID string, req *EBSRequest) error
// UnmountOne sends ebs.unmount for a single request and returns any error.
// ebs.unmount drives the synchronous block-map seal to predastore, so hot
// UnmountOne unmounts a single request and returns any error. The
// unmount drives the synchronous block-map seal to the backend, so hot
// detach gates the volume's available transition on the returned error.
UnmountOne(ctx context.Context, accountID string, req EBSRequest) error
}
+12 -12
View File
@@ -25,7 +25,7 @@ const blockdevDelMaxAttempts = 20
// object-del steps), so a wedged-but-responsive QEMU cannot stack per-step
// timeouts into an effectively unbounded hang. It comfortably covers those
// steps' own budgets (qmpCommandTimeout + the bounded blockdev-del retry) plus
// margin; the ebs.unmount seal keeps its separate unmountSealTimeout.
// margin; the volume seal is not bound by it and keeps its own budget.
const detachAggregateTimeout = 3 * time.Minute
// ErrHotAttachUnsupported is returned by AttachVolume for a direct-boot microvm,
@@ -119,9 +119,9 @@ func (m *Manager) AttachVolume(ctx context.Context, id, volumeID, device string)
return "", fmt.Errorf("VolumeMounter not wired")
}
if err := m.deps.VolumeMounter.MountOne(ctx, instance.AccountID, &ebsRequest); err != nil {
slog.ErrorContext(ctx, "AttachVolume: ebs.mount failed", "volumeId", volumeID, "err", err)
// Empty-URI response leaves backend NBD state ambiguous; unmount
// defensively to avoid orphaning a half-started mount.
slog.ErrorContext(ctx, "AttachVolume: publish failed", "volumeId", volumeID, "err", err)
// A publish with no usable reply may have started an export, so it is
// unpublished defensively rather than orphaned.
if errors.Is(err, ErrMountAmbiguous) {
_ = m.rollbackUnmount(ctx, instance.AccountID, ebsRequest)
}
@@ -300,7 +300,7 @@ func (m *Manager) AttachVolume(ctx context.Context, id, volumeID, device string)
}
// DetachVolume hot-unplugs a volume via the QMP pipeline (device_del →
// blockdev-del → object-del → ebs.unmount). Returns the recorded AWS-API device
// blockdev-del → object-del → unpublish). Returns the recorded AWS-API device
// name. force=true tolerates device_del failure; blockdev-del is always required.
func (m *Manager) DetachVolume(ctx context.Context, id, volumeID, device string, force bool) (string, error) {
instance, ok := m.Get(id)
@@ -344,8 +344,8 @@ func (m *Manager) DetachVolume(ctx context.Context, id, volumeID, device string,
// Bound the ctx-driven portion of the hot-unplug chain so a wedged-but-
// responsive QEMU cannot stack per-step QMP timeouts into an unbounded hang
// that pins attachMu forever. The ebs.unmount seal below keeps its own
// unmountSealTimeout. This deadline cannot preempt a plain-mutex QMP call,
// that pins attachMu forever. The volume seal below is not bound by it and
// keeps its own budget. This deadline cannot preempt a plain-mutex QMP call,
// which is why a fully dead QEMU is short-circuited outright below.
ctx, cancel := context.WithTimeout(ctx, detachAggregateTimeout)
defer cancel()
@@ -359,7 +359,7 @@ func (m *Manager) DetachVolume(ctx context.Context, id, volumeID, device string,
// A confirmed-dead QEMU took every block node and guest device down with it,
// so the QMP unplug steps are moot — and worse, issuing them only risks
// hanging on the wedged process. Skip straight to the ebs.unmount seal and
// hanging on the wedged process. Skip straight to the unpublish seal and
// the attachment clear. This is the detach-wedges-when-QEMU-dies fix: the
// context deadline above cannot interrupt a plain-mutex QMP path, so a
// provably-gone process must bypass it rather than wait on it. A missing PID
@@ -422,15 +422,15 @@ func (m *Manager) DetachVolume(ctx context.Context, id, volumeID, device string,
"volumeId", volumeID, "instanceId", instance.ID)
}
// ebs.unmount drives the synchronous block-map seal to predastore. On
// Unpublish drives the synchronous block-map seal to the backend. On
// failure the volume's local WAL is retained, so keep the volume attached
// and return the error; an AWS-CLI retry re-drives the seal and same-node
// reattach still works meanwhile.
if m.deps.VolumeMounter != nil {
if err := m.deps.VolumeMounter.UnmountOne(ctx, instance.AccountID, ebsReq); err != nil {
slog.ErrorContext(ctx, "DetachVolume: ebs.unmount seal failed, leaving volume attached",
slog.ErrorContext(ctx, "DetachVolume: unpublish seal failed, leaving volume attached",
"volumeId", volumeID, "err", err)
return "", fmt.Errorf("ebs.unmount seal: %w", err)
return "", fmt.Errorf("unpublish seal: %w", err)
}
}
@@ -554,7 +554,7 @@ func (m *Manager) tryBlockdevDel(ctx context.Context, instance *VM, nodeName str
return nil
}
// A prior detach already removed the block node (e.g. an AWS-CLI retry
// after the ebs.unmount seal failed): treat as success so the retry
// after the unpublish seal failed): treat as success so the retry
// resumes through object-del to the seal instead of wedging on a
// now-absent node.
if isQMPNodeNotFound(err) {
+4 -4
View File
@@ -543,7 +543,7 @@ func TestDetachVolume_DeviceGuards(t *testing.T) {
}
// TestDetachVolume_SealFailureGatesAvailable covers the durability gate in the
// detach-seal fix: when ebs.unmount (the synchronous block-map seal) fails,
// detach-seal fix: when unpublish (the synchronous block-map seal) fails,
// DetachVolume must propagate the error, must NOT flip the volume to "available",
// and must leave it in EBSRequests so the volume stays attached/retryable and its
// local WAL is retained. A regression back to fire-and-forget unmount would let a
@@ -661,7 +661,7 @@ func TestAttachVolume_MountAmbiguous_TriggersUnmountOne(t *testing.T) {
// MountOne failures (NATS timeout, backend explicit Error, marshal errors)
// do NOT trigger UnmountOne — only the empty-URI ambiguous case rolls back.
func TestAttachVolume_GenericMountError_DoesNotUnmount(t *testing.T) {
mounter := &fakeVolumeMounter{mountOneErr: errors.New("ebs.mount NATS request: timeout")}
mounter := &fakeVolumeMounter{mountOneErr: errors.New("publish request: timeout")}
m, _ := attachVolumeRunningInstance(t, nil, mounter)
_, err := m.AttachVolume(t.Context(), "i-1", "vol-1", "/dev/sdf")
@@ -1387,7 +1387,7 @@ func TestDetachVolume_DeviceDeletedTimeout_FallsBackToRetry(t *testing.T) {
// detach-wedges-when-QEMU-dies fix: when QEMU is provably gone (PID file
// present, process dead), DetachVolume issues no QMP command — those steps
// would only hang on the wedged process — and proceeds straight to the
// ebs.unmount seal and the attachment clear.
// unpublish seal and the attachment clear.
func TestDetachVolume_ConfirmedDeadQEMU_SkipsQMPAndSeals(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
@@ -1418,7 +1418,7 @@ func TestDetachVolume_ConfirmedDeadQEMU_SkipsQMPAndSeals(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "/dev/sdf", device)
assert.False(t, qmpCalled.Load(), "a confirmed-dead QEMU must receive no QMP command")
assert.Equal(t, []string{"vol-1"}, mounter.unmountedOne, "the ebs.unmount seal must still run")
assert.Equal(t, []string{"vol-1"}, mounter.unmountedOne, "the unpublish seal must still run")
calls := updater.snapshot()
require.Len(t, calls, 1, "the stale attachment must be cleared exactly once")
assert.Equal(t, "available", calls[0].State, "the volume must be returned to available")
+16 -7
View File
@@ -41,7 +41,6 @@ import (
"github.com/mulgadc/spinifex/contracts/ec2/v1"
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
telemetryv1 "github.com/mulgadc/spinifex/contracts/telemetry/v1"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/bootstrap/preflight"
acmdomain "github.com/mulgadc/spinifex/spinifex/domains/acm"
@@ -733,6 +732,11 @@ func (d *Daemon) SetConfigPath(path string) {
// NewDaemon creates a new daemon instance.
func NewDaemon(cfg *config.ClusterConfig) (*Daemon, error) {
// The fence notice is node-addressed: a daemon that cannot subscribe to it
// would leave a guest running on a volume whose export was revoked.
if err := ebsprovider.ValidateNodeID(cfg.Node); err != nil {
return nil, fmt.Errorf("cluster config node %q: %w", cfg.Node, err)
}
ctx, cancel := context.WithCancel(context.Background())
// If WalDir is not set, use BaseDir
@@ -936,6 +940,8 @@ func (d *Daemon) subscribeAll() error {
// Cluster-wide on purpose: the QMP detach is instance-scoped, so a
// volume held by an unreachable host has no other way out.
{"ec2.ForceDetachVolume", handleNATSRequest(d.node, d.volumeService.ForceDetachVolume), "spinifex-workers"},
{"ec2.DetachVolumeFromStopped", handleNATSRequest(d.node, d.instanceService.DetachVolumeFromStopped), "spinifex-workers"},
{"ec2.AttachVolumeToStopped", handleNATSRequest(d.node, d.instanceService.AttachVolumeToStopped), "spinifex-workers"},
{"ec2.DescribeVolumeStatus", handleNATSRequest(d.node, d.volumeService.DescribeVolumeStatus), "spinifex-workers"},
{"ec2.DescribeVolumesModifications", handleNATSRequest(d.node, d.volumeService.DescribeVolumesModifications), "spinifex-workers"},
{"ec2.CreateSnapshot", handleNATSRequest(d.node, d.snapshotService.CreateSnapshot), "spinifex-workers"},
@@ -1353,10 +1359,8 @@ func (d *Daemon) subscribeAll() error {
// here, so the reconcile request flows daemon-ward.
natsSub{dhcp.TopicLeaseChanged, d.handleDHCPLeaseChanged, "spinifex-workers"},
natsSub{dhcp.TopicOwnerCheck, d.handleDHCPOwnerCheck, "spinifex-workers"},
// Node-addressed with no queue group: the fenced guest is on this host,
// so a worker on another node would find nothing to stop.
natsSub{viperblocklegacyv1.VolumeFencedSubject(d.node), d.handleVolumeFenced, ""},
)
subs = append(subs, d.fenceNoticeSubs()...)
// Per-node, no queue group: a second daemon claiming this node ID also
// answers, so the teardown probe sees a duplicate and refuses to seal. An
@@ -2571,6 +2575,7 @@ func (d *Daemon) startCluster() error {
d.vmMgr.NewTerminatedTeardownReaper(),
d.vmMgr.NewOrphanQEMUReaper(),
d.vmMgr.NewStuckTerminateReaper(),
d.vmMgr.NewLocalConsoleLogReaper(),
}
if eniRec := d.newENIReconciler(); eniRec != nil {
reapers = append(reapers, eniRec)
@@ -2622,9 +2627,9 @@ const ebsProviderRequestTimeout = 30 * time.Second
// a missing responder must be reported promptly, and the check is advisory.
const ebsProviderProbeTimeout = 2 * time.Second
// configureEBSProvider wires a single NATSProvider into every EBS-adjacent
// service. The control plane never constructs a provider implementation
// itself, so this always runs regardless of which daemon answers on the wire.
// configureEBSProvider wires a NATSProvider into the EBS-adjacent services and
// the volume mounter. The control plane never constructs a provider
// implementation, so this runs whichever daemon answers on the wire.
func (d *Daemon) configureEBSProvider() error {
provider := ebsprovider.NewNATSProvider(d.natsConn, ebsProviderRequestTimeout)
d.ebsProvider = provider
@@ -2754,6 +2759,10 @@ func (d *Daemon) initJetStream() error {
err = d.jsManager.InitClusterConfigBucket()
}
if err == nil {
err = d.jsManager.InitUEFIStoreBucket()
}
if err == nil {
d.jsManager.SetSyncObserver(d)
// Replicas cannot be undeclared here: bucket creation refuses
@@ -191,9 +191,6 @@ func handleNATSRequestWithPrincipal[I any, O any](nodeID string, serviceFn func(
func (d *Daemon) handleEC2Events(msg *nats.Msg) {
start := time.Now()
action, outcome := d.dispatchEC2Command(msg)
if outcome == outcomeDeferred {
return
}
otelsetup.RecordRequest(context.Background(), ec2CmdAction(action), outcome, time.Since(start))
}
@@ -233,21 +230,6 @@ func (d *Daemon) dispatchEC2Command(msg *nats.Msg) (string, string) {
return name, d.handleAttachVolume(ctx, msg, command, instance)
case command.Attributes.DetachVolume:
return name, d.handleDetachVolume(ctx, msg, command, instance)
case command.Attributes.DrainVolume:
// A drain flushes the volume's whole dirty set to S3, so it is bounded by
// the dirty set rather than by a unit of work. nats.go delivers a
// subscription's messages serially, so running it inline would stall
// every other command for this instance — stop, terminate, hot-plug —
// for the duration. It touches no VM state, so it is safe off-thread.
//
// It therefore records its own point: timing the dispatch would call
// every drain an instant success.
started := time.Now()
go func() {
outcome := d.handleDrainVolume(ctx, msg, command, instance)
otelsetup.RecordRequest(context.Background(), ec2CmdAction(name), outcome, time.Since(started))
}()
return name, outcomeDeferred
case command.Attributes.AttachENI:
return name, d.handleAttachNetworkInterface(ctx, msg, command, instance)
case command.Attributes.DetachENI:
@@ -314,8 +296,6 @@ func ec2CommandName(command ec2v1.EC2InstanceCommand) string {
return "AttachVolume"
case command.Attributes.DetachVolume:
return "DetachVolume"
case command.Attributes.DrainVolume:
return "DrainVolume"
case command.Attributes.AttachENI:
return "AttachNetworkInterface"
case command.Attributes.DetachENI:
@@ -8,6 +8,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/nats-io/nats.go"
)
@@ -49,6 +50,11 @@ func (d *Daemon) handleEC2GetConsoleOutput(msg *nats.Msg) {
if logPath != "" {
info, err := os.Stat(logPath)
if err != nil && os.IsNotExist(err) {
// A terminated instance's file is renamed, not removed, until it ages out.
logPath = vm.RetiredConsolePath(logPath)
info, err = os.Stat(logPath)
}
if err == nil {
modTime = info.ModTime()
@@ -3,56 +3,77 @@ package daemon
import (
"context"
"encoding/json"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"fmt"
"log/slog"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/nats-io/nats.go"
)
// handleVolumeFenced stops the local guest whose volume viperblockd has just
// torn the export out from under.
// fenceNoticeSubs is this node's fence-notice subscription, whatever the EBS
// provider. Node-addressed with no queue group: the fenced guest is on this
// host, so a worker on another node would find nothing to stop.
func (d *Daemon) fenceNoticeSubs() []natsSub {
subject, err := ebsprovider.FenceNoticeSubject(d.node)
if err != nil {
slog.Error("Not serving EBS fence notices: node ID is not a single NATS subject token", "node", d.node, "err", err)
return nil
}
return []natsSub{{subject, d.handleFenceNotice, ""}}
}
// handleFenceNotice stops the local guest whose volume the EBS provider has
// just revoked the export of.
//
// By the time this arrives the disk is already gone, so the guest is doing no
// useful work and its writes cannot reach the backend. What is left is a QEMU
// process holding memory and taps, and an instance record claiming this node
// runs something that runs somewhere else now.
func (d *Daemon) handleVolumeFenced(msg *nats.Msg) string {
ctx, span := natsmsg.StartConsumerSpan(msg)
func (d *Daemon) handleFenceNotice(msg *nats.Msg) string {
ctx, span := ebsprovider.StartServerSpan(context.Background(), msg)
defer span.End()
var event viperblocklegacyv1.VolumeFencedEvent
if err := json.Unmarshal(msg.Data, &event); err != nil {
slog.ErrorContext(ctx, "volume fenced: bad event", "err", err)
ackVolumeFenced(ctx, msg, viperblocklegacyv1.VolumeFencedAck{Error: "bad event: " + err.Error()})
var notice ebsprovider.FenceNotice
decodeErr := json.Unmarshal(msg.Data, &notice)
ack := ebsprovider.FenceNoticeAck{Versioned: ebsprovider.ReplyVersioned(notice.SchemaVersion)}
refuse := func(code ebsprovider.ErrorCode, message string) string {
slog.ErrorContext(ctx, "volume fenced: refused the notice", "volumeId", notice.VolumeID, "code", code, "err", message)
ack.Error = &ebsprovider.ProviderError{Code: code, Message: message}
ebsprovider.RecordProviderError(span, ack.Error)
ackFenceNotice(ctx, msg, ack)
return outcomeError
}
if event.Volume == "" {
slog.ErrorContext(ctx, "volume fenced: event named no volume")
ackVolumeFenced(ctx, msg, viperblocklegacyv1.VolumeFencedAck{Error: "event named no volume"})
return outcomeError
if decodeErr != nil {
return refuse(ebsprovider.ErrorCodeInvalidArgument, "bad notice: "+decodeErr.Error())
}
if !ebsprovider.SupportsSchemaVersion(notice.SchemaVersion) {
return refuse(ebsprovider.ErrorCodeUnsupportedVersion,
fmt.Sprintf("unsupported schema version %d; ", notice.SchemaVersion)+ebsprovider.SupportedVersionsText())
}
if notice.VolumeID == "" {
return refuse(ebsprovider.ErrorCodeInvalidArgument, "notice named no volume")
}
// FenceVolume moves the guest out of running before it returns, so the ack
// below is sent only once nothing here will resume it.
instanceID := d.vmMgr.FenceVolume(ctx, event.Volume, event.Reason)
ackVolumeFenced(ctx, msg, viperblocklegacyv1.VolumeFencedAck{Instance: instanceID})
if instanceID == "" {
ack.InstanceID = d.vmMgr.FenceVolume(ctx, notice.VolumeID, notice.Reason)
ackFenceNotice(ctx, msg, ack)
if ack.InstanceID == "" {
// The usual case: the volume outlived the guest that was using it, so
// there is nothing local left to stop.
slog.InfoContext(ctx, "volume fenced with no local guest using it",
"volumeId", event.Volume, "winner", event.Winner)
"volumeId", notice.VolumeID, "winner", notice.Winner)
return outcomeSuccess
}
slog.ErrorContext(ctx, "volume fenced, stopped the guest that was using it",
"volumeId", event.Volume, "instanceId", instanceID, "winner", event.Winner)
"volumeId", notice.VolumeID, "instanceId", ack.InstanceID, "winner", notice.Winner)
return outcomeSuccess
}
// ackVolumeFenced answers viperblockd, which repeats the notice until it hears
// ackFenceNotice answers the provider, which repeats the notice until it hears
// back. A notice with no reply subject was published once and needs no answer.
func ackVolumeFenced(ctx context.Context, msg *nats.Msg, ack viperblocklegacyv1.VolumeFencedAck) {
func ackFenceNotice(ctx context.Context, msg *nats.Msg, ack ebsprovider.FenceNoticeAck) {
if msg.Reply == "" {
return
}
@@ -62,6 +83,6 @@ func ackVolumeFenced(ctx context.Context, msg *nats.Msg, ack viperblocklegacyv1.
return
}
if err := msg.Respond(payload); err != nil {
slog.WarnContext(ctx, "volume fenced: could not acknowledge, viperblockd will repeat the notice", "err", err)
slog.WarnContext(ctx, "volume fenced: could not acknowledge, the provider will repeat the notice", "err", err)
}
}
@@ -2,92 +2,313 @@ package daemon
import (
"encoding/json"
"fmt"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/codes"
)
// fenceDaemon is a daemon running one guest on volumeID, answering fence
// notices on a real NATS server as it does in production.
// fenceDaemon is a daemon running one guest on volumeID, subscribed to fence
// notices on a real NATS server through the subscription it makes in production.
func fenceDaemon(t *testing.T, volumeID string) (*Daemon, *vm.VM, *nats.Conn) {
t.Helper()
return fenceDaemonWithManager(t, volumeID, vm.NewManagerWithDeps(vm.Deps{NodeID: "node-a"}))
}
func fenceDaemonWithManager(t *testing.T, volumeID string, mgr *vm.Manager) (*Daemon, *vm.VM, *nats.Conn) {
t.Helper()
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
_, nc := testutil.StartTestNATS(t)
d := &Daemon{node: "node-a", vmMgr: vm.NewManagerWithDeps(vm.Deps{NodeID: "node-a"})}
guest := &vm.VM{
d := &Daemon{
node: "node-a",
vmMgr: mgr,
natsConn: nc,
natsSubscriptions: map[string]*nats.Subscription{},
}
guest := fencedGuest(volumeID)
d.vmMgr.Insert(guest)
require.NoError(t, d.registerNatsSubs(d.fenceNoticeSubs()))
require.NoError(t, nc.Flush())
return d, guest, nc
}
func fencedGuest(volumeID string) *vm.VM {
return &vm.VM{
ID: "i-fencedguest",
Status: vm.StateRunning,
Instance: &ec2.Instance{},
EBSRequests: vm.EBSRequests{Requests: []vm.EBSRequest{{Name: volumeID}}},
}
d.vmMgr.Insert(guest)
}
_, err := nc.Subscribe(viperblocklegacyv1.VolumeFencedSubject(d.node), func(msg *nats.Msg) { d.handleVolumeFenced(msg) })
// requestFenceNotice sends raw to node-a's fence-notice subject and decodes the ack.
func requestFenceNotice(t *testing.T, nc *nats.Conn, raw []byte) ebsprovider.FenceNoticeAck {
t.Helper()
subject, err := ebsprovider.FenceNoticeSubject("node-a")
require.NoError(t, err)
require.NoError(t, nc.Flush())
return d, guest, nc
reply, err := nc.Request(subject, raw, 2*time.Second)
require.NoErrorf(t, err,
"ADR-0007:S4: the daemon did not answer a fence notice, so the provider can never tell it was heard. "+
"Fix: subscribe fenceNoticeSubs and respond in handleFenceNotice (daemon_handlers_fence.go)")
var ack ebsprovider.FenceNoticeAck
require.NoError(t, json.Unmarshal(reply.Data, &ack))
return ack
}
func fenceNoticePayload(t *testing.T, notice ebsprovider.FenceNotice) []byte {
t.Helper()
payload, err := json.Marshal(notice)
require.NoError(t, err)
return payload
}
// memoryProviderPublishing is a non-viperblock provider holding volumeID
// published to node-a, announcing fences on nc.
func memoryProviderPublishing(t *testing.T, nc *nats.Conn, volumeID string) *ebsprovider.MemoryProvider {
t.Helper()
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{})
provider.NotifyFencesOn(nc)
_, err := provider.CreateVolume(t.Context(), ebsprovider.CreateVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30},
})
require.NoError(t, err)
_, err = provider.PublishVolume(t.Context(), ebsprovider.PublishVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: volumeID, NodeID: "node-a",
})
require.NoError(t, err)
return provider
}
// TestS4_FenceNoticeIsAcknowledgedOnlyOnceTheGuestIsStopped enforces ADR-0007:S4
// on the daemon's half of the fence. viperblockd repeats the notice until it is
// on the daemon's half of the fence. The provider repeats the notice until it is
// answered, so the answer is what ends delivery, and it must not come before the
// guest is out of running, or a lost notice is acknowledged for a guest left up.
func TestS4_FenceNoticeIsAcknowledgedOnlyOnceTheGuestIsStopped(t *testing.T) {
d, guest, nc := fenceDaemon(t, "vol-fencedack")
payload, err := json.Marshal(viperblocklegacyv1.VolumeFencedEvent{
Volume: "vol-fencedack", Node: "node-a", Winner: "node-b", Reason: "volume lease moved to node-b",
})
require.NoError(t, err)
reply, err := nc.Request(viperblocklegacyv1.VolumeFencedSubject(d.node), payload, 2*time.Second)
require.NoErrorf(t, err,
"ADR-0007:S4: the daemon did not answer a fence notice, so viperblockd can never tell it was heard. "+
"Fix: respond in handleVolumeFenced (daemon_handlers_fence.go)")
var ack viperblocklegacyv1.VolumeFencedAck
require.NoError(t, json.Unmarshal(reply.Data, &ack))
assert.Equal(t, guest.ID, ack.Instance, "the ack names the guest the fence stopped")
assert.Empty(t, ack.Error)
ack := requestFenceNotice(t, nc, fenceNoticePayload(t, ebsprovider.FenceNotice{
Versioned: ebsprovider.NewVersioned(),
VolumeID: "vol-fencedack", NodeID: "node-a", Winner: "node-b", Reason: "volume lease moved to node-b",
}))
assert.Equal(t, guest.ID, ack.InstanceID, "the ack names the guest the fence stopped")
assert.Nil(t, ack.Error)
assert.NotEqual(t, vm.StateRunning, d.vmMgr.Status(guest),
"ADR-0007:S4: the fence was acknowledged while the guest was still running")
}
// TestS4_FenceNoticeIsNotAcknowledgedWhileTheStopIsInProgress enforces
// ADR-0007:S4 on the order of the stop and the ack: with the stop held open,
// no answer may reach the provider until it completes.
func TestS4_FenceNoticeIsNotAcknowledgedWhileTheStopIsInProgress(t *testing.T) {
entered := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
var mgr *vm.Manager
mgr = vm.NewManagerWithDeps(vm.Deps{NodeID: "node-a", TransitionState: func(v *vm.VM, target vm.InstanceState) error {
close(entered)
<-release
mgr.Inspect(v, func(v *vm.VM) { v.Status = target })
return nil
}})
_, guest, nc := fenceDaemonWithManager(t, "vol-fenceheld", mgr)
subject, err := ebsprovider.FenceNoticeSubject("node-a")
require.NoError(t, err)
type answer struct {
msg *nats.Msg
err error
}
answers := make(chan answer, 1)
payload := fenceNoticePayload(t, ebsprovider.FenceNotice{Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-fenceheld", NodeID: "node-a"})
go func() {
msg, err := nc.Request(subject, payload, 10*time.Second)
answers <- answer{msg, err}
}()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("the fence never reached the guest's stop, so this test proved nothing about the ack order")
}
select {
case got := <-answers:
t.Fatalf("ADR-0007:S4: the fence was acknowledged (%v) before the guest's stop finished. "+
"Fix: ack only after FenceVolume returns in handleFenceNotice (daemon_handlers_fence.go)", got.err)
case <-time.After(300 * time.Millisecond):
}
releaseOnce.Do(func() { close(release) })
var got answer
select {
case got = <-answers:
case <-time.After(5 * time.Second):
t.Fatal("the fence was never acknowledged once the stop finished")
}
require.NoError(t, got.err)
var ack ebsprovider.FenceNoticeAck
require.NoError(t, json.Unmarshal(got.msg.Data, &ack))
assert.Equal(t, guest.ID, ack.InstanceID)
}
// TestS4_FenceNoticeIsServedByTheProductionSubscriptionTable keeps the
// subscription in subscribeAll: a handler nothing subscribes is a fence no
// daemon hears.
func TestS4_FenceNoticeIsServedByTheProductionSubscriptionTable(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
ns, nc, _ := testutil.StartTestJetStream(t)
d := newSubscribeTestDaemon(t, nc, "", "")
d.node = "node-a"
d.vmMgr = vm.NewManagerWithDeps(vm.Deps{NodeID: "node-a"})
guest := fencedGuest("vol-fencedsubscribed")
d.vmMgr.Insert(guest)
require.NoError(t, d.subscribeAll())
require.NoError(t, nc.Flush())
caller, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
t.Cleanup(caller.Close)
subject, err := ebsprovider.FenceNoticeSubject("node-a")
require.NoError(t, err)
reply, err := caller.Request(subject, fenceNoticePayload(t, ebsprovider.FenceNotice{
Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-fencedsubscribed", NodeID: "node-a",
}), 2*time.Second)
require.NoErrorf(t, err,
"ADR-0007:S4: the running daemon does not serve fence notices. Fix: append fenceNoticeSubs in subscribeAll (daemon.go)")
var ack ebsprovider.FenceNoticeAck
require.NoError(t, json.Unmarshal(reply.Data, &ack))
assert.Nil(t, ack.Error)
assert.Equal(t, guest.ID, ack.InstanceID)
}
// TestNewDaemon_RefusesANodeTheFenceNoticeCannotAddress keeps a daemon from
// starting where no fence notice can reach it, which would leave a guest
// running on a revoked export with nothing but a log line to say so.
func TestNewDaemon_RefusesANodeTheFenceNoticeCannotAddress(t *testing.T) {
for _, node := range []string{"", "node.a", "node-*", "node>"} {
t.Run(node, func(t *testing.T) {
d, err := NewDaemon(&config.ClusterConfig{Node: node, Nodes: map[string]config.Config{node: {}}})
require.ErrorIs(t, err, ebsprovider.ErrInvalidArgument)
assert.Contains(t, err.Error(), "cluster config node")
assert.Nil(t, d)
})
}
}
// TestFenceNotice_SpanAttributesTheAccountAndTheRefusal reads the daemon's
// span for a refused notice: a server span for the fenced verb, carrying the
// sender's account and the refusal's code rather than a successful round trip.
func TestFenceNotice_SpanAttributesTheAccountAndTheRefusal(t *testing.T) {
recorder := testutil.RecordSpans(t)
_, _, nc := fenceDaemon(t, "vol-unused")
ctx := ebsprovider.WithAccountID(t.Context(), "123456789012")
_, err := ebsprovider.NotifyFenced(ctx, nc, ebsprovider.FenceNotice{
Versioned: ebsprovider.Versioned{SchemaVersion: ebsprovider.SchemaVersion + 1}, VolumeID: "vol-unused", NodeID: "node-a",
}, 2*time.Second)
require.ErrorIs(t, err, ebsprovider.ErrUnsupportedVersion)
_, server := testutil.SpansByKind(t, recorder)
assert.Equal(t, "ebs.volume.fenced", server.Name())
assert.Equal(t, codes.Error, server.Status().Code)
assert.Equal(t, string(ebsprovider.ErrorCodeUnsupportedVersion), testutil.SpanAttribute(t, server, "ebs.provider.error_code").AsString())
assert.Equal(t, "123456789012", testutil.SpanAttribute(t, server, "aws.account_id").AsString())
}
// TestFenceNotice_AckEchoesTheNoticeVersion keeps the answer readable by the
// sender: an ack in a version other than the notice's is one it may refuse.
func TestFenceNotice_AckEchoesTheNoticeVersion(t *testing.T) {
for _, version := range ebsprovider.SupportedSchemaVersions() {
_, _, nc := fenceDaemon(t, "vol-versioned")
ack := requestFenceNotice(t, nc, fenceNoticePayload(t, ebsprovider.FenceNotice{
Versioned: ebsprovider.Versioned{SchemaVersion: version}, VolumeID: "vol-versioned", NodeID: "node-a",
}))
assert.Nil(t, ack.Error)
assert.Equalf(t, version, ack.SchemaVersion, "a notice at version %d was answered at another", version)
}
}
// TestS4_FenceNoticeFromAnyProviderStopsTheGuest holds the daemon to the
// contract rather than to viperblockd: a provider that is not viperblock
// revoking the export a local guest runs on gets that guest stopped.
func TestS4_FenceNoticeFromAnyProviderStopsTheGuest(t *testing.T) {
d, guest, nc := fenceDaemon(t, "vol-memoryfenced")
provider := memoryProviderPublishing(t, nc, "vol-memoryfenced")
stopped, err := provider.RevokePublication(t.Context(), "vol-memoryfenced", "node-b")
require.NoErrorf(t, err,
"ADR-0007:S4: the daemon did not take a fence notice from a non-viperblock provider. "+
"Fix: subscribe the contract fence-notice subject in fenceNoticeSubs (daemon_handlers_fence.go)")
assert.Equal(t, guest.ID, stopped)
assert.Equal(t, vm.StateError, d.vmMgr.Status(guest),
"ADR-0007:S4: the guest runs on against an export its provider revoked")
}
// TestFenceNotice_VolumeNoLocalGuestUsesChangesNothing is the ordinary case: a
// volume fenced after its guest has gone leaves every local guest as it was.
func TestFenceNotice_VolumeNoLocalGuestUsesChangesNothing(t *testing.T) {
d, guest, nc := fenceDaemon(t, "vol-inuse")
provider := memoryProviderPublishing(t, nc, "vol-noguest")
stopped, err := provider.RevokePublication(t.Context(), "vol-noguest", "node-b")
require.NoError(t, err, "a fence with nothing to stop is not an error")
assert.Empty(t, stopped)
assert.Equal(t, vm.StateRunning, d.vmMgr.Status(guest), "a guest on another volume must not be stopped")
}
// TestS4_RepeatedFenceNoticeIsHarmless covers the cost of the retry: the notice
// can arrive more than once, and a second copy must leave the fenced guest as the
// first did rather than re-driving it or failing.
func TestS4_RepeatedFenceNoticeIsHarmless(t *testing.T) {
d, guest, nc := fenceDaemon(t, "vol-fencedtwice")
payload, err := json.Marshal(viperblocklegacyv1.VolumeFencedEvent{Volume: "vol-fencedtwice", Winner: "node-b"})
require.NoError(t, err)
payload := fenceNoticePayload(t, ebsprovider.FenceNotice{
Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-fencedtwice", NodeID: "node-a", Winner: "node-b",
})
for range 2 {
reply, err := nc.Request(viperblocklegacyv1.VolumeFencedSubject(d.node), payload, 2*time.Second)
require.NoError(t, err)
var ack viperblocklegacyv1.VolumeFencedAck
require.NoError(t, json.Unmarshal(reply.Data, &ack))
assert.Empty(t, ack.Error, "a repeated notice is not an error")
ack := requestFenceNotice(t, nc, payload)
assert.Nil(t, ack.Error, "a repeated notice is not an error")
}
assert.Equal(t, vm.StateError, d.vmMgr.Status(guest))
}
// TestVolumeFenced_BadEventIsAnsweredWithAnError stops a notice that can never be
// acted on from being repeated forever.
func TestVolumeFenced_BadEventIsAnsweredWithAnError(t *testing.T) {
d, _, nc := fenceDaemon(t, "vol-unused")
reply, err := nc.Request(viperblocklegacyv1.VolumeFencedSubject(d.node), []byte(`{"volume":""}`), 2*time.Second)
require.NoError(t, err)
var ack viperblocklegacyv1.VolumeFencedAck
require.NoError(t, json.Unmarshal(reply.Data, &ack))
assert.NotEmpty(t, ack.Error)
// TestFenceNotice_UnactionableNoticeIsAnsweredWithAnError stops a notice that
// can never be acted on from being repeated forever, and leaves the guest alone.
func TestFenceNotice_UnactionableNoticeIsAnsweredWithAnError(t *testing.T) {
cases := map[string]struct {
raw []byte
code ebsprovider.ErrorCode
}{
"undecodable": {[]byte(`{`), ebsprovider.ErrorCodeInvalidArgument},
"no volume": {[]byte(`{"schema_version":1,"volume_id":""}`), ebsprovider.ErrorCodeInvalidArgument},
"unknown version": {[]byte(`{"schema_version":0,"volume_id":"vol-unused"}`), ebsprovider.ErrorCodeUnsupportedVersion},
"future version": {fmt.Appendf(nil, `{"schema_version":%d,"volume_id":"vol-unused"}`, ebsprovider.SchemaVersion+1), ebsprovider.ErrorCodeUnsupportedVersion},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
d, guest, nc := fenceDaemon(t, "vol-unused")
ack := requestFenceNotice(t, nc, tc.raw)
require.NotNil(t, ack.Error)
assert.Equal(t, tc.code, ack.Error.Code)
if tc.code == ebsprovider.ErrorCodeUnsupportedVersion {
assert.Contains(t, ack.Error.Message, "unsupported schema version ")
assert.Contains(t, ack.Error.Message, ebsprovider.SupportedVersionsText())
}
assert.Equal(t, vm.StateRunning, d.vmMgr.Status(guest))
})
}
}
@@ -38,9 +38,13 @@ func daemonWithFakeStateStore(t *testing.T, store *vmmock.StateStore) *Daemon {
t.Helper()
d := createTestDaemon(t, sharedNATSURL)
d.stateStore = store
objects := objectstore.NewMemoryObjectStore()
for _, stopped := range store.Stopped {
seedBootVolume(t, objects, d.config.Predastore.Bucket, stopped.AccountID, stopped.ID)
}
d.instanceService = ec2instance.NewInstanceServiceImpl(
d.config, d.resourceMgr.instanceTypes, d.natsConn,
objectstore.NewMemoryObjectStore(), d.vmMgr, d.resourceMgr, store,
objects, d.vmMgr, d.resourceMgr, store,
)
return d
}
@@ -71,7 +75,7 @@ func decodeError(t *testing.T, data []byte) map[string]any {
// stoppedVMFixture builds a minimally-valid stopped VM for handler tests.
func stoppedVMFixture(id, accountID string) *vm.VM {
return &vm.VM{
return withBootVolume(&vm.VM{
ID: id,
Status: vm.StateStopped,
InstanceType: "t3.micro",
@@ -84,7 +88,7 @@ func stoppedVMFixture(id, accountID string) *vm.VM {
InstanceId: aws.String(id),
InstanceType: aws.String("t3.micro"),
},
}
})
}
// --- handleEC2StartStoppedInstance ---
@@ -1438,6 +1438,85 @@ func TestHandleEC2GetConsoleOutput(t *testing.T) {
assert.Contains(t, string(decoded), "Boot complete.")
}
func TestHandleEC2GetConsoleOutput_ReadsRetiredFileWhenLiveNameIsGone(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
instanceID := "i-console-retired-001"
logPath := t.TempDir() + "/console-" + instanceID + ".log"
var body bytes.Buffer
for i := 0; body.Len() < 200<<10; i++ {
fmt.Fprintf(&body, "l%014d\n", i)
}
require.NoError(t, os.WriteFile(vm.RetiredConsolePath(logPath), body.Bytes(), 0o644))
daemon.vmMgr.Insert(&vm.VM{
ID: instanceID,
Status: vm.StateShuttingDown,
AccountID: testAccountID,
Config: vm.Config{ConsoleLogPath: logPath},
})
topic := fmt.Sprintf("ec2.%s.GetConsoleOutput", instanceID)
sub, err := daemon.natsConn.Subscribe(topic, daemon.handleEC2GetConsoleOutput)
require.NoError(t, err)
defer sub.Unsubscribe()
reqData, _ := json.Marshal(&ec2.GetConsoleOutputInput{InstanceId: aws.String(instanceID)})
reply, err := natsRequest(daemon.natsConn, topic, reqData, 5*time.Second)
require.NoError(t, err)
var output ec2.GetConsoleOutputOutput
require.NoError(t, json.Unmarshal(reply.Data, &output))
decoded, err := base64.StdEncoding.DecodeString(*output.Output)
require.NoError(t, err)
want := body.Bytes()[body.Len()-64*1024:]
decodedSize := len(decoded)
assert.Equal(t, 64*1024, decodedSize)
assert.True(t, bytes.Equal(want, decoded), "the newest 64 KiB of the retired file")
}
func TestHandleEC2GetConsoleOutput_AfterTrimReturnsNewest64KiB(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
instanceID := "i-console-trim-001"
dir := t.TempDir()
logPath := dir + "/console-" + instanceID + ".log"
var body bytes.Buffer
for i := 0; body.Len() < 5<<20; i++ {
fmt.Fprintf(&body, "l%014d\n", i)
}
require.NoError(t, os.WriteFile(logPath, body.Bytes(), 0o644))
daemon.vmMgr.Insert(&vm.VM{
ID: instanceID,
Status: vm.StateRunning,
AccountID: testAccountID,
Config: vm.Config{ConsoleLogPath: logPath},
})
reaper := vm.NewConsoleLogReaper(dir)
_, err := reaper.Sweep(context.Background())
require.NoError(t, err)
info, err := os.Stat(logPath)
require.NoError(t, err)
require.Equal(t, int64(1<<20), info.Size(), "the console must have been trimmed before it is read")
topic := fmt.Sprintf("ec2.%s.GetConsoleOutput", instanceID)
sub, err := daemon.natsConn.Subscribe(topic, daemon.handleEC2GetConsoleOutput)
require.NoError(t, err)
defer sub.Unsubscribe()
reqData, _ := json.Marshal(&ec2.GetConsoleOutputInput{InstanceId: aws.String(instanceID)})
reply, err := natsRequest(daemon.natsConn, topic, reqData, 5*time.Second)
require.NoError(t, err)
var output ec2.GetConsoleOutputOutput
require.NoError(t, json.Unmarshal(reply.Data, &output))
decoded, err := base64.StdEncoding.DecodeString(*output.Output)
require.NoError(t, err)
want := body.Bytes()[body.Len()-64*1024:]
decodedSize := len(decoded)
assert.Equal(t, 64*1024, decodedSize)
assert.True(t, bytes.Equal(want, decoded), "the response is the newest 64 KiB of the original console")
}
func TestHandleEC2GetConsoleOutput_EmptyLog(t *testing.T) {
natsURL := sharedNATSURL
daemon := createFullTestDaemon(t, natsURL)
@@ -2763,7 +2842,7 @@ func TestHandleEC2DescribeInstanceTypes_NoFilter(t *testing.T) {
func TestHandleEC2StartStoppedInstance_InstanceTypeNotAvailable(t *testing.T) {
daemon := createFullTestDaemonWithJetStream(t, sharedJSNATSURL)
stoppedVM := &vm.VM{
stoppedVM := withBootVolume(&vm.VM{
ID: "i-start-badtype-001",
Status: vm.StateStopped,
InstanceType: "z99.nonexistent",
@@ -2772,7 +2851,13 @@ func TestHandleEC2StartStoppedInstance_InstanceTypeNotAvailable(t *testing.T) {
InstanceId: aws.String("i-start-badtype-001"),
InstanceType: aws.String("z99.nonexistent"),
},
}
})
objects := objectstore.NewMemoryObjectStore()
seedBootVolume(t, objects, daemon.config.Predastore.Bucket, testAccountID, stoppedVM.ID)
daemon.instanceService = ec2instance.NewInstanceServiceImpl(
daemon.config, daemon.resourceMgr.instanceTypes, daemon.natsConn,
objects, daemon.vmMgr, daemon.resourceMgr, daemon.jsManager,
)
err := daemon.jsManager.WriteStoppedInstance(stoppedVM.ID, stoppedVM)
require.NoError(t, err)
t.Cleanup(func() { _ = daemon.jsManager.DeleteStoppedInstance(stoppedVM.ID) })
@@ -11,7 +11,6 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/contracts/ec2/v1"
viperblocklegacyv1 "github.com/mulgadc/spinifex/contracts/viperblockd/legacy/v1"
ec2snapshot "github.com/mulgadc/spinifex/spinifex/domains/ec2/snapshot"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/nats-io/nats.go"
@@ -139,50 +138,6 @@ func (d *Daemon) handleDetachVolume(ctx context.Context, msg *nats.Msg, command
return outcomeSuccess
}
// handleDrainVolume flushes the volume's in-flight writes to S3 by dialing the
// drain socket the local NBD plugin serves. ec2.CreateSnapshot is answered by
// any node in the cluster, but only the node hosting the instance subscribes
// ec2.cmd.{instanceID}, so routing the drain here lands it where the writes are.
//
// Runs on its own goroutine (see handleEC2Events) so a long flush cannot hold
// the instance's command subscription.
func (d *Daemon) handleDrainVolume(ctx context.Context, msg *nats.Msg, command ec2v1.EC2InstanceCommand, instance *vm.VM) string {
ctx, span := startOpSpan(ctx, "ec2.DrainVolume", command.ID)
var err error
defer func() { endOpSpan(span, err) }()
if command.DrainVolumeData == nil || command.DrainVolumeData.VolumeID == "" {
slog.ErrorContext(ctx, "DrainVolume: missing drain volume data", "instanceId", command.ID)
return respondErrorOutcome(d.node, msg, awserrors.ErrorInvalidParameterValue)
}
volumeID := command.DrainVolumeData.VolumeID
// Only stable post-teardown states prove the volume was sealed. Transitional
// states still have a live or shutting-down writer, so they must drain or
// fail rather than acknowledge an older checkpoint as current.
status := d.vmMgr.Status(instance)
if status == vm.StateStopped || status == vm.StateTerminated {
slog.InfoContext(ctx, "DrainVolume: instance teardown is complete, nothing to drain",
"volumeId", volumeID, "instanceId", command.ID, "status", status)
respondWithJSON(d.node, msg, ec2v1.DrainVolumeResponse{VolumeID: volumeID, Status: ec2v1.DrainVolumeStatusNotRunning})
return outcomeSuccess
}
slog.InfoContext(ctx, "Draining volume before snapshot", "volumeId", volumeID, "instanceId", command.ID)
// A missing or unresponsive socket under a running instance means the writes
// cannot be made current, so the caller must fail its snapshot rather than
// read a stale checkpoint.
if err = ec2snapshot.DrainVolumeSocket(d.config.DataDir, volumeID); err != nil {
slog.ErrorContext(ctx, "DrainVolume: drain failed", "volumeId", volumeID, "instanceId", command.ID, "err", err)
return respondErrorOutcome(d.node, msg, awserrors.ErrorServerInternal)
}
respondWithJSON(d.node, msg, ec2v1.DrainVolumeResponse{VolumeID: volumeID, Status: ec2v1.DrainVolumeStatusDrained})
return outcomeSuccess
}
// attachDetachErrorCode maps a vm.Manager error returned by AttachVolume
// or DetachVolume to the AWS API error code that the SDK expects.
func attachDetachErrorCode(err error) string {
@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"strings"
"testing"
"time"
@@ -12,17 +11,14 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/bluebottle/pkg/safecast"
"github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/qmp"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)
// TestAttachDetachErrorCode locks down the manager-error → AWS-API-code
@@ -370,257 +366,6 @@ func TestAttachVolume_IdempotentSameInstance_DeviceMismatch(t *testing.T) {
assert.Contains(t, string(resp.Data), awserrors.ErrorVolumeInUse)
}
// drainCommandFor builds the drain command a snapshot addresses to the node
// hosting instanceID.
func drainCommandFor(t *testing.T, instanceID, volumeID string) []byte {
t.Helper()
command := ec2v1.EC2InstanceCommand{
ID: instanceID,
Attributes: ec2v1.EC2CommandAttributes{DrainVolume: true},
DrainVolumeData: &ec2v1.DrainVolumeData{VolumeID: volumeID},
}
data, err := json.Marshal(command)
require.NoError(t, err)
return data
}
// drainTestDaemon returns a daemon owning a running instance, with a data dir
// short enough to hold the drain socket path.
func drainTestDaemon(t *testing.T, instanceID string) *Daemon {
t.Helper()
return drainTestDaemonInState(t, instanceID, vm.StateRunning)
}
// drainTestDaemonInState is drainTestDaemon for an instance the node still
// holds in some other state.
func drainTestDaemonInState(t *testing.T, instanceID string, status vm.InstanceState) *Daemon {
t.Helper()
daemon := createTestDaemon(t, sharedNATSURL)
daemon.config.DataDir = testutil.SocketTempDir(t)
daemon.vmMgr.Insert(&vm.VM{
ID: instanceID,
Status: status,
AccountID: testAccountID,
InstanceType: getTestInstanceType(t),
Instance: &ec2.Instance{},
})
return daemon
}
// drainRequest issues a drain on the instance's command subject, returning the
// raw reply.
func drainRequest(t *testing.T, daemon *Daemon, instanceID, volumeID string) *nats.Msg {
t.Helper()
reply, err := sendDrainCommand(daemon, instanceID, drainCommandFor(t, instanceID, volumeID))
require.NoError(t, err)
return reply
}
// sendDrainCommand is drainRequest without the assertions, so a test can issue
// one from a goroutine and assert on the result back on the test goroutine.
func sendDrainCommand(daemon *Daemon, instanceID string, command []byte) (*nats.Msg, error) {
msg := nats.NewMsg("ec2.cmd." + instanceID)
msg.Data = command
msg.Header.Set(natsmsg.AccountIDHeader, testAccountID)
return daemon.natsConn.RequestMsg(msg, 5*time.Second)
}
// subscribeEC2Commands wires the daemon's real dispatcher onto the per-instance
// command subject, so tests exercise the same serial delivery production has.
func subscribeEC2Commands(t *testing.T, daemon *Daemon, instanceID string) {
t.Helper()
sub, err := daemon.natsConn.Subscribe("ec2.cmd."+instanceID, daemon.handleEC2Events)
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
// A drain routed to the node hosting the instance reaches the local socket and
// acks, which is what lets a snapshot answered elsewhere read a current
// checkpoint.
func TestDrainVolume_HostNodeAcksLocalSocket(t *testing.T) {
const instanceID, volumeID = "i-drain-ok", "vol-drain-ok"
daemon := drainTestDaemon(t, instanceID)
testutil.StartDrainSocket(t, daemon.config.DataDir, volumeID, "OK\n")
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
testAccountID, drainCommandFor(t, instanceID, volumeID))
var ack ec2v1.DrainVolumeResponse
require.NoError(t, json.Unmarshal(resp.Data, &ack))
assert.Equal(t, volumeID, ack.VolumeID)
assert.Equal(t, ec2v1.DrainVolumeStatusDrained, ack.Status)
}
// The owning node having no socket for the volume means the writes cannot be
// flushed. That must reach the caller as an error, never as a silent success:
// the caller would otherwise snapshot a stale checkpoint.
func TestDrainVolume_HostNodeWithoutSocketFails(t *testing.T) {
const instanceID, volumeID = "i-drain-nosock", "vol-drain-nosock"
daemon := drainTestDaemon(t, instanceID)
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
testAccountID, drainCommandFor(t, instanceID, volumeID))
assert.Equal(t, awserrors.ErrorServerInternal, replyErrCode(t, resp.Data))
}
// A plugin that refuses the drain (ERR rather than OK) is a failure, not an ack.
func TestDrainVolume_HostNodeRelaysSocketFailure(t *testing.T) {
const instanceID, volumeID = "i-drain-err", "vol-drain-err"
daemon := drainTestDaemon(t, instanceID)
testutil.StartDrainSocket(t, daemon.config.DataDir, volumeID, "ERR\n")
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
testAccountID, drainCommandFor(t, instanceID, volumeID))
assert.Equal(t, awserrors.ErrorServerInternal, replyErrCode(t, resp.Data))
}
// A drain command with no volume is a caller error, not a server fault.
func TestDrainVolume_MissingVolumeDataIsInvalidParameter(t *testing.T) {
const instanceID = "i-drain-novol"
daemon := drainTestDaemon(t, instanceID)
command := ec2v1.EC2InstanceCommand{
ID: instanceID,
Attributes: ec2v1.EC2CommandAttributes{DrainVolume: true},
}
data, err := json.Marshal(command)
require.NoError(t, err)
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
testAccountID, data)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, replyErrCode(t, resp.Data))
}
// Only the instance's owner may drain its volume: the command carries the same
// ownership gate as every other per-instance command.
func TestDrainVolume_ForeignAccountRejected(t *testing.T) {
const instanceID, volumeID = "i-drain-foreign", "vol-drain-foreign"
daemon := drainTestDaemon(t, instanceID)
testutil.StartDrainSocket(t, daemon.config.DataDir, volumeID, "OK\n")
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
"999988887777", drainCommandFor(t, instanceID, volumeID))
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, replyErrCode(t, resp.Data))
}
// Only stable post-teardown states prove the volume is sealed. These states
// acknowledge not-running without touching the absent plugin socket.
func TestDrainVolume_CompletedTeardownAcksNotRunning(t *testing.T) {
for _, status := range []vm.InstanceState{vm.StateStopped, vm.StateTerminated} {
t.Run(string(status), func(t *testing.T) {
instanceID := "i-drain-" + string(status)
volumeID := "vol-drain-" + string(status)
daemon := drainTestDaemonInState(t, instanceID, status)
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
testAccountID, drainCommandFor(t, instanceID, volumeID))
var ack ec2v1.DrainVolumeResponse
require.NoError(t, json.Unmarshal(resp.Data, &ack))
assert.Equal(t, volumeID, ack.VolumeID)
assert.Equal(t, ec2v1.DrainVolumeStatusNotRunning, ack.Status)
})
}
}
// Transitional and uncertain states are not proof of a sealed checkpoint. An
// absent socket in any of them must fail rather than silently snapshot stale
// data while launch or teardown may still own the volume.
func TestDrainVolume_UnsealedStatesRequireDrain(t *testing.T) {
statuses := []vm.InstanceState{
vm.StateRunning,
vm.StateStopping,
vm.StateShuttingDown,
vm.StatePending,
vm.StateProvisioning,
vm.StateError,
}
for _, status := range statuses {
t.Run(string(status), func(t *testing.T) {
instanceID := "i-drain-" + string(status)
volumeID := "vol-drain-" + string(status)
daemon := drainTestDaemonInState(t, instanceID, status)
resp := requestHandler(t, daemon.natsConn, "ec2.cmd."+instanceID, daemon.handleEC2Events,
testAccountID, drainCommandFor(t, instanceID, volumeID))
assert.Equal(t, awserrors.ErrorServerInternal, replyErrCode(t, resp.Data))
})
}
}
// nats.go delivers a subscription's messages serially, so a drain run inline
// would hold ec2.cmd.{instanceID} for the length of the flush — stalling stop,
// terminate and hot-plug for that instance. A drain still flushing must not
// keep the next command waiting.
func TestDrainVolume_SlowDrainDoesNotBlockTheCommandSubject(t *testing.T) {
const instanceID = "i-drain-parallel"
daemon := drainTestDaemon(t, instanceID)
accepted, release := testutil.StartBlockingDrainSocket(t, daemon.config.DataDir, "vol-drain-slow", "OK\n")
testutil.StartDrainSocket(t, daemon.config.DataDir, "vol-drain-fast", "OK\n")
subscribeEC2Commands(t, daemon, instanceID)
type reply struct {
msg *nats.Msg
err error
}
slowCommand := drainCommandFor(t, instanceID, "vol-drain-slow")
slow := make(chan reply, 1)
go func() {
msg, err := sendDrainCommand(daemon, instanceID, slowCommand)
slow <- reply{msg, err}
}()
// Only assert once the slow drain is genuinely mid-flush.
select {
case <-accepted:
case <-time.After(5 * time.Second):
t.Fatal("the slow drain never reached its socket")
}
fast := drainRequest(t, daemon, instanceID, "vol-drain-fast")
var ack ec2v1.DrainVolumeResponse
require.NoError(t, json.Unmarshal(fast.Data, &ack))
assert.Equal(t, ec2v1.DrainVolumeStatusDrained, ack.Status)
release()
select {
case got := <-slow:
require.NoError(t, got.err)
require.NoError(t, json.Unmarshal(got.msg.Data, &ack))
assert.Equal(t, ec2v1.DrainVolumeStatusDrained, ack.Status)
case <-time.After(5 * time.Second):
t.Fatal("the released drain never replied")
}
}
// Running the drain off the delivery goroutine must not leak it: the goroutine
// has to be gone once the ack is on the wire, or a snapshot loop against a
// wedged plugin accumulates them.
func TestDrainVolume_DispatchedGoroutineDoesNotLeak(t *testing.T) {
const instanceID, volumeID = "i-drain-leak", "vol-drain-leak"
daemon := drainTestDaemon(t, instanceID)
testutil.StartDrainSocket(t, daemon.config.DataDir, volumeID, "OK\n")
subscribeEC2Commands(t, daemon, instanceID)
// Take the baseline after one full round trip: the first request is what
// makes nats.go stand up its long-lived response-inbox goroutine, which
// would otherwise read as the leak.
var ack ec2v1.DrainVolumeResponse
require.NoError(t, json.Unmarshal(drainRequest(t, daemon, instanceID, volumeID).Data, &ack))
require.Equal(t, ec2v1.DrainVolumeStatusDrained, ack.Status)
ignoreExisting := goleak.IgnoreCurrent()
require.NoError(t, json.Unmarshal(drainRequest(t, daemon, instanceID, volumeID).Data, &ack))
require.Equal(t, ec2v1.DrainVolumeStatusDrained, ack.Status)
goleak.VerifyNone(t, ignoreExisting)
}
// seedProviderVolume allocates volumeID in the daemon's EBS provider so
// operations that reach the provider (expand, delete) find it there.
func seedProviderVolume(t *testing.T, daemon *Daemon, volumeID string, sizeGiB int64) {

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