mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-03 09:43:35 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bc8107575 | |||
| bca22f1d23 | |||
| edcde560fb | |||
| a58688608c | |||
| 7a6f11ff40 |
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user