Compare commits

...

5 Commits

Author SHA1 Message Date
brynmailer-mdc 0bc8107575 fix(ec2): log a held-volume start refusal at Warn with the instance ID
A start refused because another node holds a volume is returned as
IncorrectState, a client error, yet was logged at Error without the
instance it concerned. It is now logged at Warn, and every vmMgr.Start
failure log names the instance.
2026-11-03 16:40:22 +10:00
brynmailer-mdc bca22f1d23 refactor(ec2): classify a volume held elsewhere only by the provider code
Every node sends provider requests at the one contract version, at which
every provider refuses a volume held by another node with the
volume_held_elsewhere code. The lease-text fallbacks in the instance
service and the EKS cluster reconciler matched replies that no longer
exist, so they are removed: a start is a held-volume refusal only when
the mount error carries the provider code, or when the start error wraps
ec2v1.ErrVolumeHeldElsewhere, which StartInstances rebuilds from the
owner's header.

The ec2instance.ErrVolumeHeldElsewhere alias is removed, so the sentinel
has one name, ec2v1.ErrVolumeHeldElsewhere. The reconciler's budget test
now proves an IncorrectState without the sentinel still counts, whether
its message is unrelated or carries the lease text.
2026-11-03 16:40:22 +10:00
brynmailer-mdc edcde560fb test(daemon): pin that only a held volume gets the held-elsewhere header
The owner's reply header is the one signal that a start was refused
because another node holds a volume, and every other start refusal in
the same IncorrectState code must reach the caller without it.
2026-11-03 16:40:22 +10:00
brynmailer-mdc a58688608c fix(daemon): relay every header of the owner's start reply on both forwarder paths
The ec2.start forwarder copied only the held-elsewhere header onto a
relayed reply, and its unsealed-writes branch answered with the body
alone, so any other header the owning node set was lost in the relay.

Both relay branches now go through relayStartReply, which forwards the
owner's reply body and every header unchanged.
2026-11-03 16:40:22 +10:00
brynmailer-mdc 7a6f11ff40 fix(ec2): classify a volume held elsewhere by the provider code, not lease text
The instance service and the EKS cluster reconciler recognised a start
refused because another node holds a volume only by viperblockd's lease
text, so a provider that words the refusal differently was reported as
ServerInternal and counted against the reconciler's restart budget.

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

