mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-05 18:46:28 +00:00
Compare commits
24 Commits
dev
...
feat/uefi-claim
| Author | SHA1 | Date | |
|---|---|---|---|
| c826cbd158 | |||
| 82b3b55d70 | |||
| c864bfbc84 | |||
| c9c6dae176 | |||
| c99ff34d53 | |||
| bc16f80ed6 | |||
| f5b31d6189 | |||
| 2700d72cda | |||
| 65e393d5cc | |||
| f2a99b2f9f | |||
| 3c048b7b0c | |||
| ba253cd65b | |||
| 6e53819677 | |||
| 81186cc3bc | |||
| b578502072 | |||
| 9239cad533 | |||
| fa088afb89 | |||
| 7dc2723047 | |||
| 95253f118b | |||
| 01c8e7bcc0 | |||
| 9beaa5ebff | |||
| ec0910b828 | |||
| 4781f30966 | |||
| c07e0be4e1 |
@@ -1,6 +1,9 @@
|
||||
package clusterv1
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClusterStateBucket is the KV bucket holding each daemon's heartbeat alongside
|
||||
// other cluster-wide state.
|
||||
@@ -11,6 +14,27 @@ func HeartbeatKey(node string) string {
|
||||
return "heartbeat." + node
|
||||
}
|
||||
|
||||
// LivenessWindow is how recently a node must have heartbeated for a reader
|
||||
// deciding on a takeover to count it live. It is six heartbeats, compared
|
||||
// across node clocks, where NTP skew is small against it.
|
||||
const LivenessWindow = 60 * time.Second
|
||||
|
||||
// LiveAt reports whether a heartbeat written at written still vouches for its
|
||||
// node at now: it does while it is under LivenessWindow old.
|
||||
func LiveAt(written, now time.Time) bool {
|
||||
return now.Sub(written) < LivenessWindow
|
||||
}
|
||||
|
||||
// HeartbeatWritten dates a stored heartbeat value. One that cannot be decoded
|
||||
// or dated vouches for nothing, which is an answer rather than a fault.
|
||||
func HeartbeatWritten(data []byte) (time.Time, bool) {
|
||||
var beat HeartbeatTimestamp
|
||||
if json.Unmarshal(data, &beat) != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return beat.Time()
|
||||
}
|
||||
|
||||
// HeartbeatTimestamp is the read side of a heartbeat for node liveness. It
|
||||
// decodes only the timestamp, so a reader does not depend on the capacity
|
||||
// figures the daemon writes beside it.
|
||||
|
||||
@@ -33,3 +33,23 @@ func TestHeartbeatTimestamp_UndatedBeatVouchesForNothing(t *testing.T) {
|
||||
assert.False(t, ok, "timestamp %q", ts)
|
||||
}
|
||||
}
|
||||
|
||||
// A beat is live strictly under the window, so one exactly the window old is
|
||||
// the first a takeover may treat as stopped.
|
||||
func TestLiveAt_IsStrictlyUnderTheWindow(t *testing.T) {
|
||||
now := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
assert.True(t, clusterv1.LiveAt(now.Add(-clusterv1.LivenessWindow+time.Nanosecond), now))
|
||||
assert.False(t, clusterv1.LiveAt(now.Add(-clusterv1.LivenessWindow), now))
|
||||
assert.True(t, clusterv1.LiveAt(now.Add(time.Second), now), "a beat from a clock running ahead is live")
|
||||
}
|
||||
|
||||
func TestHeartbeatWritten_DatesAStoredBeatAndRefusesAnUndecodableOne(t *testing.T) {
|
||||
written, ok := clusterv1.HeartbeatWritten([]byte(`{"node":"node-a","timestamp":"2026-10-30T01:02:03Z"}`))
|
||||
require.True(t, ok)
|
||||
assert.True(t, written.Equal(time.Date(2026, 10, 30, 1, 2, 3, 0, time.UTC)))
|
||||
|
||||
for _, raw := range []string{"not json", `{"node":"node-a"}`} {
|
||||
_, ok := clusterv1.HeartbeatWritten([]byte(raw))
|
||||
assert.False(t, ok, raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -184,7 +183,7 @@ func NewStore(ctx context.Context, nc *nats.Conn, masterKey []byte) (*Store, err
|
||||
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketACM, KVBucketACMVersion)
|
||||
kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketACM, KVBucketACMVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
@@ -171,7 +171,7 @@ func (s *Service) RunBedrockRPMSync(ctx context.Context, js jetstream.JetStream)
|
||||
if s == nil || !s.limits.RequestsPerMinuteEnabled {
|
||||
return
|
||||
}
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketBedrockRPMUsage, 1)
|
||||
kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketBedrockRPMUsage, 1)
|
||||
if err != nil {
|
||||
slog.Warn("bedrock rpm sync: open usage bucket failed, sync disabled", "err", err)
|
||||
return
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -187,7 +187,7 @@ func (s *Service) PutAccountQuota(ctx context.Context, accountID string, over Ov
|
||||
over.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
over.UpdatedBy = updatedBy
|
||||
|
||||
_, err := kvutil.Update(ctx, s.overrides, accountID, kvutil.CASConfig{
|
||||
_, err := kvstore.Update(ctx, s.overrides, accountID, kvstore.CASConfig{
|
||||
CreateIfAbsent: true,
|
||||
Attempts: overrideMaxAttempts,
|
||||
Exhausted: func(string, int) error { return errOverridesContended },
|
||||
@@ -230,7 +230,7 @@ func (s *Service) clearAccountQuota(ctx context.Context, accountID string) error
|
||||
return nil
|
||||
case errors.Is(err, jetstream.ErrKeyNotFound):
|
||||
return nil
|
||||
case kvutil.IsRevisionConflict(err):
|
||||
case kvstore.IsRevisionConflict(err):
|
||||
// A concurrent write landed between the read and the guarded
|
||||
// delete; retry against whatever revision now holds.
|
||||
continue
|
||||
|
||||
@@ -22,10 +22,10 @@ const ReconcileInterval = 30 * time.Second
|
||||
// dimension; the live-counted dimensions need no stored state.
|
||||
const KVBucketAccountUsage = "spinifex-account-usage"
|
||||
|
||||
// KVBucketQuotaReconcile is the leader-lock bucket for the gateway quota
|
||||
// reconcile. It is deliberately distinct from vpcd's network-reconcile lock so
|
||||
// the two independent loops never share one mutex and block each other.
|
||||
const KVBucketQuotaReconcile = "spinifex-quota-reconcile"
|
||||
// KeyReconcile is the sync-bucket leader lease for the gateway quota reconcile.
|
||||
// It is deliberately distinct from vpcd's network-reconcile lease so the two
|
||||
// independent loops never share one mutex and block each other.
|
||||
const KeyReconcile = "quota.reconcile"
|
||||
|
||||
// KVBucketAccountQuota holds the per-account limit overrides, keyed by
|
||||
// accountID. It is separate from the accounts bucket so a quota write never
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -73,7 +73,7 @@ func (s *Service) sealBucket(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
slog.ErrorContext(ctx, "Quota override seal has no JetStream client wired to open the override bucket")
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, s.js, KVBucketAccountQuota, 1)
|
||||
kv, err := kvstore.GetOrCreateBucket(ctx, s.js, KVBucketAccountQuota, 1)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to open quota override bucket for seal", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
@@ -136,7 +136,7 @@ func (s *Service) SealAccountOverrides(ctx context.Context, accountID string) (*
|
||||
switch {
|
||||
case writeErr == nil:
|
||||
return &OverrideSeal{Revision: newRev}, nil
|
||||
case kvutil.IsRevisionConflict(writeErr):
|
||||
case kvstore.IsRevisionConflict(writeErr):
|
||||
continue
|
||||
default:
|
||||
slog.ErrorContext(ctx, "Failed to write sealed quota override record", "accountID", accountID, "err", writeErr)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
nsconfig "github.com/mulgadc/northstar/pkg/config"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
|
||||
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
|
||||
)
|
||||
@@ -48,7 +48,7 @@ func (w *Writer) PlatformDelegation(name string) (servers []string, zone string,
|
||||
// Delegate writes, or with remove withdraws, an NS set in the base zone that
|
||||
// delegates each name to this cluster's nameservers. It takes the zone-writer
|
||||
// election first, so it never races the reconcile's own writes to the zone.
|
||||
func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, names []string, remove bool) (*ChangeResult, error) {
|
||||
func (w *Writer) Delegate(ctx context.Context, lease *kvsync.Lease, names []string, remove bool) (*ChangeResult, error) {
|
||||
if !w.Enabled() {
|
||||
return nil, errors.New("northstar is not configured on this node")
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, nam
|
||||
if len(batch.Changes) == 0 {
|
||||
return &ChangeResult{}, nil
|
||||
}
|
||||
release, err := w.awaitWriterElection(ctx, nc, holder)
|
||||
release, err := w.awaitWriterElection(ctx, lease)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -85,19 +85,19 @@ func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, nam
|
||||
return w.ApplyBatch(batch)
|
||||
}
|
||||
|
||||
func (w *Writer) awaitWriterElection(ctx context.Context, nc *nats.Conn, holder string) (func(), error) {
|
||||
if nc == nil {
|
||||
func (w *Writer) awaitWriterElection(ctx context.Context, lease *kvsync.Lease) (func(), error) {
|
||||
if lease == nil {
|
||||
return func() {}, nil
|
||||
}
|
||||
tick := time.NewTicker(delegateLockPoll)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
if release, ok := reconcilelock.AcquireLeader(ctx, nc, KVBucketDNSReconcile, holder); ok {
|
||||
if release, ok := reconcilelock.AcquireLeader(ctx, lease); ok {
|
||||
return release, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("another node is writing the zone: %w", ctx.Err())
|
||||
return nil, fmt.Errorf("another writer has the zone: %w", ctx.Err())
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
|
||||
w, _ := newMultiNodeTestWriter(t)
|
||||
_, err := w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net", "B.r53e2e.spx3.net."}, false)
|
||||
_, err := w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net", "B.r53e2e.spx3.net."}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, name := range []string{"a.r53e2e.spx3.net.", "b.r53e2e.spx3.net"} {
|
||||
@@ -29,7 +29,7 @@ func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
|
||||
assert.True(t, ok, "a name below the base domain is decided by the base zone")
|
||||
assert.Empty(t, servers)
|
||||
|
||||
_, err = w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, true)
|
||||
_, err = w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net"}, true)
|
||||
require.NoError(t, err)
|
||||
servers, _, _, err = w.PlatformDelegation("a.r53e2e.spx3.net")
|
||||
require.NoError(t, err)
|
||||
@@ -43,11 +43,11 @@ func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
|
||||
// servers written while the cluster published a larger set.
|
||||
func TestDelegateRemoveWithdrawsServersFromAnEarlierSet(t *testing.T) {
|
||||
w, _ := newMultiNodeTestWriter(t)
|
||||
_, err := w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, false)
|
||||
_, err := w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net"}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
w.nameservers = w.nameservers[:1]
|
||||
_, err = w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, true)
|
||||
_, err = w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net"}, true)
|
||||
require.NoError(t, err)
|
||||
servers, _, _, err := w.PlatformDelegation("a.r53e2e.spx3.net")
|
||||
require.NoError(t, err)
|
||||
@@ -66,7 +66,7 @@ func TestPlatformDelegationIgnoresNamesOutsideTheBaseDomain(t *testing.T) {
|
||||
func TestDelegateRefusesPlatformAndForeignNames(t *testing.T) {
|
||||
w, _ := newMultiNodeTestWriter(t)
|
||||
for _, name := range []string{"ns1.spx3.net", "x.elb.spx3.net", "compute.spx3.net", "example.com", "spx3.net"} {
|
||||
_, err := w.Delegate(t.Context(), nil, "node1", []string{"ok.spx3.net", name}, false)
|
||||
_, err := w.Delegate(t.Context(), nil, []string{"ok.spx3.net", name}, false)
|
||||
require.Error(t, err, name)
|
||||
}
|
||||
servers, _, _, err := w.PlatformDelegation("ok.spx3.net")
|
||||
@@ -76,22 +76,45 @@ func TestDelegateRefusesPlatformAndForeignNames(t *testing.T) {
|
||||
|
||||
func TestDelegateWaitsForTheZoneWriterElection(t *testing.T) {
|
||||
w, _ := newMultiNodeTestWriter(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
prev := delegateLockPoll
|
||||
delegateLockPoll = 10 * time.Millisecond
|
||||
t.Cleanup(func() { delegateLockPoll = prev })
|
||||
|
||||
release, ok := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "node2")
|
||||
release, ok := reconcilelock.AcquireLeader(t.Context(), dnsLease(js, "node2"))
|
||||
require.True(t, ok)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := w.Delegate(ctx, nc, "node1", []string{"a.spx3.net"}, false)
|
||||
require.ErrorContains(t, err, "another node is writing the zone")
|
||||
node1 := dnsLease(js, "node1")
|
||||
_, err := w.Delegate(ctx, node1, []string{"a.spx3.net"}, false)
|
||||
require.ErrorContains(t, err, "another writer has the zone")
|
||||
|
||||
release()
|
||||
_, err = w.Delegate(t.Context(), nc, "node1", []string{"a.spx3.net"}, false)
|
||||
_, err = w.Delegate(t.Context(), node1, []string{"a.spx3.net"}, false)
|
||||
require.NoError(t, err)
|
||||
servers, _, _, err := w.PlatformDelegation("a.spx3.net")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, servers, 2)
|
||||
}
|
||||
|
||||
// The reconcile pass and a delegation on one node share the DNS lease, so a
|
||||
// delegation waits for this node's own pass as it would for another node's.
|
||||
func TestDelegateWaitsForThisNodesOwnReconcilePass(t *testing.T) {
|
||||
w, _ := newMultiNodeTestWriter(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
prev := delegateLockPoll
|
||||
delegateLockPoll = 10 * time.Millisecond
|
||||
t.Cleanup(func() { delegateLockPoll = prev })
|
||||
|
||||
shared := dnsLease(js, "node1")
|
||||
release, ok := reconcilelock.AcquireLeader(t.Context(), shared)
|
||||
require.True(t, ok, "the reconcile pass holds the lease")
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := w.Delegate(ctx, shared, []string{"a.spx3.net"}, false)
|
||||
require.ErrorContains(t, err, "another writer has the zone")
|
||||
|
||||
release()
|
||||
_, err = w.Delegate(t.Context(), shared, []string{"a.spx3.net"}, false)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
)
|
||||
|
||||
// DefaultReconcileInterval is how often the drift backstop rebuilds managed
|
||||
@@ -33,6 +33,12 @@ const DefaultReconcileInterval = reconciler.DefaultResync
|
||||
// costs a cycle for nothing.
|
||||
const DefaultComputeTimeout = 2 * time.Minute
|
||||
|
||||
// NewReconcileLease returns the zone writer's election lease, shared by the
|
||||
// drift reconcile and Delegate so the two never write the zone together.
|
||||
func NewReconcileLease(store kvsync.KVStore, holder string) *kvsync.Lease {
|
||||
return reconcilelock.NewLeaderLease(store, KeyReconcile, holder)
|
||||
}
|
||||
|
||||
// DesiredFunc returns the full desired managed record set built from the live
|
||||
// resource inventory across all tenants. The daemon supplies it by enumerating
|
||||
// instances, load balancers, and EKS clusters.
|
||||
@@ -102,7 +108,8 @@ type Reconciler struct {
|
||||
// lands in. Distinct from both baseDomain and internalDomain, so pruning it
|
||||
// needs its own read the same way internalDomain does for EC2.
|
||||
serviceZone string
|
||||
nc *nats.Conn
|
||||
// lease elects the one node that writes the zones; nil skips the election.
|
||||
lease *kvsync.Lease
|
||||
// writer applies the converged batch. The reconcile owns the zone object on
|
||||
// the node that won the election, so the batch never leaves this process.
|
||||
writer *Writer
|
||||
@@ -111,7 +118,6 @@ type Reconciler struct {
|
||||
// computeTimeout bounds how long one pass spends building the desired set.
|
||||
// A field rather than a constant so a test can shrink it.
|
||||
computeTimeout time.Duration
|
||||
holder string
|
||||
sources []reconciler.Source
|
||||
}
|
||||
|
||||
@@ -121,18 +127,15 @@ type Reconciler struct {
|
||||
// changes should wake the loop. They are optional: with none supplied the loop
|
||||
// falls back to the interval alone, which is the behaviour that predates the
|
||||
// watch.
|
||||
func NewReconciler(cfg *config.Config, cluster *config.ClusterConfig, nc *nats.Conn, writer *Writer, desired DesiredFunc, sources ...reconciler.Source) *Reconciler {
|
||||
func NewReconciler(cfg *config.Config, cluster *config.ClusterConfig, lease *kvsync.Lease, writer *Writer, desired DesiredFunc, sources ...reconciler.Source) *Reconciler {
|
||||
r := &Reconciler{
|
||||
nc: nc,
|
||||
lease: lease,
|
||||
writer: writer,
|
||||
desired: desired,
|
||||
interval: DefaultReconcileInterval,
|
||||
computeTimeout: DefaultComputeTimeout,
|
||||
sources: sources,
|
||||
}
|
||||
if cfg != nil {
|
||||
r.holder = cfg.Node
|
||||
}
|
||||
zoneCfg, ok := zoneS3Config(cfg)
|
||||
if !ok || desired == nil || !writer.Enabled() {
|
||||
return r
|
||||
@@ -166,25 +169,24 @@ func (r *Reconciler) Run(ctx context.Context) {
|
||||
// No revisit deadline: the desired set is a function of the buckets
|
||||
// being watched, so a change is the only reason to run early.
|
||||
Reconcile: func(ctx context.Context) (time.Duration, error) {
|
||||
r.reconcileOnce(ctx)
|
||||
return 0, nil
|
||||
return r.reconcileOnce(ctx), nil
|
||||
},
|
||||
Resync: r.interval,
|
||||
})
|
||||
}
|
||||
|
||||
// reconcileOnce computes the converging batch and applies it, on whichever node
|
||||
// wins this cycle's election. The read that finds the stale records and the
|
||||
// wins this cycle's election, and returns the election's revisit when it lost. The read that finds the stale records and the
|
||||
// write that removes them now happen in one process, so a record re-added
|
||||
// between them is no longer deleted by a batch that predates it.
|
||||
func (r *Reconciler) reconcileOnce(ctx context.Context) {
|
||||
func (r *Reconciler) reconcileOnce(ctx context.Context) time.Duration {
|
||||
if !r.enabled {
|
||||
return
|
||||
return 0
|
||||
}
|
||||
if r.nc != nil {
|
||||
release, elected := reconcilelock.AcquireLeader(ctx, r.nc, KVBucketDNSReconcile, r.holder)
|
||||
if r.lease != nil {
|
||||
release, retry, elected := reconcilelock.ElectPass(ctx, r.lease)
|
||||
if !elected {
|
||||
return
|
||||
return retry
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
@@ -193,18 +195,19 @@ func (r *Reconciler) reconcileOnce(ctx context.Context) {
|
||||
// A corrupt zone is handled in readZone; anything reaching here left the
|
||||
// desired state unapplied, so surface it rather than burying it in WARN.
|
||||
slog.Error("dns reconcile: compute batch failed, retrying next cycle", "error", err)
|
||||
return
|
||||
return 0
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
return 0
|
||||
}
|
||||
slog.Debug("dns reconcile: converging", "changes", len(batch))
|
||||
res, err := r.writer.ApplyBatch(&ChangeBatch{Changes: batch})
|
||||
if err != nil {
|
||||
slog.Warn("dns reconcile: apply failed, retrying next cycle", "changes", len(batch), "error", err)
|
||||
return
|
||||
return 0
|
||||
}
|
||||
slog.Debug("dns reconcile: converged", "changes", res.Applied, "zones", res.Zones)
|
||||
return 0
|
||||
}
|
||||
|
||||
// computeBatchBounded runs computeBatch with a deadline, so a pass that never
|
||||
|
||||
@@ -8,41 +8,50 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// electingReconcilerFor is reconcilerFor with a live connection, so a pass runs
|
||||
// the election instead of skipping it.
|
||||
func electingReconcilerFor(w *Writer, nc *nats.Conn, holder string, desired func() DesiredSet) *Reconciler {
|
||||
// electingReconcilerFor is reconcilerFor with a lease, so a pass runs the
|
||||
// election instead of skipping it.
|
||||
func electingReconcilerFor(w *Writer, lease *kvsync.Lease, desired func() DesiredSet) *Reconciler {
|
||||
r := reconcilerFor(w, desired)
|
||||
r.nc = nc
|
||||
r.holder = holder
|
||||
r.lease = lease
|
||||
return r
|
||||
}
|
||||
|
||||
// dnsLease returns holder's DNS lease on a store of its own, standing in for
|
||||
// a separate process.
|
||||
func dnsLease(js jetstream.JetStream, holder string) *kvsync.Lease {
|
||||
return NewReconcileLease(kvstore.OpenSyncBucket(js), holder)
|
||||
}
|
||||
|
||||
// The zone lock is gone, and what replaces it is that a zone has one writer:
|
||||
// the node holding the reconcile lease. A node that loses the election must
|
||||
// therefore not touch the object at all — if it did, two nodes could be
|
||||
// read-modify-writing the same zone with nothing serialising them.
|
||||
func TestReconcileOnce_ALoserOfTheElectionDoesNotWriteTheZone(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
w, objects := newTestWriter(t)
|
||||
before := objects[testBase+".toml"]
|
||||
require.NotEmpty(t, before)
|
||||
|
||||
// A peer holds the lease for the whole pass, so this node cannot win it.
|
||||
release, elected := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "peer")
|
||||
release, elected := reconcilelock.AcquireLeader(t.Context(), dnsLease(js, "peer"))
|
||||
require.True(t, elected, "the peer has to hold the lease or this test proves nothing")
|
||||
|
||||
r := electingReconcilerFor(w, nc, "node1", func() DesiredSet {
|
||||
r := electingReconcilerFor(w, dnsLease(js, "node1"), func() DesiredSet {
|
||||
return DesiredSet{Changes: []Change{upsert("lb-1.elb."+testBase, "10.200.1.9")}}
|
||||
})
|
||||
r.reconcileOnce(t.Context())
|
||||
revisit := r.reconcileOnce(t.Context())
|
||||
|
||||
assert.Equal(t, before, objects[testBase+".toml"],
|
||||
"a node that did not win the election must leave the zone object exactly as it found it")
|
||||
assert.Positive(t, revisit, "the loser asks to retry so a dead holder is taken over")
|
||||
assert.LessOrEqual(t, revisit, reconcilelock.LeaderTTL/3, "the retry comes within a renewal interval")
|
||||
|
||||
release()
|
||||
}
|
||||
@@ -50,10 +59,10 @@ func TestReconcileOnce_ALoserOfTheElectionDoesNotWriteTheZone(t *testing.T) {
|
||||
// The counterpart, so the test above is not passing because the pass was broken
|
||||
// for some other reason: with the lease free, the same reconciler writes.
|
||||
func TestReconcileOnce_TheWinnerOfTheElectionWritesTheZone(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
w, objects := newTestWriter(t)
|
||||
|
||||
r := electingReconcilerFor(w, nc, "node1", func() DesiredSet {
|
||||
r := electingReconcilerFor(w, dnsLease(js, "node1"), func() DesiredSet {
|
||||
return DesiredSet{Changes: []Change{upsert("lb-1.elb."+testBase, "10.200.1.9")}}
|
||||
})
|
||||
r.reconcileOnce(t.Context())
|
||||
|
||||
@@ -224,7 +224,7 @@ func TestReconciler_RunReturnsWhenDisabled(t *testing.T) {
|
||||
// path of Run: the startup pass must fire before any watch or resync activity,
|
||||
// and cancelling ctx must still let the loop return.
|
||||
func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
endpoint, _ := fakeS3(t, "northstar")
|
||||
|
||||
var passes atomic.Int64
|
||||
@@ -235,8 +235,7 @@ func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
|
||||
Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1",
|
||||
AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET",
|
||||
},
|
||||
nc: nc,
|
||||
holder: "test-node",
|
||||
lease: dnsLease(js, "test-node"),
|
||||
desired: func() DesiredSet {
|
||||
passes.Add(1)
|
||||
return DesiredSet{}
|
||||
@@ -268,7 +267,7 @@ func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
|
||||
// acquirable afterwards, not merely on the call returning, since it is the
|
||||
// leadership and not the pass that the rest of the cluster waits on.
|
||||
func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
endpoint, _ := fakeS3(t, "northstar")
|
||||
|
||||
blocked := make(chan struct{})
|
||||
@@ -281,8 +280,7 @@ func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
|
||||
Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1",
|
||||
AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET",
|
||||
},
|
||||
nc: nc,
|
||||
holder: "stuck-node",
|
||||
lease: dnsLease(js, "stuck-node"),
|
||||
computeTimeout: 100 * time.Millisecond,
|
||||
desired: func() DesiredSet {
|
||||
<-blocked
|
||||
@@ -302,7 +300,7 @@ func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
|
||||
t.Fatal("reconcileOnce never returned: a stuck pass still holds the lease forever")
|
||||
}
|
||||
|
||||
release, elected := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "next-node")
|
||||
release, elected := reconcilelock.AcquireLeader(t.Context(), dnsLease(js, "next-node"))
|
||||
require.True(t, elected, "another node must be able to take the next cycle")
|
||||
release()
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ const (
|
||||
// PUT so every northstar instance reloads just that zone immediately, instead
|
||||
// of waiting for the next S3 sync poll. No queue group: all servers consume.
|
||||
SubjectZoneReload = "dns.zone.reload"
|
||||
// KVBucketDNSReconcile elects the one node that writes the zones for a drift
|
||||
// cycle. Its own bucket, so the election never blocks another reconcile loop.
|
||||
KVBucketDNSReconcile = "spinifex-dns-reconcile"
|
||||
// KeyReconcile elects the one node that writes the zones for a drift cycle.
|
||||
// Its own key, so the election never blocks another reconcile loop.
|
||||
KeyReconcile = "dns.reconcile"
|
||||
// PrivateZone is the fixed AWS-parity private DNS zone (IMDS synthHostname).
|
||||
PrivateZone = "compute.internal"
|
||||
// DefaultTTL is applied when a change omits a TTL.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -109,7 +109,7 @@ func (s *AccountSettingsServiceImpl) SealAccount(ctx context.Context, accountID
|
||||
switch {
|
||||
case writeErr == nil:
|
||||
return &AccountSettingsSeal{Revision: newRev}, nil
|
||||
case kvutil.IsRevisionConflict(writeErr):
|
||||
case kvstore.IsRevisionConflict(writeErr):
|
||||
continue
|
||||
default:
|
||||
slog.ErrorContext(ctx, "Failed to write sealed account settings record", "accountID", accountID, "err", writeErr)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
ebsencryption "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/encryption"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -57,7 +57,7 @@ func NewAccountSettingsServiceImplWithNATS(ctx context.Context, cfg *config.Conf
|
||||
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
settingsKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketAccountSettings, 10)
|
||||
settingsKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketAccountSettings, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create account settings KV bucket: %w", err)
|
||||
}
|
||||
@@ -115,13 +115,13 @@ func mapSettingsError(ctx context.Context, accountID string, err error) error {
|
||||
return errors.New(awserrors.ErrorBlocked)
|
||||
case errors.Is(err, errSettingsContended):
|
||||
slog.ErrorContext(ctx, "Account settings CAS retries exhausted under contention", "accountID", accountID)
|
||||
case errors.Is(err, kvutil.ErrRead):
|
||||
case errors.Is(err, kvstore.ErrRead):
|
||||
slog.ErrorContext(ctx, "Failed to read account settings from KV", "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvutil.ErrDecode):
|
||||
case errors.Is(err, kvstore.ErrDecode):
|
||||
slog.ErrorContext(ctx, "Corrupt account settings record in KV", "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvutil.ErrEncode):
|
||||
case errors.Is(err, kvstore.ErrEncode):
|
||||
slog.ErrorContext(ctx, "Failed to marshal account settings record", "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvutil.ErrWrite):
|
||||
case errors.Is(err, kvstore.ErrWrite):
|
||||
slog.ErrorContext(ctx, "Failed to write account settings to KV", "accountID", accountID, "err", err)
|
||||
default:
|
||||
slog.ErrorContext(ctx, "Account settings operation failed", "accountID", accountID, "err", err)
|
||||
@@ -133,7 +133,7 @@ func mapSettingsError(ctx context.Context, accountID string, err error) error {
|
||||
// changed is always true, so every success is a leader-accepted write over an
|
||||
// active value; a seal landing between the read and the write fails the CAS.
|
||||
func (s *AccountSettingsServiceImpl) mutateSettings(ctx context.Context, accountID string, set func(*AccountSettingsRecord)) error {
|
||||
_, err := kvutil.Update(ctx, s.settingsKV, settingsKey(accountID), kvutil.CASConfig{
|
||||
_, err := kvstore.Update(ctx, s.settingsKV, settingsKey(accountID), kvstore.CASConfig{
|
||||
CreateIfAbsent: true,
|
||||
Exhausted: func(string, int) error { return errSettingsContended },
|
||||
}, func(rec *AccountSettingsRecord) (bool, error) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -56,7 +56,7 @@ func NewEgressOnlyIGWServiceImplWithNATS(ctx context.Context, cfg *config.Config
|
||||
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
eigwKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketEgressOnlyIGW, 10)
|
||||
eigwKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketEgressOnlyIGW, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketEgressOnlyIGW, err)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func NewEgressOnlyIGWServiceImplWithNATS(ctx context.Context, cfg *config.Config
|
||||
}
|
||||
|
||||
// Get or create VPC KV bucket for cross-resource ownership validation
|
||||
vpcKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
|
||||
vpcKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err)
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func (s *EgressOnlyIGWServiceImpl) CreateEgressOnlyInternetGateway(ctx context.C
|
||||
slog.ErrorContext(ctx, "VPC KV unavailable, cannot verify VPC ownership")
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, *input.VpcId)); err != nil {
|
||||
if _, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, *input.VpcId)); err != nil {
|
||||
slog.WarnContext(ctx, "CreateEgressOnlyInternetGateway: VPC not found for account", "vpcId", *input.VpcId, "accountID", accountID)
|
||||
return nil, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", *input.VpcId)
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func (s *EgressOnlyIGWServiceImpl) CreateEgressOnlyInternetGateway(ctx context.C
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal Egress-only IGW record: %w", err)
|
||||
}
|
||||
if _, err := s.eigwKV.Put(ctx, kvutil.AccountKey(accountID, eigwID), data); err != nil {
|
||||
if _, err := s.eigwKV.Put(ctx, kvstore.AccountKey(accountID, eigwID), data); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func (s *EgressOnlyIGWServiceImpl) DeleteEgressOnlyInternetGateway(ctx context.C
|
||||
}
|
||||
|
||||
eigwID := *input.EgressOnlyInternetGatewayId
|
||||
key := kvutil.AccountKey(accountID, eigwID)
|
||||
key := kvstore.AccountKey(accountID, eigwID)
|
||||
|
||||
// Verify the EIGW exists before deleting
|
||||
if _, err := s.eigwKV.Get(ctx, key); err != nil {
|
||||
@@ -178,7 +178,7 @@ func (s *EgressOnlyIGWServiceImpl) DescribeEgressOnlyInternetGateways(ctx contex
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey {
|
||||
if key == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -23,7 +23,7 @@ func setupTestEIGWService(t *testing.T) *EgressOnlyIGWServiceImpl {
|
||||
// Create VPC KV bucket and register test VPCs so fail-closed ownership checks pass
|
||||
vpcEntries := map[string][]byte{}
|
||||
for _, vpcID := range []string{"vpc-test123", "vpc-tagged", "vpc-tagged2"} {
|
||||
vpcEntries[kvutil.AccountKey(testAccountID, vpcID)] = []byte(`{"vpc_id":"` + vpcID + `","state":"available"}`)
|
||||
vpcEntries[kvstore.AccountKey(testAccountID, vpcID)] = []byte(`{"vpc_id":"` + vpcID + `","state":"available"}`)
|
||||
}
|
||||
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, vpcEntries)
|
||||
|
||||
@@ -312,7 +312,7 @@ func TestCreateEgressOnlyInternetGateway_CrossAccountVPCRejected(t *testing.T) {
|
||||
// Create VPC KV bucket with a VPC owned by testAccountID
|
||||
vpcID := "vpc-alpha456"
|
||||
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, map[string][]byte{
|
||||
kvutil.AccountKey(testAccountID, vpcID): []byte(`{"vpc_id":"vpc-alpha456","state":"available"}`),
|
||||
kvstore.AccountKey(testAccountID, vpcID): []byte(`{"vpc_id":"vpc-alpha456","state":"available"}`),
|
||||
})
|
||||
|
||||
var err error
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/idempotency"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ func (s *EIPServiceImpl) allocateOnce(ctx context.Context, accountID string, all
|
||||
if key == "" || s.idemKV == nil {
|
||||
return alloc()
|
||||
}
|
||||
kvKey := kvutil.AccountKey(accountID, idempotencyKeyHash(key))
|
||||
kvKey := kvstore.AccountKey(accountID, idempotencyKeyHash(key))
|
||||
|
||||
if out := s.cachedAllocation(ctx, kvKey); out != nil {
|
||||
slog.WarnContext(ctx, "AllocateAddress: returning first result for a retried request",
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/projection"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/topology"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -80,7 +80,7 @@ func (s *EIPServiceImpl) AllocationExists(ctx context.Context, allocationID stri
|
||||
}
|
||||
suffix := "." + allocationID
|
||||
for _, k := range keys {
|
||||
if k != kvutil.VersionKey && strings.HasSuffix(k, suffix) {
|
||||
if k != kvstore.VersionKey && strings.HasSuffix(k, suffix) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func (s *EIPServiceImpl) findByAllocationIDAnyAccount(ctx context.Context, alloc
|
||||
|
||||
suffix := "." + allocationID
|
||||
for _, k := range keys {
|
||||
if k == kvutil.VersionKey || !strings.HasSuffix(k, suffix) {
|
||||
if k == kvstore.VersionKey || !strings.HasSuffix(k, suffix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.eipKV.Get(ctx, k)
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -66,7 +66,7 @@ func NewEIPServiceImpl(ctx context.Context, natsConn *nats.Conn, externalIPAM *e
|
||||
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
eipKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketEIPs, 10)
|
||||
eipKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketEIPs, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketEIPs, err)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func NewEIPServiceImpl(ctx context.Context, natsConn *nats.Conn, externalIPAM *e
|
||||
|
||||
// TTL'd, so a failure to create it is not worth refusing to serve EIPs over:
|
||||
// a nil bucket only costs post-completion dedupe.
|
||||
idemKV, err := kvutil.GetOrCreateBucketWithTTL(ctx, js, KVBucketEIPIdempotency, 1, eipIdempotencyTTL)
|
||||
idemKV, err := kvstore.GetOrCreateBucketWithTTL(ctx, js, KVBucketEIPIdempotency, 1, eipIdempotencyTTL)
|
||||
if err != nil {
|
||||
slog.Warn("EIP service: idempotency bucket unavailable; a retried AllocateAddress may allocate twice",
|
||||
"bucket", KVBucketEIPIdempotency, "err", err)
|
||||
@@ -146,7 +146,7 @@ func (s *EIPServiceImpl) allocateAddress(ctx context.Context, input *ec2.Allocat
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal EIP record: %w", err)
|
||||
}
|
||||
if _, err := s.eipKV.Put(ctx, kvutil.AccountKey(accountID, allocID), data); err != nil {
|
||||
if _, err := s.eipKV.Put(ctx, kvstore.AccountKey(accountID, allocID), data); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ func (s *EIPServiceImpl) ReleaseAddress(ctx context.Context, input *ec2.ReleaseA
|
||||
}
|
||||
|
||||
allocID := *input.AllocationId
|
||||
key := kvutil.AccountKey(accountID, allocID)
|
||||
key := kvstore.AccountKey(accountID, allocID)
|
||||
|
||||
entry, err := s.eipKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -206,7 +206,7 @@ func (s *EIPServiceImpl) AssociateAddress(ctx context.Context, input *ec2.Associ
|
||||
}
|
||||
|
||||
allocID := *input.AllocationId
|
||||
key := kvutil.AccountKey(accountID, allocID)
|
||||
key := kvstore.AccountKey(accountID, allocID)
|
||||
|
||||
entry, err := s.eipKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -536,7 +536,7 @@ func (s *EIPServiceImpl) findByENI(ctx context.Context, accountID, eniID string)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.eipKV.Get(ctx, k)
|
||||
@@ -590,7 +590,7 @@ func (s *EIPServiceImpl) DescribeAddresses(ctx context.Context, input *ec2.Descr
|
||||
|
||||
var addresses []*ec2.Address
|
||||
for _, k := range keys {
|
||||
if k == kvutil.VersionKey {
|
||||
if k == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
@@ -699,7 +699,7 @@ func (s *EIPServiceImpl) DescribeAddressesAttribute(ctx context.Context, input *
|
||||
if id == nil {
|
||||
continue
|
||||
}
|
||||
key := kvutil.AccountKey(accountID, *id)
|
||||
key := kvstore.AccountKey(accountID, *id)
|
||||
entry, err := s.eipKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
continue // not found, skip
|
||||
@@ -722,7 +722,7 @@ func (s *EIPServiceImpl) DescribeAddressesAttribute(ctx context.Context, input *
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k == kvutil.VersionKey {
|
||||
if k == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
@@ -819,7 +819,7 @@ func (s *EIPServiceImpl) findByAssociationID(ctx context.Context, accountID, ass
|
||||
}
|
||||
|
||||
for _, k := range keys {
|
||||
if k == kvutil.VersionKey {
|
||||
if k == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
@@ -857,7 +857,7 @@ func (s *EIPServiceImpl) AssociatedPublicIPForInstance(ctx context.Context, acco
|
||||
return "", false
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.eipKV.Get(ctx, k)
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -93,7 +93,7 @@ func NewIGWServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
|
||||
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
igwKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketIGW, 10)
|
||||
igwKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketIGW, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketIGW, err)
|
||||
}
|
||||
@@ -102,13 +102,13 @@ func NewIGWServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
|
||||
}
|
||||
|
||||
// Get or create VPC KV bucket for cross-resource ownership validation
|
||||
vpcKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
|
||||
vpcKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err)
|
||||
}
|
||||
|
||||
// Read EIP associations so detach can refuse while addresses are mapped.
|
||||
eipKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2eip.KVBucketEIPs, 10)
|
||||
eipKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2eip.KVBucketEIPs, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get EIP KV bucket: %w", err)
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func (s *IGWServiceImpl) createIGW(ctx context.Context, input *ec2.CreateInterne
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal IGW record: %w", err)
|
||||
}
|
||||
if _, err := s.igwKV.Put(ctx, kvutil.AccountKey(accountID, igwID), data); err != nil {
|
||||
if _, err := s.igwKV.Put(ctx, kvstore.AccountKey(accountID, igwID), data); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func (s *IGWServiceImpl) DeleteInternetGateway(ctx context.Context, input *ec2.D
|
||||
}
|
||||
|
||||
igwID := *input.InternetGatewayId
|
||||
key := kvutil.AccountKey(accountID, igwID)
|
||||
key := kvstore.AccountKey(accountID, igwID)
|
||||
|
||||
entry, err := s.igwKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -236,7 +236,7 @@ func (s *IGWServiceImpl) DescribeInternetGateways(ctx context.Context, input *ec
|
||||
foundIDs := make(map[string]bool)
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey {
|
||||
if key == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
@@ -326,7 +326,7 @@ func (s *IGWServiceImpl) AttachInternetGateway(ctx context.Context, input *ec2.A
|
||||
|
||||
igwID := *input.InternetGatewayId
|
||||
vpcID := *input.VpcId
|
||||
key := kvutil.AccountKey(accountID, igwID)
|
||||
key := kvstore.AccountKey(accountID, igwID)
|
||||
|
||||
entry, err := s.igwKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -347,7 +347,7 @@ func (s *IGWServiceImpl) AttachInternetGateway(ctx context.Context, input *ec2.A
|
||||
slog.ErrorContext(ctx, "VPC KV unavailable, cannot verify VPC ownership")
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID)); err != nil {
|
||||
if _, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, vpcID)); err != nil {
|
||||
slog.WarnContext(ctx, "AttachInternetGateway: VPC not found for account", "vpcId", vpcID, "accountID", accountID)
|
||||
return nil, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID)
|
||||
}
|
||||
@@ -388,7 +388,7 @@ func (s *IGWServiceImpl) DetachInternetGateway(ctx context.Context, input *ec2.D
|
||||
|
||||
igwID := *input.InternetGatewayId
|
||||
vpcID := *input.VpcId
|
||||
key := kvutil.AccountKey(accountID, igwID)
|
||||
key := kvstore.AccountKey(accountID, igwID)
|
||||
|
||||
entry, err := s.igwKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -449,7 +449,7 @@ func (s *IGWServiceImpl) DetachInternetGateway(ctx context.Context, input *ec2.D
|
||||
// associated into vpcID, whether to an interface or a NAT gateway. The listing
|
||||
// is filtered server-side to the account's keys.
|
||||
func (s *IGWServiceImpl) vpcHasMappedAddress(ctx context.Context, accountID, vpcID string) (bool, error) {
|
||||
lister, err := s.eipKV.ListKeysFiltered(ctx, kvutil.AccountKey(accountID, "*"))
|
||||
lister, err := s.eipKV.ListKeysFiltered(ctx, kvstore.AccountKey(accountID, "*"))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -493,11 +493,11 @@ func (s *IGWServiceImpl) CreateAttachedInternetGateway(ctx context.Context, acco
|
||||
if s.vpcKV == nil {
|
||||
return false, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID)); err != nil {
|
||||
if _, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, vpcID)); err != nil {
|
||||
return false, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID)
|
||||
}
|
||||
|
||||
key := kvutil.AccountKey(accountID, igwID)
|
||||
key := kvstore.AccountKey(accountID, igwID)
|
||||
data, err := json.Marshal(IGWRecord{
|
||||
InternetGatewayId: igwID,
|
||||
VpcId: vpcID,
|
||||
@@ -558,7 +558,7 @@ func (s *IGWServiceImpl) AttachmentIntent(ctx context.Context, accountID, vpcID
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.igwKV.Get(ctx, key)
|
||||
@@ -599,7 +599,7 @@ func (s *IGWServiceImpl) AttachmentIntent(ctx context.Context, accountID, vpcID
|
||||
// Writing only on the pending transition keeps a pass from waking the drift loop.
|
||||
func MarkAttached(ctx context.Context, kv jetstream.KeyValue, recordKey, vpcID string) error {
|
||||
confirmed := false
|
||||
record, err := kvutil.Update(ctx, kv, recordKey, kvutil.CASConfig{}, func(r *IGWRecord) (bool, error) {
|
||||
record, err := kvstore.Update(ctx, kv, recordKey, kvstore.CASConfig{}, func(r *IGWRecord) (bool, error) {
|
||||
// Reset per attempt: a retried CAS may see a record another writer has
|
||||
// already moved out of pending.
|
||||
confirmed = r.VpcId == vpcID && r.AttachState == AttachStatePending
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
ec2eip "github.com/mulgadc/spinifex/spinifex/domains/ec2/eip"
|
||||
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -55,7 +55,7 @@ func setupTestIGWService(t *testing.T) (*IGWServiceImpl, *nats.Conn) {
|
||||
// Create VPC KV bucket and register test VPCs so fail-closed ownership checks pass
|
||||
vpcEntries := map[string][]byte{}
|
||||
for _, vpcID := range []string{"vpc-test123", "vpc-other", "vpc-lifecycle", "vpc-event-test"} {
|
||||
vpcEntries[kvutil.AccountKey(testAccountID, vpcID)] = []byte(`{"vpc_id":"` + vpcID + `","state":"available"}`)
|
||||
vpcEntries[kvstore.AccountKey(testAccountID, vpcID)] = []byte(`{"vpc_id":"` + vpcID + `","state":"available"}`)
|
||||
}
|
||||
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, vpcEntries)
|
||||
|
||||
@@ -68,7 +68,7 @@ func setupTestIGWService(t *testing.T) (*IGWServiceImpl, *nats.Conn) {
|
||||
// gateway come up. Without it an attach stays pending and reports no attachment.
|
||||
func confirmAttach(t *testing.T, svc *IGWServiceImpl, igwID, vpcID string) {
|
||||
t.Helper()
|
||||
require.NoError(t, MarkAttached(context.Background(), svc.igwKV, kvutil.AccountKey(testAccountID, igwID), vpcID))
|
||||
require.NoError(t, MarkAttached(context.Background(), svc.igwKV, kvstore.AccountKey(testAccountID, igwID), vpcID))
|
||||
}
|
||||
|
||||
func createTestIGW(t *testing.T, svc *IGWServiceImpl) string {
|
||||
@@ -331,7 +331,7 @@ func TestDescribeInternetGateways_LegacyRecordReportsAttachment(t *testing.T) {
|
||||
svc, _ := setupTestIGWService(t)
|
||||
igwID := createTestIGW(t, svc)
|
||||
|
||||
key := kvutil.AccountKey(testAccountID, igwID)
|
||||
key := kvstore.AccountKey(testAccountID, igwID)
|
||||
entry, err := svc.igwKV.Get(context.Background(), key)
|
||||
require.NoError(t, err)
|
||||
var record IGWRecord
|
||||
@@ -398,7 +398,7 @@ func TestMarkAttached_NoRewriteWhenAlreadyAttached(t *testing.T) {
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
key := kvutil.AccountKey(testAccountID, igwID)
|
||||
key := kvstore.AccountKey(testAccountID, igwID)
|
||||
confirmAttach(t, svc, igwID, "vpc-test123")
|
||||
first, err := svc.igwKV.Get(context.Background(), key)
|
||||
require.NoError(t, err)
|
||||
@@ -605,7 +605,7 @@ func TestDetachInternetGateway_MappedPublicAddress(t *testing.T) {
|
||||
putEIP := func(rec ec2eip.EIPRecord) {
|
||||
data, err := json.Marshal(rec)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.eipKV.Put(context.Background(), kvutil.AccountKey(testAccountID, rec.AllocationId), data)
|
||||
_, err = svc.eipKV.Put(context.Background(), kvstore.AccountKey(testAccountID, rec.AllocationId), data)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
putEIP(ec2eip.EIPRecord{AllocationId: "eipalloc-other", AssociationId: "eipassoc-other", VpcId: "vpc-other", State: "associated"})
|
||||
@@ -614,7 +614,7 @@ func TestDetachInternetGateway_MappedPublicAddress(t *testing.T) {
|
||||
// Another account's address naming the same VPC ID is not this account's dependency.
|
||||
foreign, err := json.Marshal(ec2eip.EIPRecord{AllocationId: "eipalloc-foreign", AssociationId: "eipassoc-foreign", VpcId: "vpc-test123", State: "associated"})
|
||||
require.NoError(t, err)
|
||||
_, err = svc.eipKV.Put(context.Background(), kvutil.AccountKey("999999999999", "eipalloc-foreign"), foreign)
|
||||
_, err = svc.eipKV.Put(context.Background(), kvstore.AccountKey("999999999999", "eipalloc-foreign"), foreign)
|
||||
require.NoError(t, err)
|
||||
|
||||
detach := func() error {
|
||||
@@ -828,7 +828,7 @@ func TestAttachInternetGateway_CrossAccountVPCRejected(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
vpcID := "vpc-alpha123"
|
||||
_, err = vpcKV.Put(t.Context(), kvutil.AccountKey(testAccountID, vpcID), []byte(`{"vpc_id":"vpc-alpha123","state":"available"}`))
|
||||
_, err = vpcKV.Put(t.Context(), kvstore.AccountKey(testAccountID, vpcID), []byte(`{"vpc_id":"vpc-alpha123","state":"available"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Refresh service to pick up the VPC KV bucket
|
||||
|
||||
@@ -1265,6 +1265,10 @@ func (s *InstanceServiceImpl) StartInstance(ctx context.Context, instance *vm.VM
|
||||
slog.WarnContext(ctx, "StartInstance: vmMgr.Start failed", "instanceId", command.ID, "err", err)
|
||||
return refusal
|
||||
}
|
||||
if errors.Is(err, vm.ErrUEFIClaimHeld) {
|
||||
slog.WarnContext(ctx, "StartInstance: vmMgr.Start failed", "instanceId", command.ID, "err", err)
|
||||
return errInsufficientCapacity
|
||||
}
|
||||
slog.ErrorContext(ctx, "StartInstance: vmMgr.Start failed", "instanceId", command.ID, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
@@ -2760,7 +2764,7 @@ func (s *InstanceServiceImpl) capacityRefusal(ctx context.Context, instance *vm.
|
||||
func (s *InstanceServiceImpl) launchClaimedInstance(ctx context.Context, instance *vm.VM, undo func()) error {
|
||||
// Before anything is stamped or claimed, so the record handed back is the
|
||||
// one that was claimed.
|
||||
if err := vm.CheckUEFIVarStore(instance); err != nil {
|
||||
if err := s.vmMgr.CheckUEFIVarStore(ctx, instance); err != nil {
|
||||
slog.ErrorContext(ctx, "launchClaimedInstance: refusing to launch a UEFI instance without exactly one variable store",
|
||||
"instanceId", instance.ID, "err", err)
|
||||
undo()
|
||||
@@ -2893,6 +2897,10 @@ func (s *InstanceServiceImpl) launchClaimedInstance(ctx context.Context, instanc
|
||||
if refusal := volumeHeldElsewhereError(err); refusal != nil {
|
||||
return refusal
|
||||
}
|
||||
// A live node holds the variable store, so another node may take it.
|
||||
if errors.Is(err, vm.ErrUEFIClaimHeld) {
|
||||
return s.capacityRefusal(ctx, instance)
|
||||
}
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -26,9 +25,9 @@ func (s *Store) Exists(ctx context.Context, instanceID string) (bool, error) {
|
||||
return s.kv.Exists(ctx, instanceID)
|
||||
}
|
||||
|
||||
// MarkDeleting records that instanceID's store is to be deleted, and drops any
|
||||
// write-back still owed. It reports false with no error when the instance has
|
||||
// no store record. Only a terminate or an operator's forget may call it.
|
||||
// MarkDeleting records that instanceID's store is to be deleted. It reports
|
||||
// false with no error when the instance has no store record. Only a terminate
|
||||
// or an operator's forget may call it.
|
||||
func (s *Store) MarkDeleting(ctx context.Context, instanceID string) (bool, error) {
|
||||
for range markAttempts {
|
||||
rec, rev, err := s.Get(ctx, instanceID)
|
||||
@@ -42,7 +41,6 @@ func (s *Store) MarkDeleting(ctx context.Context, instanceID string) (bool, erro
|
||||
return true, nil
|
||||
}
|
||||
rec.State = StateDeleting
|
||||
rec.WriteBack = nil
|
||||
_, err = s.CompareAndSet(ctx, rec, rev)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
@@ -72,7 +70,7 @@ func (s *Store) InstanceIDs(ctx context.Context) ([]string, error) {
|
||||
|
||||
var ids []string
|
||||
for key := range lister.Keys() {
|
||||
if key != kvutil.VersionKey {
|
||||
if key != kvstore.VersionKey {
|
||||
ids = append(ids, key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func keysUnder(t *testing.T, objects objectstore.ObjectStore, prefix string) []s
|
||||
}
|
||||
|
||||
// Deletion is final: a writer holding the revision of a deleting record, such
|
||||
// as a write-back retry, must not bring the store back to live.
|
||||
// as a late pointer move, must not bring the store back to live.
|
||||
func TestCompareAndSet_RefusesDeletingToLive(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
rev, err := s.Create(t.Context(), liveRecord())
|
||||
@@ -47,8 +47,7 @@ func TestCompareAndSet_RefusesDeletingToLive(t *testing.T) {
|
||||
rev, err = s.CompareAndSet(t.Context(), deleting, rev)
|
||||
require.NoError(t, err)
|
||||
|
||||
revived := liveRecord()
|
||||
revived.WriteBack = writeBack()
|
||||
revived := pointedRecord()
|
||||
_, err = s.CompareAndSet(t.Context(), revived, rev)
|
||||
require.ErrorIs(t, err, uefistore.ErrDeleting)
|
||||
|
||||
@@ -58,24 +57,9 @@ func TestCompareAndSet_RefusesDeletingToLive(t *testing.T) {
|
||||
assert.Equal(t, uefistore.StateDeleting, got.State)
|
||||
}
|
||||
|
||||
// A write-back owed on a deleting store would put an object behind the delete.
|
||||
func TestCompareAndSet_RefusesDeletingWithAWriteBack(t *testing.T) {
|
||||
func TestMarkDeleting_MovesLiveToDeletingAndKeepsWhatItNames(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
rev, err := s.Create(t.Context(), liveRecord())
|
||||
require.NoError(t, err)
|
||||
rec := liveRecord()
|
||||
rec.State = uefistore.StateDeleting
|
||||
rec.WriteBack = writeBack()
|
||||
|
||||
_, err = s.CompareAndSet(t.Context(), rec, rev)
|
||||
require.ErrorIs(t, err, uefistore.ErrInvalidRecord)
|
||||
}
|
||||
|
||||
func TestMarkDeleting_MovesLiveToDeletingAndDropsTheWriteBack(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
rec := liveRecord()
|
||||
rec.WriteBack = writeBack()
|
||||
_, err := s.Create(t.Context(), rec)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
|
||||
has, err := s.MarkDeleting(t.Context(), instanceID)
|
||||
@@ -85,8 +69,8 @@ func TestMarkDeleting_MovesLiveToDeletingAndDropsTheWriteBack(t *testing.T) {
|
||||
got, rev, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uefistore.StateDeleting, got.State)
|
||||
assert.Nil(t, got.WriteBack)
|
||||
assert.Equal(t, "111122223333", got.AccountID)
|
||||
assert.Equal(t, pointer("b"), got.Pointer, "the pointer stays readable until the prefix is gone")
|
||||
|
||||
has, err = s.MarkDeleting(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package uefistore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
)
|
||||
|
||||
// ClaimPrefix is the kvsync prefix of every store's claim. An instance's claim
|
||||
// is ClaimPrefix+instanceID, and names the node that may hold variable changes
|
||||
// no object has yet.
|
||||
const ClaimPrefix = "uefi."
|
||||
|
||||
// NewClaims returns node's group of store claims over store. The daemon builds
|
||||
// it once per process, as kvsync requires of every prefix.
|
||||
func NewClaims(store kvsync.KVStore, node string) *kvsync.ClaimGroup {
|
||||
return kvsync.NewClaimGroup(store, ClaimPrefix, node)
|
||||
}
|
||||
|
||||
// ErrFenced is a stamp or pointer move refused because the record carries a
|
||||
// claim generation other than the caller's: a later acquisition has stamped
|
||||
// it, so the caller no longer owns the store and must change nothing.
|
||||
var ErrFenced = errors.New("uefistore: store record is fenced to another claim generation")
|
||||
|
||||
// fenceAttempts bounds the read-then-write loops of Stamp and MovePointer. A
|
||||
// lost race is re-read, so a concurrent stamp is seen and refused, not retried.
|
||||
const fenceAttempts = 5
|
||||
|
||||
// Stamp raises instanceID's record to generation, the claim token the caller
|
||||
// has just acquired or adopted, and returns the record it stamped, whose
|
||||
// pointer the caller boots from. One above generation is ErrFenced, and one
|
||||
// deleting is ErrDeleting. A record already at generation is still written by
|
||||
// CAS, because a follower can answer the read with an older pointer.
|
||||
func (s *Store) Stamp(ctx context.Context, instanceID string, generation uint64) (*UEFIStoreRecord, error) {
|
||||
if generation == 0 {
|
||||
return nil, fmt.Errorf("uefistore: stamp %s: generation 0 is no claim", instanceID)
|
||||
}
|
||||
for range fenceAttempts {
|
||||
rec, rev, err := s.Get(ctx, instanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case rec.State == StateDeleting:
|
||||
return nil, fmt.Errorf("%w: %s", ErrDeleting, instanceID)
|
||||
case rec.Generation > generation:
|
||||
return nil, fmt.Errorf("%w: %s is at generation %d, above the claim's %d", ErrFenced, instanceID, rec.Generation, generation)
|
||||
}
|
||||
rec.Generation = generation
|
||||
_, err = s.CompareAndSet(ctx, rec, rev)
|
||||
if err == nil {
|
||||
return rec, nil
|
||||
}
|
||||
if !errors.Is(err, kvstore.ErrConflict) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("uefistore: stamp %s: %w after %d attempts", instanceID, kvstore.ErrConflict, fenceAttempts)
|
||||
}
|
||||
|
||||
// MovePointer points instanceID's record at ref only while the record is at
|
||||
// generation. A record at any other generation is ErrFenced and is left
|
||||
// unchanged. Every move is a CAS at the revision read, even to the current
|
||||
// key, so a read a follower answered stale cannot report success.
|
||||
func (s *Store) MovePointer(ctx context.Context, instanceID string, generation uint64, ref UEFIVarStoreRef) error {
|
||||
if err := ref.validate(instanceID); err != nil {
|
||||
return fmt.Errorf("%w: move %s: %w", ErrInvalidRecord, instanceID, err)
|
||||
}
|
||||
for range fenceAttempts {
|
||||
rec, rev, err := s.Get(ctx, instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case rec.State == StateDeleting:
|
||||
return fmt.Errorf("%w: %s", ErrDeleting, instanceID)
|
||||
case rec.Generation != generation:
|
||||
return fmt.Errorf("%w: %s is at generation %d, and the writer holds %d", ErrFenced, instanceID, rec.Generation, generation)
|
||||
}
|
||||
rec.Pointer = &ref
|
||||
_, err = s.CompareAndSet(ctx, rec, rev)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, kvstore.ErrConflict) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("uefistore: move %s: %w after %d attempts", instanceID, kvstore.ErrConflict, fenceAttempts)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package uefistore_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
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/kvsync"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func revisionOf(t *testing.T, s *uefistore.Store) uint64 {
|
||||
t.Helper()
|
||||
_, rev, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
return rev
|
||||
}
|
||||
|
||||
func TestStamp_RaisesTheGenerationAndReturnsThePointerToBootFrom(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, err := s.Stamp(t.Context(), instanceID, 9)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint64(9), rec.Generation)
|
||||
assert.Equal(t, pointer("b"), rec.Pointer)
|
||||
|
||||
got, _, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rec, got, "the returned record is the one stored")
|
||||
}
|
||||
|
||||
// A daemon restart adopts its claim at the same generation. The stamp still
|
||||
// writes by CAS, so a pointer read from a lagging follower fails the write and
|
||||
// is read again rather than booted from.
|
||||
func TestStamp_AtTheRecordsGenerationConfirmsTheReadByCAS(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
before := revisionOf(t, s)
|
||||
|
||||
rec, err := s.Stamp(t.Context(), instanceID, 7)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pointer("b"), rec.Pointer)
|
||||
assert.Equal(t, uint64(7), rec.Generation)
|
||||
assert.Greater(t, revisionOf(t, s), before, "the read must be confirmed by a revision-checked write")
|
||||
}
|
||||
|
||||
func TestStamp_RefusesAndChangesNothing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deleting bool
|
||||
generation uint64
|
||||
want error
|
||||
}{
|
||||
{name: "a later acquisition stamped it", generation: 6, want: uefistore.ErrFenced},
|
||||
{name: "the store is being deleted", deleting: true, generation: 8, want: uefistore.ErrDeleting},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
rev, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
if tt.deleting {
|
||||
_, err = s.MarkDeleting(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
rev = revisionOf(t, s)
|
||||
}
|
||||
|
||||
_, err = s.Stamp(t.Context(), instanceID, tt.generation)
|
||||
require.ErrorIs(t, err, tt.want)
|
||||
assert.Equal(t, rev, revisionOf(t, s))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStamp_MissingRecordIsNotFound(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Stamp(t.Context(), instanceID, 1)
|
||||
require.ErrorIs(t, err, kvstore.ErrNotFound)
|
||||
}
|
||||
|
||||
// The record keeps only the current version, so each move replaces the
|
||||
// pointer and names nothing it replaced.
|
||||
func TestMovePointer_ReplacesThePointer(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, name := range []string{"c", "d"} {
|
||||
require.NoError(t, s.MovePointer(t.Context(), instanceID, 7, *pointer(name)))
|
||||
}
|
||||
|
||||
got, _, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pointer("d"), got.Pointer)
|
||||
assert.Equal(t, uint64(7), got.Generation)
|
||||
}
|
||||
|
||||
// A move to the key the record already names is still a CAS at the revision
|
||||
// read, so a fenced writer that read a lagging follower cannot see success.
|
||||
func TestMovePointer_ToTheCurrentKeyConfirmsTheReadByCAS(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
before := revisionOf(t, s)
|
||||
|
||||
require.NoError(t, s.MovePointer(t.Context(), instanceID, 7, *pointer("b")))
|
||||
assert.Greater(t, revisionOf(t, s), before)
|
||||
}
|
||||
|
||||
func TestMovePointer_RefusesAndChangesNothing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deleting bool
|
||||
generation uint64
|
||||
ref *uefistore.UEFIVarStoreRef
|
||||
want error
|
||||
}{
|
||||
{name: "an earlier generation", generation: 6, ref: pointer("c"), want: uefistore.ErrFenced},
|
||||
{name: "a later generation than the stamp", generation: 8, ref: pointer("c"), want: uefistore.ErrFenced},
|
||||
{name: "a deleting store", deleting: true, generation: 7, ref: pointer("c"), want: uefistore.ErrDeleting},
|
||||
{name: "a key under another instance", generation: 7,
|
||||
ref: &uefistore.UEFIVarStoreRef{Key: uefistore.KeyPrefix("i-0fedcba9876543210") + "c", Firmware: pointer("c").Firmware, WriterNode: "n"},
|
||||
want: uefistore.ErrInvalidRecord},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
if tt.deleting {
|
||||
_, err = s.MarkDeleting(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
rev := revisionOf(t, s)
|
||||
|
||||
err = s.MovePointer(t.Context(), instanceID, tt.generation, *tt.ref)
|
||||
require.ErrorIs(t, err, tt.want)
|
||||
got, gotRev, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rev, gotRev)
|
||||
assert.Equal(t, pointer("b"), got.Pointer)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// claimedStore is a store record and a claim group per node over one
|
||||
// JetStream, as two daemons see them.
|
||||
type claimedStore struct {
|
||||
records *uefistore.Store
|
||||
a, b *kvsync.Claim
|
||||
}
|
||||
|
||||
func newClaimedStore(t *testing.T) *claimedStore {
|
||||
t.Helper()
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
records := uefistore.NewStore(js)
|
||||
require.NoError(t, records.Open(t.Context()))
|
||||
_, err := records.Create(t.Context(), liveRecord())
|
||||
require.NoError(t, err)
|
||||
return &claimedStore{
|
||||
records: records,
|
||||
a: uefistore.NewClaims(kvstore.OpenSyncBucket(js), "node-a").Get(instanceID),
|
||||
b: uefistore.NewClaims(kvstore.OpenSyncBucket(js), "node-b").Get(instanceID),
|
||||
}
|
||||
}
|
||||
|
||||
// A node partitioned long enough to be taken over is fenced by the new
|
||||
// owner's stamp: its pointer move fails and the record keeps what it had.
|
||||
func TestFence_TheOldHoldersMoveFailsAfterTheNewOwnersStamp(t *testing.T) {
|
||||
c := newClaimedStore(t)
|
||||
first, err := c.a.TryLock(t.Context())
|
||||
require.NoError(t, err)
|
||||
_, err = c.records.Stamp(t.Context(), instanceID, first.Generation)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.records.MovePointer(t.Context(), instanceID, first.Generation, *pointer("b")))
|
||||
|
||||
second, err := c.b.TakeOver(t.Context(), first)
|
||||
require.NoError(t, err)
|
||||
_, err = c.records.Stamp(t.Context(), instanceID, second.Generation)
|
||||
require.NoError(t, err)
|
||||
rev := revisionOf(t, c.records)
|
||||
|
||||
err = c.records.MovePointer(t.Context(), instanceID, first.Generation, *pointer("late"))
|
||||
require.ErrorIs(t, err, uefistore.ErrFenced)
|
||||
got, gotRev, err := c.records.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rev, gotRev)
|
||||
assert.Equal(t, pointer("b"), got.Pointer)
|
||||
assert.Equal(t, second.Generation, got.Generation)
|
||||
}
|
||||
|
||||
// The new owner reads the pointer after it stamps, so a move the old holder
|
||||
// landed between the takeover and the stamp is the one it boots from.
|
||||
func TestFence_AMoveBetweenTakeoverAndStampIsThePointerTheNewOwnerBoots(t *testing.T) {
|
||||
c := newClaimedStore(t)
|
||||
first, err := c.a.TryLock(t.Context())
|
||||
require.NoError(t, err)
|
||||
_, err = c.records.Stamp(t.Context(), instanceID, first.Generation)
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := c.b.TakeOver(t.Context(), first)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.records.MovePointer(t.Context(), instanceID, first.Generation, *pointer("landed")))
|
||||
|
||||
rec, err := c.records.Stamp(t.Context(), instanceID, second.Generation)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, keyOf("landed"), rec.Pointer.Key)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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.
|
||||
// store, the bucket of store records that carry its pointer and deletion
|
||||
// obligation past the life of the instance record, and the claim that fences it.
|
||||
package uefistore
|
||||
|
||||
import (
|
||||
@@ -53,22 +53,27 @@ type FirmwareIdentity struct {
|
||||
// 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.
|
||||
//
|
||||
// Pointer names the only version of the store the record keeps, the object the
|
||||
// instance boots from, and only a writer whose claim token has generation
|
||||
// Generation may move it.
|
||||
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"`
|
||||
SchemaVersion uint16 `json:"schema_version"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
State string `json:"state"`
|
||||
Generation uint64 `json:"generation"`
|
||||
Pointer *UEFIVarStoreRef `json:"pointer,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"`
|
||||
// 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 FirmwareIdentity `json:"firmware"`
|
||||
WriterNode string `json:"writer_node"`
|
||||
WrittenAt time.Time `json:"written_at"`
|
||||
}
|
||||
|
||||
// KeyPrefix is the object-store prefix every store object of ownerID (an
|
||||
@@ -83,8 +88,8 @@ func ownsKey(ownerID, key string) bool {
|
||||
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.
|
||||
// validate checks rec against key. The pointer must name one object directly
|
||||
// under the instance's own prefix, because a deletion acts on it.
|
||||
func (rec *UEFIStoreRecord) validate(key string) error {
|
||||
switch {
|
||||
case rec.SchemaVersion > RecordSchemaVersion:
|
||||
@@ -98,29 +103,32 @@ func (rec *UEFIStoreRecord) validate(key string) error {
|
||||
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)
|
||||
case rec.State == StateDeleting && rec.WriteBack != nil:
|
||||
// A write-back retry puts an object, which would land behind the delete.
|
||||
return fmt.Errorf("%w: %s is deleting with a write-back still owed", ErrInvalidRecord, key)
|
||||
}
|
||||
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))
|
||||
if p := rec.Pointer; p != nil {
|
||||
if err := p.validate(rec.InstanceID); err != nil {
|
||||
return fmt.Errorf("%w: %s pointer: %w", ErrInvalidRecord, key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validate checks ref as a pointer of instanceID's store.
|
||||
func (ref *UEFIVarStoreRef) validate(instanceID string) error {
|
||||
switch {
|
||||
case !ownsKey(instanceID, ref.Key):
|
||||
return fmt.Errorf("key %q is not under %s", ref.Key, KeyPrefix(instanceID))
|
||||
case ref.Firmware.Arch == "" || ref.Firmware.VarsSize <= 0:
|
||||
return fmt.Errorf("key %q names no firmware arch and VARS size", ref.Key)
|
||||
case ref.WriterNode == "":
|
||||
return fmt.Errorf("key %q names no writer node", ref.Key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bucketConfig() kvstore.Config {
|
||||
return kvstore.Config{
|
||||
Name: BucketName,
|
||||
Description: "Spinifex UEFI variable store records (deletion and write-back obligations)",
|
||||
Description: "Spinifex UEFI variable store records (pointers and deletion obligations)",
|
||||
History: 1,
|
||||
// Left false: a lost bucket has lost obligations, and an empty
|
||||
// recreated one would report every store as having none.
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
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"
|
||||
@@ -31,15 +30,25 @@ 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 pointer(name string) *uefistore.UEFIVarStoreRef {
|
||||
return &uefistore.UEFIVarStoreRef{
|
||||
Key: keyOf(name),
|
||||
Firmware: uefistore.FirmwareIdentity{Arch: "x86_64", VarsSize: 540672, CodeSHA256: "c0de"},
|
||||
WriterNode: "node-2",
|
||||
WrittenAt: time.Date(2026, 11, 3, 1, 2, 3, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func keyOf(name string) string { return uefistore.KeyPrefix(instanceID) + name }
|
||||
|
||||
// pointedRecord is a live record stamped at generation 7 that points at "b".
|
||||
func pointedRecord() *uefistore.UEFIStoreRecord {
|
||||
rec := liveRecord()
|
||||
rec.Generation = 7
|
||||
rec.Pointer = pointer("b")
|
||||
return rec
|
||||
}
|
||||
|
||||
func rawPut(t *testing.T, js jetstream.JetStream, key, body string) {
|
||||
t.Helper()
|
||||
kv, err := js.KeyValue(t.Context(), uefistore.BucketName)
|
||||
@@ -60,15 +69,14 @@ func TestOpen_CreatesBucketWithNoTTLAndStampsVersion(t *testing.T) {
|
||||
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)
|
||||
v, err := kvstore.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()
|
||||
rec := pointedRecord()
|
||||
|
||||
rev, err := s.Create(t.Context(), rec)
|
||||
require.NoError(t, err)
|
||||
@@ -83,9 +91,7 @@ func TestCreate_RoundTripsAndStampsSchemaVersion(t *testing.T) {
|
||||
// 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)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
|
||||
kv, err := js.KeyValue(t.Context(), uefistore.BucketName)
|
||||
@@ -97,11 +103,12 @@ func TestRecord_WireShape(t *testing.T) {
|
||||
"instance_id": "i-0123456789abcdef0",
|
||||
"account_id": "111122223333",
|
||||
"state": "live",
|
||||
"write_back": {
|
||||
"node": "node-2",
|
||||
"generation": 7,
|
||||
"pointer": {
|
||||
"key": "uefi/i-0123456789abcdef0/b",
|
||||
"seeded_from": "uefi/i-0123456789abcdef0/a",
|
||||
"since": "2026-11-03T01:02:03Z"
|
||||
"firmware": {"arch": "x86_64", "vars_size": 540672, "code_sha256": "c0de"},
|
||||
"writer_node": "node-2",
|
||||
"written_at": "2026-11-03T01:02:03Z"
|
||||
}
|
||||
}`, string(entry.Value()))
|
||||
}
|
||||
@@ -145,8 +152,7 @@ func TestCompareAndSet_StaleRevisionConflictsAndWritesNothing(t *testing.T) {
|
||||
first, err := s.Create(t.Context(), liveRecord())
|
||||
require.NoError(t, err)
|
||||
|
||||
moved := liveRecord()
|
||||
moved.WriteBack = writeBack()
|
||||
moved := pointedRecord()
|
||||
second, err := s.CompareAndSet(t.Context(), moved, first)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, second, first)
|
||||
@@ -160,7 +166,7 @@ func TestCompareAndSet_StaleRevisionConflictsAndWritesNothing(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, second, rev)
|
||||
assert.Equal(t, uefistore.StateLive, got.State)
|
||||
assert.Equal(t, writeBack(), got.WriteBack)
|
||||
assert.Equal(t, pointer("b"), got.Pointer)
|
||||
}
|
||||
|
||||
// Two actors that read the same revision race to change it; exactly one may
|
||||
@@ -180,12 +186,11 @@ func TestCompareAndSet_ConcurrentWritersFromOneReadHaveOneWinner(t *testing.T) {
|
||||
)
|
||||
for i := range writers {
|
||||
wg.Go(func() {
|
||||
rec := liveRecord()
|
||||
rec.WriteBack = writeBack()
|
||||
rec.WriteBack.Node = fmt.Sprintf("node-%d", i)
|
||||
rec := pointedRecord()
|
||||
rec.Pointer.WriterNode = fmt.Sprintf("node-%d", i)
|
||||
if _, err := s.CompareAndSet(context.Background(), rec, rev); err == nil {
|
||||
mu.Lock()
|
||||
wins = append(wins, rec.WriteBack.Node)
|
||||
wins = append(wins, rec.Pointer.WriterNode)
|
||||
mu.Unlock()
|
||||
} else {
|
||||
assert.ErrorIs(t, err, kvstore.ErrConflict)
|
||||
@@ -197,7 +202,7 @@ func TestCompareAndSet_ConcurrentWritersFromOneReadHaveOneWinner(t *testing.T) {
|
||||
require.Len(t, wins, 1)
|
||||
got, _, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wins[0], got.WriteBack.Node)
|
||||
assert.Equal(t, wins[0], got.Pointer.WriterNode)
|
||||
}
|
||||
|
||||
func TestCompareAndDelete_StaleRevisionKeepsRecord(t *testing.T) {
|
||||
@@ -218,29 +223,20 @@ func TestCompareAndDelete_StaleRevisionKeepsRecord(t *testing.T) {
|
||||
require.ErrorIs(t, err, kvstore.ErrNotFound)
|
||||
}
|
||||
|
||||
// A superseded obligation deletes its recorded key, so a key outside the
|
||||
// A deletion acts on the key the record points at, so a key outside the
|
||||
// instance's own prefix must never reach the bucket.
|
||||
func TestCompareAndSet_RefusesInvalidRecords(t *testing.T) {
|
||||
other := uefistore.KeyPrefix("i-0fedcba9876543210") + "b"
|
||||
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"
|
||||
},
|
||||
"unknown state": func(r *uefistore.UEFIStoreRecord) { r.State = "gone" },
|
||||
"not an instance ID": func(r *uefistore.UEFIStoreRecord) { r.InstanceID = "ami-0123456789abcdef0" },
|
||||
"pointer under another instance": func(r *uefistore.UEFIStoreRecord) { r.Pointer.Key = other },
|
||||
"pointer under an image": func(r *uefistore.UEFIStoreRecord) { r.Pointer.Key = uefistore.KeyPrefix("ami-0123456789abcdef0") + "b" },
|
||||
"pointer is the prefix itself": func(r *uefistore.UEFIStoreRecord) { r.Pointer.Key = uefistore.KeyPrefix(instanceID) },
|
||||
"pointer nested below the prefix": func(r *uefistore.UEFIStoreRecord) { r.Pointer.Key = keyOf("x/y") },
|
||||
"pointer with no firmware arch": func(r *uefistore.UEFIStoreRecord) { r.Pointer.Firmware.Arch = "" },
|
||||
"pointer with no VARS size": func(r *uefistore.UEFIStoreRecord) { r.Pointer.Firmware.VarsSize = 0 },
|
||||
"pointer with no writer node": func(r *uefistore.UEFIStoreRecord) { r.Pointer.WriterNode = "" },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
@@ -248,8 +244,7 @@ func TestCompareAndSet_RefusesInvalidRecords(t *testing.T) {
|
||||
rev, err := s.Create(t.Context(), liveRecord())
|
||||
require.NoError(t, err)
|
||||
|
||||
rec := liveRecord()
|
||||
rec.WriteBack = writeBack()
|
||||
rec := pointedRecord()
|
||||
mutate(rec)
|
||||
_, err = s.CompareAndSet(t.Context(), rec, rev)
|
||||
require.ErrorIs(t, err, uefistore.ErrInvalidRecord)
|
||||
@@ -257,7 +252,7 @@ func TestCompareAndSet_RefusesInvalidRecords(t *testing.T) {
|
||||
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)
|
||||
assert.Nil(t, got.Pointer)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -266,11 +261,11 @@ func TestCompareAndSet_RefusesInvalidRecords(t *testing.T) {
|
||||
// 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"}}`,
|
||||
"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 pointer": `{"schema_version":1,"instance_id":"i-0123456789abcdef0","state":"live","generation":3,"pointer":{"key":"uefi/i-0fedcba9876543210/b","firmware":{"arch":"x86_64","vars_size":540672},"writer_node":"n"}}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
|
||||
@@ -102,3 +102,38 @@ func TestStartPaths_ReportAVolumeHeldElsewhereByCode(t *testing.T) {
|
||||
assert.Equal(t, awserrors.ErrorIncorrectState, awserrors.ValidErrorCodeFromError(err))
|
||||
})
|
||||
}
|
||||
|
||||
// uefiClaimHeldMounter fails the launch the way a start does while a live
|
||||
// node holds the instance's UEFI variable store.
|
||||
type uefiClaimHeldMounter struct{ raceVolumeMounter }
|
||||
|
||||
func (uefiClaimHeldMounter) Mount(context.Context, *vm.VM) error {
|
||||
return fmt.Errorf("%w: node-a holds i-uefi: %w", vm.ErrUEFIClaimHeld, vm.ErrMountRetryable)
|
||||
}
|
||||
|
||||
// A held UEFI store is a capacity refusal on both start paths, so the caller
|
||||
// tries another node rather than reporting a fault.
|
||||
func TestStartPaths_ReportAHeldUEFIStoreAsCapacity(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: uefiClaimHeldMounter{}})
|
||||
svc := &InstanceServiceImpl{resourceMgr: &fakeResourceCapacityProvider{instanceTypes: types}, vmMgr: mgr}
|
||||
|
||||
err := svc.launchClaimedInstance(t.Context(), &vm.VM{ID: "i-uefi-claim", AccountID: "acc", InstanceType: "t3.micro", Status: vm.StatePending}, func() {})
|
||||
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, awserrors.ValidErrorCodeFromError(err))
|
||||
})
|
||||
|
||||
t.Run("live owner", func(t *testing.T) {
|
||||
const id = "i-uefi-live"
|
||||
mgr := mgrWith(map[string]*vm.VM{id: {ID: id, Status: vm.StateStopped, InstanceType: "t3.micro"}})
|
||||
mgr.SetDeps(vm.Deps{NodeID: "node-b", VolumeMounter: uefiClaimHeldMounter{}})
|
||||
v, _ := mgr.Get(id)
|
||||
svc := &InstanceServiceImpl{resourceMgr: &fakeResourceCapacityProvider{instanceTypes: types}, vmMgr: mgr}
|
||||
|
||||
err := svc.StartInstance(t.Context(), v, ec2v1.EC2InstanceCommand{ID: id})
|
||||
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, awserrors.ValidErrorCodeFromError(err))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ type AccountCleanup struct {
|
||||
// Unreadable headers, their bodies and unrecognised entries are left for the
|
||||
// owner scan to report. A listing failure is returned and nothing is removed.
|
||||
func (s *LaunchTemplateServiceImpl) CleanupAccountResidue(ctx context.Context, accountID string, settledBefore time.Time) (*AccountCleanup, error) {
|
||||
keys, err := kvutil.Keys(ctx, s.kv)
|
||||
keys, err := kvstore.Keys(ctx, s.kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, fmt.Errorf("list launch-template keys: %w", err)
|
||||
}
|
||||
@@ -145,7 +145,7 @@ type AccountTemplateDeletes struct {
|
||||
// it cannot read or decode is left for the owner scan to report. A listing
|
||||
// failure is returned and nothing is deleted.
|
||||
func (s *LaunchTemplateServiceImpl) DeleteLiveTemplates(ctx context.Context, accountID string, referenced func(id, name string) string) (*AccountTemplateDeletes, error) {
|
||||
keys, err := kvutil.Keys(ctx, s.kv)
|
||||
keys, err := kvstore.Keys(ctx, s.kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, fmt.Errorf("list launch-template keys: %w", err)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ type AccountScan struct {
|
||||
// Headers and name-index entries are read; version bodies are classified from
|
||||
// their keys alone, so template data and user data are never loaded.
|
||||
func (s *LaunchTemplateServiceImpl) ScanAccount(ctx context.Context, accountID string) (*AccountScan, error) {
|
||||
keys, err := kvutil.Keys(ctx, s.kv)
|
||||
keys, err := kvstore.Keys(ctx, s.kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, fmt.Errorf("list launch-template keys: %w", err)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/internal/testkit/readonlystore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -45,7 +45,7 @@ func TestScanAccountClassifiesLaunchTemplateRecords(t *testing.T) {
|
||||
// A header removed alone, as a delete interrupted after it leaves the rest.
|
||||
deleted := createWithUserData(t, svc, "deleted")
|
||||
inner := svc.kv
|
||||
require.NoError(t, inner.Delete(t.Context(), kvutil.AccountKey(testAccountID, deleted)))
|
||||
require.NoError(t, inner.Delete(t.Context(), kvstore.AccountKey(testAccountID, deleted)))
|
||||
|
||||
// A create whose header write and clean-up both fail.
|
||||
svc.kv = &faultyKV{KeyValue: inner,
|
||||
@@ -59,16 +59,16 @@ func TestScanAccountClassifiesLaunchTemplateRecords(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
svc.kv = inner
|
||||
|
||||
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0badbadbadbadbad0"), "{not json")
|
||||
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0badbadbadbadbad0.v1"), `{"version_number":1}`)
|
||||
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0mismatch00000000"), `{"launch_template_id":"lt-0other"}`)
|
||||
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0unreadable000000"), `{"launch_template_id":"lt-0unreadable000000"}`)
|
||||
putRaw(t, inner, kvstore.AccountKey(testAccountID, "lt-0badbadbadbadbad0"), "{not json")
|
||||
putRaw(t, inner, kvstore.AccountKey(testAccountID, "lt-0badbadbadbadbad0.v1"), `{"version_number":1}`)
|
||||
putRaw(t, inner, kvstore.AccountKey(testAccountID, "lt-0mismatch00000000"), `{"launch_template_id":"lt-0other"}`)
|
||||
putRaw(t, inner, kvstore.AccountKey(testAccountID, "lt-0unreadable000000"), `{"launch_template_id":"lt-0unreadable000000"}`)
|
||||
putRaw(t, inner, nameKey(testAccountID, "unreadable"), "lt-0unreadable000000")
|
||||
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0bad.vx"), "{}")
|
||||
putRaw(t, inner, kvstore.AccountKey(testAccountID, "lt-0bad.vx"), "{}")
|
||||
|
||||
recorder := &readRecordingKV{KeyValue: readonlystore.NewKV(t, inner)}
|
||||
svc.kv = &faultyKV{KeyValue: recorder, failGet: func(key string) bool {
|
||||
return key == kvutil.AccountKey(testAccountID, "lt-0unreadable000000")
|
||||
return key == kvstore.AccountKey(testAccountID, "lt-0unreadable000000")
|
||||
}}
|
||||
scan, err := svc.ScanAccount(context.Background(), testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -159,7 +159,7 @@ func TestCurrentBehaviour_AccountTermination_LaunchTemplateStorageLayout(t *test
|
||||
|
||||
header := testAccountID + "." + id
|
||||
name := testAccountID + ".name.776562283129" // hex of "web(1)"
|
||||
assert.Equal(t, sortedKeys(header, header+".v1", header+".v2", name, kvutil.VersionKey), storedKeys(t, svc.kv))
|
||||
assert.Equal(t, sortedKeys(header, header+".v1", header+".v2", name, kvstore.VersionKey), storedKeys(t, svc.kv))
|
||||
|
||||
entry, err := svc.kv.Get(t.Context(), name)
|
||||
require.NoError(t, err)
|
||||
@@ -223,13 +223,13 @@ func TestAccountTermination_DeleteLaunchTemplateKeepsTheTemplateWhenAStepFails(t
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
|
||||
svc.kv = inner
|
||||
assert.Equal(t, sortedKeys(append(tc.left(header, nameIdx), kvutil.VersionKey)...), storedKeys(t, inner))
|
||||
assert.Equal(t, sortedKeys(append(tc.left(header, nameIdx), kvstore.VersionKey)...), storedKeys(t, inner))
|
||||
assert.Equal(t, []string{id}, describedTemplateIDs(t, svc, testAccountID))
|
||||
|
||||
out, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id, aws.StringValue(out.LaunchTemplate.LaunchTemplateId))
|
||||
assert.Equal(t, []string{kvutil.VersionKey}, storedKeys(t, inner))
|
||||
assert.Equal(t, []string{kvstore.VersionKey}, storedKeys(t, inner))
|
||||
assert.Empty(t, describedTemplateIDs(t, svc, testAccountID))
|
||||
})
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func TestAccountTermination_DeleteLaunchTemplateRestartsWhenTheHeaderChanges(t *
|
||||
_, err = svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, raced)
|
||||
assert.Equal(t, []string{kvutil.VersionKey}, storedKeys(t, inner))
|
||||
assert.Equal(t, []string{kvstore.VersionKey}, storedKeys(t, inner))
|
||||
}
|
||||
|
||||
// A create that fails after claiming the name removes what it wrote, so no
|
||||
@@ -287,7 +287,7 @@ func TestAccountTermination_FailedCreateRemovesWhatItWrote(t *testing.T) {
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
|
||||
svc.kv = inner
|
||||
assert.Equal(t, []string{kvutil.VersionKey}, storedKeys(t, inner))
|
||||
assert.Equal(t, []string{kvstore.VersionKey}, storedKeys(t, inner))
|
||||
retried := createTemplate(t, svc, "web", "t3.micro")
|
||||
assert.Equal(t, []string{aws.StringValue(retried.LaunchTemplateId)}, describedTemplateIDs(t, svc, testAccountID))
|
||||
})
|
||||
@@ -299,7 +299,7 @@ func TestAccountTermination_FailedCreateRemovesWhatItWrote(t *testing.T) {
|
||||
// failure is an internal error, never a missing template.
|
||||
func TestCurrentBehaviour_AccountTermination_DescribeLaunchTemplatesHidesUnreadableHeaders(t *testing.T) {
|
||||
const brokenID = "lt-0badbadbadbadbad0"
|
||||
brokenHeader := kvutil.AccountKey(testAccountID, brokenID)
|
||||
brokenHeader := kvstore.AccountKey(testAccountID, brokenID)
|
||||
tests := []struct {
|
||||
name string
|
||||
seed func(t *testing.T, svc *LaunchTemplateServiceImpl)
|
||||
@@ -350,7 +350,7 @@ func TestAccountCleanup_KeepsTemplatesAndRemovesSettledResidue(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
orphanBody := versionKey(testAccountID, "lt-0orphan000000000", 1)
|
||||
orphanName := nameKey(testAccountID, "gone")
|
||||
brokenHeader := kvutil.AccountKey(testAccountID, "lt-0broken000000000")
|
||||
brokenHeader := kvstore.AccountKey(testAccountID, "lt-0broken000000000")
|
||||
brokenBody := versionKey(testAccountID, "lt-0broken000000000", 1)
|
||||
brokenName := nameKey(testAccountID, "broken")
|
||||
for key, value := range map[string]string{
|
||||
@@ -384,7 +384,7 @@ func TestAccountCleanup_KeepsTemplatesAndRemovesSettledResidue(t *testing.T) {
|
||||
func TestAccountCleanup_InterruptedTemplateDeleteIsLeftToThePublicRetry(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
header := kvstore.AccountKey(testAccountID, id)
|
||||
kv := svc.kv
|
||||
svc.kv = &faultyKV{KeyValue: kv, failDelete: func(key string) bool { return key == header }}
|
||||
_, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -64,7 +64,7 @@ func NewLaunchTemplateServiceImplWithNATS(ctx context.Context, cfg *config.Confi
|
||||
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketLaunchTemplates, 10)
|
||||
kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketLaunchTemplates, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketLaunchTemplates, err)
|
||||
}
|
||||
@@ -87,24 +87,24 @@ func NewLaunchTemplateServiceImplWithNATS(ctx context.Context, cfg *config.Confi
|
||||
// Launch template names allow characters such as parentheses that are not valid
|
||||
// in NATS KV keys, so the name must not be used in its raw form here.
|
||||
func nameKey(accountID, name string) string {
|
||||
return kvutil.AccountKey(accountID, "name."+hex.EncodeToString([]byte(name)))
|
||||
return kvstore.AccountKey(accountID, "name."+hex.EncodeToString([]byte(name)))
|
||||
}
|
||||
|
||||
// versionKey returns a version-body key: account.lt-<id>.v<n>.
|
||||
func versionKey(accountID, ltID string, n int64) string {
|
||||
return kvutil.AccountKey(accountID, ltID+".v"+strconv.FormatInt(n, 10))
|
||||
return kvstore.AccountKey(accountID, ltID+".v"+strconv.FormatInt(n, 10))
|
||||
}
|
||||
|
||||
// versionPrefix returns the scan prefix for a template's version bodies.
|
||||
func versionPrefix(accountID, ltID string) string {
|
||||
return kvutil.AccountKey(accountID, ltID+".v")
|
||||
return kvstore.AccountKey(accountID, ltID+".v")
|
||||
}
|
||||
|
||||
// --- record load helpers ---
|
||||
|
||||
// getHeaderByID reads a header and its KV entry (for CAS) by launch template id.
|
||||
func (s *LaunchTemplateServiceImpl) getHeaderByID(ctx context.Context, accountID, ltID string) (*LaunchTemplateHeader, jetstream.KeyValueEntry, error) {
|
||||
entry, err := s.kv.Get(ctx, kvutil.AccountKey(accountID, ltID))
|
||||
entry, err := s.kv.Get(ctx, kvstore.AccountKey(accountID, ltID))
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateIdNotFound)
|
||||
@@ -386,7 +386,7 @@ func (s *LaunchTemplateServiceImpl) claimName(ctx context.Context, accountID, na
|
||||
// header means the name is taken; any other read error (transient fault, or a
|
||||
// concurrent in-flight create whose header is not yet written) fails closed so
|
||||
// the name is never stolen from a possibly-live template.
|
||||
_, herr := s.kv.Get(ctx, kvutil.AccountKey(accountID, string(entry.Value())))
|
||||
_, herr := s.kv.Get(ctx, kvstore.AccountKey(accountID, string(entry.Value())))
|
||||
switch {
|
||||
case herr == nil:
|
||||
return errNameInUse()
|
||||
@@ -404,7 +404,7 @@ func (s *LaunchTemplateServiceImpl) putHeader(ctx context.Context, accountID str
|
||||
if err != nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := s.kv.Put(ctx, kvutil.AccountKey(accountID, h.LaunchTemplateId), data); err != nil {
|
||||
if _, err := s.kv.Put(ctx, kvstore.AccountKey(accountID, h.LaunchTemplateId), data); err != nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
return nil
|
||||
@@ -510,13 +510,13 @@ func (s *LaunchTemplateServiceImpl) commitVersion(ctx context.Context, accountID
|
||||
// re-reading on a conflict. A non-nil check vets each re-read header and
|
||||
// stops the commit with its error.
|
||||
func (s *LaunchTemplateServiceImpl) commitHeader(ctx context.Context, op, accountID, ltID string, entry jetstream.KeyValueEntry, check func(*LaunchTemplateHeader) error) error {
|
||||
key := kvutil.AccountKey(accountID, ltID)
|
||||
key := kvstore.AccountKey(accountID, ltID)
|
||||
for range maxVersionRetries {
|
||||
_, err := s.kv.Update(ctx, key, entry.Value(), entry.Revision())
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !kvutil.IsRevisionConflict(err) {
|
||||
if !kvstore.IsRevisionConflict(err) {
|
||||
slog.ErrorContext(ctx, op+": header commit failed", "launchTemplateId", ltID, "err", err)
|
||||
return err
|
||||
}
|
||||
@@ -590,7 +590,7 @@ func (s *LaunchTemplateServiceImpl) ModifyLaunchTemplate(ctx context.Context, in
|
||||
// header deleted meanwhile is NotFound for the identifier the request used.
|
||||
func (s *LaunchTemplateServiceImpl) setDefaultVersion(ctx context.Context, accountID string, input *ec2.ModifyLaunchTemplateInput, header *LaunchTemplateHeader, entry jetstream.KeyValueEntry, sel string) (*LaunchTemplateHeader, error) {
|
||||
notFound := fmt.Sprintf("The launch template version %s is not found for the specified launch template.", sel)
|
||||
key := kvutil.AccountKey(accountID, header.LaunchTemplateId)
|
||||
key := kvstore.AccountKey(accountID, header.LaunchTemplateId)
|
||||
for range maxVersionRetries {
|
||||
n, err := s.resolveVersionNumber(ctx, accountID, header, sel)
|
||||
if err != nil {
|
||||
@@ -611,7 +611,7 @@ func (s *LaunchTemplateServiceImpl) setDefaultVersion(ctx context.Context, accou
|
||||
if err == nil {
|
||||
return header, nil
|
||||
}
|
||||
if !kvutil.IsRevisionConflict(err) {
|
||||
if !kvstore.IsRevisionConflict(err) {
|
||||
slog.ErrorContext(ctx, "ModifyLaunchTemplate: header update failed", "launchTemplateId", header.LaunchTemplateId, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
@@ -662,7 +662,7 @@ const maxHeaderDeleteAttempts = 3
|
||||
// through the public delete; a header changed in between restarts the sweep.
|
||||
func (s *LaunchTemplateServiceImpl) deleteTemplate(ctx context.Context, accountID string, header *LaunchTemplateHeader, entry jetstream.KeyValueEntry) error {
|
||||
ltID := header.LaunchTemplateId
|
||||
headerKey := kvutil.AccountKey(accountID, ltID)
|
||||
headerKey := kvstore.AccountKey(accountID, ltID)
|
||||
for attempt := 1; ; attempt++ {
|
||||
if err := s.deleteVersionBodies(ctx, accountID, ltID); err != nil {
|
||||
return err
|
||||
@@ -824,7 +824,7 @@ func (s *LaunchTemplateServiceImpl) commitVersionDeletes(ctx context.Context, ac
|
||||
}
|
||||
// A template delete that removed the header before the restore has already
|
||||
// swept its bodies, so the restored ones would be left headerless.
|
||||
if _, gerr := s.kv.Get(ctx, kvutil.AccountKey(accountID, ltID)); errors.Is(gerr, jetstream.ErrKeyNotFound) {
|
||||
if _, gerr := s.kv.Get(ctx, kvstore.AccountKey(accountID, ltID)); errors.Is(gerr, jetstream.ErrKeyNotFound) {
|
||||
for n := range deleted {
|
||||
if derr := s.kv.Delete(ctx, versionKey(accountID, ltID, n)); derr != nil {
|
||||
slog.ErrorContext(ctx, "DeleteLaunchTemplateVersions: failed to remove a restored version", "launchTemplateId", ltID, "version", n, "err", derr)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -116,7 +116,7 @@ func TestCreateLaunchTemplate_OrphanNameReclaim(t *testing.T) {
|
||||
lt := createTemplate(t, svc, "orphan", "t3.micro")
|
||||
|
||||
// Simulate a crash after the name claim but with the header lost.
|
||||
require.NoError(t, svc.kv.Delete(t.Context(), kvutil.AccountKey(testAccountID, aws.StringValue(lt.LaunchTemplateId))))
|
||||
require.NoError(t, svc.kv.Delete(t.Context(), kvstore.AccountKey(testAccountID, aws.StringValue(lt.LaunchTemplateId))))
|
||||
|
||||
// The name index still points at the now-orphaned id; create must reclaim it.
|
||||
out, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -38,7 +38,7 @@ func TestAccountTermination_VersionCreateRacingADeleteLeavesNoBody(t *testing.T)
|
||||
t.Run(pause, func(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
header := kvstore.AccountKey(testAccountID, id)
|
||||
raced := false
|
||||
race := func(key string, at string) {
|
||||
if raced || pause != at {
|
||||
@@ -71,7 +71,7 @@ func TestAccountTermination_DeleteRacingACommittedVersionRemovesIt(t *testing.T)
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
inner := svc.kv
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
header := kvstore.AccountKey(testAccountID, id)
|
||||
raced := false
|
||||
svc.kv = &faultyKV{KeyValue: inner, beforeDelete: func(key string) {
|
||||
if key != header || raced {
|
||||
@@ -95,7 +95,7 @@ func TestAccountTermination_FailedVersionCommitRemovesTheBody(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
before := accountKeys(t, svc.kv, testAccountID)
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
header := kvstore.AccountKey(testAccountID, id)
|
||||
svc.kv = &faultyKV{KeyValue: svc.kv, failUpdate: func(key string) bool { return key == header }}
|
||||
|
||||
_, err := svc.CreateLaunchTemplateVersion(context.Background(), newVersionInput(id), testAccountID)
|
||||
@@ -110,7 +110,7 @@ func TestAccountTermination_VersionCommitKeepsAConcurrentHeaderChange(t *testing
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
inner := svc.kv
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
header := kvstore.AccountKey(testAccountID, id)
|
||||
raced := false
|
||||
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
|
||||
if key != header || raced {
|
||||
@@ -215,10 +215,10 @@ func TestAccountTemplateReferences_FailsClosed(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
},
|
||||
"header unreadable": func(t *testing.T, svc *LaunchTemplateServiceImpl, id string) {
|
||||
svc.kv = &faultyKV{KeyValue: svc.kv, failGet: func(key string) bool { return key == kvutil.AccountKey(testAccountID, id) }}
|
||||
svc.kv = &faultyKV{KeyValue: svc.kv, failGet: func(key string) bool { return key == kvstore.AccountKey(testAccountID, id) }}
|
||||
},
|
||||
"header undecodable": func(t *testing.T, svc *LaunchTemplateServiceImpl, _ string) {
|
||||
_, err := svc.kv.Put(t.Context(), kvutil.AccountKey(testAccountID, "lt-0broken000000000"), []byte("{not json"))
|
||||
_, err := svc.kv.Put(t.Context(), kvstore.AccountKey(testAccountID, "lt-0broken000000000"), []byte("{not json"))
|
||||
require.NoError(t, err)
|
||||
},
|
||||
}
|
||||
@@ -241,7 +241,7 @@ func TestModifyLaunchTemplate_RacingADeleteIsNotFound(t *testing.T) {
|
||||
t.Run(map[bool]string{false: "by-id", true: "by-name"}[byName], func(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
header := kvstore.AccountKey(testAccountID, id)
|
||||
raced := false
|
||||
svc.kv = &faultyKV{KeyValue: svc.kv, beforeUpdate: func(key string) {
|
||||
if key == header && !raced {
|
||||
@@ -275,7 +275,7 @@ func TestModifyLaunchTemplate_RetriesAConcurrentHeaderChange(t *testing.T) {
|
||||
_, err := svc.CreateLaunchTemplateVersion(context.Background(), newVersionInput(id), testAccountID) // v3
|
||||
require.NoError(t, err)
|
||||
inner := svc.kv
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
header := kvstore.AccountKey(testAccountID, id)
|
||||
raced := false
|
||||
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
|
||||
if key != header || raced {
|
||||
@@ -306,7 +306,7 @@ func TestModifyLaunchTemplate_ConflictRetriesAreBounded(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
inner := svc.kv
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
header := kvstore.AccountKey(testAccountID, id)
|
||||
attempts := 0
|
||||
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
|
||||
if key != header {
|
||||
@@ -333,7 +333,7 @@ func TestModifyLaunchTemplate_ConflictReReadFailureIsInternal(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
inner := svc.kv
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
header := kvstore.AccountKey(testAccountID, id)
|
||||
conflicted := false
|
||||
svc.kv = &faultyKV{
|
||||
KeyValue: inner,
|
||||
@@ -410,7 +410,7 @@ func TestDefaultChangeRacingAVersionDeleteFailsNotFound(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
inner := svc.kv
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
header := kvstore.AccountKey(testAccountID, id)
|
||||
raced := false
|
||||
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
|
||||
if key != header || raced {
|
||||
@@ -460,7 +460,7 @@ func TestFailedVersionDeleteCommitRestoresTheBodies(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
before := accountKeys(t, svc.kv, testAccountID)
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
header := kvstore.AccountKey(testAccountID, id)
|
||||
svc.kv = &faultyKV{KeyValue: svc.kv, failUpdate: func(key string) bool { return key == header }}
|
||||
|
||||
_, err := deleteVersions(svc, id, "2")
|
||||
@@ -474,7 +474,7 @@ func TestFailedVersionDeleteCommitRestoresTheBodies(t *testing.T) {
|
||||
func TestVersionDeleteRacingATemplateDeleteLeavesNothing(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
header := kvstore.AccountKey(testAccountID, id)
|
||||
raced := false
|
||||
svc.kv = &faultyKV{KeyValue: svc.kv, beforeUpdate: func(key string) {
|
||||
if key != header || raced {
|
||||
@@ -531,7 +531,7 @@ func TestDefaultChangeRetriesAfterAConcurrentVersionCreate(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
id := createWithUserData(t, svc, "web")
|
||||
inner := svc.kv
|
||||
header := kvutil.AccountKey(testAccountID, id)
|
||||
header := kvstore.AccountKey(testAccountID, id)
|
||||
raced := false
|
||||
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
|
||||
if key != header || raced {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ type TemplateReferences struct {
|
||||
// header or body it cannot read or decode, is an error, because the unread
|
||||
// record may name the resource. Headerless bodies resolve from no launch.
|
||||
func (s *LaunchTemplateServiceImpl) AccountTemplateReferences(ctx context.Context, accountID string) (*TemplateReferences, error) {
|
||||
keys, err := kvutil.Keys(ctx, s.kv)
|
||||
keys, err := kvstore.Keys(ctx, s.kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, fmt.Errorf("list launch-template keys: %w", err)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
ec2eip "github.com/mulgadc/spinifex/spinifex/domains/ec2/eip"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -42,7 +42,7 @@ func TestRLC3_NatGatewayDeletesWhileRouted(t *testing.T) {
|
||||
natgwID := createTestNatGateway(t, svc)
|
||||
|
||||
testutil.SeedKV(t, js, kvBucketRouteTables, map[string][]byte{
|
||||
kvutil.AccountKey(testAccountID, "rtb-routed"): fmt.Appendf(nil,
|
||||
kvstore.AccountKey(testAccountID, "rtb-routed"): fmt.Appendf(nil,
|
||||
`{"route_table_id":"rtb-routed","vpc_id":"vpc-test1","routes":[{"destination_cidr_block":"0.0.0.0/0","nat_gateway_id":%q}]}`, natgwID),
|
||||
})
|
||||
|
||||
@@ -93,7 +93,7 @@ func createTestNatGateway(t *testing.T, svc *NatGatewayServiceImpl) string {
|
||||
|
||||
func readEIP(t *testing.T, svc *NatGatewayServiceImpl, allocID string) ec2eip.EIPRecord {
|
||||
t.Helper()
|
||||
entry, err := svc.eipKV.Get(t.Context(), kvutil.AccountKey(testAccountID, allocID))
|
||||
entry, err := svc.eipKV.Get(t.Context(), kvstore.AccountKey(testAccountID, allocID))
|
||||
require.NoError(t, err)
|
||||
var eip ec2eip.EIPRecord
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &eip))
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -45,7 +45,7 @@ func NewNatGatewayServiceImplWithNATS(ctx context.Context, natsConn *nats.Conn)
|
||||
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
natgwKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketNatGateways, 10)
|
||||
natgwKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketNatGateways, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketNatGateways, err)
|
||||
}
|
||||
@@ -61,17 +61,17 @@ func NewNatGatewayServiceImplWithNATS(ctx context.Context, natsConn *nats.Conn)
|
||||
return nil, fmt.Errorf("migrate %s: %w", KVBucketDeletedNatGateways, err)
|
||||
}
|
||||
|
||||
eipKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2eip.KVBucketEIPs, 10)
|
||||
eipKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2eip.KVBucketEIPs, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get EIP KV bucket: %w", err)
|
||||
}
|
||||
|
||||
subnetKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketSubnets, 10)
|
||||
subnetKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketSubnets, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get subnet KV bucket: %w", err)
|
||||
}
|
||||
|
||||
vpcKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
|
||||
vpcKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err)
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func NewNatGatewayServiceImplWithNATS(ctx context.Context, natsConn *nats.Conn)
|
||||
// for as long as a client may still ask about it.
|
||||
func getOrCreateDeletedBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) {
|
||||
// Terraform polls DescribeNatGateways after delete and expects state=deleted.
|
||||
return kvutil.GetOrCreateBucketWithOptions(ctx, js, kvutil.BucketOptions{
|
||||
return kvstore.GetOrCreateBucketWithOptions(ctx, js, kvstore.BucketOptions{
|
||||
Name: KVBucketDeletedNatGateways,
|
||||
Description: "Deleted NAT Gateways (auto-expire after 1 hour)",
|
||||
History: 1,
|
||||
@@ -113,7 +113,7 @@ func (s *NatGatewayServiceImpl) CreateNatGateway(ctx context.Context, input *ec2
|
||||
allocID := *input.AllocationId
|
||||
|
||||
// Validate subnet exists and get its VPC
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.AccountKey(accountID, subnetID))
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvstore.AccountKey(accountID, subnetID))
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidSubnetIDNotFound)
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func (s *NatGatewayServiceImpl) CreateNatGateway(ctx context.Context, input *ec2
|
||||
}
|
||||
|
||||
// Validate EIP exists and is not already associated
|
||||
eipEntry, err := s.eipKV.Get(ctx, kvutil.AccountKey(accountID, allocID))
|
||||
eipEntry, err := s.eipKV.Get(ctx, kvstore.AccountKey(accountID, allocID))
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidAllocationIDNotFound)
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func (s *NatGatewayServiceImpl) CreateNatGateway(ctx context.Context, input *ec2
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := s.natgwKV.Put(ctx, kvutil.AccountKey(accountID, natgwID), data); err != nil {
|
||||
if _, err := s.natgwKV.Put(ctx, kvstore.AccountKey(accountID, natgwID), data); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to store NAT Gateway", "natGatewayId", natgwID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func (s *NatGatewayServiceImpl) CreateNatGateway(ctx context.Context, input *ec2
|
||||
eipRecord.VpcId = subnetRecord.VpcId
|
||||
eipRecord.State = "associated"
|
||||
if eipData, err := json.Marshal(eipRecord); err == nil {
|
||||
if _, err := s.eipKV.Update(ctx, kvutil.AccountKey(accountID, allocID), eipData, eipEntry.Revision()); err != nil {
|
||||
if _, err := s.eipKV.Update(ctx, kvstore.AccountKey(accountID, allocID), eipData, eipEntry.Revision()); err != nil {
|
||||
slog.WarnContext(ctx, "CreateNatGateway: failed to mark EIP associated", "natGatewayId", natgwID, "allocationId", allocID, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func (s *NatGatewayServiceImpl) DeleteNatGateway(ctx context.Context, input *ec2
|
||||
}
|
||||
|
||||
natgwID := *input.NatGatewayId
|
||||
key := kvutil.AccountKey(accountID, natgwID)
|
||||
key := kvstore.AccountKey(accountID, natgwID)
|
||||
|
||||
entry, err := s.natgwKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -255,7 +255,7 @@ func (s *NatGatewayServiceImpl) publishDeleteEventsForNatGateway(ctx context.Con
|
||||
slog.WarnContext(ctx, "DeleteNatGateway: JetStream setup failed, SNAT teardown deferred to route delete", "natGatewayId", record.NatGatewayId, "err", err)
|
||||
return
|
||||
}
|
||||
rtbKV, err := kvutil.GetOrCreateBucket(ctx, js, kvBucketRouteTables, 10)
|
||||
rtbKV, err := kvstore.GetOrCreateBucket(ctx, js, kvBucketRouteTables, 10)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "DeleteNatGateway: route-table scan failed, SNAT teardown deferred to route delete", "natGatewayId", record.NatGatewayId, "err", err)
|
||||
return
|
||||
@@ -266,7 +266,7 @@ func (s *NatGatewayServiceImpl) publishDeleteEventsForNatGateway(ctx context.Con
|
||||
}
|
||||
prefix := accountID + "."
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := rtbKV.Get(ctx, key)
|
||||
@@ -302,7 +302,7 @@ func (s *NatGatewayServiceImpl) publishDeleteEventsForNatGateway(ctx context.Con
|
||||
if assoc.SubnetId == "" {
|
||||
continue
|
||||
}
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.AccountKey(accountID, assoc.SubnetId))
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvstore.AccountKey(accountID, assoc.SubnetId))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -322,7 +322,7 @@ func (s *NatGatewayServiceImpl) disassociateEIP(ctx context.Context, record *Nat
|
||||
if record.AllocationId == "" {
|
||||
return
|
||||
}
|
||||
key := kvutil.AccountKey(accountID, record.AllocationId)
|
||||
key := kvstore.AccountKey(accountID, record.AllocationId)
|
||||
entry, err := s.eipKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
if !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
@@ -380,7 +380,7 @@ func (s *NatGatewayServiceImpl) DescribeNatGateways(ctx context.Context, input *
|
||||
foundIDs := make(map[string]bool)
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@ func (s *NatGatewayServiceImpl) DescribeNatGateways(ctx context.Context, input *
|
||||
if foundIDs[id] {
|
||||
continue
|
||||
}
|
||||
key := kvutil.AccountKey(accountID, id)
|
||||
key := kvstore.AccountKey(accountID, id)
|
||||
entry, err := s.deletedNatgwKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -31,18 +31,18 @@ func setupTestServiceJS(t *testing.T) (*NatGatewayServiceImpl, jetstream.JetStre
|
||||
|
||||
// Seed VPC KV
|
||||
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, map[string][]byte{
|
||||
kvutil.AccountKey(testAccountID, "vpc-test1"): []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`),
|
||||
kvstore.AccountKey(testAccountID, "vpc-test1"): []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`),
|
||||
})
|
||||
|
||||
// Seed subnet KV (public subnet)
|
||||
testutil.SeedKV(t, js, ec2vpc.KVBucketSubnets, map[string][]byte{
|
||||
kvutil.AccountKey(testAccountID, "subnet-pub1"): []byte(`{"subnet_id":"subnet-pub1","vpc_id":"vpc-test1","cidr_block":"10.0.1.0/24","state":"available","map_public_ip_on_launch":true}`),
|
||||
kvstore.AccountKey(testAccountID, "subnet-pub1"): []byte(`{"subnet_id":"subnet-pub1","vpc_id":"vpc-test1","cidr_block":"10.0.1.0/24","state":"available","map_public_ip_on_launch":true}`),
|
||||
})
|
||||
|
||||
// Seed EIP KV
|
||||
testutil.SeedKV(t, js, ec2eip.KVBucketEIPs, map[string][]byte{
|
||||
kvutil.AccountKey(testAccountID, "eipalloc-test1"): []byte(`{"allocation_id":"eipalloc-test1","public_ip":"203.0.113.50","state":"allocated"}`),
|
||||
kvutil.AccountKey(testAccountID, "eipalloc-used"): []byte(`{"allocation_id":"eipalloc-used","public_ip":"203.0.113.51","state":"associated","association_id":"eipassoc-xxx"}`),
|
||||
kvstore.AccountKey(testAccountID, "eipalloc-test1"): []byte(`{"allocation_id":"eipalloc-test1","public_ip":"203.0.113.50","state":"allocated"}`),
|
||||
kvstore.AccountKey(testAccountID, "eipalloc-used"): []byte(`{"allocation_id":"eipalloc-used","public_ip":"203.0.113.51","state":"associated","association_id":"eipassoc-xxx"}`),
|
||||
})
|
||||
|
||||
svc, err := NewNatGatewayServiceImplWithNATS(t.Context(), nc)
|
||||
@@ -170,7 +170,7 @@ func TestDeleteNatGateway(t *testing.T) {
|
||||
assert.Equal(t, natgwID, *deleteOut.NatGatewayId)
|
||||
|
||||
// Should be gone
|
||||
_, err = svc.natgwKV.Get(t.Context(), kvutil.AccountKey(testAccountID, natgwID))
|
||||
_, err = svc.natgwKV.Get(t.Context(), kvstore.AccountKey(testAccountID, natgwID))
|
||||
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ type AccountCleanup struct {
|
||||
// referenced returns a reason is kept; unreadable records are left for the owner scan to report. A
|
||||
// listing failure is returned and nothing is deleted.
|
||||
func (s *PlacementGroupServiceImpl) CleanupAccount(ctx context.Context, accountID string, referenced func(groupID, groupName string) string) (*AccountCleanup, error) {
|
||||
keys, err := kvutil.Keys(ctx, s.kv)
|
||||
keys, err := kvstore.Keys(ctx, s.kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, fmt.Errorf("list placement-group keys: %w", err)
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func (s *PlacementGroupServiceImpl) CleanupAccount(ctx context.Context, accountI
|
||||
prefix := accountID + "."
|
||||
out := &AccountCleanup{Referenced: map[string]string{}}
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.kv.Get(ctx, key)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ type AccountScan struct {
|
||||
// A listing failure is returned as an error; per-record read failures are
|
||||
// counted. The bucket's schema-version key belongs to no account.
|
||||
func (s *PlacementGroupServiceImpl) ScanAccount(ctx context.Context, accountID string) (*AccountScan, error) {
|
||||
keys, err := kvutil.Keys(ctx, s.kv)
|
||||
keys, err := kvstore.Keys(ctx, s.kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, fmt.Errorf("list placement-group keys: %w", err)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func (s *PlacementGroupServiceImpl) ScanAccount(ctx context.Context, accountID s
|
||||
prefix := accountID + "."
|
||||
scan := &AccountScan{}
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.kv.Get(ctx, key)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/internal/testkit/readonlystore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -42,14 +42,14 @@ func TestScanAccountClassifiesPlacementGroupRecords(t *testing.T) {
|
||||
GroupName: "held", NodeInstances: map[string][]string{"node-2": {"i-0bbb", "i-0aaa"}, "node-3": {"i-0aaa"}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.kv.Put(t.Context(), kvutil.AccountKey(testAccountID, "undecodable"), []byte("{not json"))
|
||||
_, err = svc.kv.Put(t.Context(), kvstore.AccountKey(testAccountID, "undecodable"), []byte("{not json"))
|
||||
require.NoError(t, err)
|
||||
_, err = svc.kv.Put(t.Context(), kvutil.AccountKey(testAccountID, "anonymous"), []byte(`{"group_name":"anonymous"}`))
|
||||
_, err = svc.kv.Put(t.Context(), kvstore.AccountKey(testAccountID, "anonymous"), []byte(`{"group_name":"anonymous"}`))
|
||||
require.NoError(t, err)
|
||||
createTestGroup(t, svc, "unreadable", ec2.PlacementStrategySpread)
|
||||
require.Contains(t, bucketKeys(t, svc.kv), kvutil.VersionKey)
|
||||
require.Contains(t, bucketKeys(t, svc.kv), kvstore.VersionKey)
|
||||
|
||||
svc.kv = &hookedKV{KeyValue: readonlystore.NewKV(t, svc.kv), failGet: kvutil.AccountKey(testAccountID, "unreadable")}
|
||||
svc.kv = &hookedKV{KeyValue: readonlystore.NewKV(t, svc.kv), failGet: kvstore.AccountKey(testAccountID, "unreadable")}
|
||||
scan, err := svc.ScanAccount(context.Background(), testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -84,7 +84,7 @@ func TestCurrentBehaviour_AccountTermination_PlacementGroupStorageLayout(t *test
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []string{testAccountID + ".spread-a", kvutil.VersionKey}, bucketKeys(t, svc.kv))
|
||||
assert.Equal(t, []string{testAccountID + ".spread-a", kvstore.VersionKey}, bucketKeys(t, svc.kv))
|
||||
|
||||
entry, err := svc.kv.Get(t.Context(), testAccountID+".spread-a")
|
||||
require.NoError(t, err)
|
||||
@@ -100,7 +100,7 @@ func TestCurrentBehaviour_AccountTermination_PlacementGroupStorageLayout(t *test
|
||||
// a record cannot be removed through DeletePlacementGroup either, and a read
|
||||
// failure is an internal error, never an unknown group.
|
||||
func TestCurrentBehaviour_AccountTermination_DescribePlacementGroupsHidesUnreadableRecords(t *testing.T) {
|
||||
brokenKey := kvutil.AccountKey(testAccountID, "broken")
|
||||
brokenKey := kvstore.AccountKey(testAccountID, "broken")
|
||||
tests := []struct {
|
||||
name string
|
||||
seed func(t *testing.T, svc *PlacementGroupServiceImpl)
|
||||
@@ -139,7 +139,7 @@ func TestCurrentBehaviour_AccountTermination_DescribePlacementGroupsHidesUnreada
|
||||
func TestAccountTermination_DeletePlacementGroupLosesToAConcurrentMember(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTestGroup(t, svc, "cluster-a", ec2.PlacementStrategyCluster)
|
||||
key := kvutil.AccountKey(testAccountID, "cluster-a")
|
||||
key := kvstore.AccountKey(testAccountID, "cluster-a")
|
||||
|
||||
launcher := &PlacementGroupServiceImpl{kv: svc.kv, region: svc.region}
|
||||
var reserveErr, finalizeErr error
|
||||
@@ -222,7 +222,7 @@ func TestAccountCleanup_DeletesEmptyGroupsAndBlocksOnMembers(t *testing.T) {
|
||||
empty := createTestGroup(t, svc, "empty", ec2.PlacementStrategySpread)
|
||||
held := createTestGroup(t, svc, "held", ec2.PlacementStrategyCluster)
|
||||
require.NoError(t, addMember(t, svc, "held", "i-0123456789abcdef0"))
|
||||
_, err := svc.kv.Put(t.Context(), kvutil.AccountKey(testAccountID, "broken"), []byte("{not json"))
|
||||
_, err := svc.kv.Put(t.Context(), kvstore.AccountKey(testAccountID, "broken"), []byte("{not json"))
|
||||
require.NoError(t, err)
|
||||
other := "999988887777"
|
||||
_, err = svc.CreatePlacementGroup(context.Background(), &ec2.CreatePlacementGroupInput{
|
||||
@@ -238,7 +238,7 @@ func TestAccountCleanup_DeletesEmptyGroupsAndBlocksOnMembers(t *testing.T) {
|
||||
|
||||
assert.Equal(t, []string{"held"}, describedGroupNames(t, svc, testAccountID))
|
||||
assert.Equal(t, []string{"theirs"}, describedGroupNames(t, svc, other))
|
||||
_, err = svc.kv.Get(t.Context(), kvutil.AccountKey(testAccountID, "broken"))
|
||||
_, err = svc.kv.Get(t.Context(), kvstore.AccountKey(testAccountID, "broken"))
|
||||
require.NoError(t, err, "an unreadable record is never deleted")
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ func TestAccountCleanup_DeletesEmptyGroupsAndBlocksOnMembers(t *testing.T) {
|
||||
func TestAccountCleanup_ConcurrentMemberBlocksTheDelete(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
group := createTestGroup(t, svc, "cluster-a", ec2.PlacementStrategyCluster)
|
||||
key := kvutil.AccountKey(testAccountID, "cluster-a")
|
||||
key := kvstore.AccountKey(testAccountID, "cluster-a")
|
||||
launcher := &PlacementGroupServiceImpl{kv: svc.kv, region: svc.region}
|
||||
var memberErr error
|
||||
// Read 1 is the cleanup's listing read; read 2 is the delete's check.
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -87,7 +87,7 @@ func NewPlacementGroupServiceImplWithNATS(ctx context.Context, cfg *config.Confi
|
||||
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketPlacementGroups, 10)
|
||||
kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketPlacementGroups, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketPlacementGroups, err)
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func (s *PlacementGroupServiceImpl) CreatePlacementGroup(ctx context.Context, in
|
||||
}
|
||||
|
||||
groupName := *input.GroupName
|
||||
key := kvutil.AccountKey(accountID, groupName)
|
||||
key := kvstore.AccountKey(accountID, groupName)
|
||||
groupID := awsidentifiers.GenerateResourceID("pg")
|
||||
|
||||
record := PlacementGroupRecord{
|
||||
@@ -175,7 +175,7 @@ func (s *PlacementGroupServiceImpl) DeletePlacementGroup(ctx context.Context, in
|
||||
}
|
||||
|
||||
groupName := *input.GroupName
|
||||
if _, err := s.deleteGroup(ctx, accountID, kvutil.AccountKey(accountID, groupName)); err != nil {
|
||||
if _, err := s.deleteGroup(ctx, accountID, kvstore.AccountKey(accountID, groupName)); err != nil {
|
||||
if errors.Is(err, errPGAbsent) {
|
||||
// Lower case, unlike DescribePlacementGroups' message for the same code.
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidPlacementGroupUnknown, "The placement group '%s' is unknown.", groupName)
|
||||
@@ -194,7 +194,7 @@ func (s *PlacementGroupServiceImpl) DeletePlacementGroup(ctx context.Context, in
|
||||
// The returned count is the members seen by the last check.
|
||||
func (s *PlacementGroupServiceImpl) deleteGroup(ctx context.Context, accountID, key string) (int, error) {
|
||||
members := 0
|
||||
record, err := kvutil.DeleteIf(ctx, s.kv, key, casConfig(), func(record *PlacementGroupRecord) error {
|
||||
record, err := kvstore.DeleteIf(ctx, s.kv, key, casConfig(), func(record *PlacementGroupRecord) error {
|
||||
members = memberCount(record.NodeInstances)
|
||||
if members > 0 {
|
||||
return errPGInUse
|
||||
@@ -238,7 +238,7 @@ func (s *PlacementGroupServiceImpl) DescribePlacementGroups(ctx context.Context,
|
||||
|
||||
var groups []*ec2.PlacementGroup
|
||||
for _, k := range keys {
|
||||
if k == kvutil.VersionKey {
|
||||
if k == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
@@ -341,7 +341,7 @@ func pgMatchesFilters(record *PlacementGroupRecord, filters map[string][]string)
|
||||
|
||||
const maxCASRetries = 5
|
||||
|
||||
// errPGAbsent and errPGContended mark the two kvutil outcomes that map to
|
||||
// errPGAbsent and errPGContended mark the two kvstore outcomes that map to
|
||||
// specific AWS errors rather than a generic internal failure, and neither
|
||||
// escapes this file. errPGInUse is the AWS error itself.
|
||||
var (
|
||||
@@ -350,8 +350,8 @@ var (
|
||||
errPGInUse = errors.New(awserrors.ErrorInvalidPlacementGroupInUse)
|
||||
)
|
||||
|
||||
// mapCASError turns a kvutil failure into the AWS error the caller returns.
|
||||
// A non-kvutil error came from mutate and is already an AWS code.
|
||||
// mapCASError turns a kvstore failure into the AWS error the caller returns.
|
||||
// A non-kvstore error came from mutate and is already an AWS code.
|
||||
func mapCASError(ctx context.Context, op, groupName, accountID string, err error) error {
|
||||
switch {
|
||||
case errors.Is(err, errPGAbsent):
|
||||
@@ -359,16 +359,16 @@ func mapCASError(ctx context.Context, op, groupName, accountID string, err error
|
||||
case errors.Is(err, errPGContended):
|
||||
slog.ErrorContext(ctx, "Placement group CAS retries exhausted under contention",
|
||||
"op", op, "groupName", groupName, "accountID", accountID)
|
||||
case errors.Is(err, kvutil.ErrRead):
|
||||
case errors.Is(err, kvstore.ErrRead):
|
||||
slog.ErrorContext(ctx, "Failed to read placement group from KV",
|
||||
"op", op, "groupName", groupName, "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvutil.ErrDecode):
|
||||
case errors.Is(err, kvstore.ErrDecode):
|
||||
slog.ErrorContext(ctx, "Corrupt placement group record in KV",
|
||||
"op", op, "groupName", groupName, "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvutil.ErrEncode):
|
||||
case errors.Is(err, kvstore.ErrEncode):
|
||||
slog.ErrorContext(ctx, "Failed to marshal placement group record",
|
||||
"op", op, "groupName", groupName, "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvutil.ErrWrite):
|
||||
case errors.Is(err, kvstore.ErrWrite):
|
||||
slog.ErrorContext(ctx, "Failed to write placement group to KV",
|
||||
"op", op, "groupName", groupName, "accountID", accountID, "err", err)
|
||||
default:
|
||||
@@ -378,8 +378,8 @@ func mapCASError(ctx context.Context, op, groupName, accountID string, err error
|
||||
}
|
||||
|
||||
// casConfig is the shared CAS policy for every placement group mutation.
|
||||
func casConfig() kvutil.CASConfig {
|
||||
return kvutil.CASConfig{
|
||||
func casConfig() kvstore.CASConfig {
|
||||
return kvstore.CASConfig{
|
||||
Attempts: maxCASRetries,
|
||||
NotFound: errPGAbsent,
|
||||
Exhausted: func(string, int) error { return errPGContended },
|
||||
@@ -394,7 +394,7 @@ func (s *PlacementGroupServiceImpl) ReserveSpreadNodes(ctx context.Context, inpu
|
||||
}
|
||||
|
||||
var selected []string
|
||||
_, err := kvutil.Update(ctx, s.kv, kvutil.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
_, err := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
func(record *PlacementGroupRecord) (bool, error) {
|
||||
if record.State != ec2.PlacementGroupStateAvailable {
|
||||
return false, errors.New(awserrors.ErrorInvalidPlacementGroupUnknown)
|
||||
@@ -437,7 +437,7 @@ func (s *PlacementGroupServiceImpl) FinalizeSpreadInstances(ctx context.Context,
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
_, err := kvutil.Update(ctx, s.kv, kvutil.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
_, err := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
func(record *PlacementGroupRecord) (bool, error) {
|
||||
maps.Copy(record.NodeInstances, input.NodeInstances)
|
||||
return true, nil
|
||||
@@ -462,7 +462,7 @@ func (s *PlacementGroupServiceImpl) ReleaseSpreadNodes(ctx context.Context, inpu
|
||||
releaseSet[n] = true
|
||||
}
|
||||
|
||||
_, err := kvutil.Update(ctx, s.kv, kvutil.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
_, err := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
func(record *PlacementGroupRecord) (bool, error) {
|
||||
changed := false
|
||||
for node := range releaseSet {
|
||||
@@ -490,7 +490,7 @@ func (s *PlacementGroupServiceImpl) RemoveInstance(ctx context.Context, input *R
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
_, err := kvutil.Update(ctx, s.kv, kvutil.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
_, err := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
func(record *PlacementGroupRecord) (bool, error) {
|
||||
instances, exists := record.NodeInstances[input.NodeName]
|
||||
if !exists {
|
||||
@@ -533,7 +533,7 @@ func (s *PlacementGroupServiceImpl) ReserveClusterNode(ctx context.Context, inpu
|
||||
}
|
||||
|
||||
var targetNode string
|
||||
_, err := kvutil.Update(ctx, s.kv, kvutil.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
_, err := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
func(record *PlacementGroupRecord) (bool, error) {
|
||||
if record.State != ec2.PlacementGroupStateAvailable {
|
||||
return false, errors.New(awserrors.ErrorInvalidPlacementGroupUnknown)
|
||||
@@ -570,7 +570,7 @@ func (s *PlacementGroupServiceImpl) FinalizeClusterInstances(ctx context.Context
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
_, err := kvutil.Update(ctx, s.kv, kvutil.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
_, err := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
func(record *PlacementGroupRecord) (bool, error) {
|
||||
// Append rather than replace: a cluster group can have several launches in flight at once.
|
||||
for node, ids := range input.NodeInstances {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -39,7 +39,7 @@ func createTestGroup(t *testing.T, svc *PlacementGroupServiceImpl, name, strateg
|
||||
// readRecord returns the stored record for assertions.
|
||||
func readRecord(t *testing.T, svc *PlacementGroupServiceImpl, groupName string) *PlacementGroupRecord {
|
||||
t.Helper()
|
||||
entry, err := svc.kv.Get(t.Context(), kvutil.AccountKey(testAccountID, groupName))
|
||||
entry, err := svc.kv.Get(t.Context(), kvstore.AccountKey(testAccountID, groupName))
|
||||
require.NoError(t, err)
|
||||
var record PlacementGroupRecord
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &record))
|
||||
@@ -50,7 +50,7 @@ func readRecord(t *testing.T, svc *PlacementGroupServiceImpl, groupName string)
|
||||
// starting state the public API cannot produce.
|
||||
func seedRecord(t *testing.T, svc *PlacementGroupServiceImpl, groupName string, mutate func(*PlacementGroupRecord)) {
|
||||
t.Helper()
|
||||
_, err := kvutil.Update(t.Context(), svc.kv, kvutil.AccountKey(testAccountID, groupName), kvutil.CASConfig{},
|
||||
_, err := kvstore.Update(t.Context(), svc.kv, kvstore.AccountKey(testAccountID, groupName), kvstore.CASConfig{},
|
||||
func(record *PlacementGroupRecord) (bool, error) {
|
||||
mutate(record)
|
||||
return true, nil
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -52,7 +52,7 @@ func NewRouteTableServiceImplWithNATS(ctx context.Context, cfg *config.Config, n
|
||||
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
rtbKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketRouteTables, 10)
|
||||
rtbKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketRouteTables, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketRouteTables, err)
|
||||
}
|
||||
@@ -60,22 +60,22 @@ func NewRouteTableServiceImplWithNATS(ctx context.Context, cfg *config.Config, n
|
||||
return nil, fmt.Errorf("migrate %s: %w", KVBucketRouteTables, err)
|
||||
}
|
||||
|
||||
vpcKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
|
||||
vpcKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err)
|
||||
}
|
||||
|
||||
igwKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2igw.KVBucketIGW, 10)
|
||||
igwKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2igw.KVBucketIGW, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get IGW KV bucket: %w", err)
|
||||
}
|
||||
|
||||
subnetKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketSubnets, 10)
|
||||
subnetKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketSubnets, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get subnet KV bucket: %w", err)
|
||||
}
|
||||
|
||||
natgwKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2natgw.KVBucketNatGateways, 10)
|
||||
natgwKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2natgw.KVBucketNatGateways, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get NAT Gateway KV bucket: %w", err)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func NewRouteTableServiceImplWithNATS(ctx context.Context, cfg *config.Config, n
|
||||
|
||||
// getRouteTable retrieves a route table record from KV.
|
||||
func (s *RouteTableServiceImpl) getRouteTable(ctx context.Context, accountID, rtbID string) (*RouteTableRecord, error) {
|
||||
entry, err := s.rtbKV.Get(ctx, kvutil.AccountKey(accountID, rtbID))
|
||||
entry, err := s.rtbKV.Get(ctx, kvstore.AccountKey(accountID, rtbID))
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, rtbNotFoundError(rtbID)
|
||||
@@ -118,7 +118,7 @@ func (s *RouteTableServiceImpl) putRouteTable(ctx context.Context, accountID str
|
||||
slog.ErrorContext(ctx, "Failed to marshal route table record", "routeTableId", record.RouteTableId, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := s.rtbKV.Put(ctx, kvutil.AccountKey(accountID, record.RouteTableId), data); err != nil {
|
||||
if _, err := s.rtbKV.Put(ctx, kvstore.AccountKey(accountID, record.RouteTableId), data); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to write route table to KV", "routeTableId", record.RouteTableId, "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func (s *RouteTableServiceImpl) putRouteTable(ctx context.Context, accountID str
|
||||
// parallel AssociateRouteTable calls Terraform fans out per route table.
|
||||
const rtbCASMaxRetries = 16
|
||||
|
||||
// errRTBAbsent and errRTBContended mark the two kvutil outcomes that map to
|
||||
// errRTBAbsent and errRTBContended mark the two kvstore outcomes that map to
|
||||
// specific AWS errors rather than a generic internal failure. Neither
|
||||
// escapes this file.
|
||||
var (
|
||||
@@ -143,7 +143,7 @@ var (
|
||||
// several subnets with one route table at once. mutate reports whether
|
||||
// it changed the record; a false return commits nothing.
|
||||
func (s *RouteTableServiceImpl) mutateRouteTableCAS(ctx context.Context, accountID, rtbID string, mutate func(*RouteTableRecord) (bool, error)) error {
|
||||
_, err := kvutil.Update(ctx, s.rtbKV, kvutil.AccountKey(accountID, rtbID), kvutil.CASConfig{
|
||||
_, err := kvstore.Update(ctx, s.rtbKV, kvstore.AccountKey(accountID, rtbID), kvstore.CASConfig{
|
||||
Attempts: rtbCASMaxRetries,
|
||||
NotFound: errRTBAbsent,
|
||||
Exhausted: func(string, int) error { return errRTBContended },
|
||||
@@ -157,13 +157,13 @@ func (s *RouteTableServiceImpl) mutateRouteTableCAS(ctx context.Context, account
|
||||
// Contended rather than broken.
|
||||
slog.ErrorContext(ctx, "Route table CAS retries exhausted under contention",
|
||||
"routeTableId", rtbID, "attempts", rtbCASMaxRetries)
|
||||
case errors.Is(err, kvutil.ErrRead):
|
||||
case errors.Is(err, kvstore.ErrRead):
|
||||
slog.ErrorContext(ctx, "Failed to read route table from KV", "routeTableId", rtbID, "err", err)
|
||||
case errors.Is(err, kvutil.ErrDecode):
|
||||
case errors.Is(err, kvstore.ErrDecode):
|
||||
slog.ErrorContext(ctx, "Corrupt route table record in KV", "routeTableId", rtbID, "err", err)
|
||||
case errors.Is(err, kvutil.ErrEncode):
|
||||
case errors.Is(err, kvstore.ErrEncode):
|
||||
slog.ErrorContext(ctx, "Failed to marshal route table record", "routeTableId", rtbID, "err", err)
|
||||
case errors.Is(err, kvutil.ErrWrite):
|
||||
case errors.Is(err, kvstore.ErrWrite):
|
||||
slog.ErrorContext(ctx, "Failed to write route table to KV", "routeTableId", rtbID, "err", err)
|
||||
default:
|
||||
return err
|
||||
@@ -173,7 +173,7 @@ func (s *RouteTableServiceImpl) mutateRouteTableCAS(ctx context.Context, account
|
||||
|
||||
// getVPCCidr looks up a VPC's CIDR block from the VPC KV bucket.
|
||||
func (s *RouteTableServiceImpl) getVPCCidr(ctx context.Context, accountID, vpcID string) (string, error) {
|
||||
entry, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID))
|
||||
entry, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, vpcID))
|
||||
if err != nil {
|
||||
return "", awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID)
|
||||
}
|
||||
@@ -229,7 +229,7 @@ func (s *RouteTableServiceImpl) subnetsImplicitlyOnMainRT(ctx context.Context, a
|
||||
}
|
||||
var implicit []string
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.subnetKV.Get(ctx, key)
|
||||
@@ -387,7 +387,7 @@ func (s *RouteTableServiceImpl) allSubnetsForVPC(ctx context.Context, accountID,
|
||||
}
|
||||
var subnets []string
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.subnetKV.Get(ctx, key)
|
||||
@@ -455,7 +455,7 @@ func (s *RouteTableServiceImpl) allRouteTablesForVPC(ctx context.Context, accoun
|
||||
|
||||
var results []RouteTableRecord
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.rtbKV.Get(ctx, key)
|
||||
@@ -577,7 +577,7 @@ func (s *RouteTableServiceImpl) DeleteRouteTable(ctx context.Context, input *ec2
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.rtbKV.Delete(ctx, kvutil.AccountKey(accountID, rtbID)); err != nil {
|
||||
if err := s.rtbKV.Delete(ctx, kvstore.AccountKey(accountID, rtbID)); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
@@ -633,7 +633,7 @@ func (s *RouteTableServiceImpl) DescribeRouteTables(ctx context.Context, input *
|
||||
foundIDs := make(map[string]bool)
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -740,7 +740,7 @@ func (s *RouteTableServiceImpl) CreateRoute(ctx context.Context, input *ec2.Crea
|
||||
case input.GatewayId != nil && *input.GatewayId != "":
|
||||
igwID := *input.GatewayId
|
||||
// Verify IGW exists and is attached to the same VPC
|
||||
igwEntry, err := s.igwKV.Get(ctx, kvutil.AccountKey(accountID, igwID))
|
||||
igwEntry, err := s.igwKV.Get(ctx, kvstore.AccountKey(accountID, igwID))
|
||||
if err != nil {
|
||||
return nil, gatewayNotFoundError(igwID)
|
||||
}
|
||||
@@ -770,7 +770,7 @@ func (s *RouteTableServiceImpl) CreateRoute(ctx context.Context, input *ec2.Crea
|
||||
case input.NatGatewayId != nil && *input.NatGatewayId != "":
|
||||
natgwID := *input.NatGatewayId
|
||||
// Verify NAT GW exists and belongs to the same VPC
|
||||
natgwEntry, err := s.natgwKV.Get(ctx, kvutil.AccountKey(accountID, natgwID))
|
||||
natgwEntry, err := s.natgwKV.Get(ctx, kvstore.AccountKey(accountID, natgwID))
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidNatGatewayIDNotFound)
|
||||
}
|
||||
@@ -924,7 +924,7 @@ func (s *RouteTableServiceImpl) ReplaceRoute(ctx context.Context, input *ec2.Rep
|
||||
igwID := *input.GatewayId
|
||||
|
||||
// AWS reports a missing gateway ahead of a missing route.
|
||||
igwEntry, err := s.igwKV.Get(ctx, kvutil.AccountKey(accountID, igwID))
|
||||
igwEntry, err := s.igwKV.Get(ctx, kvstore.AccountKey(accountID, igwID))
|
||||
if err != nil {
|
||||
return nil, gatewayNotFoundError(igwID)
|
||||
}
|
||||
@@ -983,7 +983,7 @@ func (s *RouteTableServiceImpl) AssociateRouteTable(ctx context.Context, input *
|
||||
}
|
||||
|
||||
// Verify subnet exists and belongs to the same VPC
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.AccountKey(accountID, subnetID))
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvstore.AccountKey(accountID, subnetID))
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidSubnetIDNotFound)
|
||||
}
|
||||
@@ -1077,7 +1077,7 @@ func (s *RouteTableServiceImpl) DisassociateRouteTable(ctx context.Context, inpu
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,7 @@ func (s *RouteTableServiceImpl) ReplaceRouteTableAssociation(ctx context.Context
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1369,7 +1369,7 @@ func (s *RouteTableServiceImpl) publishNatGatewayDeleteEvents(ctx context.Contex
|
||||
if s.natsConn == nil {
|
||||
return
|
||||
}
|
||||
natgwEntry, err := s.natgwKV.Get(ctx, kvutil.AccountKey(accountID, natgwID))
|
||||
natgwEntry, err := s.natgwKV.Get(ctx, kvstore.AccountKey(accountID, natgwID))
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "NAT GW event: natgw lookup failed", "topic", networkv1.NATGatewayDeleteSubject, "natGatewayId", natgwID, "err", err)
|
||||
return
|
||||
@@ -1417,7 +1417,7 @@ func (s *RouteTableServiceImpl) publishNatGatewayEventsForAssociation(ctx contex
|
||||
if r.NatGatewayId == "" {
|
||||
continue
|
||||
}
|
||||
natgwEntry, err := s.natgwKV.Get(ctx, kvutil.AccountKey(accountID, r.NatGatewayId))
|
||||
natgwEntry, err := s.natgwKV.Get(ctx, kvstore.AccountKey(accountID, r.NatGatewayId))
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "NAT GW event: natgw lookup failed", "topic", topic, "natGatewayId", r.NatGatewayId, "err", err)
|
||||
continue
|
||||
@@ -1442,7 +1442,7 @@ func (s *RouteTableServiceImpl) publishNatGatewayEventForSubnet(ctx context.Cont
|
||||
if s.natsConn == nil {
|
||||
return
|
||||
}
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.AccountKey(accountID, subnetID))
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvstore.AccountKey(accountID, subnetID))
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "NAT GW event: subnet lookup failed", "topic", topic, "subnetId", subnetID, "err", err)
|
||||
return
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
ec2natgw "github.com/mulgadc/spinifex/spinifex/domains/ec2/natgw"
|
||||
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -28,23 +28,23 @@ func setupTestService(t *testing.T) *RouteTableServiceImpl {
|
||||
|
||||
// Seed VPC KV
|
||||
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, map[string][]byte{
|
||||
kvutil.AccountKey(testAccountID, "vpc-test1"): []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`),
|
||||
kvstore.AccountKey(testAccountID, "vpc-test1"): []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`),
|
||||
})
|
||||
|
||||
// Seed IGW KV (attached to vpc-test1)
|
||||
testutil.SeedKV(t, js, ec2igw.KVBucketIGW, map[string][]byte{
|
||||
kvutil.AccountKey(testAccountID, "igw-test1"): []byte(`{"internet_gateway_id":"igw-test1","vpc_id":"vpc-test1","state":"available"}`),
|
||||
kvstore.AccountKey(testAccountID, "igw-test1"): []byte(`{"internet_gateway_id":"igw-test1","vpc_id":"vpc-test1","state":"available"}`),
|
||||
})
|
||||
|
||||
// Seed subnet KV
|
||||
testutil.SeedKV(t, js, ec2vpc.KVBucketSubnets, map[string][]byte{
|
||||
kvutil.AccountKey(testAccountID, "subnet-test1"): []byte(`{"subnet_id":"subnet-test1","vpc_id":"vpc-test1","cidr_block":"10.0.1.0/24","state":"available"}`),
|
||||
kvutil.AccountKey(testAccountID, "subnet-priv1"): []byte(`{"subnet_id":"subnet-priv1","vpc_id":"vpc-test1","cidr_block":"10.0.11.0/24","state":"available"}`),
|
||||
kvstore.AccountKey(testAccountID, "subnet-test1"): []byte(`{"subnet_id":"subnet-test1","vpc_id":"vpc-test1","cidr_block":"10.0.1.0/24","state":"available"}`),
|
||||
kvstore.AccountKey(testAccountID, "subnet-priv1"): []byte(`{"subnet_id":"subnet-priv1","vpc_id":"vpc-test1","cidr_block":"10.0.11.0/24","state":"available"}`),
|
||||
})
|
||||
|
||||
// Seed NAT Gateway KV
|
||||
testutil.SeedKV(t, js, ec2natgw.KVBucketNatGateways, map[string][]byte{
|
||||
kvutil.AccountKey(testAccountID, "nat-test1"): []byte(`{"nat_gateway_id":"nat-test1","vpc_id":"vpc-test1","subnet_id":"subnet-test1","public_ip":"192.168.1.100","state":"available"}`),
|
||||
kvstore.AccountKey(testAccountID, "nat-test1"): []byte(`{"nat_gateway_id":"nat-test1","vpc_id":"vpc-test1","subnet_id":"subnet-test1","public_ip":"192.168.1.100","state":"available"}`),
|
||||
})
|
||||
|
||||
svc, err := NewRouteTableServiceImplWithNATS(t.Context(), nil, nc)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
)
|
||||
|
||||
// AccountSpotRequest is one persisted request in an account scan.
|
||||
@@ -54,7 +54,7 @@ func scanBucket(ctx context.Context, kv jetstream.KeyValue, accountID string) ([
|
||||
prefix := accountID + "."
|
||||
var out []AccountSpotRequest
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := kv.Get(ctx, key)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
)
|
||||
|
||||
func TestScanAccountListsActiveAndTerminalForOneAccount(t *testing.T) {
|
||||
@@ -35,7 +35,7 @@ func TestScanAccountListsActiveAndTerminalForOneAccount(t *testing.T) {
|
||||
func TestScanAccountFailsOnAnUndecodableRecord(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
putSIRs(t, svc, makeSIR("sir-aaa", "i-aaa"))
|
||||
_, err := svc.activeKV.Put(t.Context(), kvutil.AccountKey(testAccountID, "sir-bad"), []byte("{not json"))
|
||||
_, err := svc.activeKV.Put(t.Context(), kvstore.AccountKey(testAccountID, "sir-bad"), []byte("{not json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
scan, err := svc.ScanAccount(t.Context(), testAccountID)
|
||||
@@ -45,7 +45,7 @@ func TestScanAccountFailsOnAnUndecodableRecord(t *testing.T) {
|
||||
|
||||
func TestScanAccountFailsOnARecordWithoutARequest(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
_, err := svc.terminalKV.Put(t.Context(), kvutil.AccountKey(testAccountID, "sir-empty"), []byte(`{"account_id":"x"}`))
|
||||
_, err := svc.terminalKV.Put(t.Context(), kvstore.AccountKey(testAccountID, "sir-empty"), []byte(`{"account_id":"x"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.ScanAccount(t.Context(), testAccountID)
|
||||
@@ -76,7 +76,7 @@ func (k failingGetKV) Get(ctx context.Context, key string) (jetstream.KeyValueEn
|
||||
func TestScanAccountFailsWhenARecordCannotBeRead(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
putSIRs(t, svc, makeSIR("sir-aaa", "i-aaa"), makeSIR("sir-bbb", "i-bbb"))
|
||||
svc.activeKV = failingGetKV{KeyValue: svc.activeKV, key: kvutil.AccountKey(testAccountID, "sir-bbb")}
|
||||
svc.activeKV = failingGetKV{KeyValue: svc.activeKV, key: kvstore.AccountKey(testAccountID, "sir-bbb")}
|
||||
|
||||
scan, err := svc.ScanAccount(t.Context(), testAccountID)
|
||||
require.Error(t, err)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -24,14 +24,14 @@ type LaunchReferences struct {
|
||||
// account. It is complete or an error: an unlistable bucket or an unreadable
|
||||
// request is an error, because the unread request may name the resource.
|
||||
func (s *SpotInstanceServiceImpl) AccountLaunchReferences(ctx context.Context, accountID string) (*LaunchReferences, error) {
|
||||
keys, err := kvutil.Keys(ctx, s.activeKV)
|
||||
keys, err := kvstore.Keys(ctx, s.activeKV)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, fmt.Errorf("list spot requests: %w", err)
|
||||
}
|
||||
prefix := accountID + "."
|
||||
out := &LaunchReferences{}
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
record, err := s.readRecord(ctx, s.activeKV, key)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -41,7 +41,7 @@ func TestAccountLaunchReferences_ReadsOpenAndActiveRequests(t *testing.T) {
|
||||
func TestAccountLaunchReferences_UnreadableRequestFailsTheCheck(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
putSIRs(t, svc, makeSIR("sir-active01", "i-0000000000000001"))
|
||||
_, err := svc.activeKV.Put(t.Context(), kvutil.AccountKey(testAccountID, "sir-broken01"), []byte("{not json"))
|
||||
_, err := svc.activeKV.Put(t.Context(), kvstore.AccountKey(testAccountID, "sir-broken01"), []byte("{not json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.AccountLaunchReferences(t.Context(), testAccountID)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -41,7 +41,7 @@ func NewSpotInstanceServiceImplWithNATS(ctx context.Context, cfg *config.Config,
|
||||
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
activeKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketSpotRequests, 10)
|
||||
activeKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketSpotRequests, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketSpotRequests, err)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func NewSpotInstanceServiceImplWithNATS(ctx context.Context, cfg *config.Config,
|
||||
// getOrCreateTerminalBucket opens the terminal bucket without changing its
|
||||
// existing replica or placement configuration, creating it only when absent.
|
||||
func getOrCreateTerminalBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) {
|
||||
return kvutil.GetOrCreateBucketWithOptions(ctx, js, kvutil.BucketOptions{
|
||||
return kvstore.GetOrCreateBucketWithOptions(ctx, js, kvstore.BucketOptions{
|
||||
Name: KVBucketSpotRequestsTerminal,
|
||||
Description: "Terminal Spot Instance Requests (auto-expire after 1 hour)",
|
||||
History: 1,
|
||||
@@ -91,7 +91,7 @@ func (s *SpotInstanceServiceImpl) PutSpotInstanceRequests(ctx context.Context, i
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := s.activeKV.Put(ctx, kvutil.AccountKey(accountID, sirID), data); err != nil {
|
||||
if _, err := s.activeKV.Put(ctx, kvstore.AccountKey(accountID, sirID), data); err != nil {
|
||||
slog.ErrorContext(ctx, "PutSpotInstanceRequests: KV put failed", "sirId", sirID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func (s *SpotInstanceServiceImpl) CancelSpotInstanceRequests(ctx context.Context
|
||||
cancelled := make([]*ec2.CancelledSpotInstanceRequest, 0, len(input.SpotInstanceRequestIds))
|
||||
for _, idPtr := range input.SpotInstanceRequestIds {
|
||||
sirID := aws.StringValue(idPtr)
|
||||
key := kvutil.AccountKey(accountID, sirID)
|
||||
key := kvstore.AccountKey(accountID, sirID)
|
||||
|
||||
record, err := s.readRecord(ctx, s.activeKV, key)
|
||||
if err == nil {
|
||||
@@ -213,7 +213,7 @@ func (s *SpotInstanceServiceImpl) CloseForInstance(ctx context.Context, instance
|
||||
|
||||
prefix := accountID + "."
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
record, err := s.readRecord(ctx, s.activeKV, key)
|
||||
@@ -251,7 +251,7 @@ func (s *SpotInstanceServiceImpl) listRequests(ctx context.Context, kv jetstream
|
||||
prefix := accountID + "."
|
||||
var requests []*ec2.SpotInstanceRequest
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
record, err := s.readRecord(ctx, kv, key)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -39,7 +39,7 @@ func (k *interleavingKV) Get(ctx context.Context, key string) (jetstream.KeyValu
|
||||
// its write both survive alongside its own tag.
|
||||
func TestCurrentBehaviour_Tagging_RecordMirrorKeepsInterleavedWrites(t *testing.T) {
|
||||
const acct, id = "123456789012", "sg-0123456789abcdef0"
|
||||
key := kvutil.AccountKey(acct, id)
|
||||
key := kvstore.AccountKey(acct, id)
|
||||
seed, err := json.Marshal(tagMirrorRecord{Name: "original", Tags: map[string]string{}})
|
||||
require.NoError(t, err)
|
||||
kv := seedKV(t, "tagmirror-cb", map[string][]byte{key: seed})
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -83,8 +83,8 @@ var errRecordAbsent = errors.New("tagmirror: record absent")
|
||||
// applying mut and committing at the read revision, retrying on conflict so a
|
||||
// concurrent write is never reverted. An absent resource is skipped.
|
||||
func UpdateKVRecordTags[R any](ctx context.Context, kv jetstream.KeyValue, accountID, resourceID string, mut func(*R)) error {
|
||||
key := kvutil.AccountKey(accountID, resourceID)
|
||||
_, err := kvutil.Update(ctx, kv, key, kvutil.CASConfig{NotFound: errRecordAbsent}, func(rec *R) (bool, error) {
|
||||
key := kvstore.AccountKey(accountID, resourceID)
|
||||
_, err := kvstore.Update(ctx, kv, key, kvstore.CASConfig{NotFound: errRecordAbsent}, func(rec *R) (bool, error) {
|
||||
mut(rec)
|
||||
return true, nil
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -75,15 +75,15 @@ func TestUpdateKVRecordTags(t *testing.T) {
|
||||
rec, err := json.Marshal(&tagMirrorRecord{Name: "r1", Tags: map[string]string{"a": "1"}})
|
||||
require.NoError(t, err)
|
||||
kv := seedKV(t, "tagmirror-test", map[string][]byte{
|
||||
kvutil.AccountKey("acct", "res-1"): rec,
|
||||
kvutil.AccountKey("acct", "res-bad"): []byte("not-json"),
|
||||
kvstore.AccountKey("acct", "res-1"): rec,
|
||||
kvstore.AccountKey("acct", "res-bad"): []byte("not-json"),
|
||||
})
|
||||
|
||||
t.Run("mutates and persists", func(t *testing.T) {
|
||||
require.NoError(t, UpdateKVRecordTags(t.Context(), kv, "acct", "res-1", func(r *tagMirrorRecord) {
|
||||
r.Tags["b"] = "2"
|
||||
}))
|
||||
entry, err := kv.Get(t.Context(), kvutil.AccountKey("acct", "res-1"))
|
||||
entry, err := kv.Get(t.Context(), kvstore.AccountKey("acct", "res-1"))
|
||||
require.NoError(t, err)
|
||||
var got tagMirrorRecord
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &got))
|
||||
@@ -110,14 +110,14 @@ func TestMirrorKVRecordTags(t *testing.T) {
|
||||
nilTags, err := json.Marshal(&tagMirrorRecord{})
|
||||
require.NoError(t, err)
|
||||
kv := seedKV(t, "tagmirror-mirror-test", map[string][]byte{
|
||||
kvutil.AccountKey("acct", "vol-1"): withTags,
|
||||
kvutil.AccountKey("acct", "vol-2"): nilTags,
|
||||
kvstore.AccountKey("acct", "vol-1"): withTags,
|
||||
kvstore.AccountKey("acct", "vol-2"): nilTags,
|
||||
})
|
||||
|
||||
tagsOf := func(r *tagMirrorRecord) *map[string]string { return &r.Tags }
|
||||
|
||||
readTags := func(t *testing.T, id string) map[string]string {
|
||||
entry, err := kv.Get(t.Context(), kvutil.AccountKey("acct", id))
|
||||
entry, err := kv.Get(t.Context(), kvstore.AccountKey("acct", id))
|
||||
require.NoError(t, err)
|
||||
var got tagMirrorRecord
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &got))
|
||||
@@ -150,7 +150,7 @@ func TestMirrorKVRecordTags(t *testing.T) {
|
||||
// must never be touched even though it exists in this bucket.
|
||||
otherRec, err := json.Marshal(&tagMirrorRecord{Tags: map[string]string{"x": "y"}})
|
||||
require.NoError(t, err)
|
||||
_, err = kv.Put(t.Context(), kvutil.AccountKey("acct", "snap-other"), otherRec)
|
||||
_, err = kv.Put(t.Context(), kvstore.AccountKey("acct", "snap-other"), otherRec)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, MirrorKVRecordTags(t.Context(), kv, "acct", "vol-", resources, tagsOf,
|
||||
MergeTagsMut(&ec2.CreateTagsInput{Tags: []*ec2.Tag{
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -274,7 +274,7 @@ func (s *TagsServiceImpl) CreateTags(ctx context.Context, input *ec2.CreateTagsI
|
||||
func (s *TagsServiceImpl) taggedResourceIDs(ctx context.Context, accountID string) ([]string, error) {
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
keys, err := kvutil.Keys(ctx, s.kv)
|
||||
keys, err := kvstore.Keys(ctx, s.kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, fmt.Errorf("list tag keys: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -46,7 +46,7 @@ func resourceIDFromKVKey(key, accountID string) (string, bool) {
|
||||
// GetOrCreateTagsBucket opens the tag bucket, creating it at the cluster's
|
||||
// replica count if this is the first node to reach it.
|
||||
func GetOrCreateTagsBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) {
|
||||
return kvutil.GetOrCreateBucket(ctx, js, KVBucketTags, kvTagsHistory)
|
||||
return kvstore.GetOrCreateBucket(ctx, js, KVBucketTags, kvTagsHistory)
|
||||
}
|
||||
|
||||
// readKVTags returns a resource's tags from the bucket, and whether the key
|
||||
@@ -76,7 +76,7 @@ func (s *TagsServiceImpl) readKVTags(ctx context.Context, accountID, resourceID
|
||||
// before the store moved.
|
||||
func (s *TagsServiceImpl) mutateTags(ctx context.Context, accountID, resourceID string, mutate func(map[string]string)) error {
|
||||
key := tagsKVKey(accountID, resourceID)
|
||||
_, err := kvutil.Update(ctx, s.kv, key, kvutil.CASConfig{
|
||||
_, err := kvstore.Update(ctx, s.kv, key, kvstore.CASConfig{
|
||||
Attempts: tagCASAttempts,
|
||||
CreateIfAbsent: true,
|
||||
Exhausted: func(key string, attempts int) error {
|
||||
@@ -104,7 +104,7 @@ func (s *TagsServiceImpl) PutResourceTags(ctx context.Context, accountID, resour
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return kvutil.Put(ctx, s.kv, tagsKVKey(accountID, resourceID), kvutil.CASConfig{
|
||||
return kvstore.Put(ctx, s.kv, tagsKVKey(accountID, resourceID), kvstore.CASConfig{
|
||||
Attempts: tagCASAttempts,
|
||||
CreateIfAbsent: true,
|
||||
}, data)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -105,7 +105,7 @@ func (s *VPCServiceImpl) ensureDefaultVPC(ctx context.Context, accountID string,
|
||||
return claim.info(), nil
|
||||
}
|
||||
|
||||
if _, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, claim.VpcId)); err == nil {
|
||||
if _, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, claim.VpcId)); err == nil {
|
||||
return claim.info(), nil
|
||||
} else if !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, fmt.Errorf("read default VPC %s: %w", claim.VpcId, err)
|
||||
@@ -233,7 +233,7 @@ func oldestAccountRecord[T any](ctx context.Context, kv jetstream.KeyValue, acco
|
||||
var bestID string
|
||||
var bestCreated time.Time
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := kv.Get(ctx, key)
|
||||
@@ -262,7 +262,7 @@ func oldestAccountRecord[T any](ctx context.Context, kv jetstream.KeyValue, acco
|
||||
// marks the claim complete. Records are written with Create, so only the caller
|
||||
// that actually wrote one announces it to vpcd.
|
||||
func (s *VPCServiceImpl) buildDefaultVPC(ctx context.Context, accountID string, claim *defaultVPCClaim) error {
|
||||
created, err := createRecord(ctx, s.vpcKV, kvutil.AccountKey(accountID, claim.VpcId), VPCRecord{
|
||||
created, err := createRecord(ctx, s.vpcKV, kvstore.AccountKey(accountID, claim.VpcId), VPCRecord{
|
||||
VpcId: claim.VpcId,
|
||||
CidrBlock: claim.Cidr,
|
||||
State: "available",
|
||||
@@ -284,7 +284,7 @@ func (s *VPCServiceImpl) buildDefaultVPC(ctx context.Context, accountID string,
|
||||
if s.config != nil && s.config.AZ != "" {
|
||||
az = s.config.AZ
|
||||
}
|
||||
created, err = createRecord(ctx, s.subnetKV, kvutil.AccountKey(accountID, claim.SubnetId), SubnetRecord{
|
||||
created, err = createRecord(ctx, s.subnetKV, kvstore.AccountKey(accountID, claim.SubnetId), SubnetRecord{
|
||||
SubnetId: claim.SubnetId,
|
||||
VpcId: claim.VpcId,
|
||||
CidrBlock: claim.SubnetCidr,
|
||||
@@ -320,7 +320,7 @@ func (s *VPCServiceImpl) buildDefaultVPC(ctx context.Context, accountID string,
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := kvutil.Update(ctx, s.defaultsKV, accountID, kvutil.CASConfig{}, func(c *defaultVPCClaim) (bool, error) {
|
||||
if _, err := kvstore.Update(ctx, s.defaultsKV, accountID, kvstore.CASConfig{}, func(c *defaultVPCClaim) (bool, error) {
|
||||
if c.Complete || c.VpcId != claim.VpcId {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -149,12 +149,12 @@ func TestEnsureDefaultVPC_AdoptsOldestExistingDefault(t *testing.T) {
|
||||
const accountID = "000000000051"
|
||||
created := time.Date(2026, 9, 11, 14, 13, 40, 0, time.UTC)
|
||||
for i, id := range []string{"vpc-0000000000000newer", "vpc-0000000000000older"} {
|
||||
putRecord(t, js, ec2vpc.KVBucketVPCs, kvutil.AccountKey(accountID, id), ec2vpc.VPCRecord{
|
||||
putRecord(t, js, ec2vpc.KVBucketVPCs, kvstore.AccountKey(accountID, id), ec2vpc.VPCRecord{
|
||||
VpcId: id, CidrBlock: ec2vpc.DefaultVPCCidr, IsDefault: true, VNI: int64(200 + i),
|
||||
CreatedAt: created.Add(time.Duration(1-i) * time.Minute),
|
||||
})
|
||||
}
|
||||
putRecord(t, js, ec2vpc.KVBucketSubnets, kvutil.AccountKey(accountID, "subnet-00000000000older"), ec2vpc.SubnetRecord{
|
||||
putRecord(t, js, ec2vpc.KVBucketSubnets, kvstore.AccountKey(accountID, "subnet-00000000000older"), ec2vpc.SubnetRecord{
|
||||
SubnetId: "subnet-00000000000older", VpcId: "vpc-0000000000000older", CidrBlock: ec2vpc.DefaultSubnetCidr,
|
||||
IsDefault: true, CreatedAt: created,
|
||||
})
|
||||
@@ -183,7 +183,7 @@ func TestEnsureDefaultVPC_FinishesHalfBuiltClaim(t *testing.T) {
|
||||
"route_table_id": "rtb-00000000000half", "internet_gateway_id": "igw-00000000000half",
|
||||
"cidr": ec2vpc.DefaultVPCCidr, "subnet_cidr": ec2vpc.DefaultSubnetCidr, "vni": 4242,
|
||||
})
|
||||
putRecord(t, js, ec2vpc.KVBucketVPCs, kvutil.AccountKey(accountID, "vpc-00000000000000half"), ec2vpc.VPCRecord{
|
||||
putRecord(t, js, ec2vpc.KVBucketVPCs, kvstore.AccountKey(accountID, "vpc-00000000000000half"), ec2vpc.VPCRecord{
|
||||
VpcId: "vpc-00000000000000half", CidrBlock: ec2vpc.DefaultVPCCidr, IsDefault: true, VNI: 4242,
|
||||
})
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -118,7 +118,7 @@ func (s *VPCServiceImpl) CreateNetworkInterface(ctx context.Context, input *ec2.
|
||||
subnetId := *input.SubnetId
|
||||
|
||||
// Verify subnet exists and belongs to this account
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.AccountKey(accountID, subnetId))
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvstore.AccountKey(accountID, subnetId))
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidSubnetIDNotFound)
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func (s *VPCServiceImpl) CreateNetworkInterface(ctx context.Context, input *ec2.
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal ENI record: %w", err)
|
||||
}
|
||||
if _, err := s.eniKV.Put(ctx, kvutil.AccountKey(accountID, eniId), data); err != nil {
|
||||
if _, err := s.eniKV.Put(ctx, kvstore.AccountKey(accountID, eniId), data); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ func (s *VPCServiceImpl) ForceDeleteInstanceENI(ctx context.Context, accountID,
|
||||
}
|
||||
|
||||
func (s *VPCServiceImpl) deleteNetworkInterface(ctx context.Context, eniId, accountID string, force bool) (*ec2.DeleteNetworkInterfaceOutput, error) {
|
||||
key := kvutil.AccountKey(accountID, eniId)
|
||||
key := kvstore.AccountKey(accountID, eniId)
|
||||
|
||||
// Get the ENI record
|
||||
entry, err := s.eniKV.Get(ctx, key)
|
||||
@@ -346,7 +346,7 @@ func (s *VPCServiceImpl) releaseENISideEffects(ctx context.Context, eniId, accou
|
||||
// deletion that did not happen.
|
||||
func (s *VPCServiceImpl) DetachAndDeleteENI(ctx context.Context, accountID, eniID string, force bool) (deleted bool, err error) {
|
||||
const maxAttempts = 3
|
||||
key := kvutil.AccountKey(accountID, eniID)
|
||||
key := kvstore.AccountKey(accountID, eniID)
|
||||
var lastErr error
|
||||
|
||||
for range maxAttempts {
|
||||
@@ -458,7 +458,7 @@ func (s *VPCServiceImpl) ModifyNetworkInterfaceAttribute(ctx context.Context, in
|
||||
}
|
||||
|
||||
eniId := *input.NetworkInterfaceId
|
||||
key := kvutil.AccountKey(accountID, eniId)
|
||||
key := kvstore.AccountKey(accountID, eniId)
|
||||
|
||||
entry, err := s.eniKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -569,7 +569,7 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(ctx context.Context, input *e
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey {
|
||||
if key == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
@@ -707,7 +707,7 @@ func (s *VPCServiceImpl) AttachENIWithOwner(accountID, eniId, instanceId string,
|
||||
|
||||
func (s *VPCServiceImpl) attachENI(ctx context.Context, accountID, eniId, instanceId string, deviceIndex int64, instanceOwnerID string) (string, error) {
|
||||
attachmentId := awsidentifiers.GenerateResourceID("eni-attach")
|
||||
err := s.casENI(ctx, kvutil.AccountKey(accountID, eniId), eniId, func(record *ENIRecord) error {
|
||||
err := s.casENI(ctx, kvstore.AccountKey(accountID, eniId), eniId, func(record *ENIRecord) error {
|
||||
if record.Status == "in-use" {
|
||||
return errors.New(awserrors.ErrorInvalidNetworkInterfaceInUse)
|
||||
}
|
||||
@@ -737,7 +737,7 @@ func (s *VPCServiceImpl) attachENI(ctx context.Context, accountID, eniId, instan
|
||||
// DetachENI marks an ENI as detached from an instance (internal use by TerminateInstances).
|
||||
// accountID scopes the lookup to the correct KV key.
|
||||
func (s *VPCServiceImpl) DetachENI(ctx context.Context, accountID, eniId string) error {
|
||||
key := kvutil.AccountKey(accountID, eniId)
|
||||
key := kvstore.AccountKey(accountID, eniId)
|
||||
entry, err := s.eniKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
return eniNotFoundError(eniId)
|
||||
@@ -773,7 +773,7 @@ func (s *VPCServiceImpl) GetENIRecord(accountID, eniId string) (ENIRecord, error
|
||||
}
|
||||
|
||||
func (s *VPCServiceImpl) getENIRecord(ctx context.Context, accountID, eniId string) (ENIRecord, error) {
|
||||
key := kvutil.AccountKey(accountID, eniId)
|
||||
key := kvstore.AccountKey(accountID, eniId)
|
||||
entry, err := s.eniKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
return ENIRecord{}, eniNotFoundError(eniId)
|
||||
@@ -793,7 +793,7 @@ func (s *VPCServiceImpl) UpdateENI(accountID, eniId string, fn func(*ENIRecord))
|
||||
}
|
||||
|
||||
func (s *VPCServiceImpl) updateENI(ctx context.Context, accountID, eniId string, fn func(*ENIRecord)) error {
|
||||
return s.casENI(ctx, kvutil.AccountKey(accountID, eniId), eniId, func(r *ENIRecord) error {
|
||||
return s.casENI(ctx, kvstore.AccountKey(accountID, eniId), eniId, func(r *ENIRecord) error {
|
||||
fn(r)
|
||||
return nil
|
||||
})
|
||||
@@ -876,7 +876,7 @@ func (s *VPCServiceImpl) listInstanceENIs(ctx context.Context, accountID, instan
|
||||
prefix := accountID + "."
|
||||
var out []ENIRecord
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.eniKV.Get(ctx, key)
|
||||
@@ -938,7 +938,7 @@ func (s *VPCServiceImpl) scanENIs(ctx context.Context, match func(*ENIRecord) bo
|
||||
|
||||
var out []AccountENI
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey {
|
||||
if key == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
accountID, _, found := strings.Cut(key, ".")
|
||||
@@ -1004,7 +1004,7 @@ func (s *VPCServiceImpl) findENIByAttachment(ctx context.Context, accountID, att
|
||||
}
|
||||
prefix := accountID + "."
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.eniKV.Get(ctx, key)
|
||||
@@ -1028,7 +1028,7 @@ func (s *VPCServiceImpl) UpdateENIPublicIP(accountID, eniId, publicIP, poolName
|
||||
}
|
||||
|
||||
func (s *VPCServiceImpl) updateENIPublicIP(ctx context.Context, accountID, eniId, publicIP, poolName string) error {
|
||||
key := kvutil.AccountKey(accountID, eniId)
|
||||
key := kvstore.AccountKey(accountID, eniId)
|
||||
entry, err := s.eniKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ENI %s not found: %w", eniId, err)
|
||||
@@ -1063,7 +1063,7 @@ func (s *VPCServiceImpl) fillSGNames(ctx context.Context, accountID string, ids
|
||||
continue
|
||||
}
|
||||
names[id] = ""
|
||||
entry, err := s.sgKV.Get(ctx, kvutil.AccountKey(accountID, id))
|
||||
entry, err := s.sgKV.Get(ctx, kvstore.AccountKey(accountID, id))
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "fillSGNames: SG read failed", "groupId", id, "err", err)
|
||||
continue
|
||||
@@ -1080,7 +1080,7 @@ func (s *VPCServiceImpl) fillSGNames(ctx context.Context, accountID string, ids
|
||||
// vpcDNSHostnames reports whether the VPC has enableDnsHostnames on. An
|
||||
// unreadable VPC reads as off, so its interfaces go unnamed.
|
||||
func (s *VPCServiceImpl) vpcDNSHostnames(ctx context.Context, accountID, vpcID string) bool {
|
||||
entry, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID))
|
||||
entry, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, vpcID))
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "vpcDNSHostnames: VPC read failed", "vpcId", vpcID, "err", err)
|
||||
return false
|
||||
@@ -1238,7 +1238,7 @@ func (s *VPCServiceImpl) validateSGAttachment(ctx context.Context, accountID str
|
||||
|
||||
// Each SG must exist in the caller's account and belong to the same VPC.
|
||||
for _, sgId := range sgIds {
|
||||
sgEntry, err := s.sgKV.Get(ctx, kvutil.AccountKey(accountID, sgId))
|
||||
sgEntry, err := s.sgKV.Get(ctx, kvstore.AccountKey(accountID, sgId))
|
||||
if err != nil {
|
||||
return sgNotFoundError(sgId)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/projection"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/topology"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -69,7 +69,7 @@ func (s *VPCServiceImpl) findENIAnyAccount(ctx context.Context, eniID string) (*
|
||||
|
||||
suffix := "." + eniID
|
||||
for _, k := range keys {
|
||||
if k == kvutil.VersionKey || !strings.HasSuffix(k, suffix) {
|
||||
if k == kvstore.VersionKey || !strings.HasSuffix(k, suffix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.eniKV.Get(ctx, k)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -30,7 +30,7 @@ func newStaleReadENI(t *testing.T) (*VPCServiceImpl, string, func() ENIRecord) {
|
||||
// Reads the stored record directly: the wrapped bucket would serve the
|
||||
// check itself from a lagging replica.
|
||||
stored := func() ENIRecord {
|
||||
entry, err := inner.Get(context.Background(), kvutil.AccountKey(testAccountID, eniID))
|
||||
entry, err := inner.Get(context.Background(), kvstore.AccountKey(testAccountID, eniID))
|
||||
require.NoError(t, err)
|
||||
var rec ENIRecord
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &rec))
|
||||
@@ -114,7 +114,7 @@ func TestCASENIGivesUpAfterBoundedConflicts(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, eniID, _ := newStaleReadENI(t)
|
||||
conflicts := 0
|
||||
err := svc.casENI(t.Context(), kvutil.AccountKey(testAccountID, eniID), eniID, func(*ENIRecord) error {
|
||||
err := svc.casENI(t.Context(), kvstore.AccountKey(testAccountID, eniID), eniID, func(*ENIRecord) error {
|
||||
conflicts++
|
||||
// Move the record under the next compare-and-swap, as a competing writer does.
|
||||
return svc.UpdateENI(testAccountID, eniID, func(r *ENIRecord) { r.Description = "other writer" })
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ func keyWithSuffixExists(ctx context.Context, kv jetstream.KeyValue, resourceID
|
||||
}
|
||||
suffix := "." + resourceID
|
||||
for _, k := range keys {
|
||||
if k != kvutil.VersionKey && strings.HasSuffix(k, suffix) {
|
||||
if k != kvstore.VersionKey && strings.HasSuffix(k, suffix) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -245,7 +245,7 @@ func (s *VPCServiceImpl) CreateSecurityGroup(ctx context.Context, input *ec2.Cre
|
||||
}
|
||||
sgsInVPC := 0
|
||||
for _, k := range sgKeys {
|
||||
if k == kvutil.VersionKey {
|
||||
if k == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
@@ -305,7 +305,7 @@ func (s *VPCServiceImpl) CreateSecurityGroup(ctx context.Context, input *ec2.Cre
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal security group record: %w", err)
|
||||
}
|
||||
if _, err := s.sgKV.Put(ctx, kvutil.AccountKey(accountID, groupId), data); err != nil {
|
||||
if _, err := s.sgKV.Put(ctx, kvstore.AccountKey(accountID, groupId), data); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ func (s *VPCServiceImpl) DeleteSecurityGroup(ctx context.Context, input *ec2.Del
|
||||
}
|
||||
|
||||
groupId := *input.GroupId
|
||||
key := kvutil.AccountKey(accountID, groupId)
|
||||
key := kvstore.AccountKey(accountID, groupId)
|
||||
|
||||
entry, err := s.sgKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -380,7 +380,7 @@ func (s *VPCServiceImpl) validateSGRuleReferences(ctx context.Context, accountID
|
||||
if r.SourceSG == "" {
|
||||
continue
|
||||
}
|
||||
entry, err := s.sgKV.Get(ctx, kvutil.AccountKey(accountID, r.SourceSG))
|
||||
entry, err := s.sgKV.Get(ctx, kvstore.AccountKey(accountID, r.SourceSG))
|
||||
if err != nil {
|
||||
return sgNotFoundError(r.SourceSG)
|
||||
}
|
||||
@@ -406,7 +406,7 @@ func (s *VPCServiceImpl) checkSGDependencies(ctx context.Context, accountID, gro
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
for _, k := range eniKeys {
|
||||
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.eniKV.Get(ctx, k)
|
||||
@@ -435,7 +435,7 @@ func (s *VPCServiceImpl) checkSGDependencies(ctx context.Context, accountID, gro
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
for _, k := range sgKeys {
|
||||
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.sgKV.Get(ctx, k)
|
||||
@@ -561,7 +561,7 @@ func (s *VPCServiceImpl) DescribeSecurityGroups(ctx context.Context, input *ec2.
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey {
|
||||
if key == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
@@ -713,7 +713,7 @@ func (s *VPCServiceImpl) GetSecurityGroupsForVpc(ctx context.Context, input *ec2
|
||||
|
||||
groups := []*ec2.SecurityGroupForVpc{}
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.sgKV.Get(ctx, key)
|
||||
@@ -844,7 +844,7 @@ func (s *VPCServiceImpl) DescribeSecurityGroupRules(ctx context.Context, input *
|
||||
emitted := make(map[string]bool)
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.sgKV.Get(ctx, key)
|
||||
@@ -945,7 +945,7 @@ func (s *VPCServiceImpl) updateSGRuleTags(ctx context.Context, accountID, ruleID
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.sgKV.Get(ctx, key)
|
||||
@@ -1056,7 +1056,7 @@ func (s *VPCServiceImpl) AuthorizeSecurityGroupIngress(ctx context.Context, inpu
|
||||
}
|
||||
|
||||
groupId := *input.GroupId
|
||||
key := kvutil.AccountKey(accountID, groupId)
|
||||
key := kvstore.AccountKey(accountID, groupId)
|
||||
|
||||
entry, err := s.sgKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -1134,7 +1134,7 @@ func (s *VPCServiceImpl) AuthorizeSecurityGroupEgress(ctx context.Context, input
|
||||
}
|
||||
|
||||
groupId := *input.GroupId
|
||||
key := kvutil.AccountKey(accountID, groupId)
|
||||
key := kvstore.AccountKey(accountID, groupId)
|
||||
|
||||
entry, err := s.sgKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -1212,7 +1212,7 @@ func (s *VPCServiceImpl) RevokeSecurityGroupIngress(ctx context.Context, input *
|
||||
}
|
||||
|
||||
groupId := *input.GroupId
|
||||
key := kvutil.AccountKey(accountID, groupId)
|
||||
key := kvstore.AccountKey(accountID, groupId)
|
||||
|
||||
entry, err := s.sgKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -1277,7 +1277,7 @@ func (s *VPCServiceImpl) RevokeSecurityGroupEgress(ctx context.Context, input *e
|
||||
}
|
||||
|
||||
groupId := *input.GroupId
|
||||
key := kvutil.AccountKey(accountID, groupId)
|
||||
key := kvstore.AccountKey(accountID, groupId)
|
||||
|
||||
entry, err := s.sgKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -1397,7 +1397,7 @@ func (s *VPCServiceImpl) updateSGRuleDescriptions(ctx context.Context, accountID
|
||||
return errors.New(awserrors.ErrorInvalidParameterCombination)
|
||||
}
|
||||
|
||||
key := kvutil.AccountKey(accountID, req.groupId)
|
||||
key := kvstore.AccountKey(accountID, req.groupId)
|
||||
entry, err := s.sgKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
return sgNotFoundError(req.groupId)
|
||||
@@ -1581,7 +1581,7 @@ func (s *VPCServiceImpl) ModifySecurityGroupRules(ctx context.Context, input *ec
|
||||
}
|
||||
|
||||
groupId := *input.GroupId
|
||||
key := kvutil.AccountKey(accountID, groupId)
|
||||
key := kvstore.AccountKey(accountID, groupId)
|
||||
|
||||
entry, err := s.sgKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -2158,7 +2158,7 @@ func (s *VPCServiceImpl) storeDefaultSecurityGroup(ctx context.Context, accountI
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("marshal default security group: %w", err)
|
||||
}
|
||||
if _, err := s.sgKV.Create(ctx, kvutil.AccountKey(accountID, groupId), data); err != nil {
|
||||
if _, err := s.sgKV.Create(ctx, kvstore.AccountKey(accountID, groupId), data); err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return record, false, nil
|
||||
}
|
||||
@@ -2182,7 +2182,7 @@ func (s *VPCServiceImpl) announceDefaultSecurityGroup(record *SecurityGroupRecor
|
||||
// CannotDelete guard for default SGs. Used by DeleteVpc to cascade-delete the
|
||||
// per-VPC default SG. Surfaces vpcd tear-down failures to the caller.
|
||||
func (s *VPCServiceImpl) deleteSecurityGroupInternal(ctx context.Context, accountID, groupId string) error {
|
||||
key := kvutil.AccountKey(accountID, groupId)
|
||||
key := kvstore.AccountKey(accountID, groupId)
|
||||
entry, err := s.sgKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read default security group: %w", err)
|
||||
@@ -2219,7 +2219,7 @@ func (s *VPCServiceImpl) findDefaultSGForVPC(ctx context.Context, accountID, vpc
|
||||
return "", err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.sgKV.Get(ctx, k)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -62,7 +62,7 @@ func tcpPeerRequest(port int64, peer string) *ec2.SecurityGroupRuleRequest {
|
||||
// modify wrote nothing.
|
||||
func storedSGRecord(t *testing.T, svc *VPCServiceImpl, sgID string) []byte {
|
||||
t.Helper()
|
||||
entry, err := svc.sgKV.Get(t.Context(), kvutil.AccountKey(testAccountID, sgID))
|
||||
entry, err := svc.sgKV.Get(t.Context(), kvstore.AccountKey(testAccountID, sgID))
|
||||
require.NoError(t, err)
|
||||
return entry.Value()
|
||||
}
|
||||
@@ -689,7 +689,7 @@ func TestRevokeSecurityGroupIngress_InvertedPortRangeStillRevocable(t *testing.T
|
||||
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
|
||||
sgID := createTestSG(t, svc, vpcID, "stale-inverted-ports")
|
||||
|
||||
key := kvutil.AccountKey(testAccountID, sgID)
|
||||
key := kvstore.AccountKey(testAccountID, sgID)
|
||||
var rec SecurityGroupRecord
|
||||
require.NoError(t, json.Unmarshal(storedSGRecord(t, svc, sgID), &rec))
|
||||
rec.IngressRules = append(rec.IngressRules, SGRule{
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -35,7 +35,7 @@ func plantIngressRules(t *testing.T, svc *VPCServiceImpl, sgID string, rules ...
|
||||
rec.IngressRules = append(rec.IngressRules, rules...)
|
||||
data, err := json.Marshal(rec)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.sgKV.Put(t.Context(), kvutil.AccountKey(testAccountID, sgID), data)
|
||||
_, err = svc.sgKV.Put(t.Context(), kvstore.AccountKey(testAccountID, sgID), data)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -1524,7 +1524,7 @@ func findDefaultSGInVPC(t *testing.T, svc *VPCServiceImpl, vpcID string) string
|
||||
require.NoError(t, err)
|
||||
var matches []string
|
||||
for _, k := range keys {
|
||||
if k == kvutil.VersionKey {
|
||||
if k == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
entry, err := svc.sgKV.Get(t.Context(), k)
|
||||
@@ -2122,7 +2122,7 @@ func seedAvailableVPC(t *testing.T, svc *VPCServiceImpl, vpcID string) {
|
||||
rec := VPCRecord{VpcId: vpcID, CidrBlock: "10.0.0.0/16", State: "available"}
|
||||
data, err := json.Marshal(rec)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.vpcKV.Put(t.Context(), kvutil.AccountKey(testAccountID, vpcID), data)
|
||||
_, err = svc.vpcKV.Put(t.Context(), kvstore.AccountKey(testAccountID, vpcID), data)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -2359,7 +2359,7 @@ func TestAuthorizeSecurityGroupIngress_AssignsRuleIDs(t *testing.T) {
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
entry, err := svc.sgKV.Get(t.Context(), kvutil.AccountKey(testAccountID, sgID))
|
||||
entry, err := svc.sgKV.Get(t.Context(), kvstore.AccountKey(testAccountID, sgID))
|
||||
require.NoError(t, err)
|
||||
var record SecurityGroupRecord
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &record))
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/zoneid"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -173,7 +173,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
|
||||
return nil, fmt.Errorf("create JetStream client: %w", err)
|
||||
}
|
||||
|
||||
vpcKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketVPCs, 10)
|
||||
vpcKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketVPCs, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketVPCs, err)
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
|
||||
return nil, fmt.Errorf("migrate %s: %w", KVBucketVPCs, err)
|
||||
}
|
||||
|
||||
subnetKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketSubnets, 10)
|
||||
subnetKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketSubnets, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketSubnets, err)
|
||||
}
|
||||
@@ -189,7 +189,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
|
||||
return nil, fmt.Errorf("migrate %s: %w", KVBucketSubnets, err)
|
||||
}
|
||||
|
||||
vniKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketVNICounter, 10)
|
||||
vniKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketVNICounter, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketVNICounter, err)
|
||||
}
|
||||
@@ -197,7 +197,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
|
||||
return nil, fmt.Errorf("migrate %s: %w", KVBucketVNICounter, err)
|
||||
}
|
||||
|
||||
eniKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketENIs, 10)
|
||||
eniKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketENIs, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketENIs, err)
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
|
||||
return nil, fmt.Errorf("migrate %s: %w", KVBucketENIs, err)
|
||||
}
|
||||
|
||||
sgKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketSecurityGroups, 10)
|
||||
sgKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketSecurityGroups, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketSecurityGroups, err)
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
|
||||
return nil, fmt.Errorf("migrate %s: %w", KVBucketSecurityGroups, err)
|
||||
}
|
||||
|
||||
defaultsKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketDefaultVPCs, 10)
|
||||
defaultsKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketDefaultVPCs, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketDefaultVPCs, err)
|
||||
}
|
||||
@@ -221,18 +221,18 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
|
||||
return nil, fmt.Errorf("migrate %s: %w", KVBucketDefaultVPCs, err)
|
||||
}
|
||||
|
||||
rtbKV, err := kvutil.GetOrCreateBucket(ctx, js, "spinifex-vpc-route-tables", 10)
|
||||
rtbKV, err := kvstore.GetOrCreateBucket(ctx, js, "spinifex-vpc-route-tables", 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket spinifex-vpc-route-tables: %w", err)
|
||||
}
|
||||
|
||||
// Named literally, like the route table bucket above: the IGW package
|
||||
// imports this one, so its constant cannot be referenced here.
|
||||
igwKV, err := kvutil.GetOrCreateBucket(ctx, js, "spinifex-igw", 10)
|
||||
igwKV, err := kvstore.GetOrCreateBucket(ctx, js, "spinifex-igw", 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket spinifex-igw: %w", err)
|
||||
}
|
||||
eigwKV, err := kvutil.GetOrCreateBucket(ctx, js, "spinifex-eigw", 10)
|
||||
eigwKV, err := kvstore.GetOrCreateBucket(ctx, js, "spinifex-eigw", 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket spinifex-eigw: %w", err)
|
||||
}
|
||||
@@ -268,7 +268,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
|
||||
// nextVNI allocates the next VNI using atomic increment on the NATS KV counter.
|
||||
func (s *VPCServiceImpl) nextVNI(ctx context.Context) (int64, error) {
|
||||
var allocated int64
|
||||
_, err := kvutil.Update(ctx, s.vniKV, vniCounterKey, kvutil.CASConfig{CreateIfAbsent: true, Attempts: vniCASAttempts},
|
||||
_, err := kvstore.Update(ctx, s.vniKV, vniCounterKey, kvstore.CASConfig{CreateIfAbsent: true, Attempts: vniCASAttempts},
|
||||
func(next *int64) (bool, error) {
|
||||
if *next == 0 {
|
||||
*next = vniStart
|
||||
@@ -331,7 +331,7 @@ func (s *VPCServiceImpl) CreateVpc(ctx context.Context, input *ec2.CreateVpcInpu
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal VPC record: %w", err)
|
||||
}
|
||||
if _, err := s.vpcKV.Put(ctx, kvutil.AccountKey(accountID, vpcID), data); err != nil {
|
||||
if _, err := s.vpcKV.Put(ctx, kvstore.AccountKey(accountID, vpcID), data); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
@@ -366,7 +366,7 @@ func (s *VPCServiceImpl) CreateVpc(ctx context.Context, input *ec2.CreateVpcInpu
|
||||
// requireVPCExists returns InvalidVpcID.NotFound if the VPC doesn't exist for
|
||||
// this account.
|
||||
func (s *VPCServiceImpl) requireVPCExists(ctx context.Context, accountID, vpcId string) error {
|
||||
if _, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcId)); err != nil {
|
||||
if _, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, vpcId)); err != nil {
|
||||
return vpcNotFoundError(vpcId)
|
||||
}
|
||||
return nil
|
||||
@@ -379,7 +379,7 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu
|
||||
}
|
||||
|
||||
vpcID := *input.VpcId
|
||||
key := kvutil.AccountKey(accountID, vpcID)
|
||||
key := kvstore.AccountKey(accountID, vpcID)
|
||||
|
||||
if _, err := s.vpcKV.Get(ctx, key); err != nil {
|
||||
// AWS-faithful: an absent VPC is NotFound (the tofu/SDK provider
|
||||
@@ -398,7 +398,7 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
for _, k := range subnetKeys {
|
||||
if k == kvutil.VersionKey {
|
||||
if k == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
@@ -435,7 +435,7 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
for _, k := range sgKeys {
|
||||
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.sgKV.Get(ctx, k)
|
||||
@@ -472,7 +472,7 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
for _, k := range rtbKeys {
|
||||
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.rtbKV.Get(ctx, k)
|
||||
@@ -533,7 +533,7 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if rtbID != "" {
|
||||
if err := s.rtbKV.Delete(ctx, kvutil.AccountKey(accountID, rtbID)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
if err := s.rtbKV.Delete(ctx, kvstore.AccountKey(accountID, rtbID)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
slog.ErrorContext(ctx, "DeleteVpc: main route table reap failed", "vpcId", vpcID, "routeTableId", rtbID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
@@ -574,7 +574,7 @@ func rejectAttachedGateway(ctx context.Context, kv jetstream.KeyValue, accountID
|
||||
}
|
||||
|
||||
for _, k := range keys {
|
||||
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := kv.Get(ctx, k)
|
||||
@@ -653,7 +653,7 @@ func (s *VPCServiceImpl) DescribeVpcs(ctx context.Context, input *ec2.DescribeVp
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey {
|
||||
if key == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
@@ -723,7 +723,7 @@ func (s *VPCServiceImpl) CreateSubnet(ctx context.Context, input *ec2.CreateSubn
|
||||
}
|
||||
|
||||
// Verify VPC exists and belongs to this account
|
||||
vpcEntry, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID))
|
||||
vpcEntry, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, vpcID))
|
||||
if err != nil {
|
||||
return nil, vpcNotFoundError(vpcID)
|
||||
}
|
||||
@@ -757,7 +757,7 @@ func (s *VPCServiceImpl) CreateSubnet(ctx context.Context, input *ec2.CreateSubn
|
||||
}
|
||||
|
||||
for _, k := range subnetKeys {
|
||||
if k == kvutil.VersionKey {
|
||||
if k == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
@@ -822,7 +822,7 @@ func (s *VPCServiceImpl) CreateSubnet(ctx context.Context, input *ec2.CreateSubn
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal subnet record: %w", err)
|
||||
}
|
||||
if _, err := s.subnetKV.Put(ctx, kvutil.AccountKey(accountID, subnetID), data); err != nil {
|
||||
if _, err := s.subnetKV.Put(ctx, kvstore.AccountKey(accountID, subnetID), data); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
@@ -843,7 +843,7 @@ func (s *VPCServiceImpl) DeleteSubnet(ctx context.Context, input *ec2.DeleteSubn
|
||||
}
|
||||
|
||||
subnetID := *input.SubnetId
|
||||
key := kvutil.AccountKey(accountID, subnetID)
|
||||
key := kvstore.AccountKey(accountID, subnetID)
|
||||
|
||||
// Read subnet record before deletion (needed for vpcd event)
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, key)
|
||||
@@ -902,7 +902,7 @@ func (s *VPCServiceImpl) checkSubnetResidents(ctx context.Context, accountID, su
|
||||
}
|
||||
prefix := accountID + "."
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.eniKV.Get(ctx, key)
|
||||
@@ -950,7 +950,7 @@ func (s *VPCServiceImpl) clearRouteTableAssociationsForSubnet(ctx context.Contex
|
||||
}
|
||||
prefix := accountID + "."
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.rtbKV.Get(ctx, key)
|
||||
@@ -1035,7 +1035,7 @@ func (s *VPCServiceImpl) DescribeSubnets(ctx context.Context, input *ec2.Describ
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey {
|
||||
if key == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
@@ -1386,7 +1386,7 @@ func (s *VPCServiceImpl) ModifySubnetAttribute(ctx context.Context, input *ec2.M
|
||||
}
|
||||
|
||||
subnetID := *input.SubnetId
|
||||
key := kvutil.AccountKey(accountID, subnetID)
|
||||
key := kvstore.AccountKey(accountID, subnetID)
|
||||
|
||||
entry, err := s.subnetKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -1438,7 +1438,7 @@ func (s *VPCServiceImpl) ModifyVpcAttribute(ctx context.Context, input *ec2.Modi
|
||||
}
|
||||
|
||||
vpcID := *input.VpcId
|
||||
key := kvutil.AccountKey(accountID, vpcID)
|
||||
key := kvstore.AccountKey(accountID, vpcID)
|
||||
|
||||
entry, err := s.vpcKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -1486,7 +1486,7 @@ func (s *VPCServiceImpl) DescribeVpcAttribute(ctx context.Context, input *ec2.De
|
||||
}
|
||||
|
||||
vpcID := *input.VpcId
|
||||
key := kvutil.AccountKey(accountID, vpcID)
|
||||
key := kvstore.AccountKey(accountID, vpcID)
|
||||
|
||||
entry, err := s.vpcKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
@@ -1587,7 +1587,7 @@ func (s *VPCServiceImpl) writeMainRouteTable(ctx context.Context, accountID, vpc
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("marshal main route table: %w", err)
|
||||
}
|
||||
if _, err := s.rtbKV.Create(ctx, kvutil.AccountKey(accountID, rtbID), data); err != nil {
|
||||
if _, err := s.rtbKV.Create(ctx, kvstore.AccountKey(accountID, rtbID), data); err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -1611,7 +1611,7 @@ func (s *VPCServiceImpl) findMainRouteTableID(ctx context.Context, accountID, vp
|
||||
return "", fmt.Errorf("list rtb keys: %w", err)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.rtbKV.Get(ctx, key)
|
||||
@@ -1652,7 +1652,7 @@ func (s *VPCServiceImpl) getDefaultSubnet(ctx context.Context, accountID string)
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvutil.VersionKey {
|
||||
if key == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
@@ -1680,7 +1680,7 @@ func (s *VPCServiceImpl) GetSubnet(accountID, subnetId string) (*SubnetRecord, e
|
||||
}
|
||||
|
||||
func (s *VPCServiceImpl) getSubnet(ctx context.Context, accountID, subnetId string) (*SubnetRecord, error) {
|
||||
key := kvutil.AccountKey(accountID, subnetId)
|
||||
key := kvstore.AccountKey(accountID, subnetId)
|
||||
entry, err := s.subnetKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("subnet %s not found: %w", subnetId, err)
|
||||
@@ -1726,7 +1726,7 @@ func (s *VPCServiceImpl) SubnetAvailabilityZones(ctx context.Context) ([]string,
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, k := range keys {
|
||||
if k == kvutil.VersionKey {
|
||||
if k == kvstore.VersionKey {
|
||||
continue
|
||||
}
|
||||
entry, err := s.subnetKV.Get(ctx, k)
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/zoneid"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -2689,7 +2689,7 @@ func TestCreateSubnet_ZoneStoreFailureWritesNothingAndPublishesNothing(t *testin
|
||||
require.ErrorIs(t, err, jetstream.ErrNoKeysFound)
|
||||
}
|
||||
for _, k := range keys {
|
||||
assert.Equal(t, kvutil.VersionKey, k, "no subnet record may exist")
|
||||
assert.Equal(t, kvstore.VersionKey, k, "no subnet record may exist")
|
||||
}
|
||||
assert.Zero(t, events.Load(), "no topology event may be published")
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -111,7 +111,7 @@ func TestLoadOrCreateSigningKey_FailsWhenNoKeyDecrypts(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "master key does not match")
|
||||
|
||||
entries, err := kvutil.Keys(t.Context(), kv)
|
||||
entries, err := kvstore.Keys(t.Context(), kv)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 1, "a failed load must not mint a key alongside the unreadable one")
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -111,7 +111,7 @@ func openSigningBucket(ctx context.Context, js jetstream.JetStream, masterKey []
|
||||
if len(masterKey) == 0 {
|
||||
return nil, errors.New("ecrauth: empty master key")
|
||||
}
|
||||
return kvutil.GetOrCreateBucket(ctx, js, SigningBucket, signingKeyHistory)
|
||||
return kvstore.GetOrCreateBucket(ctx, js, SigningBucket, signingKeyHistory)
|
||||
}
|
||||
|
||||
// reloadKeys reads every stored signing key, returning the verify set, each
|
||||
@@ -125,7 +125,7 @@ func openSigningBucket(ctx context.Context, js jetstream.JetStream, masterKey []
|
||||
// load instead takes the gateway down on every node, permanently, over a key no
|
||||
// live request needs.
|
||||
func reloadKeys(ctx context.Context, kv jetstream.KeyValue, masterKey []byte) (active *SigningKey, verify map[string]*ecdsa.PublicKey, metas []keyMeta, undecodable int, err error) {
|
||||
names, err := kvutil.Keys(ctx, kv)
|
||||
names, err := kvstore.Keys(ctx, kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil, nil, 0, fmt.Errorf("list signing keys: %w", err)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -179,7 +179,7 @@ func (s *KVMetaStore) bucket(ctx context.Context, accountID string) (jetstream.K
|
||||
return kv, nil
|
||||
}
|
||||
name := KVAccountBucket(accountID)
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, s.js, name, KVBucketAccountHistory)
|
||||
kv, err := kvstore.GetOrCreateBucket(ctx, s.js, name, KVBucketAccountHistory)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ecr: open account bucket %s: %w", name, err)
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ func TestReaper_Awsvpc_ReleasesENI(t *testing.T) {
|
||||
rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
|
||||
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec))
|
||||
|
||||
sc := NewScheduler(nc, svc, "test-holder")
|
||||
sc := testScheduler(t, nc, svc, "test-holder")
|
||||
sc.reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
|
||||
|
||||
assert.Equal(t, 1, eni.releaseCalls)
|
||||
|
||||
@@ -27,7 +27,7 @@ func dropRevisit(pass func(context.Context) (time.Duration, error)) func(context
|
||||
// drained, services never relaunched, stopped tasks never pruned.
|
||||
func TestSchedulerPasses_EnumerationFailureIsReported(t *testing.T) {
|
||||
svc, nc := newTestService(t)
|
||||
sc := NewScheduler(nc, svc, "test-holder")
|
||||
sc := testScheduler(t, nc, svc, "test-holder")
|
||||
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestRegister_RestoresReapedInstanceToActive(t *testing.T) {
|
||||
rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
|
||||
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec))
|
||||
|
||||
NewScheduler(nc, svc, "test-holder").reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
|
||||
testScheduler(t, nc, svc, "test-holder").reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
|
||||
|
||||
reaped := instanceStatus(t, svc, "web", "i-1")
|
||||
require.Equal(t, InstanceStatusDraining, reaped.Status)
|
||||
@@ -89,7 +89,7 @@ func TestReaper_ReleasesCapacityAndRecoversClean(t *testing.T) {
|
||||
seeded.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
|
||||
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &seeded))
|
||||
|
||||
NewScheduler(nc, svc, "test-holder").reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
|
||||
testScheduler(t, nc, svc, "test-holder").reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
|
||||
|
||||
reaped := instanceStatus(t, svc, "web", "i-1")
|
||||
require.Equal(t, InstanceStatusDraining, reaped.Status)
|
||||
|
||||
@@ -50,7 +50,7 @@ func schedulerRig(t *testing.T) (*Scheduler, *Service, jetstream.KeyValue) {
|
||||
registerInstance(t, svc, "web", "i-1", 4096, 8192)
|
||||
kv, err := svc.bucket(t.Context(), testAccountID)
|
||||
require.NoError(t, err)
|
||||
return NewScheduler(nc, svc, "test-holder"), svc, kv
|
||||
return testScheduler(t, nc, svc, "test-holder"), svc, kv
|
||||
}
|
||||
|
||||
// runOneTask places a task and returns its ID, read back from the record rather
|
||||
|
||||
@@ -13,17 +13,19 @@ import (
|
||||
|
||||
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Scheduler timing. The leader lease lives in spinifex-ecs-leader (60s TTL); the
|
||||
// holder refreshes well inside that. The reaper marks an instance DRAINING after
|
||||
// three missed 30s heartbeats.
|
||||
// KeyScheduler is the ECS scheduler's leader lease in the sync bucket.
|
||||
const KeyScheduler = "ecs.scheduler"
|
||||
|
||||
// Scheduler timing. A follower asks again at the lease's renewal rate. The
|
||||
// reaper marks an instance DRAINING after three missed 30s heartbeats.
|
||||
const (
|
||||
schedulerLeaderKey = "scheduler"
|
||||
leaseRefresh = 20 * time.Second
|
||||
schedulerLeaseTTL = 60 * time.Second
|
||||
leaseRefresh = schedulerLeaseTTL / 3
|
||||
reaperInterval = 30 * time.Second
|
||||
heartbeatTimeout = 90 * time.Second
|
||||
stoppedReasonReaped = "ContainerInstance disconnected"
|
||||
@@ -59,17 +61,14 @@ const (
|
||||
reconcileResync = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Scheduler is the per-daemon ECS control loop. A single leader (elected via the
|
||||
// shared leader bucket) owns the Layer-2 bus subscriptions and the heartbeat
|
||||
// Scheduler is the per-daemon ECS control loop. A single leader (elected on its
|
||||
// lease) owns the Layer-2 bus subscriptions and the heartbeat
|
||||
// reaper; losers idle and retry. Placement itself happens synchronously in the
|
||||
// RunTask handler, so a brief leaderless gap never blocks RunTask.
|
||||
type Scheduler struct {
|
||||
nc *nats.Conn
|
||||
svc *Service
|
||||
holder string
|
||||
|
||||
lease *kvlease.Lease
|
||||
leaseErr error
|
||||
nc *nats.Conn
|
||||
svc *Service
|
||||
lease *kvsync.Lease
|
||||
|
||||
// wake carries a bus message that changed something the service reconcile
|
||||
// reads. Fed by the task-state subscription and not by the heartbeat one:
|
||||
@@ -89,47 +88,28 @@ type Scheduler struct {
|
||||
subs []*nats.Subscription
|
||||
}
|
||||
|
||||
// NewScheduler constructs a Scheduler. holder identifies this daemon in the lease.
|
||||
func NewScheduler(nc *nats.Conn, svc *Service, holder string) *Scheduler {
|
||||
// One slot: it means "something the reconcile reads has changed", and a
|
||||
// second arriving before the pass runs says nothing the first did not.
|
||||
sc := &Scheduler{nc: nc, svc: svc, holder: holder, wake: make(chan struct{}, 1)}
|
||||
sc.lease, sc.leaseErr = kvlease.New(kvlease.Config{
|
||||
Name: "ecs/scheduler",
|
||||
Bucket: sc.leaderBucket,
|
||||
Key: schedulerLeaderKey,
|
||||
Holder: holder,
|
||||
TTL: KVBucketECSLeaderTTL,
|
||||
Renew: leaseRefresh,
|
||||
Retry: leaseRefresh,
|
||||
// The leader owns the Layer-2 bus subscriptions and the instance-role
|
||||
// converge pass. A node that can do neither stands down rather than hold
|
||||
// the lease and process nothing.
|
||||
OnGained: sc.onGained,
|
||||
OnLost: sc.unsubscribeBus,
|
||||
})
|
||||
return sc
|
||||
// NewSchedulerLease returns the ECS scheduler's leader lease for holder.
|
||||
func NewSchedulerLease(store kvsync.KVStore, holder string) *kvsync.Lease {
|
||||
return kvsync.NewLease(store, KeyScheduler, holder, schedulerLeaseTTL)
|
||||
}
|
||||
|
||||
func (sc *Scheduler) leaderBucket(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
js, err := jetstream.New(sc.nc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return InitLeaderBucket(ctx, js)
|
||||
// NewScheduler constructs a Scheduler that leads while it holds lease.
|
||||
func NewScheduler(nc *nats.Conn, svc *Service, lease *kvsync.Lease) *Scheduler {
|
||||
// One slot: it means "something the reconcile reads has changed", and a
|
||||
// second arriving before the pass runs says nothing the first did not.
|
||||
return &Scheduler{nc: nc, svc: svc, lease: lease, wake: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
// Run drives the leadership + reaper loop until ctx is cancelled. It is intended
|
||||
// to run as a daemon-boot goroutine; panics are the caller's recover concern.
|
||||
func (sc *Scheduler) Run(ctx context.Context) {
|
||||
if sc.leaseErr != nil {
|
||||
slog.ErrorContext(ctx, "ECS scheduler: lease config invalid", "holder", sc.holder, "err", sc.leaseErr)
|
||||
return
|
||||
}
|
||||
leaseDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(leaseDone)
|
||||
sc.lease.Run(ctx)
|
||||
// The leader owns the Layer-2 bus subscriptions and the instance-role
|
||||
// converge pass. A node that can do neither stands down rather than hold
|
||||
// the lease and process nothing.
|
||||
kvsync.RunElection(ctx, sc.lease, sc.onGained, sc.unsubscribeBus)
|
||||
}()
|
||||
|
||||
// Two loops rather than four tickers, split by what wakes them. Services are
|
||||
|
||||
@@ -6,7 +6,9 @@ package ecs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ecs"
|
||||
@@ -14,26 +16,67 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ecs/taskdefinition"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// testScheduler is holder's scheduler on a sync bucket of its own, as a
|
||||
// separate daemon's would be.
|
||||
func testScheduler(t *testing.T, nc *nats.Conn, svc *Service, holder string) *Scheduler {
|
||||
t.Helper()
|
||||
return NewScheduler(nc, svc, NewSchedulerLease(kvstore.OpenSyncBucket(testutil.NewJetStream(t, nc)), holder))
|
||||
}
|
||||
|
||||
// electOnce runs sc's election with the scheduler's own callbacks and returns
|
||||
// what its first onGained returned. stop ends the election and waits for it.
|
||||
func electOnce(t *testing.T, sc *Scheduler) (stop func(), gainedErr error) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
gained := make(chan error, 1)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
kvsync.RunElection(ctx, sc.lease, func(ctx context.Context) error {
|
||||
err := sc.onGained(ctx)
|
||||
gained <- err
|
||||
return err
|
||||
}, sc.unsubscribeBus)
|
||||
}()
|
||||
stop = func() { cancel(); <-done }
|
||||
t.Cleanup(stop)
|
||||
select {
|
||||
case gainedErr = <-gained:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the election never gained the lease")
|
||||
}
|
||||
return sync.OnceFunc(stop), gainedErr
|
||||
}
|
||||
|
||||
// requireLeaseKeyAbsent fails unless no node holds the scheduler's key.
|
||||
func requireLeaseKeyAbsent(t *testing.T, nc *nats.Conn, msg string) {
|
||||
t.Helper()
|
||||
_, _, err := kvstore.OpenSyncBucket(testutil.NewJetStream(t, nc)).Load(t.Context(), KeyScheduler)
|
||||
require.ErrorIs(t, err, kvsync.ErrNotFound, msg)
|
||||
}
|
||||
|
||||
// TestScheduler_StandsDownWithoutIAM is the fleet outage this guards: a leader
|
||||
// that cannot converge instance roles holds the lease, writes no sts:AssumeRole
|
||||
// grant, and every agent's credentials lapse an hour later.
|
||||
func TestScheduler_StandsDownWithoutIAM(t *testing.T) {
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
svc := NewService(nc, testRegion, "")
|
||||
sc := NewScheduler(nc, svc, "holder-a")
|
||||
require.NoError(t, sc.leaseErr)
|
||||
sc := testScheduler(t, nc, svc, "holder-a")
|
||||
|
||||
assert.False(t, sc.lease.TryAcquire(t.Context()), "a node without IAM must refuse the lease")
|
||||
stop, err := electOnce(t, sc)
|
||||
require.Error(t, err, "a node without IAM must refuse the lease")
|
||||
stop()
|
||||
assert.False(t, sc.lease.Held())
|
||||
assert.Zero(t, sc.subCount())
|
||||
|
||||
// The key must be free, or a healthy node waits out the full TTL.
|
||||
kv, err := InitLeaderBucket(t.Context(), js)
|
||||
require.NoError(t, err)
|
||||
_, err = kv.Get(t.Context(), schedulerLeaderKey)
|
||||
require.Error(t, err)
|
||||
requireLeaseKeyAbsent(t, nc, "a node that stood down left its key")
|
||||
}
|
||||
|
||||
// TestConvergeInstanceRoles_RewritesProvisionedAccounts pins the pass that
|
||||
@@ -49,7 +92,7 @@ func TestConvergeInstanceRoles_RewritesProvisionedAccounts(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
iam.RolePolicies[ecsInstanceRoleName] = `{"Version":"2012-10-17","Statement":[]}`
|
||||
sc := NewScheduler(nc, svc, "holder-a")
|
||||
sc := testScheduler(t, nc, svc, "holder-a")
|
||||
require.NoError(t, sc.convergeInstanceRoles(t.Context()))
|
||||
|
||||
assert.Contains(t, iam.RolePolicies[ecsInstanceRoleName], "sts:AssumeRole")
|
||||
@@ -65,7 +108,7 @@ func TestConvergeInstanceRoles_SkipsAccountsWithoutTheRole(t *testing.T) {
|
||||
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
sc := NewScheduler(nc, svc, "holder-a")
|
||||
sc := testScheduler(t, nc, svc, "holder-a")
|
||||
require.NoError(t, sc.convergeInstanceRoles(t.Context()))
|
||||
|
||||
assert.Zero(t, iam.CreateRoleCalls)
|
||||
@@ -77,7 +120,7 @@ func TestConvergeInstanceRoles_SkipsAccountsWithoutTheRole(t *testing.T) {
|
||||
// caller that reaches the pass without going through leader election.
|
||||
func TestConvergeInstanceRoles_NilIAMIsAnError(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
sc := NewScheduler(nc, NewService(nc, testRegion, ""), "holder-a")
|
||||
sc := testScheduler(t, nc, NewService(nc, testRegion, ""), "holder-a")
|
||||
|
||||
err := sc.convergeInstanceRoles(t.Context())
|
||||
require.Error(t, err)
|
||||
@@ -94,3 +137,59 @@ func TestECSInstanceRolePolicyDoc_GrantsAssumeRole(t *testing.T) {
|
||||
assert.Contains(t, doc, `"ecs:*"`)
|
||||
assert.Contains(t, doc, `"ecr:GetAuthorizationToken"`)
|
||||
}
|
||||
|
||||
// containerInstanceStatus is i-1's status in the web cluster.
|
||||
func containerInstanceStatus(t *testing.T, svc *Service) string {
|
||||
t.Helper()
|
||||
di, err := svc.DescribeContainerInstances(context.Background(), &ecs.DescribeContainerInstancesInput{
|
||||
Cluster: aws.String("web"), ContainerInstances: []*string{aws.String("i-1")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
return aws.StringValue(di.ContainerInstances[0].Status)
|
||||
}
|
||||
|
||||
// A leader that loses the lease must drop the bus and stop reaping, or two
|
||||
// nodes would mark instances DRAINING and stop their tasks at once. Re-elected,
|
||||
// it takes both back up.
|
||||
func TestScheduler_LosingTheLeaseDropsTheBusAndTheReaperUntilReElection(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
|
||||
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
registerInstance(t, svc, "web", "i-1", 1024, 2048)
|
||||
kv, err := svc.bucket(t.Context(), testAccountID)
|
||||
require.NoError(t, err)
|
||||
var rec InstanceRecord
|
||||
_, err = taskdefinition.GetJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec)
|
||||
require.NoError(t, err)
|
||||
rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
|
||||
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec))
|
||||
|
||||
store := kvstore.OpenSyncBucket(testutil.NewJetStream(t, nc))
|
||||
sc := NewScheduler(nc, svc, kvsync.NewLease(store, KeyScheduler, "holder-a", 300*time.Millisecond))
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); kvsync.RunElection(ctx, sc.lease, sc.onGained, sc.unsubscribeBus) }()
|
||||
t.Cleanup(func() { cancel(); <-done })
|
||||
|
||||
held := func() bool { return sc.lease.Held() && sc.subCount() == 4 }
|
||||
require.Eventually(t, held, 5*time.Second, 10*time.Millisecond, "the leader subscribes to the four bus subjects")
|
||||
|
||||
syncKV, err := store.KV(t.Context())
|
||||
require.NoError(t, err)
|
||||
_, err = syncKV.Put(t.Context(), KeyScheduler, []byte(`{"holder":"holder-b","generation":0}`))
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool { return !sc.lease.Held() && sc.subCount() == 0 }, 5*time.Second, 10*time.Millisecond,
|
||||
"a lost lease must drop every bus subscription")
|
||||
|
||||
revisit, err := sc.timerPass(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, leaseRefresh, revisit)
|
||||
assert.Equal(t, "ACTIVE", containerInstanceStatus(t, svc), "a node that lost the lease must not reap")
|
||||
|
||||
require.NoError(t, syncKV.Delete(t.Context(), KeyScheduler))
|
||||
require.Eventually(t, held, 5*time.Second, 10*time.Millisecond, "re-elected, the bus comes back")
|
||||
_, err = sc.timerPass(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "DRAINING", containerInstanceStatus(t, svc), "the re-elected leader reaps again")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -571,7 +572,7 @@ func TestScheduler_ReapBucket_StopsStaleInstanceTasks(t *testing.T) {
|
||||
rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
|
||||
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec))
|
||||
|
||||
sc := NewScheduler(nc, svc, "test-holder")
|
||||
sc := testScheduler(t, nc, svc, "test-holder")
|
||||
sc.reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
|
||||
|
||||
di, err := svc.DescribeContainerInstances(context.Background(), &ecs.DescribeContainerInstancesInput{
|
||||
@@ -586,32 +587,38 @@ func TestScheduler_ReapBucket_StopsStaleInstanceTasks(t *testing.T) {
|
||||
assert.Equal(t, stoppedReasonReaped, aws.StringValue(dt.Tasks[0].StoppedReason))
|
||||
}
|
||||
|
||||
// Leader election: first holder wins Create; a second holder is rejected.
|
||||
// Leader election: the first holder wins; a second node, and a second attempt
|
||||
// in the same process, are both refused while it leads.
|
||||
func TestScheduler_AcquireLease_SingleLeader(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
|
||||
a := NewScheduler(nc, svc, "holder-a")
|
||||
b := NewScheduler(nc, svc, "holder-b")
|
||||
require.True(t, a.lease.TryAcquire(t.Context()))
|
||||
assert.False(t, b.lease.TryAcquire(t.Context()))
|
||||
assert.True(t, a.lease.TryAcquire(t.Context()), "a holder re-attempting keeps leadership")
|
||||
a := testScheduler(t, nc, svc, "holder-a")
|
||||
b := testScheduler(t, nc, svc, "holder-b")
|
||||
tok, err := a.lease.TryLock(t.Context())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { a.lease.Unlock(context.Background(), tok) })
|
||||
|
||||
_, err = b.lease.TryLock(t.Context())
|
||||
assert.ErrorAs(t, err, new(*kvsync.HeldError))
|
||||
_, err = a.lease.TryLock(t.Context())
|
||||
assert.ErrorAs(t, err, new(*kvsync.HeldError), "a second attempt in the leading process is refused")
|
||||
assert.True(t, a.lease.Held(), "a refused second attempt keeps leadership")
|
||||
}
|
||||
|
||||
// Leadership owns the Layer-2 bus subscriptions. This pins the wiring — gaining
|
||||
// the lease subscribes, losing it unsubscribes — which is the regression this
|
||||
// conversion could introduce and the one thing kvlease cannot check itself.
|
||||
// Leadership owns the Layer-2 bus subscriptions. This pins the wiring: gaining
|
||||
// the lease subscribes, losing it unsubscribes.
|
||||
func TestScheduler_LeadershipWiresBusSubscriptions(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
|
||||
sc := NewScheduler(nc, svc, "holder-a")
|
||||
require.NoError(t, sc.leaseErr)
|
||||
sc := testScheduler(t, nc, svc, "holder-a")
|
||||
|
||||
require.Zero(t, sc.subCount(), "subscriptions must not exist before election")
|
||||
|
||||
require.True(t, sc.lease.TryAcquire(t.Context()))
|
||||
stop, err := electOnce(t, sc)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 4, sc.subCount(), "leader must own the register, heartbeat, task-state and reconcile-wake subscriptions")
|
||||
|
||||
sc.lease.Release(t.Context())
|
||||
stop()
|
||||
assert.Zero(t, sc.subCount(), "a released leader must drop its subscriptions")
|
||||
}
|
||||
|
||||
@@ -620,20 +627,21 @@ func TestScheduler_LeadershipWiresBusSubscriptions(t *testing.T) {
|
||||
func TestScheduler_LoserHoldsNoSubscriptions(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
|
||||
a := NewScheduler(nc, svc, "holder-a")
|
||||
b := NewScheduler(nc, svc, "holder-b")
|
||||
a := testScheduler(t, nc, svc, "holder-a")
|
||||
b := testScheduler(t, nc, svc, "holder-b")
|
||||
|
||||
require.True(t, a.lease.TryAcquire(t.Context()))
|
||||
require.False(t, b.lease.TryAcquire(t.Context()))
|
||||
_, err := electOnce(t, a)
|
||||
require.NoError(t, err)
|
||||
_, err = b.lease.TryLock(t.Context())
|
||||
require.ErrorAs(t, err, new(*kvsync.HeldError))
|
||||
assert.Zero(t, b.subCount())
|
||||
assert.Equal(t, 4, a.subCount())
|
||||
}
|
||||
|
||||
func TestScheduler_RunReleasesLeaseOnShutdown(t *testing.T) {
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
|
||||
sc := NewScheduler(nc, svc, "holder-a")
|
||||
require.NoError(t, sc.leaseErr)
|
||||
sc := testScheduler(t, nc, svc, "holder-a")
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
done := make(chan struct{})
|
||||
@@ -643,10 +651,7 @@ func TestScheduler_RunReleasesLeaseOnShutdown(t *testing.T) {
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
kv, err := InitLeaderBucket(t.Context(), js)
|
||||
require.NoError(t, err)
|
||||
_, err = kv.Get(t.Context(), schedulerLeaderKey)
|
||||
require.Error(t, err, "Run returned with the lease key still present")
|
||||
requireLeaseKeyAbsent(t, nc, "Run returned with the lease key still present")
|
||||
}
|
||||
|
||||
// --- GPU placement dimension (Epic C2) ---
|
||||
|
||||
@@ -4,10 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -19,18 +17,11 @@ import (
|
||||
// cluster, task-definition, task, service, and container-instance state. It is
|
||||
// created lazily on first cluster touch (no daemon-boot pre-creation) so accounts
|
||||
// without any ECS clusters never grow a bucket (ecs-v1.md Q2).
|
||||
//
|
||||
// Shared leader bucket "spinifex-ecs-leader" holds 60s-TTL CAS locks keyed by
|
||||
// "{accountID}/{clusterName}" for the per-cluster scheduler leader election
|
||||
// (Q3). Created once at daemon boot.
|
||||
|
||||
const (
|
||||
KVBucketECSAccountPrefix = "ecs-account-"
|
||||
KVBucketECSAccountVersion = 1
|
||||
KVBucketECSAccountHistory = 1
|
||||
|
||||
KVBucketECSLeader = "spinifex-ecs-leader"
|
||||
KVBucketECSLeaderVersion = 1
|
||||
KVBucketECSLeaderTTL = 60 * time.Second
|
||||
)
|
||||
|
||||
// Key-path helpers for the per-account bucket. The layout matches ecs-v1.md Q2.
|
||||
@@ -119,15 +110,15 @@ func CapacityProviderKey(name string) string {
|
||||
return CapacityProvidersPrefix() + name
|
||||
}
|
||||
|
||||
// Store is the per-daemon ECS KV handle. Per-account and leader buckets are
|
||||
// accessed via the package-level factories below.
|
||||
// Store is the per-daemon ECS KV handle. Per-account buckets are accessed via
|
||||
// the package-level factories below.
|
||||
type Store struct {
|
||||
nc *nats.Conn
|
||||
}
|
||||
|
||||
// NewStore constructs a Store bound to the supplied NATS connection. It does not
|
||||
// touch JetStream — per-account buckets are created lazily by
|
||||
// GetOrCreateAccountBucket and the leader bucket by InitLeaderBucket.
|
||||
// GetOrCreateAccountBucket.
|
||||
func NewStore(nc *nats.Conn) (*Store, error) {
|
||||
if nc == nil {
|
||||
return nil, errors.New("ecs store: nats connection is nil")
|
||||
@@ -158,7 +149,7 @@ func accountBuckets(ctx context.Context, nc *nats.Conn) ([]accountBucket, error)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jetstream: %w", err)
|
||||
}
|
||||
names, err := kvutil.BucketNames(ctx, js)
|
||||
names, err := kvstore.BucketNames(ctx, js)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -176,7 +167,7 @@ func accountBuckets(ctx context.Context, nc *nats.Conn) ([]accountBucket, error)
|
||||
// return the existing handle.
|
||||
func GetOrCreateAccountBucket(ctx context.Context, js jetstream.JetStream, accountID string) (jetstream.KeyValue, error) {
|
||||
bucket := AccountBucketName(accountID)
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, js, bucket, KVBucketECSAccountHistory)
|
||||
kv, err := kvstore.GetOrCreateBucket(ctx, js, bucket, KVBucketECSAccountHistory)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create ECS per-account KV bucket %s: %w", bucket, err)
|
||||
}
|
||||
@@ -185,13 +176,3 @@ func GetOrCreateAccountBucket(ctx context.Context, js jetstream.JetStream, accou
|
||||
}
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
// InitLeaderBucket creates (or attaches to) the shared spinifex-ecs-leader bucket
|
||||
// used for per-cluster scheduler leader-lease CAS locks.
|
||||
func InitLeaderBucket(ctx context.Context, js jetstream.JetStream) (jetstream.KeyValue, error) {
|
||||
return kvlease.OpenBucket(ctx, js, kvlease.BucketConfig{
|
||||
Name: KVBucketECSLeader,
|
||||
TTL: KVBucketECSLeaderTTL,
|
||||
Version: KVBucketECSLeaderVersion,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
package ecs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -46,71 +42,6 @@ func TestGetOrCreateAccountBucket_Idempotent(t *testing.T) {
|
||||
assert.Equal(t, kv1.Bucket(), kv2.Bucket())
|
||||
}
|
||||
|
||||
func TestInitLeaderBucket_Idempotent(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
|
||||
kv1, err := InitLeaderBucket(t.Context(), js)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, kv1)
|
||||
assert.Equal(t, KVBucketECSLeader, kv1.Bucket())
|
||||
|
||||
kv2, err := InitLeaderBucket(t.Context(), js)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, kv2)
|
||||
assert.Equal(t, KVBucketECSLeader, kv2.Bucket())
|
||||
}
|
||||
|
||||
// The scheduler calls InitLeaderBucket on every tick. Creating first and falling
|
||||
// back on already-exists reaches the same handle, so idempotency alone cannot
|
||||
// tell the two orders apart — only the API traffic can.
|
||||
//
|
||||
// On a multi-node cluster the stored bucket is replicated while this call asks
|
||||
// for the default, and JetStream answers the mismatch with an error rather than
|
||||
// a no-op: 10058, once per tick, forever. That is invisible from the caller and
|
||||
// showed up only as a 16% JetStream API error rate on the meta leader.
|
||||
func TestInitLeaderBucket_AttachesWithoutCreating(t *testing.T) {
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
|
||||
// Watch from before the first call. The server queues the API advisory after
|
||||
// the reply the caller waits on, so subscribing once the bucket exists can
|
||||
// still catch the create that first call legitimately made.
|
||||
creates := make(chan string, 8)
|
||||
sub, err := nc.Subscribe("$JS.EVENT.ADVISORY.API", func(m *nats.Msg) {
|
||||
var adv struct {
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
if json.Unmarshal(m.Data, &adv) == nil &&
|
||||
strings.HasPrefix(adv.Subject, "$JS.API.STREAM.CREATE.") {
|
||||
creates <- adv.Subject
|
||||
}
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
_, err = InitLeaderBucket(t.Context(), js)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Take the first, legitimate create off the channel before the call under
|
||||
// test, so anything left there afterwards can only have come from it.
|
||||
select {
|
||||
case <-creates:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the first InitLeaderBucket must create the bucket")
|
||||
}
|
||||
|
||||
_, err = InitLeaderBucket(t.Context(), js)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
select {
|
||||
case subject := <-creates:
|
||||
t.Fatalf("InitLeaderBucket issued %s on an existing bucket; attach before create", subject)
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// Key-path helpers must produce the ecs-v1.md Q2 layout exactly: prefixes are
|
||||
// what the List* enumerations watch, so a drift here silently breaks listing.
|
||||
func TestKeyPaths(t *testing.T) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -128,7 +128,7 @@ func deleteRecord(ctx context.Context, kv jetstream.KeyValue, cluster, principal
|
||||
// casUpdate does a revision-checked read-modify-write. mutate returns true
|
||||
// when a field changed. Returns ErrNotFound if absent.
|
||||
func casUpdate(ctx context.Context, kv jetstream.KeyValue, cluster, principalARN string, mutate func(*Record) bool) (*Record, error) {
|
||||
return kvutil.Update(ctx, kv, Key(cluster, principalARN), kvutil.CASConfig{NotFound: ErrNotFound}, func(r *Record) (bool, error) {
|
||||
return kvstore.Update(ctx, kv, Key(cluster, principalARN), kvstore.CASConfig{NotFound: ErrNotFound}, func(r *Record) (bool, error) {
|
||||
return mutate(r), nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -193,7 +193,7 @@ func deleteRecord(ctx context.Context, kv jetstream.KeyValue, cluster, addon str
|
||||
// casUpdate does a revision-checked read-modify-write. mutate returns true
|
||||
// when a field changed. Returns ErrNotFound if absent.
|
||||
func casUpdate(ctx context.Context, kv jetstream.KeyValue, cluster, addon string, mutate func(*Record) bool) (*Record, error) {
|
||||
return kvutil.Update(ctx, kv, Key(cluster, addon), kvutil.CASConfig{NotFound: ErrNotFound}, func(r *Record) (bool, error) {
|
||||
return kvstore.Update(ctx, kv, Key(cluster, addon), kvstore.CASConfig{NotFound: ErrNotFound}, func(r *Record) (bool, error) {
|
||||
return mutate(r), nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
ekscluster "github.com/mulgadc/spinifex/spinifex/domains/eks/cluster"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -55,7 +56,7 @@ func TestEKSRequest_InternalAddonsWireBody(t *testing.T) {
|
||||
ControlPlaneNodes: []ekscluster.ControlPlaneNode{{InstanceID: cpInstanceID}},
|
||||
}))
|
||||
|
||||
svc, err := ekscluster.NewEKSServiceImpl(ekscluster.EKSServiceDeps{NATSConn: nc, Region: authzRegion})
|
||||
svc, err := ekscluster.NewEKSServiceImpl(ekscluster.EKSServiceDeps{NATSConn: nc, Region: authzRegion, Leases: ekscluster.NewClusterLeases(kvstore.OpenSyncBucket(js), "test")})
|
||||
require.NoError(t, err)
|
||||
sub, err := nc.Subscribe("eks.ListStagedAddonManifests", func(m *nats.Msg) {
|
||||
acct := natsmsg.AccountIDFromMsg(m)
|
||||
@@ -112,7 +113,7 @@ func TestEKSRequest_InternalWorkersWireBody(t *testing.T) {
|
||||
require.NoError(t, ekscluster.PutNodegroupRecord(t.Context(), kv, rec))
|
||||
}
|
||||
|
||||
svc, err := ekscluster.NewEKSServiceImpl(ekscluster.EKSServiceDeps{NATSConn: nc, Region: authzRegion})
|
||||
svc, err := ekscluster.NewEKSServiceImpl(ekscluster.EKSServiceDeps{NATSConn: nc, Region: authzRegion, Leases: ekscluster.NewClusterLeases(kvstore.OpenSyncBucket(js), "test")})
|
||||
require.NoError(t, err)
|
||||
sub, err := nc.Subscribe("eks.ListManagedWorkers", func(m *nats.Msg) {
|
||||
acct := natsmsg.AccountIDFromMsg(m)
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -25,8 +25,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultReconcileLeaseRefresh = 20 * time.Second
|
||||
defaultReconcileInterval = 30 * time.Second
|
||||
defaultReconcileInterval = 30 * time.Second
|
||||
// reconcileResync is the drift backstop for a loop whose signals now arrive as
|
||||
// watch updates and state reports. It also bounds every deadline below it, so
|
||||
// the 15-minute create timeout is served by the resync rather than by a timer.
|
||||
@@ -123,6 +122,13 @@ type natsSubscriber interface {
|
||||
// ErrReconcilerLeaseLost is returned by Run when the leader lease is lost mid-loop.
|
||||
var ErrReconcilerLeaseLost = errors.New("eks: reconciler lease lost")
|
||||
|
||||
// closedLost stands in for the Lost channel of a reconciler that never acquired.
|
||||
var closedLost = func() <-chan struct{} {
|
||||
ch := make(chan struct{})
|
||||
close(ch)
|
||||
return ch
|
||||
}()
|
||||
|
||||
// ErrReconcilerClusterDeleting is returned by Run when meta.Status is DELETING;
|
||||
// DeleteCluster owns teardown so the reconciler exits.
|
||||
var ErrReconcilerClusterDeleting = errors.New("eks: cluster deleting")
|
||||
@@ -138,15 +144,12 @@ type HTTPDoer interface {
|
||||
// ClusterReconciler drives one cluster's CREATING → ACTIVE → DELETING lifecycle
|
||||
// under a leader lease. One instance per (accountID, clusterName).
|
||||
type ClusterReconciler struct {
|
||||
leaderKV jetstream.KeyValue
|
||||
acctKV jetstream.KeyValue
|
||||
accountID string
|
||||
clusterName string
|
||||
holderID string
|
||||
healthURL string
|
||||
|
||||
lease *kvlease.Lease
|
||||
leaseRefresh time.Duration
|
||||
lease *kvsync.Lease
|
||||
interval time.Duration
|
||||
healthTimeout time.Duration
|
||||
createTimeout time.Duration
|
||||
@@ -166,6 +169,9 @@ type ClusterReconciler struct {
|
||||
// terminal records why the loop is ending — a terminal cluster status or a
|
||||
// lost lease — and stopRun cancels it. reconciler.Run has no exit condition
|
||||
// of its own, so ending it is cancelling it, and this is what Run reports.
|
||||
// lost is the Lost channel of the acquisition AcquireLease took.
|
||||
lost <-chan struct{}
|
||||
|
||||
terminalMu sync.Mutex
|
||||
terminal error
|
||||
stopRun context.CancelFunc
|
||||
@@ -226,11 +232,6 @@ func WithReconcileInterval(d time.Duration) ReconcilerOption {
|
||||
return func(r *ClusterReconciler) { r.interval = d }
|
||||
}
|
||||
|
||||
// WithLeaseRefresh overrides the leader-lease refresh period (should be < KVBucketEKSLeaderTTL).
|
||||
func WithLeaseRefresh(d time.Duration) ReconcilerOption {
|
||||
return func(r *ClusterReconciler) { r.leaseRefresh = d }
|
||||
}
|
||||
|
||||
// WithHealthzTimeout overrides the per-probe HTTP timeout.
|
||||
func WithHealthzTimeout(d time.Duration) ReconcilerOption {
|
||||
return func(r *ClusterReconciler) { r.healthTimeout = d }
|
||||
@@ -335,12 +336,12 @@ func WithEtcdResetRecovery(grace, backoff time.Duration, maxAttempts int) Reconc
|
||||
}
|
||||
}
|
||||
|
||||
// NewClusterReconciler constructs a ClusterReconciler. healthURL is the
|
||||
// fully-qualified probe target (e.g. https://nlb-dns:443/healthz) or empty
|
||||
// to skip /healthz probing (CREATING transitions on bootstrap state alone).
|
||||
func NewClusterReconciler(leaderKV, acctKV jetstream.KeyValue, accountID, clusterName, holderID, healthURL string, opts ...ReconcilerOption) (*ClusterReconciler, error) {
|
||||
if leaderKV == nil {
|
||||
return nil, errors.New("eks: NewClusterReconciler nil leaderKV")
|
||||
// NewClusterReconciler constructs a ClusterReconciler that leads while it holds
|
||||
// lease, the cluster's lease from ClusterLeases. healthURL is the probe target
|
||||
// (e.g. https://nlb-dns:443/healthz) or empty to skip /healthz probing.
|
||||
func NewClusterReconciler(lease *kvsync.Lease, acctKV jetstream.KeyValue, accountID, clusterName, healthURL string, opts ...ReconcilerOption) (*ClusterReconciler, error) {
|
||||
if lease == nil {
|
||||
return nil, errors.New("eks: NewClusterReconciler nil lease")
|
||||
}
|
||||
if acctKV == nil {
|
||||
return nil, errors.New("eks: NewClusterReconciler nil acctKV")
|
||||
@@ -351,17 +352,12 @@ func NewClusterReconciler(leaderKV, acctKV jetstream.KeyValue, accountID, cluste
|
||||
if clusterName == "" {
|
||||
return nil, errors.New("eks: NewClusterReconciler empty clusterName")
|
||||
}
|
||||
if holderID == "" {
|
||||
return nil, errors.New("eks: NewClusterReconciler empty holderID")
|
||||
}
|
||||
r := &ClusterReconciler{
|
||||
leaderKV: leaderKV,
|
||||
acctKV: acctKV,
|
||||
accountID: accountID,
|
||||
clusterName: clusterName,
|
||||
holderID: holderID,
|
||||
healthURL: healthURL,
|
||||
leaseRefresh: defaultReconcileLeaseRefresh,
|
||||
lease: lease,
|
||||
interval: defaultReconcileInterval,
|
||||
healthTimeout: defaultHealthzTimeout,
|
||||
createTimeout: defaultCreateTimeout,
|
||||
@@ -390,42 +386,23 @@ func NewClusterReconciler(leaderKV, acctKV jetstream.KeyValue, accountID, cluste
|
||||
for _, o := range opts {
|
||||
o(r)
|
||||
}
|
||||
lease, err := kvlease.New(kvlease.Config{
|
||||
Name: "eks/cluster-reconciler",
|
||||
Bucket: kvlease.StaticBucket(leaderKV),
|
||||
Key: reconcilerLeaderKey(accountID, clusterName),
|
||||
Holder: holderID,
|
||||
Attrs: []any{"account", accountID, "cluster", clusterName},
|
||||
TTL: KVBucketEKSLeaderTTL,
|
||||
Renew: r.leaseRefresh,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cluster reconciler lease: %w", err)
|
||||
}
|
||||
r.lease = lease
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func reconcilerLeaderKey(accountID, clusterName string) string {
|
||||
return accountID + "/" + clusterName
|
||||
}
|
||||
|
||||
// AcquireLease attempts a CAS Create on the leader bucket. Returns
|
||||
// (release, true) on success; (nil, false) otherwise. The bucket TTL reaps
|
||||
// stale leases when release does not run.
|
||||
// AcquireLease takes the cluster's lease. Returns (release, true) on success;
|
||||
// (nil, false) when another holder, or another run in this process, has it.
|
||||
func (r *ClusterReconciler) AcquireLease(ctx context.Context) (func(), bool) {
|
||||
if !r.lease.TryAcquire(ctx) {
|
||||
return nil, false
|
||||
release, lost, ok := tryLease(ctx, r.lease)
|
||||
if ok {
|
||||
r.lost = lost
|
||||
}
|
||||
return func() { r.lease.Release(ctx) }, true
|
||||
return release, ok
|
||||
}
|
||||
|
||||
// Run drives reconcile until ctx is cancelled, the lease is lost, or the cluster
|
||||
// reaches a terminal state. Caller must AcquireLease first.
|
||||
// reaches a terminal state. Caller must AcquireLease first; without it Run
|
||||
// returns ErrReconcilerLeaseLost at once.
|
||||
func (r *ClusterReconciler) Run(ctx context.Context) error {
|
||||
if r.lease == nil {
|
||||
return errors.New("ClusterReconciler: Run called without AcquireLease")
|
||||
}
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
defer stop()
|
||||
r.stopRun = stop
|
||||
@@ -463,11 +440,16 @@ func (r *ClusterReconciler) Run(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// The lease turning over is not a KV write this loop watches, so losing it has
|
||||
// to end the loop from outside it.
|
||||
// to end the loop from outside it. The channel is the acquisition's own, taken
|
||||
// with its token, so a later acquisition's cannot stand in for it.
|
||||
lost := r.lost
|
||||
if lost == nil {
|
||||
lost = closedLost
|
||||
}
|
||||
go func() {
|
||||
select {
|
||||
case <-runCtx.Done():
|
||||
case <-r.lease.Lost():
|
||||
case <-lost:
|
||||
r.endRun(ErrReconcilerLeaseLost)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -21,14 +21,12 @@ func newStateReconcilerHarness(t *testing.T, opts ...ReconcilerOption) (*Cluster
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
leaderKV, err := InitLeaderBucket(t.Context(), js)
|
||||
require.NoError(t, err)
|
||||
acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, PutClusterMeta(t.Context(), acctKV, sampleClusterMeta("alpha")))
|
||||
|
||||
base := []ReconcilerOption{WithStateSource(nc, StateSubject(testAccountID, "alpha"))}
|
||||
r, err := NewClusterReconciler(leaderKV, acctKV, testAccountID, "alpha", "holder-1", "", append(base, opts...)...)
|
||||
r, err := NewClusterReconciler(otherNodeLease(js, "holder-1"), acctKV, testAccountID, "alpha", "", append(base, opts...)...)
|
||||
require.NoError(t, err)
|
||||
return r, nc, acctKV
|
||||
}
|
||||
@@ -146,7 +144,6 @@ func TestClusterReconciler_StateReportSubscriptionDrivesActive(t *testing.T) {
|
||||
t.Parallel()
|
||||
r, nc, acctKV := newStateReconcilerHarness(t,
|
||||
WithReconcileInterval(10*time.Millisecond),
|
||||
WithLeaseRefresh(10*time.Second),
|
||||
)
|
||||
freshenClusterCreatedAt(t, acctKV)
|
||||
seedBootstrapState(t, acctKV)
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -54,39 +56,47 @@ func onePass(t *testing.T, r *ClusterReconciler) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func newReconcilerHarness(t *testing.T, healthURL string, opts ...ReconcilerOption) (*ClusterReconciler, jetstream.KeyValue, jetstream.KeyValue) {
|
||||
func newReconcilerHarness(t *testing.T, healthURL string, opts ...ReconcilerOption) (*ClusterReconciler, jetstream.JetStream, jetstream.KeyValue) {
|
||||
t.Helper()
|
||||
return newReconcilerHarnessTTL(t, clusterLeaseTTL, healthURL, opts...)
|
||||
}
|
||||
|
||||
// newReconcilerHarnessTTL is newReconcilerHarness with a lease of the given ttl,
|
||||
// for a test that has to see a renewal.
|
||||
func newReconcilerHarnessTTL(t *testing.T, ttl time.Duration, healthURL string, opts ...ReconcilerOption) (*ClusterReconciler, jetstream.JetStream, jetstream.KeyValue) {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
leaderKV, err := InitLeaderBucket(t.Context(), js)
|
||||
require.NoError(t, err)
|
||||
acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, PutClusterMeta(t.Context(), acctKV, sampleClusterMeta("alpha")))
|
||||
|
||||
r, err := NewClusterReconciler(leaderKV, acctKV, testAccountID, "alpha", "holder-1", healthURL, opts...)
|
||||
leases := kvsync.NewLeaseGroup(kvstore.OpenSyncBucket(js), KeyPrefixCluster, "holder-1", ttl)
|
||||
r, err := NewClusterReconciler(leases.Get(clusterLeaseName(testAccountID, "alpha")), acctKV, testAccountID, "alpha", healthURL, opts...)
|
||||
require.NoError(t, err)
|
||||
return r, leaderKV, acctKV
|
||||
return r, js, acctKV
|
||||
}
|
||||
|
||||
// otherNodeLease is the cluster's reconciler lease as another node sees it.
|
||||
func otherNodeLease(js jetstream.JetStream, holder string) *kvsync.Lease {
|
||||
return NewClusterLeases(kvstore.OpenSyncBucket(js), holder).Get(clusterLeaseName(testAccountID, "alpha"))
|
||||
}
|
||||
|
||||
func TestNewClusterReconciler_EmptyInputsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
leaderKV, err := InitLeaderBucket(t.Context(), js)
|
||||
require.NoError(t, err)
|
||||
acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID)
|
||||
require.NoError(t, err)
|
||||
lease := otherNodeLease(js, "h")
|
||||
|
||||
_, err = NewClusterReconciler(nil, acctKV, testAccountID, "alpha", "h", "")
|
||||
_, err = NewClusterReconciler(nil, acctKV, testAccountID, "alpha", "")
|
||||
require.Error(t, err)
|
||||
_, err = NewClusterReconciler(leaderKV, nil, testAccountID, "alpha", "h", "")
|
||||
_, err = NewClusterReconciler(lease, nil, testAccountID, "alpha", "")
|
||||
require.Error(t, err)
|
||||
_, err = NewClusterReconciler(leaderKV, acctKV, "", "alpha", "h", "")
|
||||
_, err = NewClusterReconciler(lease, acctKV, "", "alpha", "")
|
||||
require.Error(t, err)
|
||||
_, err = NewClusterReconciler(leaderKV, acctKV, testAccountID, "", "h", "")
|
||||
require.Error(t, err)
|
||||
_, err = NewClusterReconciler(leaderKV, acctKV, testAccountID, "alpha", "", "")
|
||||
_, err = NewClusterReconciler(lease, acctKV, testAccountID, "", "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -99,21 +109,21 @@ func TestClusterReconciler_AcquireLeaseFirstHolderWins(t *testing.T) {
|
||||
require.NotNil(t, release)
|
||||
defer release()
|
||||
|
||||
// Re-acquiring from the same holder succeeds: the lease is already held.
|
||||
// A second run in the same process is refused while the first holds it.
|
||||
release2, ok2 := r.AcquireLease(t.Context())
|
||||
assert.True(t, ok2)
|
||||
assert.NotNil(t, release2)
|
||||
assert.False(t, ok2)
|
||||
assert.Nil(t, release2)
|
||||
}
|
||||
|
||||
func TestClusterReconciler_AcquireLeaseSecondHolderLoses(t *testing.T) {
|
||||
t.Parallel()
|
||||
r1, leaderKV, acctKV := newReconcilerHarness(t, "")
|
||||
r1, js, acctKV := newReconcilerHarness(t, "")
|
||||
|
||||
release, ok := r1.AcquireLease(t.Context())
|
||||
require.True(t, ok)
|
||||
defer release()
|
||||
|
||||
r2, err := NewClusterReconciler(leaderKV, acctKV, testAccountID, "alpha", "holder-2", "")
|
||||
r2, err := NewClusterReconciler(otherNodeLease(js, "holder-2"), acctKV, testAccountID, "alpha", "")
|
||||
require.NoError(t, err)
|
||||
release2, ok2 := r2.AcquireLease(t.Context())
|
||||
assert.False(t, ok2)
|
||||
@@ -154,7 +164,6 @@ func TestClusterReconciler_CreatingTransitionsToActiveOnReadyAndHealthz(t *testi
|
||||
"https://nlb.example/healthz",
|
||||
WithHTTPClient(stub),
|
||||
WithReconcileInterval(10*time.Millisecond),
|
||||
WithLeaseRefresh(10*time.Second),
|
||||
)
|
||||
release, ok := r.AcquireLease(t.Context())
|
||||
require.True(t, ok)
|
||||
@@ -193,7 +202,6 @@ func TestClusterReconciler_CreatingStaysWhenBootstrapMissing(t *testing.T) {
|
||||
"https://nlb.example/healthz",
|
||||
WithHTTPClient(stub),
|
||||
WithReconcileInterval(10*time.Millisecond),
|
||||
WithLeaseRefresh(10*time.Second),
|
||||
)
|
||||
release, ok := r.AcquireLease(t.Context())
|
||||
require.True(t, ok)
|
||||
@@ -228,7 +236,6 @@ func TestClusterReconciler_CreatingStaysWhenJWKSUnverified(t *testing.T) {
|
||||
"https://nlb.example/healthz",
|
||||
WithHTTPClient(stub),
|
||||
WithReconcileInterval(10*time.Millisecond),
|
||||
WithLeaseRefresh(10*time.Second),
|
||||
)
|
||||
release, ok := r.AcquireLease(t.Context())
|
||||
require.True(t, ok)
|
||||
@@ -262,7 +269,6 @@ func TestClusterReconciler_CreatingStaysWhenHealthzFails(t *testing.T) {
|
||||
"https://nlb.example/healthz",
|
||||
WithHTTPClient(stub),
|
||||
WithReconcileInterval(10*time.Millisecond),
|
||||
WithLeaseRefresh(10*time.Second),
|
||||
)
|
||||
release, ok := r.AcquireLease(t.Context())
|
||||
require.True(t, ok)
|
||||
@@ -287,7 +293,6 @@ func TestClusterReconciler_CreatingTimesOutToFailed(t *testing.T) {
|
||||
"https://nlb.example/healthz",
|
||||
WithHTTPClient(stub),
|
||||
WithReconcileInterval(10*time.Millisecond),
|
||||
WithLeaseRefresh(10*time.Second),
|
||||
WithCreateTimeout(1*time.Millisecond),
|
||||
)
|
||||
release, ok := r.AcquireLease(t.Context())
|
||||
@@ -320,7 +325,6 @@ func TestClusterReconciler_CreatingWithinDeadlineStaysCreating(t *testing.T) {
|
||||
"https://nlb.example/healthz",
|
||||
WithHTTPClient(stub),
|
||||
WithReconcileInterval(10*time.Millisecond),
|
||||
WithLeaseRefresh(10*time.Second),
|
||||
WithCreateTimeout(time.Hour),
|
||||
)
|
||||
release, ok := r.AcquireLease(t.Context())
|
||||
@@ -345,7 +349,6 @@ func TestClusterReconciler_DeletingExitsLoop(t *testing.T) {
|
||||
"https://nlb.example/healthz",
|
||||
WithHTTPClient(stub),
|
||||
WithReconcileInterval(10*time.Millisecond),
|
||||
WithLeaseRefresh(10*time.Second),
|
||||
)
|
||||
release, ok := r.AcquireLease(t.Context())
|
||||
require.True(t, ok)
|
||||
@@ -363,11 +366,10 @@ func TestClusterReconciler_DeletingExitsLoop(t *testing.T) {
|
||||
func TestClusterReconciler_LostLeaseExitsLoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := &stubHTTPDoer{status: http.StatusOK}
|
||||
r, leaderKV, acctKV := newReconcilerHarness(t,
|
||||
r, js, acctKV := newReconcilerHarnessTTL(t, 300*time.Millisecond,
|
||||
"https://nlb.example/healthz",
|
||||
WithHTTPClient(stub),
|
||||
WithReconcileInterval(10*time.Second),
|
||||
WithLeaseRefresh(20*time.Millisecond),
|
||||
)
|
||||
release, ok := r.AcquireLease(t.Context())
|
||||
require.True(t, ok)
|
||||
@@ -381,16 +383,17 @@ func TestClusterReconciler_LostLeaseExitsLoop(t *testing.T) {
|
||||
runErr := make(chan error, 1)
|
||||
go func() { runErr <- r.Run(ctx) }()
|
||||
|
||||
// Steal the lease in another holder's name. The renewal loses its CAS at the
|
||||
// stale revision, which closes Lost and exits Run.
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
_, err := leaderKV.Put(t.Context(), reconcilerLeaderKey(testAccountID, "alpha"), []byte("holder-2"))
|
||||
// Overwrite the key in another holder's name. The next renewal loses its
|
||||
// CAS at the stale revision, which closes Lost and exits Run.
|
||||
syncKV, err := kvstore.OpenSyncBucket(js).KV(t.Context())
|
||||
require.NoError(t, err)
|
||||
_, err = syncKV.Put(t.Context(), KeyPrefixCluster+clusterLeaseName(testAccountID, "alpha"), []byte(`{"holder":"holder-2","generation":0}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case err := <-runErr:
|
||||
assert.ErrorIs(t, err, ErrReconcilerLeaseLost)
|
||||
case <-time.After(2 * time.Second):
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Run did not exit after lease loss")
|
||||
}
|
||||
}
|
||||
@@ -402,7 +405,6 @@ func TestClusterReconciler_ActiveProbesAndWarnsOnHealthzFail(t *testing.T) {
|
||||
"https://nlb.example/healthz",
|
||||
WithHTTPClient(stub),
|
||||
WithReconcileInterval(10*time.Millisecond),
|
||||
WithLeaseRefresh(10*time.Second),
|
||||
)
|
||||
release, ok := r.AcquireLease(t.Context())
|
||||
require.True(t, ok)
|
||||
@@ -438,7 +440,6 @@ func TestClusterReconciler_ActiveHealthRecoversClearsIssue(t *testing.T) {
|
||||
"https://nlb.example/healthz",
|
||||
WithHTTPClient(stub),
|
||||
WithReconcileInterval(10*time.Millisecond),
|
||||
WithLeaseRefresh(10*time.Second),
|
||||
)
|
||||
release, ok := r.AcquireLease(t.Context())
|
||||
require.True(t, ok)
|
||||
@@ -468,9 +469,111 @@ func TestClusterReconciler_ProbeHealthzEmptyURLNoop(t *testing.T) {
|
||||
"",
|
||||
WithHTTPClient(stub),
|
||||
WithReconcileInterval(10*time.Millisecond),
|
||||
WithLeaseRefresh(10*time.Second),
|
||||
)
|
||||
|
||||
require.NoError(t, r.probeHealthz(context.Background()))
|
||||
assert.EqualValues(t, 0, stub.calls.Load(), "no HTTP call when URL empty")
|
||||
}
|
||||
|
||||
// putForeignHolder writes the cluster's lease key in another holder's name and
|
||||
// never renews it, standing in for a node that died holding it.
|
||||
func putForeignHolder(t *testing.T, js jetstream.JetStream) {
|
||||
t.Helper()
|
||||
syncKV, err := kvstore.OpenSyncBucket(js).KV(t.Context())
|
||||
require.NoError(t, err)
|
||||
_, err = syncKV.Put(t.Context(), KeyPrefixCluster+clusterLeaseName(testAccountID, "alpha"), []byte(`{"holder":"holder-2","generation":0}`))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func requireStillRunning(t *testing.T, finished <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-finished:
|
||||
t.Fatal("the reconciler goroutine exited while its cluster still wants one")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// A dead node's key is not cleared by the store, so the reconciler has to keep
|
||||
// asking until it has sat a TTL; one attempt would leave the cluster unreconciled.
|
||||
func TestRunClusterReconciler_TakesOverADeadHoldersKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
r, js, acctKV := newReconcilerHarnessTTL(t, 300*time.Millisecond, "https://nlb.example/healthz",
|
||||
WithHTTPClient(&stubHTTPDoer{status: http.StatusOK}), WithReconcileInterval(10*time.Second))
|
||||
freshenClusterCreatedAt(t, acctKV)
|
||||
seedBootstrapState(t, acctKV)
|
||||
putForeignHolder(t, js)
|
||||
|
||||
release, finished, err := RunClusterReconciler(t.Context(), r.lease, acctKV, testAccountID, "alpha", "https://nlb.example/healthz",
|
||||
WithHTTPClient(&stubHTTPDoer{status: http.StatusOK}), WithReconcileInterval(10*time.Second))
|
||||
require.NoError(t, err, "a held lease is contended for, not an error")
|
||||
defer release()
|
||||
|
||||
require.Eventually(t, r.lease.Held, 5*time.Second, 10*time.Millisecond, "the dead holder's key must be taken over")
|
||||
requireStillRunning(t, finished)
|
||||
}
|
||||
|
||||
// Losing the lease ends a Run, not the reconciler: it contends again and
|
||||
// resumes once it holds the lease once more.
|
||||
func TestRunClusterReconciler_ResumesAfterALostLease(t *testing.T) {
|
||||
t.Parallel()
|
||||
r, js, acctKV := newReconcilerHarnessTTL(t, 300*time.Millisecond, "https://nlb.example/healthz")
|
||||
freshenClusterCreatedAt(t, acctKV)
|
||||
seedBootstrapState(t, acctKV)
|
||||
release, finished, err := RunClusterReconciler(t.Context(), r.lease, acctKV, testAccountID, "alpha", "https://nlb.example/healthz",
|
||||
WithHTTPClient(&stubHTTPDoer{status: http.StatusOK}), WithReconcileInterval(10*time.Second))
|
||||
require.NoError(t, err)
|
||||
defer release()
|
||||
require.Eventually(t, r.lease.Held, 5*time.Second, 10*time.Millisecond)
|
||||
|
||||
putForeignHolder(t, js)
|
||||
require.Eventually(t, func() bool { return !r.lease.Held() }, 5*time.Second, 10*time.Millisecond, "the overwrite must cost the lease")
|
||||
require.Eventually(t, r.lease.Held, 5*time.Second, 10*time.Millisecond, "the reconciler must win the lease back")
|
||||
requireStillRunning(t, finished)
|
||||
}
|
||||
|
||||
// A cluster that has left CREATING and ACTIVE wants no reconciler, so one that
|
||||
// cannot get the lease stops asking.
|
||||
func TestRunClusterReconciler_StopsContendingForADeletingCluster(t *testing.T) {
|
||||
t.Parallel()
|
||||
r, js, acctKV := newReconcilerHarnessTTL(t, 300*time.Millisecond, "https://nlb.example/healthz")
|
||||
meta, err := GetClusterMeta(t.Context(), acctKV, "alpha")
|
||||
require.NoError(t, err)
|
||||
meta.Status = ClusterStatusDeleting
|
||||
require.NoError(t, PutClusterMeta(t.Context(), acctKV, meta))
|
||||
putForeignHolder(t, js)
|
||||
|
||||
release, finished, err := RunClusterReconciler(t.Context(), r.lease, acctKV, testAccountID, "alpha", "")
|
||||
require.NoError(t, err)
|
||||
defer release()
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the reconciler kept contending for a DELETING cluster")
|
||||
}
|
||||
assert.False(t, r.lease.Held())
|
||||
}
|
||||
|
||||
// Run watches the Lost channel of the acquisition AcquireLease took. A later
|
||||
// acquisition of the same lease must not keep a Run alive whose own was lost.
|
||||
func TestClusterReconciler_RunWatchesItsOwnAcquisition(t *testing.T) {
|
||||
t.Parallel()
|
||||
r, js, acctKV := newReconcilerHarnessTTL(t, 300*time.Millisecond, "https://nlb.example/healthz",
|
||||
WithHTTPClient(&stubHTTPDoer{status: http.StatusOK}), WithReconcileInterval(10*time.Second))
|
||||
_, ok := r.AcquireLease(t.Context())
|
||||
require.True(t, ok)
|
||||
|
||||
putForeignHolder(t, js)
|
||||
require.Eventually(t, func() bool { return !r.lease.Held() }, 5*time.Second, 10*time.Millisecond)
|
||||
syncKV, err := kvstore.OpenSyncBucket(js).KV(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, syncKV.Delete(t.Context(), KeyPrefixCluster+clusterLeaseName(testAccountID, "alpha")))
|
||||
tok, err := r.lease.TryLock(t.Context())
|
||||
require.NoError(t, err, "a later acquisition of the same lease")
|
||||
defer r.lease.Unlock(t.Context(), tok)
|
||||
|
||||
seedBootstrapState(t, acctKV)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
assert.ErrorIs(t, r.Run(ctx), ErrReconcilerLeaseLost, "the acquisition this Run was given is gone")
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -280,7 +280,7 @@ func casUpdateMeta(ctx context.Context, kv jetstream.KeyValue, name string, muta
|
||||
if name == "" {
|
||||
return errors.New("eks: casUpdateMeta empty name")
|
||||
}
|
||||
_, err := kvutil.Update(ctx, kv, ClusterMetaKey(name), kvutil.CASConfig{
|
||||
_, err := kvstore.Update(ctx, kv, ClusterMetaKey(name), kvstore.CASConfig{
|
||||
Attempts: maxClusterStateCASRetries,
|
||||
NotFound: ErrClusterNotFound,
|
||||
Exhausted: func(string, int) error {
|
||||
@@ -435,7 +435,7 @@ func putIfGeneration(ctx context.Context, kv jetstream.KeyValue, meta *ClusterMe
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !kvutil.IsRevisionConflict(err) {
|
||||
if !kvstore.IsRevisionConflict(err) {
|
||||
return fmt.Errorf("kv write %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -22,12 +21,6 @@ var teardownConcurrency = 4
|
||||
// writer added refs to the record while it was tearing down.
|
||||
const maxTeardownPasses = 5
|
||||
|
||||
// Teardown lease timing. TTL must match the leader bucket's TTL; vars for tests.
|
||||
var (
|
||||
teardownLeaseTTL = KVBucketEKSLeaderTTL
|
||||
teardownLeaseRenew = defaultReconcileLeaseRefresh
|
||||
)
|
||||
|
||||
var (
|
||||
errTeardownNotRecorded = errors.New("eks: cluster is not DELETING, teardown refused")
|
||||
errTeardownLeaseLost = errors.New("eks: teardown lease lost")
|
||||
@@ -35,45 +28,17 @@ var (
|
||||
errLaunchInFlight = errors.New("eks: cluster create launch still running")
|
||||
)
|
||||
|
||||
// teardownLeaderKey is the per-cluster cross-node gate for a DELETING
|
||||
// teardown. It is distinct from reconcilerLeaderKey so a delete never contends
|
||||
// with the still-live reconciler, which holds that lease until it reads DELETING.
|
||||
func teardownLeaderKey(accountID, clusterName string) string {
|
||||
return reconcilerLeaderKey(accountID, clusterName) + "/teardown"
|
||||
}
|
||||
|
||||
// teardownLease is a held teardown lease: lost closes if renewal loses the key.
|
||||
type teardownLease struct {
|
||||
release func()
|
||||
lost <-chan struct{}
|
||||
}
|
||||
|
||||
// acquireTeardownLease takes the per-cluster teardown lease, renewing it on
|
||||
// ctx. The lease re-adopts a key its own holder already has, so it serialises
|
||||
// nodes, not goroutines; startTeardown's in-process gate covers the latter. A
|
||||
// nil leaderKV (single-node/test) skips gating.
|
||||
// acquireTeardownLease takes the per-cluster teardown lease, renewing it until
|
||||
// released. It refuses when another node holds it or another goroutine here does.
|
||||
func (s *EKSServiceImpl) acquireTeardownLease(ctx context.Context, accountID, clusterName string) (teardownLease, bool) {
|
||||
if s.leaderKV == nil {
|
||||
return teardownLease{release: func() {}}, true
|
||||
}
|
||||
key := teardownLeaderKey(accountID, clusterName)
|
||||
lease, err := kvlease.New(kvlease.Config{
|
||||
Name: "eks/teardown",
|
||||
Bucket: kvlease.StaticBucket(s.leaderKV),
|
||||
Key: key,
|
||||
Holder: s.deps.HolderID,
|
||||
Attrs: []any{"account", accountID, "cluster", clusterName},
|
||||
TTL: teardownLeaseTTL,
|
||||
Renew: teardownLeaseRenew,
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "eks: teardown lease config invalid", "key", key, "err", err)
|
||||
return teardownLease{}, false
|
||||
}
|
||||
if !lease.TryAcquire(ctx) {
|
||||
return teardownLease{}, false
|
||||
}
|
||||
return teardownLease{release: func() { lease.Release(ctx) }, lost: lease.Lost()}, true
|
||||
release, lost, ok := tryLease(ctx, s.leases.Get(teardownLeaseName(accountID, clusterName)))
|
||||
return teardownLease{release: release, lost: lost}, ok
|
||||
}
|
||||
|
||||
// teardownPassTimeout bounds one converger run, so a step that hangs cannot
|
||||
@@ -81,11 +46,11 @@ func (s *EKSServiceImpl) acquireTeardownLease(ctx context.Context, accountID, cl
|
||||
var teardownPassTimeout = 15 * time.Minute
|
||||
|
||||
// holdTeardown claims the right to tear a cluster down on this node: the
|
||||
// in-process gate, which the lease cannot provide because it re-adopts a key
|
||||
// its own node holds, then the cross-node lease. It refuses, without waiting,
|
||||
// when either is held, or, if capped, when the node is at teardownConcurrency.
|
||||
func (s *EKSServiceImpl) holdTeardown(accountID, name string, capped bool) (release func(), lost <-chan struct{}, ok bool) {
|
||||
key := teardownLeaderKey(accountID, name)
|
||||
// in-process gate, which also bounds this node's teardowns, then the cross-node
|
||||
// lease. It refuses, without waiting, when either is held, or, if capped, when
|
||||
// the node is at teardownConcurrency.
|
||||
func (s *EKSServiceImpl) holdTeardown(accountID, name string, capped bool) (release func(retire bool), lost <-chan struct{}, ok bool) {
|
||||
key := teardownLeaseName(accountID, name)
|
||||
s.teardownMu.Lock()
|
||||
if s.teardownRunning == nil {
|
||||
s.teardownRunning = map[string]struct{}{}
|
||||
@@ -110,7 +75,15 @@ func (s *EKSServiceImpl) holdTeardown(accountID, name string, capped bool) (rele
|
||||
leave()
|
||||
return nil, nil, false
|
||||
}
|
||||
return func() { lease.release(); leave() }, lease.lost, true
|
||||
// Retiring drops the teardown lease before the gate opens: every Get of it
|
||||
// is under the gate, so no later teardown of a recreated name can hold it.
|
||||
return func(retire bool) {
|
||||
lease.release()
|
||||
if retire {
|
||||
s.leases.Forget(key)
|
||||
}
|
||||
leave()
|
||||
}, lease.lost, true
|
||||
}
|
||||
|
||||
// startTeardown hands one DELETING cluster to a background converger and
|
||||
@@ -122,7 +95,8 @@ func (s *EKSServiceImpl) startTeardown(accountID, name string) bool {
|
||||
return false
|
||||
}
|
||||
s.teardownWG.Go(func() {
|
||||
defer release()
|
||||
deleted := false
|
||||
defer func() { release(deleted) }()
|
||||
ctx, cancel := context.WithTimeout(s.bgCtx, teardownPassTimeout)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
@@ -131,6 +105,7 @@ func (s *EKSServiceImpl) startTeardown(accountID, name string) bool {
|
||||
"cluster", name, "account", accountID, "err", err)
|
||||
return
|
||||
}
|
||||
deleted = true
|
||||
slog.Info("eks: cluster teardown complete", "cluster", name, "account", accountID,
|
||||
"duration_ms", telemetry.Millis(time.Since(started)))
|
||||
})
|
||||
@@ -211,6 +186,9 @@ func (s *EKSServiceImpl) convergeClusterDeletion(ctx context.Context, accountID,
|
||||
return terr
|
||||
}
|
||||
|
||||
// Forgotten while the record still blocks a same-name create, and after
|
||||
// teardownClusterInfra stopped this node's reconciler.
|
||||
s.leases.Forget(clusterLeaseName(accountID, name))
|
||||
done, err := s.removeClusterRecord(ctx, acctKV, name, meta)
|
||||
if err != nil {
|
||||
if !errors.Is(err, errNodegroupsAttached) && !errors.Is(err, errTeardownNotRecorded) {
|
||||
@@ -292,7 +270,7 @@ func (s *EKSServiceImpl) removeClusterRecord(ctx context.Context, acctKV jetstre
|
||||
}
|
||||
|
||||
errRefsAdded := errors.New("refs added")
|
||||
_, err = kvutil.DeleteIf(ctx, acctKV, ClusterMetaKey(name), kvutil.CASConfig{
|
||||
_, err = kvstore.DeleteIf(ctx, acctKV, ClusterMetaKey(name), kvstore.CASConfig{
|
||||
Attempts: maxClusterStateCASRetries,
|
||||
NotFound: ErrClusterNotFound,
|
||||
}, func(m *ClusterMeta) error {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/eks"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -132,22 +134,18 @@ func TestS8_EKSSecondDeleteAnswersFromTheRecordWithoutASecondTeardown(t *testing
|
||||
assert.Len(t, f.inst.terminated(), 1, "the repeat request must start no teardown of its own")
|
||||
}
|
||||
|
||||
// TestS8_EKSTeardownLeaseOutlivesTheRequest: the teardown lease renews on the
|
||||
// service's context, so cancelling the request context mid-teardown does not
|
||||
// let the lease expire and a second node start a duplicate teardown.
|
||||
// TestS8_EKSTeardownLeaseOutlivesTheRequest: the teardown lease renews on its
|
||||
// own, so cancelling the request context mid-teardown does not let the lease
|
||||
// lapse and a second node start a duplicate teardown.
|
||||
func TestS8_EKSTeardownLeaseOutlivesTheRequest(t *testing.T) {
|
||||
f := newDeleteClusterFixture(t, "alpha")
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
js := testutil.NewJetStream(t, nc)
|
||||
// A short TTL with frequent renewals keeps the wait brief while leaving
|
||||
// several renewal chances inside each TTL window.
|
||||
// A short TTL keeps the wait brief while leaving several renewal chances
|
||||
// inside each TTL window.
|
||||
const ttl = 400 * time.Millisecond
|
||||
short, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: "eks-teardown-short-ttl", TTL: ttl})
|
||||
require.NoError(t, err)
|
||||
f.svc.leaderKV = short
|
||||
origTTL, origRenew := teardownLeaseTTL, teardownLeaseRenew
|
||||
teardownLeaseTTL, teardownLeaseRenew = ttl, 50*time.Millisecond
|
||||
t.Cleanup(func() { teardownLeaseTTL, teardownLeaseRenew = origTTL, origRenew })
|
||||
js := testutil.NewJetStream(t, f.svc.deps.NATSConn)
|
||||
f.svc.leases = kvsync.NewLeaseGroup(kvstore.OpenSyncBucket(js), KeyPrefixCluster, "node-1", ttl)
|
||||
other := kvsync.NewLeaseGroup(kvstore.OpenSyncBucket(js), KeyPrefixCluster, "node-2", ttl).
|
||||
Get(teardownLeaseName(testAccountID, "alpha"))
|
||||
|
||||
entered, release := blockTerminate(t, f.inst)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -155,19 +153,22 @@ func TestS8_EKSTeardownLeaseOutlivesTheRequest(t *testing.T) {
|
||||
waitEntered(t, entered)
|
||||
cancel()
|
||||
|
||||
// The other node first sees the key, then sees it again past a full TTL: it
|
||||
// takes over only if no renewal moved the key in between.
|
||||
_, err := other.TryLock(t.Context())
|
||||
require.ErrorAs(t, err, new(*kvsync.HeldError))
|
||||
time.Sleep(ttl * 5 / 2)
|
||||
_, err = short.Create(t.Context(), teardownLeaderKey(testAccountID, "alpha"), []byte("node-2"))
|
||||
require.ErrorIs(t, err, jetstream.ErrKeyExists,
|
||||
_, err = other.TryLock(t.Context())
|
||||
require.ErrorAs(t, err, new(*kvsync.HeldError),
|
||||
"the running teardown must still hold its lease after the request context is gone")
|
||||
|
||||
release()
|
||||
f.svc.WaitTeardowns()
|
||||
}
|
||||
|
||||
// TestS8_EKSKickAndSameNodeSweepRunOneTeardown: the teardown lease re-adopts a
|
||||
// key its own node holds, so only the in-process gate stops a same-node reaper
|
||||
// sweep from starting a second teardown beside the request's. Each member must
|
||||
// be terminated exactly once.
|
||||
// TestS8_EKSKickAndSameNodeSweepRunOneTeardown: a same-node reaper sweep must
|
||||
// not start a second teardown beside the request's. Each member must be
|
||||
// terminated exactly once.
|
||||
func TestS8_EKSKickAndSameNodeSweepRunOneTeardown(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newDeleteClusterFixture(t, "alpha")
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestEKSBootstrapRunRemovesItsOwnEntryOnExit(t *testing.T) {
|
||||
eventually(t, func() bool {
|
||||
f.svc.bootstrapMu.Lock()
|
||||
defer f.svc.bootstrapMu.Unlock()
|
||||
_, ok := f.svc.bootstraps[reconcilerLeaderKey(testAccountID, "alpha")]
|
||||
_, ok := f.svc.bootstraps[clusterLeaseName(testAccountID, "alpha")]
|
||||
return !ok
|
||||
}, "an exited bootstrap must remove its own entry")
|
||||
}
|
||||
|
||||
@@ -252,8 +252,7 @@ func TestS2_EKSFailedClusterReclaimRunsUnderTheTeardownLease(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
meta.Status = ClusterStatusFailed
|
||||
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
|
||||
_, err = f.svc.leaderKV.Create(t.Context(), teardownLeaderKey(testAccountID, "alpha"), []byte("node-2"))
|
||||
require.NoError(t, err)
|
||||
holdElsewhere(t, f.svc, teardownLeaseName(testAccountID, "alpha"))
|
||||
|
||||
_, err = f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
|
||||
code, _, _ := awserrors.ResolveErrorDetail(err)
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/systeminstance"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -83,6 +85,7 @@ func newEKSServiceFixture(t *testing.T) *eksServiceFixture {
|
||||
GatewayBaseURL: "https://gw.local:9999",
|
||||
Region: "us-east-1",
|
||||
HolderID: "node-1",
|
||||
Leases: NewClusterLeases(kvstore.OpenSyncBucket(js), "node-1"),
|
||||
SystemGatewayURL: "https://gw.local:9999",
|
||||
SystemAccessKey: "AKIAEXAMPLE",
|
||||
SystemSecretKey: "s3cr3t-key",
|
||||
@@ -371,6 +374,33 @@ func TestDeleteCluster_EgressEIPReleaseRealErrorLeavesMeta(t *testing.T) {
|
||||
assert.NotEmpty(t, meta.EgressEIPAllocationID, "alloc ID must remain for retry")
|
||||
}
|
||||
|
||||
// otherNodeLeases is the cluster lease group as another node holding holder
|
||||
// sees it, on its own sync bucket over the same JetStream as svc.
|
||||
func otherNodeLeases(t *testing.T, svc *EKSServiceImpl, holder string) *kvsync.LeaseGroup {
|
||||
t.Helper()
|
||||
return NewClusterLeases(kvstore.OpenSyncBucket(testutil.NewJetStream(t, svc.deps.NATSConn)), holder)
|
||||
}
|
||||
|
||||
// holdElsewhere has another node take the named lease until the test ends.
|
||||
func holdElsewhere(t *testing.T, svc *EKSServiceImpl, name string) {
|
||||
t.Helper()
|
||||
lease := otherNodeLeases(t, svc, "node-2").Get(name)
|
||||
tok, err := lease.TryLock(t.Context())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { lease.Unlock(context.Background(), tok) })
|
||||
}
|
||||
|
||||
// leaseKeyHeld reports whether any node holds the named lease's key.
|
||||
func leaseKeyHeld(t *testing.T, svc *EKSServiceImpl, name string) bool {
|
||||
t.Helper()
|
||||
_, _, err := kvstore.OpenSyncBucket(testutil.NewJetStream(t, svc.deps.NATSConn)).Load(t.Context(), KeyPrefixCluster+name)
|
||||
if errors.Is(err, kvsync.ErrNotFound) {
|
||||
return false
|
||||
}
|
||||
require.NoError(t, err)
|
||||
return true
|
||||
}
|
||||
|
||||
// TestDeleteClusterLeaseHeldElsewhereLeavesTeardownToTheHolder: when another
|
||||
// node holds the teardown lease, DeleteCluster still records DELETING and
|
||||
// returns it, but runs no teardown of its own and leaves the record for the
|
||||
@@ -379,8 +409,7 @@ func TestDeleteClusterLeaseHeldElsewhereLeavesTeardownToTheHolder(t *testing.T)
|
||||
t.Parallel()
|
||||
f := newDeleteClusterFixture(t, "alpha")
|
||||
|
||||
_, err := f.svc.leaderKV.Create(t.Context(), teardownLeaderKey(testAccountID, "alpha"), []byte("node-2"))
|
||||
require.NoError(t, err)
|
||||
holdElsewhere(t, f.svc, teardownLeaseName(testAccountID, "alpha"))
|
||||
|
||||
deleteAndSettle(t, f.svc, "alpha")
|
||||
|
||||
@@ -399,8 +428,73 @@ func TestDeleteClusterTeardownReleasesItsLease(t *testing.T) {
|
||||
|
||||
deleteAndSettle(t, f.svc, "alpha")
|
||||
|
||||
_, getErr := f.svc.leaderKV.Get(t.Context(), teardownLeaderKey(testAccountID, "alpha"))
|
||||
assert.ErrorIs(t, getErr, jetstream.ErrKeyNotFound, "the teardown lease must be released after the teardown")
|
||||
assert.False(t, leaseKeyHeld(t, f.svc, teardownLeaseName(testAccountID, "alpha")),
|
||||
"the teardown lease must be released after the teardown")
|
||||
}
|
||||
|
||||
// TestDeleteClusterForgetsTheClusterLeases: a deleted cluster's reconciler and
|
||||
// teardown leases leave the group, so a cluster recreated under the same name
|
||||
// starts from fresh ones.
|
||||
func TestDeleteClusterForgetsTheClusterLeases(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newDeleteClusterFixture(t, "alpha")
|
||||
names := []string{clusterLeaseName(testAccountID, "alpha"), teardownLeaseName(testAccountID, "alpha")}
|
||||
before := make([]*kvsync.Lease, len(names))
|
||||
for i, name := range names {
|
||||
before[i] = f.svc.leases.Get(name)
|
||||
}
|
||||
|
||||
deleteAndSettle(t, f.svc, "alpha")
|
||||
|
||||
_, err := GetClusterMeta(t.Context(), f.kv, "alpha")
|
||||
require.ErrorIs(t, err, ErrClusterNotFound, "the teardown must have completed")
|
||||
for i, name := range names {
|
||||
assert.NotSame(t, before[i], f.svc.leases.Get(name), "%s must be forgotten", name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteClusterCleanupLeavesARecreatedClustersLease: once the record is
|
||||
// gone a same-name cluster may lead, and the old teardown's cleanup, still
|
||||
// running, must not release the lease it took.
|
||||
func TestDeleteClusterCleanupLeavesARecreatedClustersLease(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newDeleteClusterFixture(t, "alpha")
|
||||
entered, release := blockTerminate(t, f.inst)
|
||||
_, err := f.svc.DeleteCluster(context.Background(), deleteInput("alpha"), testAccountID)
|
||||
require.NoError(t, err)
|
||||
waitEntered(t, entered)
|
||||
|
||||
// Holding the gate's mutex parks the old teardown before its gate opens.
|
||||
f.svc.teardownMu.Lock()
|
||||
release()
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := GetClusterMeta(t.Context(), f.kv, "alpha")
|
||||
return errors.Is(err, ErrClusterNotFound)
|
||||
}, 10*time.Second, 10*time.Millisecond, "the old teardown must remove the record")
|
||||
|
||||
replacement := f.svc.leases.Get(clusterLeaseName(testAccountID, "alpha"))
|
||||
tok, err := replacement.TryLock(t.Context())
|
||||
require.NoError(t, err, "the recreated cluster's reconciler takes the free lease")
|
||||
f.svc.teardownMu.Unlock()
|
||||
f.svc.WaitTeardowns()
|
||||
|
||||
assert.True(t, replacement.Held(), "the old teardown's cleanup released the recreated cluster's lease")
|
||||
replacement.Unlock(t.Context(), tok)
|
||||
}
|
||||
|
||||
// TestDeleteClusterKeepsTheLeasesOfAnIncompleteTeardown: a teardown that
|
||||
// fails leaves the record DELETING for retry, so its leases stay in the group.
|
||||
func TestDeleteClusterKeepsTheLeasesOfAnIncompleteTeardown(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newDeleteClusterFixture(t, "alpha")
|
||||
f.eip.releaseErr = errors.New("AddressLimitExceeded")
|
||||
name := teardownLeaseName(testAccountID, "alpha")
|
||||
before := f.svc.leases.Get(name)
|
||||
|
||||
deleteAndSettle(t, f.svc, "alpha")
|
||||
|
||||
requireTeardownRetained(t, f.kv, "alpha")
|
||||
assert.Same(t, before, f.svc.leases.Get(name))
|
||||
}
|
||||
|
||||
func TestTeardownLeaseReleaseSurvivesCallerCancellation(t *testing.T) {
|
||||
@@ -413,8 +507,7 @@ func TestTeardownLeaseReleaseSurvivesCallerCancellation(t *testing.T) {
|
||||
cancel()
|
||||
lease.release()
|
||||
|
||||
_, err := f.svc.leaderKV.Get(t.Context(), teardownLeaderKey(testAccountID, "alpha"))
|
||||
require.ErrorIs(t, err, jetstream.ErrKeyNotFound)
|
||||
assert.False(t, leaseKeyHeld(t, f.svc, teardownLeaseName(testAccountID, "alpha")))
|
||||
}
|
||||
|
||||
// TestDeleteCluster_ManagedCPVPCReclaimsCustomerVPCSGs guards the contract: in
|
||||
|
||||
@@ -62,8 +62,7 @@ func TestDeletingReaperLeavesAHeldTeardownAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newDeleteClusterFixture(t, "beta")
|
||||
markDeleting(t, f, "beta", time.Second)
|
||||
_, err := f.svc.leaderKV.Create(t.Context(), teardownLeaderKey(testAccountID, "beta"), []byte("node-2"))
|
||||
require.NoError(t, err)
|
||||
holdElsewhere(t, f.svc, teardownLeaseName(testAccountID, "beta"))
|
||||
|
||||
assert.Equal(t, 0, sweepAndSettle(t, f))
|
||||
|
||||
|
||||
@@ -106,18 +106,22 @@ func (r *EKSNodegroupDeletingReaper) due(rec *NodegroupRecord) bool {
|
||||
// startNodegroupDelete runs one converger pass in the background and reports
|
||||
// whether it started; a pass already running here or on another node refuses.
|
||||
func (s *EKSServiceImpl) startNodegroupDelete(accountID string, kv jetstream.KeyValue, cluster, ng string) bool {
|
||||
release, _, ok := s.holdTeardown(accountID, cluster+"/nodegroup/"+ng, false)
|
||||
gate := cluster + "/nodegroup/" + ng
|
||||
release, _, ok := s.holdTeardown(accountID, gate, false)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
s.teardownWG.Go(func() {
|
||||
defer release()
|
||||
deleted := false
|
||||
defer func() { release(deleted) }()
|
||||
ctx, cancel := context.WithTimeout(s.bgCtx, teardownPassTimeout)
|
||||
defer cancel()
|
||||
if err := s.convergeNodegroupDeletion(ctx, accountID, kv, cluster, ng); err != nil {
|
||||
slog.Warn("eks: nodegroup delete incomplete, the record stays DELETING for retry",
|
||||
"cluster", cluster, "nodegroup", ng, "account", accountID, "err", err)
|
||||
return
|
||||
}
|
||||
deleted = true
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
)
|
||||
|
||||
// KeyPrefixCluster is the prefix of every per-cluster lease in the sync bucket.
|
||||
const KeyPrefixCluster = "eks.cluster."
|
||||
|
||||
// clusterLeaseTTL is every per-cluster lease's time to live.
|
||||
const clusterLeaseTTL = 60 * time.Second
|
||||
|
||||
// NewClusterLeases returns the per-cluster lease group for holder. The EKS
|
||||
// owner builds it once per process: a cluster's reconciler and teardown leases
|
||||
// are members of it, and a deleted cluster's are forgotten.
|
||||
func NewClusterLeases(store kvsync.KVStore, holder string) *kvsync.LeaseGroup {
|
||||
return kvsync.NewLeaseGroup(store, KeyPrefixCluster, holder, clusterLeaseTTL)
|
||||
}
|
||||
|
||||
// tryLease makes one attempt at lease, logging any failure but a held lease,
|
||||
// and returns its release, which survives a cancelled ctx, and its Lost.
|
||||
func tryLease(ctx context.Context, lease *kvsync.Lease) (func(), <-chan struct{}, bool) {
|
||||
tok, err := lease.TryLock(ctx)
|
||||
if err != nil {
|
||||
if _, held := errors.AsType[*kvsync.HeldError](err); !held {
|
||||
slog.WarnContext(ctx, "eks: lease attempt failed", "key", lease.Key(), "err", err)
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
return func() { lease.Unlock(ctx, tok) }, lease.Lost(), true
|
||||
}
|
||||
|
||||
// clusterLeaseName names a cluster's reconciler lease in the group.
|
||||
func clusterLeaseName(accountID, clusterName string) string {
|
||||
return accountID + "/" + clusterName
|
||||
}
|
||||
|
||||
// teardownLeaseName names the per-cluster cross-node gate for a DELETING
|
||||
// teardown. It is distinct from clusterLeaseName so a delete never contends
|
||||
// with the still-live reconciler, which holds that lease until it reads DELETING.
|
||||
func teardownLeaseName(accountID, clusterName string) string {
|
||||
return clusterLeaseName(accountID, clusterName) + "/teardown"
|
||||
}
|
||||
@@ -662,3 +662,23 @@ func TestDeleteNodegroup_WorkerStuckPastDeadlineEndsDeleteFailedKeepingProfile(t
|
||||
requireErrorCode(t, err, awserrors.ErrorEKSResourceNotFound)
|
||||
assert.NotContains(t, fixtureIAM(t, f).Profiles, rec.InstanceProfileName)
|
||||
}
|
||||
|
||||
// A finished nodegroup delete drops its teardown lease from the group, so the
|
||||
// group does not grow with every nodegroup ever deleted and a recreated
|
||||
// nodegroup of the same name starts from a fresh lease.
|
||||
func TestNodegroupDelete_RetiresItsTeardownLease(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newEKSServiceFixture(t)
|
||||
seedActiveClusterWithToken(t, f, "c1")
|
||||
createReadyNodegroup(t, f, "c1", "ng1", 1)
|
||||
name := teardownLeaseName(testAccountID, "c1/nodegroup/ng1")
|
||||
before := f.svc.leases.Get(name)
|
||||
|
||||
_, err := deleteNodegroupCall(f, "c1", "ng1")
|
||||
require.NoError(t, err)
|
||||
sweepNodegroups(t, f)
|
||||
|
||||
_, err = GetNodegroupRecord(t.Context(), f.kv, "c1", "ng1")
|
||||
require.ErrorIs(t, err, ErrNodegroupNotFound, "the delete must have completed")
|
||||
assert.NotSame(t, before, f.svc.leases.Get(name), "the finished delete's teardown lease must leave the group")
|
||||
}
|
||||
|
||||
@@ -5,14 +5,12 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// ErrLeaseHeld signals the leader lease is held by another node.
|
||||
// Spawn treats it as benign and retries on the next tick.
|
||||
var ErrLeaseHeld = errors.New("eks: reconciler lease held by another node")
|
||||
|
||||
// ReconcilerRegistry tracks active per-cluster reconciler goroutines; one entry per (accountID, clusterName).
|
||||
type ReconcilerRegistry struct {
|
||||
mu sync.Mutex
|
||||
@@ -64,9 +62,6 @@ func (r *ReconcilerRegistry) Spawn(parent context.Context, accountID, clusterNam
|
||||
r.mu.Unlock()
|
||||
cancel()
|
||||
close(done)
|
||||
if errors.Is(err, ErrLeaseHeld) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -151,33 +146,67 @@ func registryKey(accountID, clusterName string) string {
|
||||
return accountID + "/" + clusterName
|
||||
}
|
||||
|
||||
// RunClusterReconciler is the production SpawnReconcilerFn: constructs a ClusterReconciler,
|
||||
// acquires the lease, and drives Run in a goroutine. Returns ErrLeaseHeld if another node wins.
|
||||
// RunClusterReconciler contends for the cluster's lease and drives Run while it
|
||||
// holds it, in a goroutine, until ctx ends or the cluster leaves CREATING and
|
||||
// ACTIVE. A held lease is asked for again every ttl/3, so a dead holder's key is
|
||||
// taken over once it has sat a TTL, and a lost one is contended for again.
|
||||
// release stops the goroutine and returns once the lease is unlocked.
|
||||
func RunClusterReconciler(
|
||||
ctx context.Context,
|
||||
leaderKV, acctKV jetstream.KeyValue,
|
||||
accountID, clusterName, holderID, healthURL string,
|
||||
lease *kvsync.Lease, acctKV jetstream.KeyValue,
|
||||
accountID, clusterName, healthURL string,
|
||||
opts ...ReconcilerOption,
|
||||
) (func(), <-chan struct{}, error) {
|
||||
r, err := NewClusterReconciler(leaderKV, acctKV, accountID, clusterName, holderID, healthURL, opts...)
|
||||
newRun := func() (*ClusterReconciler, error) {
|
||||
return NewClusterReconciler(lease, acctKV, accountID, clusterName, healthURL, opts...)
|
||||
}
|
||||
r, err := newRun()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
release, ok := r.AcquireLease(ctx)
|
||||
if !ok {
|
||||
slog.Info("RunClusterReconciler: lease held elsewhere, skipping spawn",
|
||||
"accountID", accountID, "cluster", clusterName)
|
||||
return nil, nil, ErrLeaseHeld
|
||||
}
|
||||
// Closed when Run returns so the registry can drop this entry on a self-exit
|
||||
// (terminal state / lost lease), letting a later same-name create re-spawn.
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
defer close(finished)
|
||||
if runErr := r.Run(ctx); runErr != nil && !errors.Is(runErr, context.Canceled) {
|
||||
slog.Info("RunClusterReconciler: Run exited",
|
||||
"accountID", accountID, "cluster", clusterName, "err", runErr)
|
||||
for {
|
||||
if release, ok := r.AcquireLease(runCtx); ok {
|
||||
err := r.Run(runCtx)
|
||||
release()
|
||||
if !errors.Is(err, ErrReconcilerLeaseLost) {
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
slog.Info("RunClusterReconciler: Run exited",
|
||||
"accountID", accountID, "cluster", clusterName, "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
slog.Info("RunClusterReconciler: lease lost, contending again",
|
||||
"accountID", accountID, "cluster", clusterName)
|
||||
}
|
||||
if !clusterContended(runCtx, acctKV, clusterName) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-runCtx.Done():
|
||||
return
|
||||
case <-time.After(lease.TTL() / 3):
|
||||
}
|
||||
// A fresh reconciler per holding: a finished Run keeps its exit reason.
|
||||
r, _ = newRun()
|
||||
}
|
||||
}()
|
||||
return release, finished, nil
|
||||
return func() { cancel(); <-finished }, finished, nil
|
||||
}
|
||||
|
||||
// clusterContended reports whether the cluster is still one a reconciler is
|
||||
// wanted for. A read that fails is not evidence it is gone, so it keeps trying.
|
||||
func clusterContended(ctx context.Context, acctKV jetstream.KeyValue, clusterName string) bool {
|
||||
meta, err := GetClusterMeta(ctx, acctKV, clusterName)
|
||||
switch {
|
||||
case errors.Is(err, ErrClusterNotFound):
|
||||
return false
|
||||
case err != nil:
|
||||
return ctx.Err() == nil
|
||||
default:
|
||||
return meta.Status == ClusterStatusCreating || meta.Status == ClusterStatusActive
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user