The lease text stays as a fallback for replies that carry no code: the
legacy ebs.mount reply, a publish below schema version 3 (where
volume_in_use also means a read-only mismatch), and an owner node that
predates the header.
2026-11-03 16:40:22 +10:00
14 changed files with 289 additions and 102 deletions
+7
View File
@@ -37,6 +37,13 @@ contract or an approved compatibility plan; it is not a package refactor.
Only the subject is owned here today.
The request (`account_id`) and reply (`vpc_id`, `error`) bodies are still declared as anonymous structs on each side; moving them into this package is a contract change that needs its own characterization first.
## Volume held elsewhere
A start refused because another node holds one of the instance's volumes is an `IncorrectState` error payload on the `ec2.start`, `ec2.start.<node>` and `ec2.cmd.<instance-id>` replies, and the AWS body cannot say why.
The owning node adds the `X-Volume-Held-Elsewhere: true` header to that reply, the `ec2.start` forwarder relays it, and `StartInstances` wraps `ErrVolumeHeldElsewhere` around the error it rebuilds, so a caller matches it with `errors.Is` rather than reading the message.
The owning node sets the header only from the EBS provider's `volume_held_elsewhere` code, which every provider returns for this refusal; no reader matches the message text.
`VolumeHeldElsewhereText` is the wording viperblockd puts in that refusal's message, not a signal.
## Platform inventory
`PlatformInventorySubject` (`ec2.v1.PlatformInventory`) is a request/reply route on which a daemon in the `spinifex-workers` queue group reports one account's key pairs, placement groups, launch templates and their `ec2-tags` entries from the EC2 owner stores.
+10 -4
View File
@@ -1,7 +1,13 @@
package ec2v1
// VolumeHeldElsewhereText is the text of a start refused because another node
// holds one of the instance's volumes. It crosses NATS as message text, so
// readers match on it; the provider that refuses and every reader share this
// string so they cannot drift.
import "errors"
// VolumeHeldElsewhereText is the message viperblockd gives a lease refusal. It
// is wording for the operator; callers classify the refusal by its code.
const VolumeHeldElsewhereText = "volume is leased by another owner"
// ErrVolumeHeldElsewhere marks a start refused because another node still
// holds one of the instance's volumes. The owning node sets it from the EBS
// provider's held-elsewhere code, and a start reply carries it across NATS as
// the X-Volume-Held-Elsewhere header rather than as message text.
var ErrVolumeHeldElsewhere = errors.New("a volume is leased by another node")
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"log/slog"
"time"
@@ -102,7 +103,7 @@ func startLiveInstance(ctx context.Context, natsConn *nats.Conn, instanceID, acc
if responseError, parseErr := awserrors.ValidateErrorPayload(msg.Data); parseErr != nil {
slog.ErrorContext(ctx, "StartInstances: owner returned error", "instance_id", instanceID, "code", *responseError.Code)
return nil, false, startRefusalError(responseError)
return nil, false, startRefusalError(responseError, msg.Header)
}
slog.InfoContext(ctx, "StartInstances: restarted via owner node", "instance_id", instanceID, "response", string(msg.Data))
@@ -135,18 +136,25 @@ func startStoppedInstance(ctx context.Context, natsConn *nats.Conn, instanceID,
if responseError, parseErr := awserrors.ValidateErrorPayload(msg.Data); parseErr != nil {
slog.ErrorContext(ctx, "StartInstances: Daemon returned error", "instance_id", instanceID, "code", *responseError.Code)
return nil, startRefusalError(responseError)
return nil, startRefusalError(responseError, msg.Header)
}
slog.InfoContext(ctx, "StartInstances: Command sent successfully", "instance_id", instanceID, "response", string(msg.Data))
return newStateChange(instanceID, 0, "pending", 80, "stopped"), nil
}
// startRefusalError rebuilds the daemon's error, keeping the message it sent.
// Reconstructing from the code alone renders the generic ErrorLookup text
// instead, which replaces the name of the node holding the volume with
// boilerplate about attaching volumes -- the one fact the operator needed.
func startRefusalError(responseError ec2.ResponseError) error {
// startRefusalError rebuilds the daemon's error, keeping the message it sent,
// which names the node holding the volume, and wrapping ErrVolumeHeldElsewhere
// when the reply's header says that is why the start was refused.
func startRefusalError(responseError ec2.ResponseError, header nats.Header) error {
err := startRefusalCodeError(responseError)
if header.Get(natsmsg.VolumeHeldElsewhereHeader) == "true" {
return fmt.Errorf("%w: %w", ec2v1.ErrVolumeHeldElsewhere, err)
}
return err
}
func startRefusalCodeError(responseError ec2.ResponseError) error {
var code, message string
if responseError.Code != nil {
code = *responseError.Code
@@ -3,11 +3,14 @@ package instance
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -161,3 +164,33 @@ func TestStartInstances_MixedSuccessAndFailure(t *testing.T) {
require.Error(t, err)
assert.Equal(t, "InvalidInstanceID.NotFound", err.Error())
}
// The AWS body says only IncorrectState, so the header is the one thing that
// tells a held volume apart from any other refusal in that code.
func TestStartInstances_VolumeHeldElsewhereHeader(t *testing.T) {
t.Parallel()
for name, held := range map[string]bool{"with header": true, "without header": false} {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
const id = "i-0abc0000000000002"
_, err := nc.Subscribe(ec2v1.InstanceCommandSubject(id), func(msg *nats.Msg) {
reply := nats.NewMsg(msg.Reply)
reply.Data = awserrors.GenerateErrorPayloadWithMessage(awserrors.ErrorIncorrectState, "volume vol-1 is published to node-a")
if held {
reply.Header.Set(natsmsg.VolumeHeldElsewhereHeader, "true")
}
_ = msg.RespondMsg(reply)
})
require.NoError(t, err)
_, err = StartInstances(t.Context(), &ec2.StartInstancesInput{InstanceIds: []*string{aws.String(id)}}, nc, "123456789012")
require.Error(t, err)
assert.Equal(t, held, errors.Is(err, ec2v1.ErrVolumeHeldElsewhere))
code, message, ok := awserrors.ResolveErrorDetail(err)
require.True(t, ok)
assert.Equal(t, awserrors.ErrorIncorrectState, code)
assert.Equal(t, "volume vol-1 is published to node-a", message)
})
}
}
+12 -32
View File
@@ -1237,7 +1237,12 @@ func (s *InstanceServiceImpl) StartInstance(ctx context.Context, instance *vm.VM
"instanceId", command.ID, "err", err)
return errors.New(awserrors.ErrorInvalidInstanceIDNotFound)
default:
slog.ErrorContext(ctx, "StartInstance: vmMgr.Start failed", "err", err)
// A volume held elsewhere is the caller's 400, not a fault on this node.
if refusal := volumeHeldElsewhereError(err); refusal != nil {
slog.WarnContext(ctx, "StartInstance: vmMgr.Start failed", "instanceId", command.ID, "err", err)
return refusal
}
slog.ErrorContext(ctx, "StartInstance: vmMgr.Start failed", "instanceId", command.ID, "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
}
@@ -2838,40 +2843,15 @@ func (s *InstanceServiceImpl) RecoverInstance(ctx context.Context, instance *vm.
return s.launchClaimedInstance(ctx, instance, undo)
}
// volumeExclusionMarkers are the refusals that mean another node is writing this
// volume right now, or is still live and holds its unsealed writes. A previous
// owner that has stopped heartbeating is opened over with a warning instead. The
// text arrives having crossed NATS from viperblockd.
var volumeExclusionMarkers = []string{
ec2v1.VolumeHeldElsewhereText,
}
// ErrVolumeHeldElsewhere reports a launch refused because another node still
// holds one of the instance's volume leases.
//
// Recovery treats it differently from every other failure — it clears on the
// lease's own clock rather than needing anything fixed — so it has to be
// recognisable with errors.Is rather than inferred from text that has already
// crossed NATS once.
var ErrVolumeHeldElsewhere = errors.New("a volume is leased by another node")
// volumeHeldElsewhereError converts a mount refused because another node holds
// the volume into a client-facing error carrying that reason. Returns nil for
// anything else. Without it the caller gets ServerInternal and is told to
// retry, which cannot succeed while the holder is down, and the text naming the
// holder never leaves the node's log.
// volumeHeldElsewhereError converts a mount the EBS provider refused with its
// held-elsewhere code into IncorrectState naming the holder, wrapping
// ec2v1.ErrVolumeHeldElsewhere, and returns nil for anything else.
func volumeHeldElsewhereError(err error) error {
if err == nil {
if !errors.Is(err, ebsprovider.ErrVolumeHeldElsewhere) {
return nil
}
msg := err.Error()
for _, marker := range volumeExclusionMarkers {
if strings.Contains(msg, marker) {
return fmt.Errorf("%w: %w", ErrVolumeHeldElsewhere,
awserrors.Errorf(awserrors.ErrorIncorrectState, "%s", msg))
}
}
return nil
return fmt.Errorf("%w: %w", ec2v1.ErrVolumeHeldElsewhere,
awserrors.Errorf(awserrors.ErrorIncorrectState, "%s", err.Error()))
}
// restoreClaimedStoppedInstance writes instance back to the shared
@@ -4,50 +4,19 @@ package instance
//would widen the handler API for a test rather than for a caller.
import (
"context"
"errors"
"fmt"
"testing"
"github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestVolumeHeldElsewhere_NamesTheHolder covers both refusals that mean the
// data is on another node. An operator who gets ServerInternal is told to retry
// something that cannot succeed while that node is down, so the node name and
// the reason have to survive the trip out of the daemon.
func TestVolumeHeldElsewhere_NamesTheHolder(t *testing.T) {
cases := []struct {
name string
err error
}{
{
name: "live holder, refused by the lease",
err: errors.New("failed to mount volume: volume is leased by another owner: " +
"held by node-b since 2026-09-02T17:04:39Z"),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := volumeHeldElsewhereError(tc.err)
require.Error(t, got, "a volume held elsewhere must not fall through to ServerInternal")
code := awserrors.ValidErrorCodeFromError(got)
assert.NotEqual(t, awserrors.ErrorServerInternal, code,
"ServerInternal reads as a transient fault and invites a retry that cannot succeed")
assert.Contains(t, got.Error(), "node-b", "the caller has to learn where the data is")
assert.ErrorIs(t, got, ErrVolumeHeldElsewhere,
"recovery retries this on a shorter clock than anything else, so it has to be matchable")
resolved, _, ok := awserrors.ResolveErrorDetail(got)
require.True(t, ok, "the sentinel must not hide the code from the layer that returns it")
assert.Equal(t, awserrors.ErrorIncorrectState, resolved)
})
}
}
// TestVolumeHeldElsewhere_IgnoresUnrelatedFailures guards the other direction:
// a genuine internal fault must keep reporting as one rather than being
// relabelled as somebody else's volume.
@@ -60,4 +29,76 @@ func TestVolumeHeldElsewhere_IgnoresUnrelatedFailures(t *testing.T) {
// than refused, so this text is not an exclusion and must not become one.
assert.NoError(t, volumeHeldElsewhereError(errors.New(
"took over from node-b, which held unsealed writes since 2026-09-02T17:04:39Z")))
// Only the provider's code classifies a refusal, so its wording alone does not.
assert.NoError(t, volumeHeldElsewhereError(errors.New(
"failed to mount volume: "+ec2v1.VolumeHeldElsewhereText+": held by node-b")))
}
// A provider is free to word its refusal however it likes; the held-elsewhere
// code is what says another node has the volume.
func TestVolumeHeldElsewhere_MatchesTheProviderCodeWhateverTheWording(t *testing.T) {
refusal := fmt.Errorf("publish volume vol-1: %w", &ebsprovider.ProviderError{
Code: ebsprovider.ErrorCodeVolumeHeldElsewhere,
Message: "volume vol-1 is published to node-a",
})
got := volumeHeldElsewhereError(refusal)
require.Error(t, got, "a held-elsewhere code must be classified even without viperblockd's text")
assert.ErrorIs(t, got, ec2v1.ErrVolumeHeldElsewhere)
assert.Contains(t, got.Error(), "node-a", "the caller has to learn where the data is")
code, _, ok := awserrors.ResolveErrorDetail(got)
require.True(t, ok)
assert.Equal(t, awserrors.ErrorIncorrectState, code)
}
// volume_in_use is a read-only mismatch, not another node's claim, so it must
// not be relabelled as a volume held elsewhere even though the sentinels nest.
func TestVolumeHeldElsewhere_VolumeInUseAloneIsNotHeldElsewhere(t *testing.T) {
mismatch := fmt.Errorf("publish volume vol-1: %w", &ebsprovider.ProviderError{
Code: ebsprovider.ErrorCodeVolumeInUse,
Message: "volume vol-1 is exported read-write, not read-only",
})
assert.NoError(t, volumeHeldElsewhereError(mismatch))
}
// heldElsewhereMounter refuses every mount the way the contract mounter
// reports a provider's held-elsewhere code, in wording that is not viperblockd's.
type heldElsewhereMounter struct{ raceVolumeMounter }
func (heldElsewhereMounter) Mount(context.Context, *vm.VM) error {
return fmt.Errorf("publish volume vol-1: %w", &ebsprovider.ProviderError{
Code: ebsprovider.ErrorCodeVolumeHeldElsewhere,
Message: "volume vol-1 is published to node-a",
})
}
// Both start paths a caller can reach must hand back the classified refusal:
// the stopped-instance claim (ec2.start) and the live owner (ec2.cmd), which
// the EKS reconciler tries first.
func TestStartPaths_ReportAVolumeHeldElsewhereByCode(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
types, _ := defaultPrepareInstanceTypes()
t.Run("claimed relaunch", func(t *testing.T) {
mgr := vm.NewManager()
mgr.SetDeps(vm.Deps{NodeID: "node-b", VolumeMounter: heldElsewhereMounter{}})
svc := &InstanceServiceImpl{resourceMgr: &fakeResourceCapacityProvider{instanceTypes: types}, vmMgr: mgr}
err := svc.launchClaimedInstance(t.Context(), &vm.VM{ID: "i-held-claim", AccountID: "acc", InstanceType: "t3.micro", Status: vm.StatePending}, func() {})
assert.ErrorIs(t, err, ec2v1.ErrVolumeHeldElsewhere)
assert.Equal(t, awserrors.ErrorIncorrectState, awserrors.ValidErrorCodeFromError(err))
})
t.Run("live owner", func(t *testing.T) {
const id = "i-held-live"
mgr := mgrWith(map[string]*vm.VM{id: {ID: id, Status: vm.StateStopped, InstanceType: "t3.micro"}})
mgr.SetDeps(vm.Deps{NodeID: "node-b", VolumeMounter: heldElsewhereMounter{}})
v, _ := mgr.Get(id)
svc := &InstanceServiceImpl{resourceMgr: &fakeResourceCapacityProvider{instanceTypes: types}, vmMgr: mgr}
err := svc.StartInstance(t.Context(), v, ec2v1.EC2InstanceCommand{ID: id})
assert.ErrorIs(t, err, ec2v1.ErrVolumeHeldElsewhere)
assert.Equal(t, awserrors.ErrorIncorrectState, awserrors.ValidErrorCodeFromError(err))
})
}
@@ -795,8 +795,7 @@ func retryableStartRefusal(err error) bool {
if code, ok := awserrors.ResolveErrorCode(err); ok && code == awserrors.ErrorInsufficientInstanceCapacity {
return true
}
_, message, _ := awserrors.ResolveErrorDetail(err)
return strings.Contains(message, ec2v1.VolumeHeldElsewhereText) || strings.Contains(err.Error(), ec2v1.VolumeHeldElsewhereText)
return errors.Is(err, ec2v1.ErrVolumeHeldElsewhere)
}
// maybeReformEtcdQuorum escalates a wedged HA control plane that the in-place
@@ -3,9 +3,11 @@ package cluster
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -30,12 +32,12 @@ func (c *refusingCPControl) StartInstance(context.Context, string) error {
func (c *refusingCPControl) StopInstance(context.Context, string) error { return nil }
// What the start path returns after crossing NATS: the code and the message the
// owning node sent, with no sentinel left to match on.
// What the start path returns after crossing NATS: StartInstances wraps the
// sentinel back on when the owner's reply carries the held-elsewhere header.
var (
errStartCapacity = errors.New(awserrors.ErrorInsufficientInstanceCapacity)
errStartHeld = awserrors.Errorf(awserrors.ErrorIncorrectState, "%s",
"volume is leased by another owner: held by node-a since 2026-10-30T01:00:00Z")
errStartHeld = fmt.Errorf("%w: %w", ec2v1.ErrVolumeHeldElsewhere,
awserrors.Errorf(awserrors.ErrorIncorrectState, "%s", "volume vol-1 is published to node-a"))
)
// A refusal that clears by itself must not use up the restart budget, or the
@@ -72,18 +74,30 @@ func TestMaybeRecoverCP_RetryableStartRefusalIsRetriedNotReplaced(t *testing.T)
}
}
// The cap still bounds the faults nothing says will clear.
// The cap still bounds the faults nothing says will clear. IncorrectState
// without the held-elsewhere sentinel is one, whatever its message says.
func TestMaybeRecoverCP_UnknownStartFailureStillCountsAgainstTheBudget(t *testing.T) {
t.Parallel()
cp := &refusingCPControl{err: errors.New(awserrors.ErrorServerInternal)}
r := newRecoveryReconciler(t, cp)
r.degradedSince = time.Now().Add(-10 * time.Minute)
for name, refusal := range map[string]error{
"server internal": errors.New(awserrors.ErrorServerInternal),
"unrelated incorrect state": awserrors.Errorf(awserrors.ErrorIncorrectState, "%s",
"instance i-cp is not in a state from which it can be started"),
"lease text without the sentinel": awserrors.Errorf(awserrors.ErrorIncorrectState, "%s",
ec2v1.VolumeHeldElsewhereText+": held by node-a since 2026-10-30T01:00:00Z"),
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
cp := &refusingCPControl{err: refusal}
r := newRecoveryReconciler(t, cp)
r.degradedSince = time.Now().Add(-10 * time.Minute)
for range r.maxRestartAttempts + 2 {
r.lastRestartAt = time.Time{}
r.maybeRecoverControlPlane(t.Context(), metaWithCP("i-cp"), "stale")
for range r.maxRestartAttempts + 2 {
r.lastRestartAt = time.Time{}
r.maybeRecoverControlPlane(t.Context(), metaWithCP("i-cp"), "stale")
}
require.Equal(t, r.maxRestartAttempts, cp.startCalls, "stops at the attempt cap")
assert.Equal(t, r.maxRestartAttempts, r.restartAttempts)
})
}
require.Equal(t, r.maxRestartAttempts, cp.startCalls, "stops at the attempt cap")
assert.Equal(t, r.maxRestartAttempts, r.restartAttempts)
}
@@ -238,6 +238,10 @@ const AccountIDHeader = "X-Account-ID"
// forwarder must relay it instead of starting the instance elsewhere.
const UnsealedWritesHeader = "X-Unsealed-Writes"
// VolumeHeldElsewhereHeader marks a start reply refused because another node
// holds one of the instance's volumes, which the AWS error body cannot say.
const VolumeHeldElsewhereHeader = "X-Volume-Held-Elsewhere"
// PrincipalARNHeader carries the caller's resolved IAM principal ARN from gateway to daemon handlers.
const PrincipalARNHeader = "X-Principal-ARN"
+6 -2
View File
@@ -85,9 +85,13 @@ func respondWithServiceError(nodeID string, msg *nats.Msg, err error) {
payload := awserrors.GenerateErrorPayloadWithMessage(awserrors.ValidErrorCodeFromError(err), message)
// The AWS body stays as it is; the header is how ec2.start tells its
// forwarder this refusal must not be answered by starting elsewhere.
var extra nats.Header
extra := nats.Header{}
if _, ok := errors.AsType[*ec2instance.UnsealedWritesError](err); ok {
extra = nats.Header{natsmsg.UnsealedWritesHeader: []string{"true"}}
extra.Set(natsmsg.UnsealedWritesHeader, "true")
}
// A start caller cannot tell a held volume from any other IncorrectState.
if errors.Is(err, ec2v1.ErrVolumeHeldElsewhere) {
extra.Set(natsmsg.VolumeHeldElsewhereHeader, "true")
}
respondNATSMsgWithHeader(nodeID, msg, payload, extra)
}
@@ -7,6 +7,7 @@ import (
"fmt"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"log/slog"
"maps"
"time"
"github.com/aws/aws-sdk-go/aws"
@@ -314,7 +315,7 @@ func (d *Daemon) handleEC2StartStoppedInstance(msg *nats.Msg) string {
// checkpoint and lose what this node has not sealed.
slog.Warn("ec2.start: owner at capacity holds unsealed writes; not starting elsewhere",
"instanceId", peek.InstanceID, "lastNode", lastNode)
if relayErr := msg.Respond(resp.Data); relayErr != nil {
if relayErr := relayStartReply(msg, resp); relayErr != nil {
slog.Error("ec2.start: failed to relay response from original node",
"instanceId", peek.InstanceID, "lastNode", lastNode, "err", relayErr)
return outcomeError
@@ -323,7 +324,7 @@ func (d *Daemon) handleEC2StartStoppedInstance(msg *nats.Msg) string {
}
if !isCapacity {
// Relay success or any non-capacity error as-is.
if relayErr := msg.Respond(resp.Data); relayErr != nil {
if relayErr := relayStartReply(msg, resp); relayErr != nil {
slog.Error("ec2.start: failed to relay response from original node",
"instanceId", peek.InstanceID, "lastNode", lastNode, "err", relayErr)
return outcomeError
@@ -350,3 +351,12 @@ func (d *Daemon) handleEC2StartStoppedInstance(msg *nats.Msg) string {
return handleNATSRequest(d.node, d.instanceService.StartStoppedInstance)(msg)
}
// relayStartReply answers msg with the owner's reply, body and every header,
// since the headers carry what the AWS body cannot, such as why it refused.
func relayStartReply(msg, resp *nats.Msg) error {
reply := nats.NewMsg("")
reply.Data = resp.Data
maps.Copy(reply.Header, resp.Header)
return msg.RespondMsg(reply)
}
@@ -1,15 +1,20 @@
package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit"
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
ec2image "github.com/mulgadc/spinifex/spinifex/domains/ec2/image"
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
ec2key "github.com/mulgadc/spinifex/spinifex/domains/ec2/key"
@@ -586,10 +591,17 @@ func TestHandleEC2StartStoppedInstance_Forwarding(t *testing.T) {
t.Run("owner success is relayed", func(t *testing.T) {
d, _, body := newFixture(t, "i-p1-fwd-ok", "node-p1-ok")
owner(t, d, "node-p1-ok", func(m *nats.Msg) { _ = m.Respond([]byte(`{"started":true}`)) })
owner(t, d, "node-p1-ok", func(m *nats.Msg) {
resp := nats.NewMsg(m.Reply)
resp.Data = []byte(`{"started":true}`)
resp.Header.Set(natsmsg.NodeIDHeader, "node-p1-ok")
_ = m.RespondMsg(resp)
})
reply := requestHandler(t, d.natsConn, "ec2.start.p1-ok", asMsgHandler(d.handleEC2StartStoppedInstance), testAccountID, body)
assert.JSONEq(t, `{"started":true}`, string(reply.Data))
assert.Equal(t, "node-p1-ok", reply.Header.Get(natsmsg.NodeIDHeader),
"the relayed verdict is the owner's, headers included")
})
t.Run("owner at capacity falls back locally", func(t *testing.T) {
@@ -616,12 +628,15 @@ func TestHandleEC2StartStoppedInstance_Forwarding(t *testing.T) {
resp := nats.NewMsg(m.Reply)
resp.Data = awserrors.GenerateErrorPayload(awserrors.ErrorInsufficientInstanceCapacity)
resp.Header.Set(natsmsg.UnsealedWritesHeader, "true")
resp.Header.Set(natsmsg.NodeIDHeader, "node-p1-dirty")
_ = m.RespondMsg(resp)
})
reply := requestHandler(t, d.natsConn, "ec2.start.p1-dirty", asMsgHandler(d.handleEC2StartStoppedInstance), testAccountID, body)
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, decodeError(t, reply.Data)["Code"],
"an owner that still holds unsealed writes must not be bypassed by a local start")
assert.Equal(t, "true", reply.Header.Get(natsmsg.UnsealedWritesHeader))
assert.Equal(t, "node-p1-dirty", reply.Header.Get(natsmsg.NodeIDHeader))
})
t.Run("owner not subscribed falls back locally", func(t *testing.T) {
@@ -642,6 +657,71 @@ func TestHandleEC2StartStoppedInstance_Forwarding(t *testing.T) {
})
}
// A start refused because another node holds a volume crosses the owner's
// reply, the ec2.start forwarder's relay and the StartInstances client, and
// must still match the sentinel at the end, whatever words the provider used.
func TestStartInstances_VolumeHeldElsewhereSurvivesTheForward(t *testing.T) {
ns, nc := testutil.StartTestNATS(t)
store := vmmock.New()
v := stoppedVMFixture("i-0abc0000000000001", testAccountID)
v.LastNode = "node-owner"
store.Stopped[v.ID] = v
d := createTestDaemon(t, ns.ClientURL())
d.stateStore = store
d.instanceService = ec2instance.NewInstanceServiceImpl(
d.config, d.resourceMgr.instanceTypes, d.natsConn,
objectstore.NewMemoryObjectStore(), d.vmMgr, d.resourceMgr, store,
)
held := fmt.Errorf("%w: %w", ec2v1.ErrVolumeHeldElsewhere,
awserrors.Errorf(awserrors.ErrorIncorrectState, "%s", "volume vol-1 is published to node-a"))
owner := func(context.Context, *ec2instance.StartStoppedInstanceInput, string) (*ec2instance.StartStoppedInstanceOutput, error) {
return nil, held
}
for subject, fn := range map[string]nats.MsgHandler{
"ec2.start.node-owner": asMsgHandler(handleNATSRequest("node-owner", owner)),
"ec2.start": asMsgHandler(d.handleEC2StartStoppedInstance),
} {
sub, err := nc.Subscribe(subject, fn)
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
_, err := ec2instanceapi.StartInstances(t.Context(),
&ec2.StartInstancesInput{InstanceIds: []*string{aws.String(v.ID)}}, nc, testAccountID)
require.Error(t, err)
assert.ErrorIs(t, err, ec2v1.ErrVolumeHeldElsewhere, "the caller cannot read this refusal from its words")
code, message, ok := awserrors.ResolveErrorDetail(err)
require.True(t, ok)
assert.Equal(t, awserrors.ErrorIncorrectState, code)
assert.Contains(t, message, "node-a", "the operator still learns where the volume is")
}
// The header is the one signal that a refusal was a volume held elsewhere, so
// the owner must not put it on any other start failure in the same AWS code.
func TestStartOwner_HeldElsewhereHeaderOnlyOnAHeldVolume(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
for name, tc := range map[string]struct {
err error
want string
}{
"held elsewhere": {fmt.Errorf("%w: %w", ec2v1.ErrVolumeHeldElsewhere,
awserrors.Errorf(awserrors.ErrorIncorrectState, "%s", "volume vol-1 is published to node-a")), "true"},
"other incorrect state": {awserrors.Errorf(awserrors.ErrorIncorrectState, "%s",
"instance i-0abc0000000000003 is not stopped"), ""},
"internal fault": {errors.New(awserrors.ErrorServerInternal), ""},
} {
t.Run(name, func(t *testing.T) {
owner := func(context.Context, *ec2instance.StartStoppedInstanceInput, string) (*ec2instance.StartStoppedInstanceOutput, error) {
return nil, tc.err
}
reply := requestHandler(t, nc, "ec2.start.owner-"+strings.ReplaceAll(name, " ", "-"),
asMsgHandler(handleNATSRequest("node-owner", owner)), testAccountID, []byte(`{}`))
assert.Equal(t, tc.want, reply.Header.Get(natsmsg.VolumeHeldElsewhereHeader))
})
}
}
// --- handleSetInstanceTags / handleSetInstanceMonitoring ---
func setTagsCommand(id string) ec2v1.EC2InstanceCommand {
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"slices"
"time"
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
"github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/cache"
@@ -301,7 +301,7 @@ func (f recoveryFault) stateReasonCode() string {
// classifyRecoveryFault reads what the launch refused with.
func classifyRecoveryFault(err error) recoveryFault {
if errors.Is(err, ec2instance.ErrVolumeHeldElsewhere) {
if errors.Is(err, ec2v1.ErrVolumeHeldElsewhere) {
return recoveryFaultLeaseHeld
}
if code, ok := awserrors.ResolveErrorCode(err); ok && code == awserrors.ErrorInsufficientInstanceCapacity {
@@ -17,6 +17,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/bootstrap/config"
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
@@ -403,7 +404,7 @@ func TestWhatTheLaunchRefusalMeansForTheNextAttempt(t *testing.T) {
{
name: "the old owner still holds the volume lease",
err: fmt.Errorf("mount refused: %w", fmt.Errorf("%w: %w",
ec2instance.ErrVolumeHeldElsewhere,
ec2v1.ErrVolumeHeldElsewhere,
awserrors.Errorf(awserrors.ErrorIncorrectState, "volume is leased by another owner: node-1"))),
want: recoveryFaultLeaseHeld,
code: "Server.HostRecoveryFailed",