Compare commits

...

19 Commits

Author SHA1 Message Date
brynmailer-mdc bc16f80ed6 refactor: trim the kvsync lease internals and their callers
Drop the lease's separate stop channel, the election's held flag, the
reconcile try-leader glue, the unused ErrLeaseHeld, Lease.Holder and the
EKS lease-group fallback, and route constructor checks through one panic.
2026-11-05 17:11:08 +10:00
brynmailer-mdc f5b31d6189 chore: sort the reconcile lock imports for gofmt 2026-11-05 17:10:29 +10:00
brynmailer-mdc 2700d72cda fix: wait out a forming JetStream at start and retry a held pass within a renewal interval 2026-11-05 17:10:29 +10:00
brynmailer-mdc 65e393d5cc fix: Unlock abandons an in-flight lease renewal instead of waiting it out 2026-11-05 17:10:29 +10:00
brynmailer-mdc f2a99b2f9f fix: confirm a claim's adoption by CAS so a stale replica read cannot adopt a taken-over key 2026-11-05 17:10:29 +10:00
brynmailer-mdc 3c048b7b0c test: cover lease gating, teardown-lease retirement, obsolete buckets and kvsync edge cases 2026-11-05 17:10:29 +10:00
brynmailer-mdc ba253cd65b test: per-service leaders stop on a taken-over lease and resume after re-election 2026-11-05 17:10:29 +10:00
brynmailer-mdc 6e53819677 fix: keep contending for an EKS cluster's lease while it is live
The reconciler asked for its lease once, and a fresh lease only takes over a
foreign key after watching it for a TTL, so a dead holder's key left the
cluster unreconciled for good. It now asks every ttl/3 while the cluster is
CREATING or ACTIVE, contends again after a loss, and watches the Lost channel
of the acquisition it was given.
2026-11-05 17:10:29 +10:00
brynmailer-mdc 81186cc3bc fix: retry a held per-pass lease once a dead holder can be taken over
A pass that lost the election waited for the loop's next resync, so a dead
holder blocked DNS, vpcd drift and Route 53 for up to two intervals. A lost
pass now asks to run again just past one TTL. vpcd's one-shot startup
election waits up to a minute for JetStream quorum, as it did before.
2026-11-05 17:10:29 +10:00
brynmailer-mdc b578502072 fix: lose a lease at its deadline even when the renewal is stuck
A renewal CAS that blocked past its context kept the lease reported held
after a peer could take over. Each renewal is now given up at 2*ttl/3 from
the last send, and every renewal is scheduled from that send rather than from
install.
2026-11-05 17:10:29 +10:00
brynmailer-mdc 9239cad533 fix: open a bucket without holding its lock across the network
KV and Reopen held the bucket mutex across the open, so a caller behind a
stuck open waited with no context to stop it. One open is now in flight at a
time and each waiter stops at its own context.
2026-11-05 17:10:29 +10:00
brynmailer-mdc fa088afb89 test: check the sync bucket in the KV quorum suite
The suite looked for the per-caller lease buckets, which the daemon now
deletes. It now requires the sync bucket at full replication and none of
the old lease buckets.
2026-11-05 17:10:29 +10:00
brynmailer-mdc 7dc2723047 fix: forget EKS cluster leases before a same-name cluster can take them
The reconciler lease is forgotten while the DELETING record still blocks a
create, and the teardown lease before the teardown gate opens, so cleanup
can no longer release a recreated cluster's lease.
2026-11-05 17:10:29 +10:00
brynmailer-mdc 95253f118b fix: count a lease's validity from the write's send
A peer can see a revision as soon as it commits, so validity measured from
the reply could outlast a takeover. A grant whose reply took two thirds of
the TTL is now refused.
2026-11-05 17:10:29 +10:00
brynmailer-mdc 01c8e7bcc0 fix: never recreate a lost bucket through the first-use open
A failed recovery dropped the handle, and the next call's first-use open
created the bucket again, which RecreateIfMissing=false forbids. For the
sync bucket that restarted revisions under live tokens.
2026-11-05 17:10:29 +10:00
brynmailer-mdc 9beaa5ebff refactor: port the leader leases to kvsync and delete kvlease
Every leader election now runs a kvsync Lease in the shared sync bucket,
constructed once by the role that owns it. The two vpcd sites and the two
DNS sites share one Lease, so they exclude each other on one node too.

The daemon deletes the nine per-caller lease buckets at start, and
kvlease is removed.
2026-11-05 17:10:29 +10:00
brynmailer-mdc ec0910b828 feat: add kvsync claims and leases over a compare-and-swap store
kvsync provides Claim and Lease, each with a group for per-resource keys,
and RunElection, over a three-method KVStore interface. Every acquisition
returns a token whose generation is the revision of its own write. A lease
expires by its contenders' local observation on a monotonic clock, renews
every ttl/3 and is lost after 2*ttl/3 without a landed renewal. Keys are
registered per store value and a collision panics.

kvstore.Bucket implements KVStore with Load and CompareAndSwap, and
OpenSyncBucket returns the spinifex-sync bucket every lock is built on.
2026-11-05 17:09:38 +10:00
brynmailer-mdc 4781f30966 refactor: merge the kvstore test exports into one file 2026-11-05 17:02:01 +10:00
brynmailer-mdc c07e0be4e1 refactor: fold kvutil into kvstore and rename Claim to Take
kvutil was the layer under kvstore, so its bucket, replica, version, key
and CAS helpers now live in kvstore and every importer changes only its
import path and qualifier. kvutil.Claim becomes kvstore.Take so that it
does not read as a lock claim, and the kvutil package is deleted.

The bucket-creation invariant now exempts kvstore/buckets.go, the one
file holding the chokepoint, rather than the whole package.
2026-11-05 17:02:01 +10:00
230 changed files with 4781 additions and 2684 deletions
+1 -2
View File
@@ -14,7 +14,6 @@ import (
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam" iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn" "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" "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"
"github.com/nats-io/nats.go/jetstream" "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) 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 { if err != nil {
return nil, err return nil, err
} }
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"time" "time"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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/nats-io/nats.go/jetstream"
"golang.org/x/time/rate" "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 { if s == nil || !s.limits.RequestsPerMinuteEnabled {
return return
} }
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketBedrockRPMUsage, 1) kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketBedrockRPMUsage, 1)
if err != nil { if err != nil {
slog.Warn("bedrock rpm sync: open usage bucket failed, sync disabled", "err", err) slog.Warn("bedrock rpm sync: open usage bucket failed, sync disabled", "err", err)
return return
+3 -3
View File
@@ -9,7 +9,7 @@ import (
"time" "time"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" 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" "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.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
over.UpdatedBy = updatedBy over.UpdatedBy = updatedBy
_, err := kvutil.Update(ctx, s.overrides, accountID, kvutil.CASConfig{ _, err := kvstore.Update(ctx, s.overrides, accountID, kvstore.CASConfig{
CreateIfAbsent: true, CreateIfAbsent: true,
Attempts: overrideMaxAttempts, Attempts: overrideMaxAttempts,
Exhausted: func(string, int) error { return errOverridesContended }, Exhausted: func(string, int) error { return errOverridesContended },
@@ -230,7 +230,7 @@ func (s *Service) clearAccountQuota(ctx context.Context, accountID string) error
return nil return nil
case errors.Is(err, jetstream.ErrKeyNotFound): case errors.Is(err, jetstream.ErrKeyNotFound):
return nil return nil
case kvutil.IsRevisionConflict(err): case kvstore.IsRevisionConflict(err):
// A concurrent write landed between the read and the guarded // A concurrent write landed between the read and the guarded
// delete; retry against whatever revision now holds. // delete; retry against whatever revision now holds.
continue continue
+4 -4
View File
@@ -22,10 +22,10 @@ const ReconcileInterval = 30 * time.Second
// dimension; the live-counted dimensions need no stored state. // dimension; the live-counted dimensions need no stored state.
const KVBucketAccountUsage = "spinifex-account-usage" const KVBucketAccountUsage = "spinifex-account-usage"
// KVBucketQuotaReconcile is the leader-lock bucket for the gateway quota // KeyReconcile is the sync-bucket leader lease for the gateway quota reconcile.
// reconcile. It is deliberately distinct from vpcd's network-reconcile lock so // It is deliberately distinct from vpcd's network-reconcile lease so the two
// the two independent loops never share one mutex and block each other. // independent loops never share one mutex and block each other.
const KVBucketQuotaReconcile = "spinifex-quota-reconcile" const KeyReconcile = "quota.reconcile"
// KVBucketAccountQuota holds the per-account limit overrides, keyed by // KVBucketAccountQuota holds the per-account limit overrides, keyed by
// accountID. It is separate from the accounts bucket so a quota write never // accountID. It is separate from the accounts bucket so a quota write never
+3 -3
View File
@@ -8,7 +8,7 @@ import (
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" 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" "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") slog.ErrorContext(ctx, "Quota override seal has no JetStream client wired to open the override bucket")
return nil, errors.New(awserrors.ErrorInternalError) 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 { if err != nil {
slog.ErrorContext(ctx, "Failed to open quota override bucket for seal", "err", err) slog.ErrorContext(ctx, "Failed to open quota override bucket for seal", "err", err)
return nil, errors.New(awserrors.ErrorInternalError) return nil, errors.New(awserrors.ErrorInternalError)
@@ -136,7 +136,7 @@ func (s *Service) SealAccountOverrides(ctx context.Context, accountID string) (*
switch { switch {
case writeErr == nil: case writeErr == nil:
return &OverrideSeal{Revision: newRev}, nil return &OverrideSeal{Revision: newRev}, nil
case kvutil.IsRevisionConflict(writeErr): case kvstore.IsRevisionConflict(writeErr):
continue continue
default: default:
slog.ErrorContext(ctx, "Failed to write sealed quota override record", "accountID", accountID, "err", writeErr) slog.ErrorContext(ctx, "Failed to write sealed quota override record", "accountID", accountID, "err", writeErr)
+7 -7
View File
@@ -8,7 +8,7 @@ import (
"time" "time"
nsconfig "github.com/mulgadc/northstar/pkg/config" 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" 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 // 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 // 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. // 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() { if !w.Enabled() {
return nil, errors.New("northstar is not configured on this node") 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 { if len(batch.Changes) == 0 {
return &ChangeResult{}, nil return &ChangeResult{}, nil
} }
release, err := w.awaitWriterElection(ctx, nc, holder) release, err := w.awaitWriterElection(ctx, lease)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -85,19 +85,19 @@ func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, nam
return w.ApplyBatch(batch) return w.ApplyBatch(batch)
} }
func (w *Writer) awaitWriterElection(ctx context.Context, nc *nats.Conn, holder string) (func(), error) { func (w *Writer) awaitWriterElection(ctx context.Context, lease *kvsync.Lease) (func(), error) {
if nc == nil { if lease == nil {
return func() {}, nil return func() {}, nil
} }
tick := time.NewTicker(delegateLockPoll) tick := time.NewTicker(delegateLockPoll)
defer tick.Stop() defer tick.Stop()
for { for {
if release, ok := reconcilelock.AcquireLeader(ctx, nc, KVBucketDNSReconcile, holder); ok { if release, ok := reconcilelock.AcquireLeader(ctx, lease); ok {
return release, nil return release, nil
} }
select { select {
case <-ctx.Done(): 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: case <-tick.C:
} }
} }
+33 -10
View File
@@ -14,7 +14,7 @@ import (
func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) { func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
w, _ := newMultiNodeTestWriter(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) require.NoError(t, err)
for _, name := range []string{"a.r53e2e.spx3.net.", "b.r53e2e.spx3.net"} { 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.True(t, ok, "a name below the base domain is decided by the base zone")
assert.Empty(t, servers) 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) require.NoError(t, err)
servers, _, _, err = w.PlatformDelegation("a.r53e2e.spx3.net") servers, _, _, err = w.PlatformDelegation("a.r53e2e.spx3.net")
require.NoError(t, err) require.NoError(t, err)
@@ -43,11 +43,11 @@ func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
// servers written while the cluster published a larger set. // servers written while the cluster published a larger set.
func TestDelegateRemoveWithdrawsServersFromAnEarlierSet(t *testing.T) { func TestDelegateRemoveWithdrawsServersFromAnEarlierSet(t *testing.T) {
w, _ := newMultiNodeTestWriter(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) require.NoError(t, err)
w.nameservers = w.nameservers[:1] 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) require.NoError(t, err)
servers, _, _, err := w.PlatformDelegation("a.r53e2e.spx3.net") servers, _, _, err := w.PlatformDelegation("a.r53e2e.spx3.net")
require.NoError(t, err) require.NoError(t, err)
@@ -66,7 +66,7 @@ func TestPlatformDelegationIgnoresNamesOutsideTheBaseDomain(t *testing.T) {
func TestDelegateRefusesPlatformAndForeignNames(t *testing.T) { func TestDelegateRefusesPlatformAndForeignNames(t *testing.T) {
w, _ := newMultiNodeTestWriter(t) w, _ := newMultiNodeTestWriter(t)
for _, name := range []string{"ns1.spx3.net", "x.elb.spx3.net", "compute.spx3.net", "example.com", "spx3.net"} { 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) require.Error(t, err, name)
} }
servers, _, _, err := w.PlatformDelegation("ok.spx3.net") servers, _, _, err := w.PlatformDelegation("ok.spx3.net")
@@ -76,22 +76,45 @@ func TestDelegateRefusesPlatformAndForeignNames(t *testing.T) {
func TestDelegateWaitsForTheZoneWriterElection(t *testing.T) { func TestDelegateWaitsForTheZoneWriterElection(t *testing.T) {
w, _ := newMultiNodeTestWriter(t) w, _ := newMultiNodeTestWriter(t)
_, nc, _ := testutil.StartTestJetStream(t) _, _, js := testutil.StartTestJetStream(t)
prev := delegateLockPoll prev := delegateLockPoll
delegateLockPoll = 10 * time.Millisecond delegateLockPoll = 10 * time.Millisecond
t.Cleanup(func() { delegateLockPoll = prev }) 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) require.True(t, ok)
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel() defer cancel()
_, err := w.Delegate(ctx, nc, "node1", []string{"a.spx3.net"}, false) node1 := dnsLease(js, "node1")
require.ErrorContains(t, err, "another node is writing the zone") _, err := w.Delegate(ctx, node1, []string{"a.spx3.net"}, false)
require.ErrorContains(t, err, "another writer has the zone")
release() 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) require.NoError(t, err)
servers, _, _, err := w.PlatformDelegation("a.spx3.net") servers, _, _, err := w.PlatformDelegation("a.spx3.net")
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, servers, 2) assert.Len(t, servers, 2)
} }
// The reconcile pass and a delegation on one node share the DNS lease, so a
// delegation waits for this node's own pass as it would for another node's.
func TestDelegateWaitsForThisNodesOwnReconcilePass(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
_, _, js := testutil.StartTestJetStream(t)
prev := delegateLockPoll
delegateLockPoll = 10 * time.Millisecond
t.Cleanup(func() { delegateLockPoll = prev })
shared := dnsLease(js, "node1")
release, ok := reconcilelock.AcquireLeader(t.Context(), shared)
require.True(t, ok, "the reconcile pass holds the lease")
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
_, err := w.Delegate(ctx, shared, []string{"a.spx3.net"}, false)
require.ErrorContains(t, err, "another writer has the zone")
release()
_, err = w.Delegate(t.Context(), shared, []string{"a.spx3.net"}, false)
require.NoError(t, err)
}
+22 -19
View File
@@ -11,7 +11,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/bootstrap/config" "github.com/mulgadc/spinifex/spinifex/bootstrap/config"
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile" reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler" "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 // DefaultReconcileInterval is how often the drift backstop rebuilds managed
@@ -33,6 +33,12 @@ const DefaultReconcileInterval = reconciler.DefaultResync
// costs a cycle for nothing. // costs a cycle for nothing.
const DefaultComputeTimeout = 2 * time.Minute 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 // DesiredFunc returns the full desired managed record set built from the live
// resource inventory across all tenants. The daemon supplies it by enumerating // resource inventory across all tenants. The daemon supplies it by enumerating
// instances, load balancers, and EKS clusters. // instances, load balancers, and EKS clusters.
@@ -102,7 +108,8 @@ type Reconciler struct {
// lands in. Distinct from both baseDomain and internalDomain, so pruning it // lands in. Distinct from both baseDomain and internalDomain, so pruning it
// needs its own read the same way internalDomain does for EC2. // needs its own read the same way internalDomain does for EC2.
serviceZone string 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 // 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. // the node that won the election, so the batch never leaves this process.
writer *Writer writer *Writer
@@ -111,7 +118,6 @@ type Reconciler struct {
// computeTimeout bounds how long one pass spends building the desired set. // computeTimeout bounds how long one pass spends building the desired set.
// A field rather than a constant so a test can shrink it. // A field rather than a constant so a test can shrink it.
computeTimeout time.Duration computeTimeout time.Duration
holder string
sources []reconciler.Source sources []reconciler.Source
} }
@@ -121,18 +127,15 @@ type Reconciler struct {
// changes should wake the loop. They are optional: with none supplied the loop // 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 // falls back to the interval alone, which is the behaviour that predates the
// watch. // 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{ r := &Reconciler{
nc: nc, lease: lease,
writer: writer, writer: writer,
desired: desired, desired: desired,
interval: DefaultReconcileInterval, interval: DefaultReconcileInterval,
computeTimeout: DefaultComputeTimeout, computeTimeout: DefaultComputeTimeout,
sources: sources, sources: sources,
} }
if cfg != nil {
r.holder = cfg.Node
}
zoneCfg, ok := zoneS3Config(cfg) zoneCfg, ok := zoneS3Config(cfg)
if !ok || desired == nil || !writer.Enabled() { if !ok || desired == nil || !writer.Enabled() {
return r 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 // No revisit deadline: the desired set is a function of the buckets
// being watched, so a change is the only reason to run early. // being watched, so a change is the only reason to run early.
Reconcile: func(ctx context.Context) (time.Duration, error) { Reconcile: func(ctx context.Context) (time.Duration, error) {
r.reconcileOnce(ctx) return r.reconcileOnce(ctx), nil
return 0, nil
}, },
Resync: r.interval, Resync: r.interval,
}) })
} }
// reconcileOnce computes the converging batch and applies it, on whichever node // 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 // 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. // 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 { if !r.enabled {
return return 0
} }
if r.nc != nil { if r.lease != nil {
release, elected := reconcilelock.AcquireLeader(ctx, r.nc, KVBucketDNSReconcile, r.holder) release, retry, elected := reconcilelock.ElectPass(ctx, r.lease)
if !elected { if !elected {
return return retry
} }
defer release() 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 // A corrupt zone is handled in readZone; anything reaching here left the
// desired state unapplied, so surface it rather than burying it in WARN. // desired state unapplied, so surface it rather than burying it in WARN.
slog.Error("dns reconcile: compute batch failed, retrying next cycle", "error", err) slog.Error("dns reconcile: compute batch failed, retrying next cycle", "error", err)
return return 0
} }
if len(batch) == 0 { if len(batch) == 0 {
return return 0
} }
slog.Debug("dns reconcile: converging", "changes", len(batch)) slog.Debug("dns reconcile: converging", "changes", len(batch))
res, err := r.writer.ApplyBatch(&ChangeBatch{Changes: batch}) res, err := r.writer.ApplyBatch(&ChangeBatch{Changes: batch})
if err != nil { if err != nil {
slog.Warn("dns reconcile: apply failed, retrying next cycle", "changes", len(batch), "error", err) 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) slog.Debug("dns reconcile: converged", "changes", res.Applied, "zones", res.Zones)
return 0
} }
// computeBatchBounded runs computeBatch with a deadline, so a pass that never // computeBatchBounded runs computeBatch with a deadline, so a pass that never
+21 -12
View File
@@ -8,41 +8,50 @@ import (
"github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/internal/testkit"
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile" 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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// electingReconcilerFor is reconcilerFor with a live connection, so a pass runs // electingReconcilerFor is reconcilerFor with a lease, so a pass runs the
// the election instead of skipping it. // election instead of skipping it.
func electingReconcilerFor(w *Writer, nc *nats.Conn, holder string, desired func() DesiredSet) *Reconciler { func electingReconcilerFor(w *Writer, lease *kvsync.Lease, desired func() DesiredSet) *Reconciler {
r := reconcilerFor(w, desired) r := reconcilerFor(w, desired)
r.nc = nc r.lease = lease
r.holder = holder
return r 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 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 // 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 // therefore not touch the object at all — if it did, two nodes could be
// read-modify-writing the same zone with nothing serialising them. // read-modify-writing the same zone with nothing serialising them.
func TestReconcileOnce_ALoserOfTheElectionDoesNotWriteTheZone(t *testing.T) { func TestReconcileOnce_ALoserOfTheElectionDoesNotWriteTheZone(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t) _, _, js := testutil.StartTestJetStream(t)
w, objects := newTestWriter(t) w, objects := newTestWriter(t)
before := objects[testBase+".toml"] before := objects[testBase+".toml"]
require.NotEmpty(t, before) require.NotEmpty(t, before)
// A peer holds the lease for the whole pass, so this node cannot win it. // 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") 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")}} 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"], 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") "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() 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 // 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. // for some other reason: with the lease free, the same reconciler writes.
func TestReconcileOnce_TheWinnerOfTheElectionWritesTheZone(t *testing.T) { func TestReconcileOnce_TheWinnerOfTheElectionWritesTheZone(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t) _, _, js := testutil.StartTestJetStream(t)
w, objects := newTestWriter(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")}} return DesiredSet{Changes: []Change{upsert("lb-1.elb."+testBase, "10.200.1.9")}}
}) })
r.reconcileOnce(t.Context()) r.reconcileOnce(t.Context())
+5 -7
View File
@@ -224,7 +224,7 @@ func TestReconciler_RunReturnsWhenDisabled(t *testing.T) {
// path of Run: the startup pass must fire before any watch or resync activity, // path of Run: the startup pass must fire before any watch or resync activity,
// and cancelling ctx must still let the loop return. // and cancelling ctx must still let the loop return.
func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) { func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t) _, _, js := testutil.StartTestJetStream(t)
endpoint, _ := fakeS3(t, "northstar") endpoint, _ := fakeS3(t, "northstar")
var passes atomic.Int64 var passes atomic.Int64
@@ -235,8 +235,7 @@ func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1", Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1",
AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET", AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET",
}, },
nc: nc, lease: dnsLease(js, "test-node"),
holder: "test-node",
desired: func() DesiredSet { desired: func() DesiredSet {
passes.Add(1) passes.Add(1)
return DesiredSet{} return DesiredSet{}
@@ -268,7 +267,7 @@ func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
// acquirable afterwards, not merely on the call returning, since it is the // 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. // leadership and not the pass that the rest of the cluster waits on.
func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) { func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t) _, _, js := testutil.StartTestJetStream(t)
endpoint, _ := fakeS3(t, "northstar") endpoint, _ := fakeS3(t, "northstar")
blocked := make(chan struct{}) blocked := make(chan struct{})
@@ -281,8 +280,7 @@ func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1", Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1",
AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET", AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET",
}, },
nc: nc, lease: dnsLease(js, "stuck-node"),
holder: "stuck-node",
computeTimeout: 100 * time.Millisecond, computeTimeout: 100 * time.Millisecond,
desired: func() DesiredSet { desired: func() DesiredSet {
<-blocked <-blocked
@@ -302,7 +300,7 @@ func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
t.Fatal("reconcileOnce never returned: a stuck pass still holds the lease forever") 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") require.True(t, elected, "another node must be able to take the next cycle")
release() release()
} }
+3 -3
View File
@@ -11,9 +11,9 @@ const (
// PUT so every northstar instance reloads just that zone immediately, instead // 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. // of waiting for the next S3 sync poll. No queue group: all servers consume.
SubjectZoneReload = "dns.zone.reload" SubjectZoneReload = "dns.zone.reload"
// KVBucketDNSReconcile elects the one node that writes the zones for a drift // KeyReconcile elects the one node that writes the zones for a drift cycle.
// cycle. Its own bucket, so the election never blocks another reconcile loop. // Its own key, so the election never blocks another reconcile loop.
KVBucketDNSReconcile = "spinifex-dns-reconcile" KeyReconcile = "dns.reconcile"
// PrivateZone is the fixed AWS-parity private DNS zone (IMDS synthHostname). // PrivateZone is the fixed AWS-parity private DNS zone (IMDS synthHostname).
PrivateZone = "compute.internal" PrivateZone = "compute.internal"
// DefaultTTL is applied when a change omits a TTL. // DefaultTTL is applied when a change omits a TTL.
+2 -2
View File
@@ -8,7 +8,7 @@ import (
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" 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" "github.com/nats-io/nats.go/jetstream"
) )
@@ -109,7 +109,7 @@ func (s *AccountSettingsServiceImpl) SealAccount(ctx context.Context, accountID
switch { switch {
case writeErr == nil: case writeErr == nil:
return &AccountSettingsSeal{Revision: newRev}, nil return &AccountSettingsSeal{Revision: newRev}, nil
case kvutil.IsRevisionConflict(writeErr): case kvstore.IsRevisionConflict(writeErr):
continue continue
default: default:
slog.ErrorContext(ctx, "Failed to write sealed account settings record", "accountID", accountID, "err", writeErr) slog.ErrorContext(ctx, "Failed to write sealed account settings record", "accountID", accountID, "err", writeErr)
+7 -7
View File
@@ -13,7 +13,7 @@ import (
ebsencryption "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/encryption" ebsencryption "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/encryption"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" 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/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create account settings KV bucket: %w", err) 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) return errors.New(awserrors.ErrorBlocked)
case errors.Is(err, errSettingsContended): case errors.Is(err, errSettingsContended):
slog.ErrorContext(ctx, "Account settings CAS retries exhausted under contention", "accountID", accountID) 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) 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) 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) 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) slog.ErrorContext(ctx, "Failed to write account settings to KV", "accountID", accountID, "err", err)
default: default:
slog.ErrorContext(ctx, "Account settings operation failed", "accountID", accountID, "err", err) 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 // 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. // 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 { 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, CreateIfAbsent: true,
Exhausted: func(string, int) error { return errSettingsContended }, Exhausted: func(string, int) error { return errSettingsContended },
}, func(rec *AccountSettingsRecord) (bool, error) { }, func(rec *AccountSettingsRecord) (bool, error) {
+7 -7
View File
@@ -19,7 +19,7 @@ import (
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags" 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/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketEgressOnlyIGW, err) 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 // 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 { if err != nil {
return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err) 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") slog.ErrorContext(ctx, "VPC KV unavailable, cannot verify VPC ownership")
return nil, errors.New(awserrors.ErrorServerInternal) 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) slog.WarnContext(ctx, "CreateEgressOnlyInternetGateway: VPC not found for account", "vpcId", *input.VpcId, "accountID", accountID)
return nil, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", *input.VpcId) return nil, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", *input.VpcId)
} }
@@ -109,7 +109,7 @@ func (s *EgressOnlyIGWServiceImpl) CreateEgressOnlyInternetGateway(ctx context.C
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal Egress-only IGW record: %w", err) 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) return nil, errors.New(awserrors.ErrorServerInternal)
} }
@@ -127,7 +127,7 @@ func (s *EgressOnlyIGWServiceImpl) DeleteEgressOnlyInternetGateway(ctx context.C
} }
eigwID := *input.EgressOnlyInternetGatewayId eigwID := *input.EgressOnlyInternetGatewayId
key := kvutil.AccountKey(accountID, eigwID) key := kvstore.AccountKey(accountID, eigwID)
// Verify the EIGW exists before deleting // Verify the EIGW exists before deleting
if _, err := s.eigwKV.Get(ctx, key); err != nil { if _, err := s.eigwKV.Get(ctx, key); err != nil {
@@ -178,7 +178,7 @@ func (s *EgressOnlyIGWServiceImpl) DescribeEgressOnlyInternetGateways(ctx contex
} }
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey { if key == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(key, prefix) { if !strings.HasPrefix(key, prefix) {
@@ -9,7 +9,7 @@ import (
"github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/internal/testkit"
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc" ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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/assert"
"github.com/stretchr/testify/require" "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 // Create VPC KV bucket and register test VPCs so fail-closed ownership checks pass
vpcEntries := map[string][]byte{} vpcEntries := map[string][]byte{}
for _, vpcID := range []string{"vpc-test123", "vpc-tagged", "vpc-tagged2"} { 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) 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 // Create VPC KV bucket with a VPC owned by testAccountID
vpcID := "vpc-alpha456" vpcID := "vpc-alpha456"
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, map[string][]byte{ 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 var err error
@@ -12,7 +12,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/idempotency" "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" "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 { if key == "" || s.idemKV == nil {
return alloc() return alloc()
} }
kvKey := kvutil.AccountKey(accountID, idempotencyKeyHash(key)) kvKey := kvstore.AccountKey(accountID, idempotencyKeyHash(key))
if out := s.cachedAllocation(ctx, kvKey); out != nil { if out := s.cachedAllocation(ctx, kvKey); out != nil {
slog.WarnContext(ctx, "AllocateAddress: returning first result for a retried request", slog.WarnContext(ctx, "AllocateAddress: returning first result for a retried request",
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/domains/network/projection" "github.com/mulgadc/spinifex/spinifex/domains/network/projection"
"github.com/mulgadc/spinifex/spinifex/domains/network/topology" "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" "github.com/nats-io/nats.go/jetstream"
) )
@@ -80,7 +80,7 @@ func (s *EIPServiceImpl) AllocationExists(ctx context.Context, allocationID stri
} }
suffix := "." + allocationID suffix := "." + allocationID
for _, k := range keys { for _, k := range keys {
if k != kvutil.VersionKey && strings.HasSuffix(k, suffix) { if k != kvstore.VersionKey && strings.HasSuffix(k, suffix) {
return true, nil return true, nil
} }
} }
@@ -98,7 +98,7 @@ func (s *EIPServiceImpl) findByAllocationIDAnyAccount(ctx context.Context, alloc
suffix := "." + allocationID suffix := "." + allocationID
for _, k := range keys { for _, k := range keys {
if k == kvutil.VersionKey || !strings.HasSuffix(k, suffix) { if k == kvstore.VersionKey || !strings.HasSuffix(k, suffix) {
continue continue
} }
entry, err := s.eipKV.Get(ctx, k) entry, err := s.eipKV.Get(ctx, k)
+12 -12
View File
@@ -20,7 +20,7 @@ import (
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags" 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/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketEIPs, err) 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: // TTL'd, so a failure to create it is not worth refusing to serve EIPs over:
// a nil bucket only costs post-completion dedupe. // 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 { if err != nil {
slog.Warn("EIP service: idempotency bucket unavailable; a retried AllocateAddress may allocate twice", slog.Warn("EIP service: idempotency bucket unavailable; a retried AllocateAddress may allocate twice",
"bucket", KVBucketEIPIdempotency, "err", err) "bucket", KVBucketEIPIdempotency, "err", err)
@@ -146,7 +146,7 @@ func (s *EIPServiceImpl) allocateAddress(ctx context.Context, input *ec2.Allocat
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal EIP record: %w", err) 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) return nil, errors.New(awserrors.ErrorServerInternal)
} }
@@ -167,7 +167,7 @@ func (s *EIPServiceImpl) ReleaseAddress(ctx context.Context, input *ec2.ReleaseA
} }
allocID := *input.AllocationId allocID := *input.AllocationId
key := kvutil.AccountKey(accountID, allocID) key := kvstore.AccountKey(accountID, allocID)
entry, err := s.eipKV.Get(ctx, key) entry, err := s.eipKV.Get(ctx, key)
if err != nil { if err != nil {
@@ -206,7 +206,7 @@ func (s *EIPServiceImpl) AssociateAddress(ctx context.Context, input *ec2.Associ
} }
allocID := *input.AllocationId allocID := *input.AllocationId
key := kvutil.AccountKey(accountID, allocID) key := kvstore.AccountKey(accountID, allocID)
entry, err := s.eipKV.Get(ctx, key) entry, err := s.eipKV.Get(ctx, key)
if err != nil { if err != nil {
@@ -536,7 +536,7 @@ func (s *EIPServiceImpl) findByENI(ctx context.Context, accountID, eniID string)
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return nil, err return nil, err
} }
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) { if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue continue
} }
entry, err := s.eipKV.Get(ctx, k) 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 var addresses []*ec2.Address
for _, k := range keys { for _, k := range keys {
if k == kvutil.VersionKey { if k == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(k, prefix) { if !strings.HasPrefix(k, prefix) {
@@ -699,7 +699,7 @@ func (s *EIPServiceImpl) DescribeAddressesAttribute(ctx context.Context, input *
if id == nil { if id == nil {
continue continue
} }
key := kvutil.AccountKey(accountID, *id) key := kvstore.AccountKey(accountID, *id)
entry, err := s.eipKV.Get(ctx, key) entry, err := s.eipKV.Get(ctx, key)
if err != nil { if err != nil {
continue // not found, skip continue // not found, skip
@@ -722,7 +722,7 @@ func (s *EIPServiceImpl) DescribeAddressesAttribute(ctx context.Context, input *
return nil, errors.New(awserrors.ErrorServerInternal) return nil, errors.New(awserrors.ErrorServerInternal)
} }
for _, k := range keys { for _, k := range keys {
if k == kvutil.VersionKey { if k == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(k, prefix) { if !strings.HasPrefix(k, prefix) {
@@ -819,7 +819,7 @@ func (s *EIPServiceImpl) findByAssociationID(ctx context.Context, accountID, ass
} }
for _, k := range keys { for _, k := range keys {
if k == kvutil.VersionKey { if k == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(k, prefix) { if !strings.HasPrefix(k, prefix) {
@@ -857,7 +857,7 @@ func (s *EIPServiceImpl) AssociatedPublicIPForInstance(ctx context.Context, acco
return "", false return "", false
} }
for _, k := range keys { for _, k := range keys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) { if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue continue
} }
entry, err := s.eipKV.Get(ctx, k) entry, err := s.eipKV.Get(ctx, k)
+15 -15
View File
@@ -22,7 +22,7 @@ import (
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging" "github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags" 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/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketIGW, err) 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 // 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 { if err != nil {
return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err) return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err)
} }
// Read EIP associations so detach can refuse while addresses are mapped. // 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 { if err != nil {
return nil, fmt.Errorf("failed to get EIP KV bucket: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("failed to marshal IGW record: %w", err) 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) return nil, errors.New(awserrors.ErrorServerInternal)
} }
@@ -160,7 +160,7 @@ func (s *IGWServiceImpl) DeleteInternetGateway(ctx context.Context, input *ec2.D
} }
igwID := *input.InternetGatewayId igwID := *input.InternetGatewayId
key := kvutil.AccountKey(accountID, igwID) key := kvstore.AccountKey(accountID, igwID)
entry, err := s.igwKV.Get(ctx, key) entry, err := s.igwKV.Get(ctx, key)
if err != nil { if err != nil {
@@ -236,7 +236,7 @@ func (s *IGWServiceImpl) DescribeInternetGateways(ctx context.Context, input *ec
foundIDs := make(map[string]bool) foundIDs := make(map[string]bool)
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey { if key == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(key, prefix) { if !strings.HasPrefix(key, prefix) {
@@ -326,7 +326,7 @@ func (s *IGWServiceImpl) AttachInternetGateway(ctx context.Context, input *ec2.A
igwID := *input.InternetGatewayId igwID := *input.InternetGatewayId
vpcID := *input.VpcId vpcID := *input.VpcId
key := kvutil.AccountKey(accountID, igwID) key := kvstore.AccountKey(accountID, igwID)
entry, err := s.igwKV.Get(ctx, key) entry, err := s.igwKV.Get(ctx, key)
if err != nil { 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") slog.ErrorContext(ctx, "VPC KV unavailable, cannot verify VPC ownership")
return nil, errors.New(awserrors.ErrorServerInternal) 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) slog.WarnContext(ctx, "AttachInternetGateway: VPC not found for account", "vpcId", vpcID, "accountID", accountID)
return nil, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID) 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 igwID := *input.InternetGatewayId
vpcID := *input.VpcId vpcID := *input.VpcId
key := kvutil.AccountKey(accountID, igwID) key := kvstore.AccountKey(accountID, igwID)
entry, err := s.igwKV.Get(ctx, key) entry, err := s.igwKV.Get(ctx, key)
if err != nil { 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 // associated into vpcID, whether to an interface or a NAT gateway. The listing
// is filtered server-side to the account's keys. // is filtered server-side to the account's keys.
func (s *IGWServiceImpl) vpcHasMappedAddress(ctx context.Context, accountID, vpcID string) (bool, error) { 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 { if err != nil {
return false, err return false, err
} }
@@ -493,11 +493,11 @@ func (s *IGWServiceImpl) CreateAttachedInternetGateway(ctx context.Context, acco
if s.vpcKV == nil { if s.vpcKV == nil {
return false, errors.New(awserrors.ErrorServerInternal) 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) return false, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID)
} }
key := kvutil.AccountKey(accountID, igwID) key := kvstore.AccountKey(accountID, igwID)
data, err := json.Marshal(IGWRecord{ data, err := json.Marshal(IGWRecord{
InternetGatewayId: igwID, InternetGatewayId: igwID,
VpcId: vpcID, VpcId: vpcID,
@@ -558,7 +558,7 @@ func (s *IGWServiceImpl) AttachmentIntent(ctx context.Context, accountID, vpcID
} }
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.igwKV.Get(ctx, key) 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. // 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 { func MarkAttached(ctx context.Context, kv jetstream.KeyValue, recordKey, vpcID string) error {
confirmed := false 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 // Reset per attempt: a retried CAS may see a record another writer has
// already moved out of pending. // already moved out of pending.
confirmed = r.VpcId == vpcID && r.AttachState == AttachStatePending confirmed = r.VpcId == vpcID && r.AttachState == AttachStatePending
@@ -13,7 +13,7 @@ import (
ec2eip "github.com/mulgadc/spinifex/spinifex/domains/ec2/eip" ec2eip "github.com/mulgadc/spinifex/spinifex/domains/ec2/eip"
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc" ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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/nats-io/nats.go"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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 // Create VPC KV bucket and register test VPCs so fail-closed ownership checks pass
vpcEntries := map[string][]byte{} vpcEntries := map[string][]byte{}
for _, vpcID := range []string{"vpc-test123", "vpc-other", "vpc-lifecycle", "vpc-event-test"} { 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) 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. // gateway come up. Without it an attach stays pending and reports no attachment.
func confirmAttach(t *testing.T, svc *IGWServiceImpl, igwID, vpcID string) { func confirmAttach(t *testing.T, svc *IGWServiceImpl, igwID, vpcID string) {
t.Helper() 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 { func createTestIGW(t *testing.T, svc *IGWServiceImpl) string {
@@ -331,7 +331,7 @@ func TestDescribeInternetGateways_LegacyRecordReportsAttachment(t *testing.T) {
svc, _ := setupTestIGWService(t) svc, _ := setupTestIGWService(t)
igwID := createTestIGW(t, svc) igwID := createTestIGW(t, svc)
key := kvutil.AccountKey(testAccountID, igwID) key := kvstore.AccountKey(testAccountID, igwID)
entry, err := svc.igwKV.Get(context.Background(), key) entry, err := svc.igwKV.Get(context.Background(), key)
require.NoError(t, err) require.NoError(t, err)
var record IGWRecord var record IGWRecord
@@ -398,7 +398,7 @@ func TestMarkAttached_NoRewriteWhenAlreadyAttached(t *testing.T) {
}, testAccountID) }, testAccountID)
require.NoError(t, err) require.NoError(t, err)
key := kvutil.AccountKey(testAccountID, igwID) key := kvstore.AccountKey(testAccountID, igwID)
confirmAttach(t, svc, igwID, "vpc-test123") confirmAttach(t, svc, igwID, "vpc-test123")
first, err := svc.igwKV.Get(context.Background(), key) first, err := svc.igwKV.Get(context.Background(), key)
require.NoError(t, err) require.NoError(t, err)
@@ -605,7 +605,7 @@ func TestDetachInternetGateway_MappedPublicAddress(t *testing.T) {
putEIP := func(rec ec2eip.EIPRecord) { putEIP := func(rec ec2eip.EIPRecord) {
data, err := json.Marshal(rec) data, err := json.Marshal(rec)
require.NoError(t, err) 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) require.NoError(t, err)
} }
putEIP(ec2eip.EIPRecord{AllocationId: "eipalloc-other", AssociationId: "eipassoc-other", VpcId: "vpc-other", State: "associated"}) 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. // 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"}) foreign, err := json.Marshal(ec2eip.EIPRecord{AllocationId: "eipalloc-foreign", AssociationId: "eipassoc-foreign", VpcId: "vpc-test123", State: "associated"})
require.NoError(t, err) 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) require.NoError(t, err)
detach := func() error { detach := func() error {
@@ -828,7 +828,7 @@ func TestAttachInternetGateway_CrossAccountVPCRejected(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
vpcID := "vpc-alpha123" 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) require.NoError(t, err)
// Refresh service to pick up the VPC KV bucket // Refresh service to pick up the VPC KV bucket
@@ -10,7 +10,6 @@ import (
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3" "github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" "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/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
) )
@@ -72,7 +71,7 @@ func (s *Store) InstanceIDs(ctx context.Context) ([]string, error) {
var ids []string var ids []string
for key := range lister.Keys() { for key := range lister.Keys() {
if key != kvutil.VersionKey { if key != kvstore.VersionKey {
ids = append(ids, key) ids = append(ids, key)
} }
} }
@@ -11,7 +11,6 @@ import (
testutil "github.com/mulgadc/spinifex/internal/testkit" testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore" "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" "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/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -60,7 +59,7 @@ func TestOpen_CreatesBucketWithNoTTLAndStampsVersion(t *testing.T) {
assert.Equal(t, int64(1), status.History()) assert.Equal(t, int64(1), status.History())
assert.Zero(t, status.TTL(), "a TTL would expire deletion obligations before they are settled") 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) require.NoError(t, err)
assert.Equal(t, uefistore.BucketVersion, v) assert.Equal(t, uefistore.BucketVersion, v)
} }
@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"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/nats-io/nats.go/jetstream"
) )
@@ -33,7 +33,7 @@ type AccountCleanup struct {
// Unreadable headers, their bodies and unrecognised entries are left for the // Unreadable headers, their bodies and unrecognised entries are left for the
// owner scan to report. A listing failure is returned and nothing is removed. // 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) { 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) { if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list launch-template keys: %w", err) 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 // it cannot read or decode is left for the owner scan to report. A listing
// failure is returned and nothing is deleted. // failure is returned and nothing is deleted.
func (s *LaunchTemplateServiceImpl) DeleteLiveTemplates(ctx context.Context, accountID string, referenced func(id, name string) string) (*AccountTemplateDeletes, error) { 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) { if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list launch-template keys: %w", err) return nil, fmt.Errorf("list launch-template keys: %w", err)
} }
@@ -9,7 +9,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"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/nats-io/nats.go/jetstream"
) )
@@ -37,7 +37,7 @@ type AccountScan struct {
// Headers and name-index entries are read; version bodies are classified from // Headers and name-index entries are read; version bodies are classified from
// their keys alone, so template data and user data are never loaded. // their keys alone, so template data and user data are never loaded.
func (s *LaunchTemplateServiceImpl) ScanAccount(ctx context.Context, accountID string) (*AccountScan, error) { 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) { if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list launch-template keys: %w", err) 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/aws"
"github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit/readonlystore" "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/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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. // A header removed alone, as a delete interrupted after it leaves the rest.
deleted := createWithUserData(t, svc, "deleted") deleted := createWithUserData(t, svc, "deleted")
inner := svc.kv 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. // A create whose header write and clean-up both fail.
svc.kv = &faultyKV{KeyValue: inner, svc.kv = &faultyKV{KeyValue: inner,
@@ -59,16 +59,16 @@ func TestScanAccountClassifiesLaunchTemplateRecords(t *testing.T) {
require.Error(t, err) require.Error(t, err)
svc.kv = inner svc.kv = inner
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0badbadbadbadbad0"), "{not json") putRaw(t, inner, kvstore.AccountKey(testAccountID, "lt-0badbadbadbadbad0"), "{not json")
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0badbadbadbadbad0.v1"), `{"version_number":1}`) putRaw(t, inner, kvstore.AccountKey(testAccountID, "lt-0badbadbadbadbad0.v1"), `{"version_number":1}`)
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0mismatch00000000"), `{"launch_template_id":"lt-0other"}`) putRaw(t, inner, kvstore.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-0unreadable000000"), `{"launch_template_id":"lt-0unreadable000000"}`)
putRaw(t, inner, nameKey(testAccountID, "unreadable"), "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)} recorder := &readRecordingKV{KeyValue: readonlystore.NewKV(t, inner)}
svc.kv = &faultyKV{KeyValue: recorder, failGet: func(key string) bool { 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) scan, err := svc.ScanAccount(context.Background(), testAccountID)
require.NoError(t, err) require.NoError(t, err)
@@ -16,7 +16,7 @@ import (
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -159,7 +159,7 @@ func TestCurrentBehaviour_AccountTermination_LaunchTemplateStorageLayout(t *test
header := testAccountID + "." + id header := testAccountID + "." + id
name := testAccountID + ".name.776562283129" // hex of "web(1)" 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) entry, err := svc.kv.Get(t.Context(), name)
require.NoError(t, err) require.NoError(t, err)
@@ -223,13 +223,13 @@ func TestAccountTermination_DeleteLaunchTemplateKeepsTheTemplateWhenAStepFails(t
assert.Equal(t, awserrors.ErrorServerInternal, err.Error()) assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
svc.kv = inner 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)) assert.Equal(t, []string{id}, describedTemplateIDs(t, svc, testAccountID))
out, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID) out, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, id, aws.StringValue(out.LaunchTemplate.LaunchTemplateId)) 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)) 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) _, err = svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
require.NoError(t, err) require.NoError(t, err)
assert.True(t, raced) 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 // 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()) assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
svc.kv = inner 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") retried := createTemplate(t, svc, "web", "t3.micro")
assert.Equal(t, []string{aws.StringValue(retried.LaunchTemplateId)}, describedTemplateIDs(t, svc, testAccountID)) 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. // failure is an internal error, never a missing template.
func TestCurrentBehaviour_AccountTermination_DescribeLaunchTemplatesHidesUnreadableHeaders(t *testing.T) { func TestCurrentBehaviour_AccountTermination_DescribeLaunchTemplatesHidesUnreadableHeaders(t *testing.T) {
const brokenID = "lt-0badbadbadbadbad0" const brokenID = "lt-0badbadbadbadbad0"
brokenHeader := kvutil.AccountKey(testAccountID, brokenID) brokenHeader := kvstore.AccountKey(testAccountID, brokenID)
tests := []struct { tests := []struct {
name string name string
seed func(t *testing.T, svc *LaunchTemplateServiceImpl) seed func(t *testing.T, svc *LaunchTemplateServiceImpl)
@@ -350,7 +350,7 @@ func TestAccountCleanup_KeepsTemplatesAndRemovesSettledResidue(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
orphanBody := versionKey(testAccountID, "lt-0orphan000000000", 1) orphanBody := versionKey(testAccountID, "lt-0orphan000000000", 1)
orphanName := nameKey(testAccountID, "gone") orphanName := nameKey(testAccountID, "gone")
brokenHeader := kvutil.AccountKey(testAccountID, "lt-0broken000000000") brokenHeader := kvstore.AccountKey(testAccountID, "lt-0broken000000000")
brokenBody := versionKey(testAccountID, "lt-0broken000000000", 1) brokenBody := versionKey(testAccountID, "lt-0broken000000000", 1)
brokenName := nameKey(testAccountID, "broken") brokenName := nameKey(testAccountID, "broken")
for key, value := range map[string]string{ for key, value := range map[string]string{
@@ -384,7 +384,7 @@ func TestAccountCleanup_KeepsTemplatesAndRemovesSettledResidue(t *testing.T) {
func TestAccountCleanup_InterruptedTemplateDeleteIsLeftToThePublicRetry(t *testing.T) { func TestAccountCleanup_InterruptedTemplateDeleteIsLeftToThePublicRetry(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
id := createWithUserData(t, svc, "web") id := createWithUserData(t, svc, "web")
header := kvutil.AccountKey(testAccountID, id) header := kvstore.AccountKey(testAccountID, id)
kv := svc.kv kv := svc.kv
svc.kv = &faultyKV{KeyValue: kv, failDelete: func(key string) bool { return key == header }} 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) _, 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" awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags" 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/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketLaunchTemplates, err) 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 // 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. // in NATS KV keys, so the name must not be used in its raw form here.
func nameKey(accountID, name string) string { 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>. // versionKey returns a version-body key: account.lt-<id>.v<n>.
func versionKey(accountID, ltID string, n int64) string { 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. // versionPrefix returns the scan prefix for a template's version bodies.
func versionPrefix(accountID, ltID string) string { func versionPrefix(accountID, ltID string) string {
return kvutil.AccountKey(accountID, ltID+".v") return kvstore.AccountKey(accountID, ltID+".v")
} }
// --- record load helpers --- // --- record load helpers ---
// getHeaderByID reads a header and its KV entry (for CAS) by launch template id. // 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) { 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 err != nil {
if errors.Is(err, jetstream.ErrKeyNotFound) { if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateIdNotFound) 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 // 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 // concurrent in-flight create whose header is not yet written) fails closed so
// the name is never stolen from a possibly-live template. // 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 { switch {
case herr == nil: case herr == nil:
return errNameInUse() return errNameInUse()
@@ -404,7 +404,7 @@ func (s *LaunchTemplateServiceImpl) putHeader(ctx context.Context, accountID str
if err != nil { if err != nil {
return errors.New(awserrors.ErrorServerInternal) 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 errors.New(awserrors.ErrorServerInternal)
} }
return nil 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 // re-reading on a conflict. A non-nil check vets each re-read header and
// stops the commit with its error. // 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 { 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 { for range maxVersionRetries {
_, err := s.kv.Update(ctx, key, entry.Value(), entry.Revision()) _, err := s.kv.Update(ctx, key, entry.Value(), entry.Revision())
if err == nil { if err == nil {
return nil return nil
} }
if !kvutil.IsRevisionConflict(err) { if !kvstore.IsRevisionConflict(err) {
slog.ErrorContext(ctx, op+": header commit failed", "launchTemplateId", ltID, "err", err) slog.ErrorContext(ctx, op+": header commit failed", "launchTemplateId", ltID, "err", err)
return 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. // 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) { 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) 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 { for range maxVersionRetries {
n, err := s.resolveVersionNumber(ctx, accountID, header, sel) n, err := s.resolveVersionNumber(ctx, accountID, header, sel)
if err != nil { if err != nil {
@@ -611,7 +611,7 @@ func (s *LaunchTemplateServiceImpl) setDefaultVersion(ctx context.Context, accou
if err == nil { if err == nil {
return header, nil return header, nil
} }
if !kvutil.IsRevisionConflict(err) { if !kvstore.IsRevisionConflict(err) {
slog.ErrorContext(ctx, "ModifyLaunchTemplate: header update failed", "launchTemplateId", header.LaunchTemplateId, "err", err) slog.ErrorContext(ctx, "ModifyLaunchTemplate: header update failed", "launchTemplateId", header.LaunchTemplateId, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal) 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. // 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 { func (s *LaunchTemplateServiceImpl) deleteTemplate(ctx context.Context, accountID string, header *LaunchTemplateHeader, entry jetstream.KeyValueEntry) error {
ltID := header.LaunchTemplateId ltID := header.LaunchTemplateId
headerKey := kvutil.AccountKey(accountID, ltID) headerKey := kvstore.AccountKey(accountID, ltID)
for attempt := 1; ; attempt++ { for attempt := 1; ; attempt++ {
if err := s.deleteVersionBodies(ctx, accountID, ltID); err != nil { if err := s.deleteVersionBodies(ctx, accountID, ltID); err != nil {
return err 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 // A template delete that removed the header before the restore has already
// swept its bodies, so the restored ones would be left headerless. // 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 { for n := range deleted {
if derr := s.kv.Delete(ctx, versionKey(accountID, ltID, n)); derr != nil { 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) 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/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -116,7 +116,7 @@ func TestCreateLaunchTemplate_OrphanNameReclaim(t *testing.T) {
lt := createTemplate(t, svc, "orphan", "t3.micro") lt := createTemplate(t, svc, "orphan", "t3.micro")
// Simulate a crash after the name claim but with the header lost. // 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. // The name index still points at the now-orphaned id; create must reclaim it.
out, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{ 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/aws"
"github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -38,7 +38,7 @@ func TestAccountTermination_VersionCreateRacingADeleteLeavesNoBody(t *testing.T)
t.Run(pause, func(t *testing.T) { t.Run(pause, func(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
id := createWithUserData(t, svc, "web") id := createWithUserData(t, svc, "web")
header := kvutil.AccountKey(testAccountID, id) header := kvstore.AccountKey(testAccountID, id)
raced := false raced := false
race := func(key string, at string) { race := func(key string, at string) {
if raced || pause != at { if raced || pause != at {
@@ -71,7 +71,7 @@ func TestAccountTermination_DeleteRacingACommittedVersionRemovesIt(t *testing.T)
svc := setupTestService(t) svc := setupTestService(t)
id := createWithUserData(t, svc, "web") id := createWithUserData(t, svc, "web")
inner := svc.kv inner := svc.kv
header := kvutil.AccountKey(testAccountID, id) header := kvstore.AccountKey(testAccountID, id)
raced := false raced := false
svc.kv = &faultyKV{KeyValue: inner, beforeDelete: func(key string) { svc.kv = &faultyKV{KeyValue: inner, beforeDelete: func(key string) {
if key != header || raced { if key != header || raced {
@@ -95,7 +95,7 @@ func TestAccountTermination_FailedVersionCommitRemovesTheBody(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
id := createWithUserData(t, svc, "web") id := createWithUserData(t, svc, "web")
before := accountKeys(t, svc.kv, testAccountID) 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 }} svc.kv = &faultyKV{KeyValue: svc.kv, failUpdate: func(key string) bool { return key == header }}
_, err := svc.CreateLaunchTemplateVersion(context.Background(), newVersionInput(id), testAccountID) _, err := svc.CreateLaunchTemplateVersion(context.Background(), newVersionInput(id), testAccountID)
@@ -110,7 +110,7 @@ func TestAccountTermination_VersionCommitKeepsAConcurrentHeaderChange(t *testing
svc := setupTestService(t) svc := setupTestService(t)
id := createWithUserData(t, svc, "web") id := createWithUserData(t, svc, "web")
inner := svc.kv inner := svc.kv
header := kvutil.AccountKey(testAccountID, id) header := kvstore.AccountKey(testAccountID, id)
raced := false raced := false
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) { svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
if key != header || raced { if key != header || raced {
@@ -215,10 +215,10 @@ func TestAccountTemplateReferences_FailsClosed(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
}, },
"header unreadable": func(t *testing.T, svc *LaunchTemplateServiceImpl, id string) { "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) { "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) 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) { t.Run(map[bool]string{false: "by-id", true: "by-name"}[byName], func(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
id := createWithUserData(t, svc, "web") id := createWithUserData(t, svc, "web")
header := kvutil.AccountKey(testAccountID, id) header := kvstore.AccountKey(testAccountID, id)
raced := false raced := false
svc.kv = &faultyKV{KeyValue: svc.kv, beforeUpdate: func(key string) { svc.kv = &faultyKV{KeyValue: svc.kv, beforeUpdate: func(key string) {
if key == header && !raced { if key == header && !raced {
@@ -275,7 +275,7 @@ func TestModifyLaunchTemplate_RetriesAConcurrentHeaderChange(t *testing.T) {
_, err := svc.CreateLaunchTemplateVersion(context.Background(), newVersionInput(id), testAccountID) // v3 _, err := svc.CreateLaunchTemplateVersion(context.Background(), newVersionInput(id), testAccountID) // v3
require.NoError(t, err) require.NoError(t, err)
inner := svc.kv inner := svc.kv
header := kvutil.AccountKey(testAccountID, id) header := kvstore.AccountKey(testAccountID, id)
raced := false raced := false
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) { svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
if key != header || raced { if key != header || raced {
@@ -306,7 +306,7 @@ func TestModifyLaunchTemplate_ConflictRetriesAreBounded(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
id := createWithUserData(t, svc, "web") id := createWithUserData(t, svc, "web")
inner := svc.kv inner := svc.kv
header := kvutil.AccountKey(testAccountID, id) header := kvstore.AccountKey(testAccountID, id)
attempts := 0 attempts := 0
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) { svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
if key != header { if key != header {
@@ -333,7 +333,7 @@ func TestModifyLaunchTemplate_ConflictReReadFailureIsInternal(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
id := createWithUserData(t, svc, "web") id := createWithUserData(t, svc, "web")
inner := svc.kv inner := svc.kv
header := kvutil.AccountKey(testAccountID, id) header := kvstore.AccountKey(testAccountID, id)
conflicted := false conflicted := false
svc.kv = &faultyKV{ svc.kv = &faultyKV{
KeyValue: inner, KeyValue: inner,
@@ -410,7 +410,7 @@ func TestDefaultChangeRacingAVersionDeleteFailsNotFound(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
id := createWithUserData(t, svc, "web") id := createWithUserData(t, svc, "web")
inner := svc.kv inner := svc.kv
header := kvutil.AccountKey(testAccountID, id) header := kvstore.AccountKey(testAccountID, id)
raced := false raced := false
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) { svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
if key != header || raced { if key != header || raced {
@@ -460,7 +460,7 @@ func TestFailedVersionDeleteCommitRestoresTheBodies(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
id := createWithUserData(t, svc, "web") id := createWithUserData(t, svc, "web")
before := accountKeys(t, svc.kv, testAccountID) 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 }} svc.kv = &faultyKV{KeyValue: svc.kv, failUpdate: func(key string) bool { return key == header }}
_, err := deleteVersions(svc, id, "2") _, err := deleteVersions(svc, id, "2")
@@ -474,7 +474,7 @@ func TestFailedVersionDeleteCommitRestoresTheBodies(t *testing.T) {
func TestVersionDeleteRacingATemplateDeleteLeavesNothing(t *testing.T) { func TestVersionDeleteRacingATemplateDeleteLeavesNothing(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
id := createWithUserData(t, svc, "web") id := createWithUserData(t, svc, "web")
header := kvutil.AccountKey(testAccountID, id) header := kvstore.AccountKey(testAccountID, id)
raced := false raced := false
svc.kv = &faultyKV{KeyValue: svc.kv, beforeUpdate: func(key string) { svc.kv = &faultyKV{KeyValue: svc.kv, beforeUpdate: func(key string) {
if key != header || raced { if key != header || raced {
@@ -531,7 +531,7 @@ func TestDefaultChangeRetriesAfterAConcurrentVersionCreate(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
id := createWithUserData(t, svc, "web") id := createWithUserData(t, svc, "web")
inner := svc.kv inner := svc.kv
header := kvutil.AccountKey(testAccountID, id) header := kvstore.AccountKey(testAccountID, id)
raced := false raced := false
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) { svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
if key != header || raced { if key != header || raced {
@@ -8,7 +8,7 @@ import (
"strings" "strings"
"github.com/aws/aws-sdk-go/aws" "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" "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 // 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. // record may name the resource. Headerless bodies resolve from no launch.
func (s *LaunchTemplateServiceImpl) AccountTemplateReferences(ctx context.Context, accountID string) (*TemplateReferences, error) { 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) { if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list launch-template keys: %w", err) return nil, fmt.Errorf("list launch-template keys: %w", err)
} }
@@ -10,7 +10,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2"
ec2eip "github.com/mulgadc/spinifex/spinifex/domains/ec2/eip" ec2eip "github.com/mulgadc/spinifex/spinifex/domains/ec2/eip"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -42,7 +42,7 @@ func TestRLC3_NatGatewayDeletesWhileRouted(t *testing.T) {
natgwID := createTestNatGateway(t, svc) natgwID := createTestNatGateway(t, svc)
testutil.SeedKV(t, js, kvBucketRouteTables, map[string][]byte{ 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), `{"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 { func readEIP(t *testing.T, svc *NatGatewayServiceImpl, allocID string) ec2eip.EIPRecord {
t.Helper() 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) require.NoError(t, err)
var eip ec2eip.EIPRecord var eip ec2eip.EIPRecord
require.NoError(t, json.Unmarshal(entry.Value(), &eip)) require.NoError(t, json.Unmarshal(entry.Value(), &eip))
+17 -17
View File
@@ -20,7 +20,7 @@ import (
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags" 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/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketNatGateways, err) 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) 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 { if err != nil {
return nil, fmt.Errorf("failed to get EIP KV bucket: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("failed to get subnet KV bucket: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err) 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. // for as long as a client may still ask about it.
func getOrCreateDeletedBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) { func getOrCreateDeletedBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) {
// Terraform polls DescribeNatGateways after delete and expects state=deleted. // 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, Name: KVBucketDeletedNatGateways,
Description: "Deleted NAT Gateways (auto-expire after 1 hour)", Description: "Deleted NAT Gateways (auto-expire after 1 hour)",
History: 1, History: 1,
@@ -113,7 +113,7 @@ func (s *NatGatewayServiceImpl) CreateNatGateway(ctx context.Context, input *ec2
allocID := *input.AllocationId allocID := *input.AllocationId
// Validate subnet exists and get its VPC // 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 { if err != nil {
return nil, errors.New(awserrors.ErrorInvalidSubnetIDNotFound) 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 // 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 { if err != nil {
return nil, errors.New(awserrors.ErrorInvalidAllocationIDNotFound) return nil, errors.New(awserrors.ErrorInvalidAllocationIDNotFound)
} }
@@ -152,7 +152,7 @@ func (s *NatGatewayServiceImpl) CreateNatGateway(ctx context.Context, input *ec2
if err != nil { if err != nil {
return nil, errors.New(awserrors.ErrorServerInternal) 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) slog.ErrorContext(ctx, "Failed to store NAT Gateway", "natGatewayId", natgwID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal) return nil, errors.New(awserrors.ErrorServerInternal)
} }
@@ -165,7 +165,7 @@ func (s *NatGatewayServiceImpl) CreateNatGateway(ctx context.Context, input *ec2
eipRecord.VpcId = subnetRecord.VpcId eipRecord.VpcId = subnetRecord.VpcId
eipRecord.State = "associated" eipRecord.State = "associated"
if eipData, err := json.Marshal(eipRecord); err == nil { 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) 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 natgwID := *input.NatGatewayId
key := kvutil.AccountKey(accountID, natgwID) key := kvstore.AccountKey(accountID, natgwID)
entry, err := s.natgwKV.Get(ctx, key) entry, err := s.natgwKV.Get(ctx, key)
if err != nil { 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) slog.WarnContext(ctx, "DeleteNatGateway: JetStream setup failed, SNAT teardown deferred to route delete", "natGatewayId", record.NatGatewayId, "err", err)
return return
} }
rtbKV, err := kvutil.GetOrCreateBucket(ctx, js, kvBucketRouteTables, 10) rtbKV, err := kvstore.GetOrCreateBucket(ctx, js, kvBucketRouteTables, 10)
if err != nil { if err != nil {
slog.WarnContext(ctx, "DeleteNatGateway: route-table scan failed, SNAT teardown deferred to route delete", "natGatewayId", record.NatGatewayId, "err", err) slog.WarnContext(ctx, "DeleteNatGateway: route-table scan failed, SNAT teardown deferred to route delete", "natGatewayId", record.NatGatewayId, "err", err)
return return
@@ -266,7 +266,7 @@ func (s *NatGatewayServiceImpl) publishDeleteEventsForNatGateway(ctx context.Con
} }
prefix := accountID + "." prefix := accountID + "."
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := rtbKV.Get(ctx, key) entry, err := rtbKV.Get(ctx, key)
@@ -302,7 +302,7 @@ func (s *NatGatewayServiceImpl) publishDeleteEventsForNatGateway(ctx context.Con
if assoc.SubnetId == "" { if assoc.SubnetId == "" {
continue 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 { if err != nil {
continue continue
} }
@@ -322,7 +322,7 @@ func (s *NatGatewayServiceImpl) disassociateEIP(ctx context.Context, record *Nat
if record.AllocationId == "" { if record.AllocationId == "" {
return return
} }
key := kvutil.AccountKey(accountID, record.AllocationId) key := kvstore.AccountKey(accountID, record.AllocationId)
entry, err := s.eipKV.Get(ctx, key) entry, err := s.eipKV.Get(ctx, key)
if err != nil { if err != nil {
if !errors.Is(err, jetstream.ErrKeyNotFound) { if !errors.Is(err, jetstream.ErrKeyNotFound) {
@@ -380,7 +380,7 @@ func (s *NatGatewayServiceImpl) DescribeNatGateways(ctx context.Context, input *
foundIDs := make(map[string]bool) foundIDs := make(map[string]bool)
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
@@ -415,7 +415,7 @@ func (s *NatGatewayServiceImpl) DescribeNatGateways(ctx context.Context, input *
if foundIDs[id] { if foundIDs[id] {
continue continue
} }
key := kvutil.AccountKey(accountID, id) key := kvstore.AccountKey(accountID, id)
entry, err := s.deletedNatgwKV.Get(ctx, key) entry, err := s.deletedNatgwKV.Get(ctx, key)
if err != nil { if err != nil {
if errors.Is(err, jetstream.ErrKeyNotFound) { if errors.Is(err, jetstream.ErrKeyNotFound) {
@@ -11,7 +11,7 @@ import (
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc" ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" 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/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -31,18 +31,18 @@ func setupTestServiceJS(t *testing.T) (*NatGatewayServiceImpl, jetstream.JetStre
// Seed VPC KV // Seed VPC KV
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, map[string][]byte{ 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) // Seed subnet KV (public subnet)
testutil.SeedKV(t, js, ec2vpc.KVBucketSubnets, map[string][]byte{ 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 // Seed EIP KV
testutil.SeedKV(t, js, ec2eip.KVBucketEIPs, map[string][]byte{ 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"}`), kvstore.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-used"): []byte(`{"allocation_id":"eipalloc-used","public_ip":"203.0.113.51","state":"associated","association_id":"eipassoc-xxx"}`),
}) })
svc, err := NewNatGatewayServiceImplWithNATS(t.Context(), nc) svc, err := NewNatGatewayServiceImplWithNATS(t.Context(), nc)
@@ -170,7 +170,7 @@ func TestDeleteNatGateway(t *testing.T) {
assert.Equal(t, natgwID, *deleteOut.NatGatewayId) assert.Equal(t, natgwID, *deleteOut.NatGatewayId)
// Should be gone // 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) assert.ErrorIs(t, err, jetstream.ErrKeyNotFound)
} }
@@ -8,7 +8,7 @@ import (
"log/slog" "log/slog"
"strings" "strings"
"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/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 // 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. // 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) { 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) { if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list placement-group keys: %w", err) return nil, fmt.Errorf("list placement-group keys: %w", err)
} }
@@ -47,7 +47,7 @@ func (s *PlacementGroupServiceImpl) CleanupAccount(ctx context.Context, accountI
prefix := accountID + "." prefix := accountID + "."
out := &AccountCleanup{Referenced: map[string]string{}} out := &AccountCleanup{Referenced: map[string]string{}}
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.kv.Get(ctx, key) entry, err := s.kv.Get(ctx, key)
@@ -8,7 +8,7 @@ import (
"log/slog" "log/slog"
"strings" "strings"
"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/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 // A listing failure is returned as an error; per-record read failures are
// counted. The bucket's schema-version key belongs to no account. // counted. The bucket's schema-version key belongs to no account.
func (s *PlacementGroupServiceImpl) ScanAccount(ctx context.Context, accountID string) (*AccountScan, error) { 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) { if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list placement-group keys: %w", err) 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 + "." prefix := accountID + "."
scan := &AccountScan{} scan := &AccountScan{}
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.kv.Get(ctx, key) 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/aws"
"github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit/readonlystore" "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/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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"}}, GroupName: "held", NodeInstances: map[string][]string{"node-2": {"i-0bbb", "i-0aaa"}, "node-3": {"i-0aaa"}},
}, testAccountID) }, testAccountID)
require.NoError(t, err) 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) 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) require.NoError(t, err)
createTestGroup(t, svc, "unreadable", ec2.PlacementStrategySpread) 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) scan, err := svc.ScanAccount(context.Background(), testAccountID)
require.NoError(t, err) require.NoError(t, err)
@@ -14,7 +14,7 @@ import (
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -84,7 +84,7 @@ func TestCurrentBehaviour_AccountTermination_PlacementGroupStorageLayout(t *test
}, testAccountID) }, testAccountID)
require.NoError(t, err) 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") entry, err := svc.kv.Get(t.Context(), testAccountID+".spread-a")
require.NoError(t, err) 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 // a record cannot be removed through DeletePlacementGroup either, and a read
// failure is an internal error, never an unknown group. // failure is an internal error, never an unknown group.
func TestCurrentBehaviour_AccountTermination_DescribePlacementGroupsHidesUnreadableRecords(t *testing.T) { func TestCurrentBehaviour_AccountTermination_DescribePlacementGroupsHidesUnreadableRecords(t *testing.T) {
brokenKey := kvutil.AccountKey(testAccountID, "broken") brokenKey := kvstore.AccountKey(testAccountID, "broken")
tests := []struct { tests := []struct {
name string name string
seed func(t *testing.T, svc *PlacementGroupServiceImpl) seed func(t *testing.T, svc *PlacementGroupServiceImpl)
@@ -139,7 +139,7 @@ func TestCurrentBehaviour_AccountTermination_DescribePlacementGroupsHidesUnreada
func TestAccountTermination_DeletePlacementGroupLosesToAConcurrentMember(t *testing.T) { func TestAccountTermination_DeletePlacementGroupLosesToAConcurrentMember(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
createTestGroup(t, svc, "cluster-a", ec2.PlacementStrategyCluster) 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} launcher := &PlacementGroupServiceImpl{kv: svc.kv, region: svc.region}
var reserveErr, finalizeErr error var reserveErr, finalizeErr error
@@ -222,7 +222,7 @@ func TestAccountCleanup_DeletesEmptyGroupsAndBlocksOnMembers(t *testing.T) {
empty := createTestGroup(t, svc, "empty", ec2.PlacementStrategySpread) empty := createTestGroup(t, svc, "empty", ec2.PlacementStrategySpread)
held := createTestGroup(t, svc, "held", ec2.PlacementStrategyCluster) held := createTestGroup(t, svc, "held", ec2.PlacementStrategyCluster)
require.NoError(t, addMember(t, svc, "held", "i-0123456789abcdef0")) 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) require.NoError(t, err)
other := "999988887777" other := "999988887777"
_, err = svc.CreatePlacementGroup(context.Background(), &ec2.CreatePlacementGroupInput{ _, 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{"held"}, describedGroupNames(t, svc, testAccountID))
assert.Equal(t, []string{"theirs"}, describedGroupNames(t, svc, other)) 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") 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) { func TestAccountCleanup_ConcurrentMemberBlocksTheDelete(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
group := createTestGroup(t, svc, "cluster-a", ec2.PlacementStrategyCluster) 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} launcher := &PlacementGroupServiceImpl{kv: svc.kv, region: svc.region}
var memberErr error var memberErr error
// Read 1 is the cleanup's listing read; read 2 is the delete's check. // 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" awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags" 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/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketPlacementGroups, err) 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 groupName := *input.GroupName
key := kvutil.AccountKey(accountID, groupName) key := kvstore.AccountKey(accountID, groupName)
groupID := awsidentifiers.GenerateResourceID("pg") groupID := awsidentifiers.GenerateResourceID("pg")
record := PlacementGroupRecord{ record := PlacementGroupRecord{
@@ -175,7 +175,7 @@ func (s *PlacementGroupServiceImpl) DeletePlacementGroup(ctx context.Context, in
} }
groupName := *input.GroupName 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) { if errors.Is(err, errPGAbsent) {
// Lower case, unlike DescribePlacementGroups' message for the same code. // Lower case, unlike DescribePlacementGroups' message for the same code.
return nil, awserrors.Errorf(awserrors.ErrorInvalidPlacementGroupUnknown, "The placement group '%s' is unknown.", groupName) 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. // The returned count is the members seen by the last check.
func (s *PlacementGroupServiceImpl) deleteGroup(ctx context.Context, accountID, key string) (int, error) { func (s *PlacementGroupServiceImpl) deleteGroup(ctx context.Context, accountID, key string) (int, error) {
members := 0 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) members = memberCount(record.NodeInstances)
if members > 0 { if members > 0 {
return errPGInUse return errPGInUse
@@ -238,7 +238,7 @@ func (s *PlacementGroupServiceImpl) DescribePlacementGroups(ctx context.Context,
var groups []*ec2.PlacementGroup var groups []*ec2.PlacementGroup
for _, k := range keys { for _, k := range keys {
if k == kvutil.VersionKey { if k == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(k, prefix) { if !strings.HasPrefix(k, prefix) {
@@ -341,7 +341,7 @@ func pgMatchesFilters(record *PlacementGroupRecord, filters map[string][]string)
const maxCASRetries = 5 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 // specific AWS errors rather than a generic internal failure, and neither
// escapes this file. errPGInUse is the AWS error itself. // escapes this file. errPGInUse is the AWS error itself.
var ( var (
@@ -350,8 +350,8 @@ var (
errPGInUse = errors.New(awserrors.ErrorInvalidPlacementGroupInUse) errPGInUse = errors.New(awserrors.ErrorInvalidPlacementGroupInUse)
) )
// mapCASError turns a kvutil failure into the AWS error the caller returns. // mapCASError turns a kvstore failure into the AWS error the caller returns.
// A non-kvutil error came from mutate and is already an AWS code. // 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 { func mapCASError(ctx context.Context, op, groupName, accountID string, err error) error {
switch { switch {
case errors.Is(err, errPGAbsent): 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): case errors.Is(err, errPGContended):
slog.ErrorContext(ctx, "Placement group CAS retries exhausted under contention", slog.ErrorContext(ctx, "Placement group CAS retries exhausted under contention",
"op", op, "groupName", groupName, "accountID", accountID) "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", slog.ErrorContext(ctx, "Failed to read placement group from KV",
"op", op, "groupName", groupName, "accountID", accountID, "err", err) "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", slog.ErrorContext(ctx, "Corrupt placement group record in KV",
"op", op, "groupName", groupName, "accountID", accountID, "err", err) "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", slog.ErrorContext(ctx, "Failed to marshal placement group record",
"op", op, "groupName", groupName, "accountID", accountID, "err", err) "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", slog.ErrorContext(ctx, "Failed to write placement group to KV",
"op", op, "groupName", groupName, "accountID", accountID, "err", err) "op", op, "groupName", groupName, "accountID", accountID, "err", err)
default: 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. // casConfig is the shared CAS policy for every placement group mutation.
func casConfig() kvutil.CASConfig { func casConfig() kvstore.CASConfig {
return kvutil.CASConfig{ return kvstore.CASConfig{
Attempts: maxCASRetries, Attempts: maxCASRetries,
NotFound: errPGAbsent, NotFound: errPGAbsent,
Exhausted: func(string, int) error { return errPGContended }, Exhausted: func(string, int) error { return errPGContended },
@@ -394,7 +394,7 @@ func (s *PlacementGroupServiceImpl) ReserveSpreadNodes(ctx context.Context, inpu
} }
var selected []string 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) { func(record *PlacementGroupRecord) (bool, error) {
if record.State != ec2.PlacementGroupStateAvailable { if record.State != ec2.PlacementGroupStateAvailable {
return false, errors.New(awserrors.ErrorInvalidPlacementGroupUnknown) return false, errors.New(awserrors.ErrorInvalidPlacementGroupUnknown)
@@ -437,7 +437,7 @@ func (s *PlacementGroupServiceImpl) FinalizeSpreadInstances(ctx context.Context,
return nil, errors.New(awserrors.ErrorMissingParameter) 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) { func(record *PlacementGroupRecord) (bool, error) {
maps.Copy(record.NodeInstances, input.NodeInstances) maps.Copy(record.NodeInstances, input.NodeInstances)
return true, nil return true, nil
@@ -462,7 +462,7 @@ func (s *PlacementGroupServiceImpl) ReleaseSpreadNodes(ctx context.Context, inpu
releaseSet[n] = true 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) { func(record *PlacementGroupRecord) (bool, error) {
changed := false changed := false
for node := range releaseSet { for node := range releaseSet {
@@ -490,7 +490,7 @@ func (s *PlacementGroupServiceImpl) RemoveInstance(ctx context.Context, input *R
return nil, errors.New(awserrors.ErrorMissingParameter) 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) { func(record *PlacementGroupRecord) (bool, error) {
instances, exists := record.NodeInstances[input.NodeName] instances, exists := record.NodeInstances[input.NodeName]
if !exists { if !exists {
@@ -533,7 +533,7 @@ func (s *PlacementGroupServiceImpl) ReserveClusterNode(ctx context.Context, inpu
} }
var targetNode string 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) { func(record *PlacementGroupRecord) (bool, error) {
if record.State != ec2.PlacementGroupStateAvailable { if record.State != ec2.PlacementGroupStateAvailable {
return false, errors.New(awserrors.ErrorInvalidPlacementGroupUnknown) return false, errors.New(awserrors.ErrorInvalidPlacementGroupUnknown)
@@ -570,7 +570,7 @@ func (s *PlacementGroupServiceImpl) FinalizeClusterInstances(ctx context.Context
return nil, errors.New(awserrors.ErrorMissingParameter) 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) { func(record *PlacementGroupRecord) (bool, error) {
// Append rather than replace: a cluster group can have several launches in flight at once. // Append rather than replace: a cluster group can have several launches in flight at once.
for node, ids := range input.NodeInstances { for node, ids := range input.NodeInstances {
@@ -10,7 +10,7 @@ import (
"github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config" "github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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/assert"
"github.com/stretchr/testify/require" "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. // readRecord returns the stored record for assertions.
func readRecord(t *testing.T, svc *PlacementGroupServiceImpl, groupName string) *PlacementGroupRecord { func readRecord(t *testing.T, svc *PlacementGroupServiceImpl, groupName string) *PlacementGroupRecord {
t.Helper() 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) require.NoError(t, err)
var record PlacementGroupRecord var record PlacementGroupRecord
require.NoError(t, json.Unmarshal(entry.Value(), &record)) 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. // starting state the public API cannot produce.
func seedRecord(t *testing.T, svc *PlacementGroupServiceImpl, groupName string, mutate func(*PlacementGroupRecord)) { func seedRecord(t *testing.T, svc *PlacementGroupServiceImpl, groupName string, mutate func(*PlacementGroupRecord)) {
t.Helper() 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) { func(record *PlacementGroupRecord) (bool, error) {
mutate(record) mutate(record)
return true, nil return true, nil
+29 -29
View File
@@ -25,7 +25,7 @@ import (
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging" "github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags" 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/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketRouteTables, err) 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) 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 { if err != nil {
return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("failed to get IGW KV bucket: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("failed to get subnet KV bucket: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("failed to get NAT Gateway KV bucket: %w", err) 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. // getRouteTable retrieves a route table record from KV.
func (s *RouteTableServiceImpl) getRouteTable(ctx context.Context, accountID, rtbID string) (*RouteTableRecord, error) { 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 err != nil {
if errors.Is(err, jetstream.ErrKeyNotFound) { if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, rtbNotFoundError(rtbID) 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) slog.ErrorContext(ctx, "Failed to marshal route table record", "routeTableId", record.RouteTableId, "err", err)
return errors.New(awserrors.ErrorServerInternal) 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) slog.ErrorContext(ctx, "Failed to write route table to KV", "routeTableId", record.RouteTableId, "err", err)
return errors.New(awserrors.ErrorServerInternal) 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. // parallel AssociateRouteTable calls Terraform fans out per route table.
const rtbCASMaxRetries = 16 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 // specific AWS errors rather than a generic internal failure. Neither
// escapes this file. // escapes this file.
var ( var (
@@ -143,7 +143,7 @@ var (
// several subnets with one route table at once. mutate reports whether // several subnets with one route table at once. mutate reports whether
// it changed the record; a false return commits nothing. // 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 { 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, Attempts: rtbCASMaxRetries,
NotFound: errRTBAbsent, NotFound: errRTBAbsent,
Exhausted: func(string, int) error { return errRTBContended }, Exhausted: func(string, int) error { return errRTBContended },
@@ -157,13 +157,13 @@ func (s *RouteTableServiceImpl) mutateRouteTableCAS(ctx context.Context, account
// Contended rather than broken. // Contended rather than broken.
slog.ErrorContext(ctx, "Route table CAS retries exhausted under contention", slog.ErrorContext(ctx, "Route table CAS retries exhausted under contention",
"routeTableId", rtbID, "attempts", rtbCASMaxRetries) "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) 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) 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) 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) slog.ErrorContext(ctx, "Failed to write route table to KV", "routeTableId", rtbID, "err", err)
default: default:
return err 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. // 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) { 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 { if err != nil {
return "", awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID) return "", awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID)
} }
@@ -229,7 +229,7 @@ func (s *RouteTableServiceImpl) subnetsImplicitlyOnMainRT(ctx context.Context, a
} }
var implicit []string var implicit []string
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.subnetKV.Get(ctx, key) entry, err := s.subnetKV.Get(ctx, key)
@@ -387,7 +387,7 @@ func (s *RouteTableServiceImpl) allSubnetsForVPC(ctx context.Context, accountID,
} }
var subnets []string var subnets []string
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.subnetKV.Get(ctx, key) entry, err := s.subnetKV.Get(ctx, key)
@@ -455,7 +455,7 @@ func (s *RouteTableServiceImpl) allRouteTablesForVPC(ctx context.Context, accoun
var results []RouteTableRecord var results []RouteTableRecord
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.rtbKV.Get(ctx, key) 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) return nil, errors.New(awserrors.ErrorServerInternal)
} }
@@ -633,7 +633,7 @@ func (s *RouteTableServiceImpl) DescribeRouteTables(ctx context.Context, input *
foundIDs := make(map[string]bool) foundIDs := make(map[string]bool)
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
@@ -740,7 +740,7 @@ func (s *RouteTableServiceImpl) CreateRoute(ctx context.Context, input *ec2.Crea
case input.GatewayId != nil && *input.GatewayId != "": case input.GatewayId != nil && *input.GatewayId != "":
igwID := *input.GatewayId igwID := *input.GatewayId
// Verify IGW exists and is attached to the same VPC // 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 { if err != nil {
return nil, gatewayNotFoundError(igwID) return nil, gatewayNotFoundError(igwID)
} }
@@ -770,7 +770,7 @@ func (s *RouteTableServiceImpl) CreateRoute(ctx context.Context, input *ec2.Crea
case input.NatGatewayId != nil && *input.NatGatewayId != "": case input.NatGatewayId != nil && *input.NatGatewayId != "":
natgwID := *input.NatGatewayId natgwID := *input.NatGatewayId
// Verify NAT GW exists and belongs to the same VPC // 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 { if err != nil {
return nil, errors.New(awserrors.ErrorInvalidNatGatewayIDNotFound) return nil, errors.New(awserrors.ErrorInvalidNatGatewayIDNotFound)
} }
@@ -924,7 +924,7 @@ func (s *RouteTableServiceImpl) ReplaceRoute(ctx context.Context, input *ec2.Rep
igwID := *input.GatewayId igwID := *input.GatewayId
// AWS reports a missing gateway ahead of a missing route. // 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 { if err != nil {
return nil, gatewayNotFoundError(igwID) 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 // 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 { if err != nil {
return nil, errors.New(awserrors.ErrorInvalidSubnetIDNotFound) return nil, errors.New(awserrors.ErrorInvalidSubnetIDNotFound)
} }
@@ -1077,7 +1077,7 @@ func (s *RouteTableServiceImpl) DisassociateRouteTable(ctx context.Context, inpu
} }
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
@@ -1168,7 +1168,7 @@ func (s *RouteTableServiceImpl) ReplaceRouteTableAssociation(ctx context.Context
} }
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
@@ -1369,7 +1369,7 @@ func (s *RouteTableServiceImpl) publishNatGatewayDeleteEvents(ctx context.Contex
if s.natsConn == nil { if s.natsConn == nil {
return return
} }
natgwEntry, err := s.natgwKV.Get(ctx, kvutil.AccountKey(accountID, natgwID)) natgwEntry, err := s.natgwKV.Get(ctx, kvstore.AccountKey(accountID, natgwID))
if err != nil { if err != nil {
slog.WarnContext(ctx, "NAT GW event: natgw lookup failed", "topic", networkv1.NATGatewayDeleteSubject, "natGatewayId", natgwID, "err", err) slog.WarnContext(ctx, "NAT GW event: natgw lookup failed", "topic", networkv1.NATGatewayDeleteSubject, "natGatewayId", natgwID, "err", err)
return return
@@ -1417,7 +1417,7 @@ func (s *RouteTableServiceImpl) publishNatGatewayEventsForAssociation(ctx contex
if r.NatGatewayId == "" { if r.NatGatewayId == "" {
continue 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 { if err != nil {
slog.WarnContext(ctx, "NAT GW event: natgw lookup failed", "topic", topic, "natGatewayId", r.NatGatewayId, "err", err) slog.WarnContext(ctx, "NAT GW event: natgw lookup failed", "topic", topic, "natGatewayId", r.NatGatewayId, "err", err)
continue continue
@@ -1442,7 +1442,7 @@ func (s *RouteTableServiceImpl) publishNatGatewayEventForSubnet(ctx context.Cont
if s.natsConn == nil { if s.natsConn == nil {
return return
} }
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.AccountKey(accountID, subnetID)) subnetEntry, err := s.subnetKV.Get(ctx, kvstore.AccountKey(accountID, subnetID))
if err != nil { if err != nil {
slog.WarnContext(ctx, "NAT GW event: subnet lookup failed", "topic", topic, "subnetId", subnetID, "err", err) slog.WarnContext(ctx, "NAT GW event: subnet lookup failed", "topic", topic, "subnetId", subnetID, "err", err)
return return
@@ -13,7 +13,7 @@ import (
ec2natgw "github.com/mulgadc/spinifex/spinifex/domains/ec2/natgw" ec2natgw "github.com/mulgadc/spinifex/spinifex/domains/ec2/natgw"
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc" ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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/nats-io/nats.go"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -28,23 +28,23 @@ func setupTestService(t *testing.T) *RouteTableServiceImpl {
// Seed VPC KV // Seed VPC KV
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, map[string][]byte{ 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) // Seed IGW KV (attached to vpc-test1)
testutil.SeedKV(t, js, ec2igw.KVBucketIGW, map[string][]byte{ 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 // Seed subnet KV
testutil.SeedKV(t, js, ec2vpc.KVBucketSubnets, map[string][]byte{ 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"}`), kvstore.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-priv1"): []byte(`{"subnet_id":"subnet-priv1","vpc_id":"vpc-test1","cidr_block":"10.0.11.0/24","state":"available"}`),
}) })
// Seed NAT Gateway KV // Seed NAT Gateway KV
testutil.SeedKV(t, js, ec2natgw.KVBucketNatGateways, map[string][]byte{ 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) svc, err := NewRouteTableServiceImplWithNATS(t.Context(), nil, nc)
@@ -10,7 +10,7 @@ import (
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
"github.com/nats-io/nats.go/jetstream" "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. // 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 + "." prefix := accountID + "."
var out []AccountSpotRequest var out []AccountSpotRequest
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := kv.Get(ctx, key) entry, err := kv.Get(ctx, key)
@@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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) { func TestScanAccountListsActiveAndTerminalForOneAccount(t *testing.T) {
@@ -35,7 +35,7 @@ func TestScanAccountListsActiveAndTerminalForOneAccount(t *testing.T) {
func TestScanAccountFailsOnAnUndecodableRecord(t *testing.T) { func TestScanAccountFailsOnAnUndecodableRecord(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
putSIRs(t, svc, makeSIR("sir-aaa", "i-aaa")) 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) require.NoError(t, err)
scan, err := svc.ScanAccount(t.Context(), testAccountID) scan, err := svc.ScanAccount(t.Context(), testAccountID)
@@ -45,7 +45,7 @@ func TestScanAccountFailsOnAnUndecodableRecord(t *testing.T) {
func TestScanAccountFailsOnARecordWithoutARequest(t *testing.T) { func TestScanAccountFailsOnARecordWithoutARequest(t *testing.T) {
svc := setupTestService(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) require.NoError(t, err)
_, err = svc.ScanAccount(t.Context(), testAccountID) _, 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) { func TestScanAccountFailsWhenARecordCannotBeRead(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
putSIRs(t, svc, makeSIR("sir-aaa", "i-aaa"), makeSIR("sir-bbb", "i-bbb")) 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) scan, err := svc.ScanAccount(t.Context(), testAccountID)
require.Error(t, err) require.Error(t, err)
@@ -8,7 +8,7 @@ import (
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2" "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" "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 // 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. // request is an error, because the unread request may name the resource.
func (s *SpotInstanceServiceImpl) AccountLaunchReferences(ctx context.Context, accountID string) (*LaunchReferences, error) { 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) { if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list spot requests: %w", err) return nil, fmt.Errorf("list spot requests: %w", err)
} }
prefix := accountID + "." prefix := accountID + "."
out := &LaunchReferences{} out := &LaunchReferences{}
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
record, err := s.readRecord(ctx, s.activeKV, key) 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/aws"
"github.com/aws/aws-sdk-go/service/ec2" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -41,7 +41,7 @@ func TestAccountLaunchReferences_ReadsOpenAndActiveRequests(t *testing.T) {
func TestAccountLaunchReferences_UnreadableRequestFailsTheCheck(t *testing.T) { func TestAccountLaunchReferences_UnreadableRequestFailsTheCheck(t *testing.T) {
svc := setupTestService(t) svc := setupTestService(t)
putSIRs(t, svc, makeSIR("sir-active01", "i-0000000000000001")) 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) require.NoError(t, err)
_, err = svc.AccountLaunchReferences(t.Context(), testAccountID) _, err = svc.AccountLaunchReferences(t.Context(), testAccountID)
@@ -15,7 +15,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/bootstrap/config" "github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" 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/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketSpotRequests, err) 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 // getOrCreateTerminalBucket opens the terminal bucket without changing its
// existing replica or placement configuration, creating it only when absent. // existing replica or placement configuration, creating it only when absent.
func getOrCreateTerminalBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) { 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, Name: KVBucketSpotRequestsTerminal,
Description: "Terminal Spot Instance Requests (auto-expire after 1 hour)", Description: "Terminal Spot Instance Requests (auto-expire after 1 hour)",
History: 1, History: 1,
@@ -91,7 +91,7 @@ func (s *SpotInstanceServiceImpl) PutSpotInstanceRequests(ctx context.Context, i
if err != nil { if err != nil {
return nil, errors.New(awserrors.ErrorServerInternal) 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) slog.ErrorContext(ctx, "PutSpotInstanceRequests: KV put failed", "sirId", sirID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal) 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)) cancelled := make([]*ec2.CancelledSpotInstanceRequest, 0, len(input.SpotInstanceRequestIds))
for _, idPtr := range input.SpotInstanceRequestIds { for _, idPtr := range input.SpotInstanceRequestIds {
sirID := aws.StringValue(idPtr) sirID := aws.StringValue(idPtr)
key := kvutil.AccountKey(accountID, sirID) key := kvstore.AccountKey(accountID, sirID)
record, err := s.readRecord(ctx, s.activeKV, key) record, err := s.readRecord(ctx, s.activeKV, key)
if err == nil { if err == nil {
@@ -213,7 +213,7 @@ func (s *SpotInstanceServiceImpl) CloseForInstance(ctx context.Context, instance
prefix := accountID + "." prefix := accountID + "."
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
record, err := s.readRecord(ctx, s.activeKV, key) record, err := s.readRecord(ctx, s.activeKV, key)
@@ -251,7 +251,7 @@ func (s *SpotInstanceServiceImpl) listRequests(ctx context.Context, kv jetstream
prefix := accountID + "." prefix := accountID + "."
var requests []*ec2.SpotInstanceRequest var requests []*ec2.SpotInstanceRequest
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
record, err := s.readRecord(ctx, kv, key) record, err := s.readRecord(ctx, kv, key)
@@ -6,7 +6,7 @@ import (
"sync" "sync"
"testing" "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/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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. // its write both survive alongside its own tag.
func TestCurrentBehaviour_Tagging_RecordMirrorKeepsInterleavedWrites(t *testing.T) { func TestCurrentBehaviour_Tagging_RecordMirrorKeepsInterleavedWrites(t *testing.T) {
const acct, id = "123456789012", "sg-0123456789abcdef0" 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{}}) seed, err := json.Marshal(tagMirrorRecord{Name: "original", Tags: map[string]string{}})
require.NoError(t, err) require.NoError(t, err)
kv := seedKV(t, "tagmirror-cb", map[string][]byte{key: seed}) kv := seedKV(t, "tagmirror-cb", map[string][]byte{key: seed})
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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/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 // applying mut and committing at the read revision, retrying on conflict so a
// concurrent write is never reverted. An absent resource is skipped. // 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 { func UpdateKVRecordTags[R any](ctx context.Context, kv jetstream.KeyValue, accountID, resourceID string, mut func(*R)) error {
key := kvutil.AccountKey(accountID, resourceID) key := kvstore.AccountKey(accountID, resourceID)
_, err := kvutil.Update(ctx, kv, key, kvutil.CASConfig{NotFound: errRecordAbsent}, func(rec *R) (bool, error) { _, err := kvstore.Update(ctx, kv, key, kvstore.CASConfig{NotFound: errRecordAbsent}, func(rec *R) (bool, error) {
mut(rec) mut(rec)
return true, nil return true, nil
}) })
@@ -8,7 +8,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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"}}) rec, err := json.Marshal(&tagMirrorRecord{Name: "r1", Tags: map[string]string{"a": "1"}})
require.NoError(t, err) require.NoError(t, err)
kv := seedKV(t, "tagmirror-test", map[string][]byte{ kv := seedKV(t, "tagmirror-test", map[string][]byte{
kvutil.AccountKey("acct", "res-1"): rec, kvstore.AccountKey("acct", "res-1"): rec,
kvutil.AccountKey("acct", "res-bad"): []byte("not-json"), kvstore.AccountKey("acct", "res-bad"): []byte("not-json"),
}) })
t.Run("mutates and persists", func(t *testing.T) { t.Run("mutates and persists", func(t *testing.T) {
require.NoError(t, UpdateKVRecordTags(t.Context(), kv, "acct", "res-1", func(r *tagMirrorRecord) { require.NoError(t, UpdateKVRecordTags(t.Context(), kv, "acct", "res-1", func(r *tagMirrorRecord) {
r.Tags["b"] = "2" 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) require.NoError(t, err)
var got tagMirrorRecord var got tagMirrorRecord
require.NoError(t, json.Unmarshal(entry.Value(), &got)) require.NoError(t, json.Unmarshal(entry.Value(), &got))
@@ -110,14 +110,14 @@ func TestMirrorKVRecordTags(t *testing.T) {
nilTags, err := json.Marshal(&tagMirrorRecord{}) nilTags, err := json.Marshal(&tagMirrorRecord{})
require.NoError(t, err) require.NoError(t, err)
kv := seedKV(t, "tagmirror-mirror-test", map[string][]byte{ kv := seedKV(t, "tagmirror-mirror-test", map[string][]byte{
kvutil.AccountKey("acct", "vol-1"): withTags, kvstore.AccountKey("acct", "vol-1"): withTags,
kvutil.AccountKey("acct", "vol-2"): nilTags, kvstore.AccountKey("acct", "vol-2"): nilTags,
}) })
tagsOf := func(r *tagMirrorRecord) *map[string]string { return &r.Tags } tagsOf := func(r *tagMirrorRecord) *map[string]string { return &r.Tags }
readTags := func(t *testing.T, id string) map[string]string { 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) require.NoError(t, err)
var got tagMirrorRecord var got tagMirrorRecord
require.NoError(t, json.Unmarshal(entry.Value(), &got)) 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. // must never be touched even though it exists in this bucket.
otherRec, err := json.Marshal(&tagMirrorRecord{Tags: map[string]string{"x": "y"}}) otherRec, err := json.Marshal(&tagMirrorRecord{Tags: map[string]string{"x": "y"}})
require.NoError(t, err) 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, err)
require.NoError(t, MirrorKVRecordTags(t.Context(), kv, "acct", "vol-", resources, tagsOf, require.NoError(t, MirrorKVRecordTags(t.Context(), kv, "acct", "vol-", resources, tagsOf,
MergeTagsMut(&ec2.CreateTagsInput{Tags: []*ec2.Tag{ MergeTagsMut(&ec2.CreateTagsInput{Tags: []*ec2.Tag{
+2 -2
View File
@@ -23,7 +23,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn" "github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" 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/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/nats-io/nats.go/jetstream" "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) { func (s *TagsServiceImpl) taggedResourceIDs(ctx context.Context, accountID string) ([]string, error) {
seen := make(map[string]struct{}) 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) { if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list tag keys: %w", err) return nil, fmt.Errorf("list tag keys: %w", err)
} }
+4 -4
View File
@@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"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/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 // GetOrCreateTagsBucket opens the tag bucket, creating it at the cluster's
// replica count if this is the first node to reach it. // replica count if this is the first node to reach it.
func GetOrCreateTagsBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) { 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 // 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. // before the store moved.
func (s *TagsServiceImpl) mutateTags(ctx context.Context, accountID, resourceID string, mutate func(map[string]string)) error { func (s *TagsServiceImpl) mutateTags(ctx context.Context, accountID, resourceID string, mutate func(map[string]string)) error {
key := tagsKVKey(accountID, resourceID) key := tagsKVKey(accountID, resourceID)
_, err := kvutil.Update(ctx, s.kv, key, kvutil.CASConfig{ _, err := kvstore.Update(ctx, s.kv, key, kvstore.CASConfig{
Attempts: tagCASAttempts, Attempts: tagCASAttempts,
CreateIfAbsent: true, CreateIfAbsent: true,
Exhausted: func(key string, attempts int) error { Exhausted: func(key string, attempts int) error {
@@ -104,7 +104,7 @@ func (s *TagsServiceImpl) PutResourceTags(ctx context.Context, accountID, resour
if err != nil { if err != nil {
return err 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, Attempts: tagCASAttempts,
CreateIfAbsent: true, CreateIfAbsent: true,
}, data) }, data)
+6 -6
View File
@@ -12,7 +12,7 @@ import (
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1" networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" 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" "github.com/nats-io/nats.go/jetstream"
) )
@@ -105,7 +105,7 @@ func (s *VPCServiceImpl) ensureDefaultVPC(ctx context.Context, accountID string,
return claim.info(), nil 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 return claim.info(), nil
} else if !errors.Is(err, jetstream.ErrKeyNotFound) { } else if !errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, fmt.Errorf("read default VPC %s: %w", claim.VpcId, err) 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 bestID string
var bestCreated time.Time var bestCreated time.Time
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := kv.Get(ctx, key) 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 // marks the claim complete. Records are written with Create, so only the caller
// that actually wrote one announces it to vpcd. // that actually wrote one announces it to vpcd.
func (s *VPCServiceImpl) buildDefaultVPC(ctx context.Context, accountID string, claim *defaultVPCClaim) error { 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, VpcId: claim.VpcId,
CidrBlock: claim.Cidr, CidrBlock: claim.Cidr,
State: "available", State: "available",
@@ -284,7 +284,7 @@ func (s *VPCServiceImpl) buildDefaultVPC(ctx context.Context, accountID string,
if s.config != nil && s.config.AZ != "" { if s.config != nil && s.config.AZ != "" {
az = 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, SubnetId: claim.SubnetId,
VpcId: claim.VpcId, VpcId: claim.VpcId,
CidrBlock: claim.SubnetCidr, 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 { if c.Complete || c.VpcId != claim.VpcId {
return false, nil return false, nil
} }
+4 -4
View File
@@ -13,7 +13,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/internal/testkit"
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc" 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"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -149,12 +149,12 @@ func TestEnsureDefaultVPC_AdoptsOldestExistingDefault(t *testing.T) {
const accountID = "000000000051" const accountID = "000000000051"
created := time.Date(2026, 9, 11, 14, 13, 40, 0, time.UTC) created := time.Date(2026, 9, 11, 14, 13, 40, 0, time.UTC)
for i, id := range []string{"vpc-0000000000000newer", "vpc-0000000000000older"} { 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), VpcId: id, CidrBlock: ec2vpc.DefaultVPCCidr, IsDefault: true, VNI: int64(200 + i),
CreatedAt: created.Add(time.Duration(1-i) * time.Minute), 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, SubnetId: "subnet-00000000000older", VpcId: "vpc-0000000000000older", CidrBlock: ec2vpc.DefaultSubnetCidr,
IsDefault: true, CreatedAt: created, IsDefault: true, CreatedAt: created,
}) })
@@ -183,7 +183,7 @@ func TestEnsureDefaultVPC_FinishesHalfBuiltClaim(t *testing.T) {
"route_table_id": "rtb-00000000000half", "internet_gateway_id": "igw-00000000000half", "route_table_id": "rtb-00000000000half", "internet_gateway_id": "igw-00000000000half",
"cidr": ec2vpc.DefaultVPCCidr, "subnet_cidr": ec2vpc.DefaultSubnetCidr, "vni": 4242, "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, VpcId: "vpc-00000000000000half", CidrBlock: ec2vpc.DefaultVPCCidr, IsDefault: true, VNI: 4242,
}) })
+18 -18
View File
@@ -23,7 +23,7 @@ import (
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging" "github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/tags" "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" "github.com/nats-io/nats.go/jetstream"
) )
@@ -118,7 +118,7 @@ func (s *VPCServiceImpl) CreateNetworkInterface(ctx context.Context, input *ec2.
subnetId := *input.SubnetId subnetId := *input.SubnetId
// Verify subnet exists and belongs to this account // 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 { if err != nil {
return nil, errors.New(awserrors.ErrorInvalidSubnetIDNotFound) return nil, errors.New(awserrors.ErrorInvalidSubnetIDNotFound)
} }
@@ -205,7 +205,7 @@ func (s *VPCServiceImpl) CreateNetworkInterface(ctx context.Context, input *ec2.
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal ENI record: %w", err) 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) 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) { 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 // Get the ENI record
entry, err := s.eniKV.Get(ctx, key) 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. // deletion that did not happen.
func (s *VPCServiceImpl) DetachAndDeleteENI(ctx context.Context, accountID, eniID string, force bool) (deleted bool, err error) { func (s *VPCServiceImpl) DetachAndDeleteENI(ctx context.Context, accountID, eniID string, force bool) (deleted bool, err error) {
const maxAttempts = 3 const maxAttempts = 3
key := kvutil.AccountKey(accountID, eniID) key := kvstore.AccountKey(accountID, eniID)
var lastErr error var lastErr error
for range maxAttempts { for range maxAttempts {
@@ -458,7 +458,7 @@ func (s *VPCServiceImpl) ModifyNetworkInterfaceAttribute(ctx context.Context, in
} }
eniId := *input.NetworkInterfaceId eniId := *input.NetworkInterfaceId
key := kvutil.AccountKey(accountID, eniId) key := kvstore.AccountKey(accountID, eniId)
entry, err := s.eniKV.Get(ctx, key) entry, err := s.eniKV.Get(ctx, key)
if err != nil { if err != nil {
@@ -569,7 +569,7 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(ctx context.Context, input *e
} }
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey { if key == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(key, prefix) { 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) { func (s *VPCServiceImpl) attachENI(ctx context.Context, accountID, eniId, instanceId string, deviceIndex int64, instanceOwnerID string) (string, error) {
attachmentId := awsidentifiers.GenerateResourceID("eni-attach") 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" { if record.Status == "in-use" {
return errors.New(awserrors.ErrorInvalidNetworkInterfaceInUse) 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). // DetachENI marks an ENI as detached from an instance (internal use by TerminateInstances).
// accountID scopes the lookup to the correct KV key. // accountID scopes the lookup to the correct KV key.
func (s *VPCServiceImpl) DetachENI(ctx context.Context, accountID, eniId string) error { 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) entry, err := s.eniKV.Get(ctx, key)
if err != nil { if err != nil {
return eniNotFoundError(eniId) 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) { 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) entry, err := s.eniKV.Get(ctx, key)
if err != nil { if err != nil {
return ENIRecord{}, eniNotFoundError(eniId) 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 { 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) fn(r)
return nil return nil
}) })
@@ -876,7 +876,7 @@ func (s *VPCServiceImpl) listInstanceENIs(ctx context.Context, accountID, instan
prefix := accountID + "." prefix := accountID + "."
var out []ENIRecord var out []ENIRecord
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.eniKV.Get(ctx, key) 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 var out []AccountENI
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey { if key == kvstore.VersionKey {
continue continue
} }
accountID, _, found := strings.Cut(key, ".") accountID, _, found := strings.Cut(key, ".")
@@ -1004,7 +1004,7 @@ func (s *VPCServiceImpl) findENIByAttachment(ctx context.Context, accountID, att
} }
prefix := accountID + "." prefix := accountID + "."
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.eniKV.Get(ctx, key) 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 { 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) entry, err := s.eniKV.Get(ctx, key)
if err != nil { if err != nil {
return fmt.Errorf("ENI %s not found: %w", eniId, err) 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 continue
} }
names[id] = "" 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 { if err != nil {
slog.WarnContext(ctx, "fillSGNames: SG read failed", "groupId", id, "err", err) slog.WarnContext(ctx, "fillSGNames: SG read failed", "groupId", id, "err", err)
continue continue
@@ -1080,7 +1080,7 @@ func (s *VPCServiceImpl) fillSGNames(ctx context.Context, accountID string, ids
// vpcDNSHostnames reports whether the VPC has enableDnsHostnames on. An // vpcDNSHostnames reports whether the VPC has enableDnsHostnames on. An
// unreadable VPC reads as off, so its interfaces go unnamed. // unreadable VPC reads as off, so its interfaces go unnamed.
func (s *VPCServiceImpl) vpcDNSHostnames(ctx context.Context, accountID, vpcID string) bool { 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 { if err != nil {
slog.WarnContext(ctx, "vpcDNSHostnames: VPC read failed", "vpcId", vpcID, "err", err) slog.WarnContext(ctx, "vpcDNSHostnames: VPC read failed", "vpcId", vpcID, "err", err)
return false 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. // Each SG must exist in the caller's account and belong to the same VPC.
for _, sgId := range sgIds { 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 { if err != nil {
return sgNotFoundError(sgId) return sgNotFoundError(sgId)
} }
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/domains/network/projection" "github.com/mulgadc/spinifex/spinifex/domains/network/projection"
"github.com/mulgadc/spinifex/spinifex/domains/network/topology" "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" "github.com/nats-io/nats.go/jetstream"
) )
@@ -69,7 +69,7 @@ func (s *VPCServiceImpl) findENIAnyAccount(ctx context.Context, eniID string) (*
suffix := "." + eniID suffix := "." + eniID
for _, k := range keys { for _, k := range keys {
if k == kvutil.VersionKey || !strings.HasSuffix(k, suffix) { if k == kvstore.VersionKey || !strings.HasSuffix(k, suffix) {
continue continue
} }
entry, err := s.eniKV.Get(ctx, k) entry, err := s.eniKV.Get(ctx, k)
@@ -7,7 +7,7 @@ import (
"testing" "testing"
testutil "github.com/mulgadc/spinifex/internal/testkit" 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/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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 // Reads the stored record directly: the wrapped bucket would serve the
// check itself from a lagging replica. // check itself from a lagging replica.
stored := func() ENIRecord { 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) require.NoError(t, err)
var rec ENIRecord var rec ENIRecord
require.NoError(t, json.Unmarshal(entry.Value(), &rec)) require.NoError(t, json.Unmarshal(entry.Value(), &rec))
@@ -114,7 +114,7 @@ func TestCASENIGivesUpAfterBoundedConflicts(t *testing.T) {
t.Parallel() t.Parallel()
svc, eniID, _ := newStaleReadENI(t) svc, eniID, _ := newStaleReadENI(t)
conflicts := 0 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++ conflicts++
// Move the record under the next compare-and-swap, as a competing writer does. // 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" }) return svc.UpdateENI(testAccountID, eniID, func(r *ENIRecord) { r.Description = "other writer" })
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"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/nats-io/nats.go/jetstream"
) )
@@ -42,7 +42,7 @@ func keyWithSuffixExists(ctx context.Context, kv jetstream.KeyValue, resourceID
} }
suffix := "." + resourceID suffix := "." + resourceID
for _, k := range keys { for _, k := range keys {
if k != kvutil.VersionKey && strings.HasSuffix(k, suffix) { if k != kvstore.VersionKey && strings.HasSuffix(k, suffix) {
return true, nil return true, nil
} }
} }
+20 -20
View File
@@ -23,7 +23,7 @@ import (
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers" awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging" "github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags" 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" "github.com/nats-io/nats.go/jetstream"
) )
@@ -245,7 +245,7 @@ func (s *VPCServiceImpl) CreateSecurityGroup(ctx context.Context, input *ec2.Cre
} }
sgsInVPC := 0 sgsInVPC := 0
for _, k := range sgKeys { for _, k := range sgKeys {
if k == kvutil.VersionKey { if k == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(k, prefix) { if !strings.HasPrefix(k, prefix) {
@@ -305,7 +305,7 @@ func (s *VPCServiceImpl) CreateSecurityGroup(ctx context.Context, input *ec2.Cre
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal security group record: %w", err) 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) return nil, errors.New(awserrors.ErrorServerInternal)
} }
@@ -331,7 +331,7 @@ func (s *VPCServiceImpl) DeleteSecurityGroup(ctx context.Context, input *ec2.Del
} }
groupId := *input.GroupId groupId := *input.GroupId
key := kvutil.AccountKey(accountID, groupId) key := kvstore.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(ctx, key) entry, err := s.sgKV.Get(ctx, key)
if err != nil { if err != nil {
@@ -380,7 +380,7 @@ func (s *VPCServiceImpl) validateSGRuleReferences(ctx context.Context, accountID
if r.SourceSG == "" { if r.SourceSG == "" {
continue 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 { if err != nil {
return sgNotFoundError(r.SourceSG) return sgNotFoundError(r.SourceSG)
} }
@@ -406,7 +406,7 @@ func (s *VPCServiceImpl) checkSGDependencies(ctx context.Context, accountID, gro
return errors.New(awserrors.ErrorServerInternal) return errors.New(awserrors.ErrorServerInternal)
} }
for _, k := range eniKeys { for _, k := range eniKeys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) { if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue continue
} }
entry, err := s.eniKV.Get(ctx, k) 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) return errors.New(awserrors.ErrorServerInternal)
} }
for _, k := range sgKeys { for _, k := range sgKeys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) { if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue continue
} }
entry, err := s.sgKV.Get(ctx, k) entry, err := s.sgKV.Get(ctx, k)
@@ -561,7 +561,7 @@ func (s *VPCServiceImpl) DescribeSecurityGroups(ctx context.Context, input *ec2.
} }
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey { if key == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(key, prefix) { if !strings.HasPrefix(key, prefix) {
@@ -713,7 +713,7 @@ func (s *VPCServiceImpl) GetSecurityGroupsForVpc(ctx context.Context, input *ec2
groups := []*ec2.SecurityGroupForVpc{} groups := []*ec2.SecurityGroupForVpc{}
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.sgKV.Get(ctx, key) entry, err := s.sgKV.Get(ctx, key)
@@ -844,7 +844,7 @@ func (s *VPCServiceImpl) DescribeSecurityGroupRules(ctx context.Context, input *
emitted := make(map[string]bool) emitted := make(map[string]bool)
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.sgKV.Get(ctx, key) entry, err := s.sgKV.Get(ctx, key)
@@ -945,7 +945,7 @@ func (s *VPCServiceImpl) updateSGRuleTags(ctx context.Context, accountID, ruleID
} }
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.sgKV.Get(ctx, key) entry, err := s.sgKV.Get(ctx, key)
@@ -1056,7 +1056,7 @@ func (s *VPCServiceImpl) AuthorizeSecurityGroupIngress(ctx context.Context, inpu
} }
groupId := *input.GroupId groupId := *input.GroupId
key := kvutil.AccountKey(accountID, groupId) key := kvstore.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(ctx, key) entry, err := s.sgKV.Get(ctx, key)
if err != nil { if err != nil {
@@ -1134,7 +1134,7 @@ func (s *VPCServiceImpl) AuthorizeSecurityGroupEgress(ctx context.Context, input
} }
groupId := *input.GroupId groupId := *input.GroupId
key := kvutil.AccountKey(accountID, groupId) key := kvstore.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(ctx, key) entry, err := s.sgKV.Get(ctx, key)
if err != nil { if err != nil {
@@ -1212,7 +1212,7 @@ func (s *VPCServiceImpl) RevokeSecurityGroupIngress(ctx context.Context, input *
} }
groupId := *input.GroupId groupId := *input.GroupId
key := kvutil.AccountKey(accountID, groupId) key := kvstore.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(ctx, key) entry, err := s.sgKV.Get(ctx, key)
if err != nil { if err != nil {
@@ -1277,7 +1277,7 @@ func (s *VPCServiceImpl) RevokeSecurityGroupEgress(ctx context.Context, input *e
} }
groupId := *input.GroupId groupId := *input.GroupId
key := kvutil.AccountKey(accountID, groupId) key := kvstore.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(ctx, key) entry, err := s.sgKV.Get(ctx, key)
if err != nil { if err != nil {
@@ -1397,7 +1397,7 @@ func (s *VPCServiceImpl) updateSGRuleDescriptions(ctx context.Context, accountID
return errors.New(awserrors.ErrorInvalidParameterCombination) return errors.New(awserrors.ErrorInvalidParameterCombination)
} }
key := kvutil.AccountKey(accountID, req.groupId) key := kvstore.AccountKey(accountID, req.groupId)
entry, err := s.sgKV.Get(ctx, key) entry, err := s.sgKV.Get(ctx, key)
if err != nil { if err != nil {
return sgNotFoundError(req.groupId) return sgNotFoundError(req.groupId)
@@ -1581,7 +1581,7 @@ func (s *VPCServiceImpl) ModifySecurityGroupRules(ctx context.Context, input *ec
} }
groupId := *input.GroupId groupId := *input.GroupId
key := kvutil.AccountKey(accountID, groupId) key := kvstore.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(ctx, key) entry, err := s.sgKV.Get(ctx, key)
if err != nil { if err != nil {
@@ -2158,7 +2158,7 @@ func (s *VPCServiceImpl) storeDefaultSecurityGroup(ctx context.Context, accountI
if err != nil { if err != nil {
return nil, false, fmt.Errorf("marshal default security group: %w", err) 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) { if errors.Is(err, jetstream.ErrKeyExists) {
return record, false, nil 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 // CannotDelete guard for default SGs. Used by DeleteVpc to cascade-delete the
// per-VPC default SG. Surfaces vpcd tear-down failures to the caller. // per-VPC default SG. Surfaces vpcd tear-down failures to the caller.
func (s *VPCServiceImpl) deleteSecurityGroupInternal(ctx context.Context, accountID, groupId string) error { 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) entry, err := s.sgKV.Get(ctx, key)
if err != nil { if err != nil {
return fmt.Errorf("read default security group: %w", err) return fmt.Errorf("read default security group: %w", err)
@@ -2219,7 +2219,7 @@ func (s *VPCServiceImpl) findDefaultSGForVPC(ctx context.Context, accountID, vpc
return "", err return "", err
} }
for _, k := range keys { for _, k := range keys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) { if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue continue
} }
entry, err := s.sgKV.Get(ctx, k) entry, err := s.sgKV.Get(ctx, k)
@@ -15,7 +15,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" 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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -62,7 +62,7 @@ func tcpPeerRequest(port int64, peer string) *ec2.SecurityGroupRuleRequest {
// modify wrote nothing. // modify wrote nothing.
func storedSGRecord(t *testing.T, svc *VPCServiceImpl, sgID string) []byte { func storedSGRecord(t *testing.T, svc *VPCServiceImpl, sgID string) []byte {
t.Helper() 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) require.NoError(t, err)
return entry.Value() return entry.Value()
} }
@@ -689,7 +689,7 @@ func TestRevokeSecurityGroupIngress_InvertedPortRangeStillRevocable(t *testing.T
vpcID := createTestVPC(t, svc, "10.0.0.0/16") vpcID := createTestVPC(t, svc, "10.0.0.0/16")
sgID := createTestSG(t, svc, vpcID, "stale-inverted-ports") sgID := createTestSG(t, svc, vpcID, "stale-inverted-ports")
key := kvutil.AccountKey(testAccountID, sgID) key := kvstore.AccountKey(testAccountID, sgID)
var rec SecurityGroupRecord var rec SecurityGroupRecord
require.NoError(t, json.Unmarshal(storedSGRecord(t, svc, sgID), &rec)) require.NoError(t, json.Unmarshal(storedSGRecord(t, svc, sgID), &rec))
rec.IngressRules = append(rec.IngressRules, SGRule{ rec.IngressRules = append(rec.IngressRules, SGRule{
@@ -13,7 +13,7 @@ import (
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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/assert"
"github.com/stretchr/testify/require" "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...) rec.IngressRules = append(rec.IngressRules, rules...)
data, err := json.Marshal(rec) data, err := json.Marshal(rec)
require.NoError(t, err) 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) require.NoError(t, err)
} }
@@ -13,7 +13,7 @@ import (
"github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters" 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/nats-io/nats.go"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -1524,7 +1524,7 @@ func findDefaultSGInVPC(t *testing.T, svc *VPCServiceImpl, vpcID string) string
require.NoError(t, err) require.NoError(t, err)
var matches []string var matches []string
for _, k := range keys { for _, k := range keys {
if k == kvutil.VersionKey { if k == kvstore.VersionKey {
continue continue
} }
entry, err := svc.sgKV.Get(t.Context(), k) 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"} rec := VPCRecord{VpcId: vpcID, CidrBlock: "10.0.0.0/16", State: "available"}
data, err := json.Marshal(rec) data, err := json.Marshal(rec)
require.NoError(t, err) 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) require.NoError(t, err)
} }
@@ -2359,7 +2359,7 @@ func TestAuthorizeSecurityGroupIngress_AssignsRuleIDs(t *testing.T) {
}, testAccountID) }, testAccountID)
require.NoError(t, err) 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) require.NoError(t, err)
var record SecurityGroupRecord var record SecurityGroupRecord
require.NoError(t, json.Unmarshal(entry.Value(), &record)) require.NoError(t, json.Unmarshal(entry.Value(), &record))
+35 -35
View File
@@ -26,7 +26,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging" "github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags" awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/zoneid" "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/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketVPCs, err) 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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketSubnets, err) 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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketVNICounter, err) 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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketENIs, err) 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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketSecurityGroups, err) 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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketDefaultVPCs, err) 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) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket spinifex-vpc-route-tables: %w", err) 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 // Named literally, like the route table bucket above: the IGW package
// imports this one, so its constant cannot be referenced here. // 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket spinifex-igw: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket spinifex-eigw: %w", err) 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. // nextVNI allocates the next VNI using atomic increment on the NATS KV counter.
func (s *VPCServiceImpl) nextVNI(ctx context.Context) (int64, error) { func (s *VPCServiceImpl) nextVNI(ctx context.Context) (int64, error) {
var allocated int64 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) { func(next *int64) (bool, error) {
if *next == 0 { if *next == 0 {
*next = vniStart *next = vniStart
@@ -331,7 +331,7 @@ func (s *VPCServiceImpl) CreateVpc(ctx context.Context, input *ec2.CreateVpcInpu
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal VPC record: %w", err) 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) 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 // requireVPCExists returns InvalidVpcID.NotFound if the VPC doesn't exist for
// this account. // this account.
func (s *VPCServiceImpl) requireVPCExists(ctx context.Context, accountID, vpcId string) error { 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 vpcNotFoundError(vpcId)
} }
return nil return nil
@@ -379,7 +379,7 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu
} }
vpcID := *input.VpcId vpcID := *input.VpcId
key := kvutil.AccountKey(accountID, vpcID) key := kvstore.AccountKey(accountID, vpcID)
if _, err := s.vpcKV.Get(ctx, key); err != nil { if _, err := s.vpcKV.Get(ctx, key); err != nil {
// AWS-faithful: an absent VPC is NotFound (the tofu/SDK provider // 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) return nil, errors.New(awserrors.ErrorServerInternal)
} }
for _, k := range subnetKeys { for _, k := range subnetKeys {
if k == kvutil.VersionKey { if k == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(k, prefix) { 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) return nil, errors.New(awserrors.ErrorServerInternal)
} }
for _, k := range sgKeys { for _, k := range sgKeys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) { if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue continue
} }
entry, err := s.sgKV.Get(ctx, k) 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) return nil, errors.New(awserrors.ErrorServerInternal)
} }
for _, k := range rtbKeys { for _, k := range rtbKeys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) { if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue continue
} }
entry, err := s.rtbKV.Get(ctx, k) 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) return nil, errors.New(awserrors.ErrorServerInternal)
} }
if rtbID != "" { 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) slog.ErrorContext(ctx, "DeleteVpc: main route table reap failed", "vpcId", vpcID, "routeTableId", rtbID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal) return nil, errors.New(awserrors.ErrorServerInternal)
} }
@@ -574,7 +574,7 @@ func rejectAttachedGateway(ctx context.Context, kv jetstream.KeyValue, accountID
} }
for _, k := range keys { for _, k := range keys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) { if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue continue
} }
entry, err := kv.Get(ctx, k) entry, err := kv.Get(ctx, k)
@@ -653,7 +653,7 @@ func (s *VPCServiceImpl) DescribeVpcs(ctx context.Context, input *ec2.DescribeVp
} }
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey { if key == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(key, prefix) { 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 // 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 { if err != nil {
return nil, vpcNotFoundError(vpcID) return nil, vpcNotFoundError(vpcID)
} }
@@ -757,7 +757,7 @@ func (s *VPCServiceImpl) CreateSubnet(ctx context.Context, input *ec2.CreateSubn
} }
for _, k := range subnetKeys { for _, k := range subnetKeys {
if k == kvutil.VersionKey { if k == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(k, prefix) { if !strings.HasPrefix(k, prefix) {
@@ -822,7 +822,7 @@ func (s *VPCServiceImpl) CreateSubnet(ctx context.Context, input *ec2.CreateSubn
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal subnet record: %w", err) 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) return nil, errors.New(awserrors.ErrorServerInternal)
} }
@@ -843,7 +843,7 @@ func (s *VPCServiceImpl) DeleteSubnet(ctx context.Context, input *ec2.DeleteSubn
} }
subnetID := *input.SubnetId subnetID := *input.SubnetId
key := kvutil.AccountKey(accountID, subnetID) key := kvstore.AccountKey(accountID, subnetID)
// Read subnet record before deletion (needed for vpcd event) // Read subnet record before deletion (needed for vpcd event)
subnetEntry, err := s.subnetKV.Get(ctx, key) subnetEntry, err := s.subnetKV.Get(ctx, key)
@@ -902,7 +902,7 @@ func (s *VPCServiceImpl) checkSubnetResidents(ctx context.Context, accountID, su
} }
prefix := accountID + "." prefix := accountID + "."
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.eniKV.Get(ctx, key) entry, err := s.eniKV.Get(ctx, key)
@@ -950,7 +950,7 @@ func (s *VPCServiceImpl) clearRouteTableAssociationsForSubnet(ctx context.Contex
} }
prefix := accountID + "." prefix := accountID + "."
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.rtbKV.Get(ctx, key) 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 { for _, key := range keys {
if key == kvutil.VersionKey { if key == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(key, prefix) { if !strings.HasPrefix(key, prefix) {
@@ -1386,7 +1386,7 @@ func (s *VPCServiceImpl) ModifySubnetAttribute(ctx context.Context, input *ec2.M
} }
subnetID := *input.SubnetId subnetID := *input.SubnetId
key := kvutil.AccountKey(accountID, subnetID) key := kvstore.AccountKey(accountID, subnetID)
entry, err := s.subnetKV.Get(ctx, key) entry, err := s.subnetKV.Get(ctx, key)
if err != nil { if err != nil {
@@ -1438,7 +1438,7 @@ func (s *VPCServiceImpl) ModifyVpcAttribute(ctx context.Context, input *ec2.Modi
} }
vpcID := *input.VpcId vpcID := *input.VpcId
key := kvutil.AccountKey(accountID, vpcID) key := kvstore.AccountKey(accountID, vpcID)
entry, err := s.vpcKV.Get(ctx, key) entry, err := s.vpcKV.Get(ctx, key)
if err != nil { if err != nil {
@@ -1486,7 +1486,7 @@ func (s *VPCServiceImpl) DescribeVpcAttribute(ctx context.Context, input *ec2.De
} }
vpcID := *input.VpcId vpcID := *input.VpcId
key := kvutil.AccountKey(accountID, vpcID) key := kvstore.AccountKey(accountID, vpcID)
entry, err := s.vpcKV.Get(ctx, key) entry, err := s.vpcKV.Get(ctx, key)
if err != nil { if err != nil {
@@ -1587,7 +1587,7 @@ func (s *VPCServiceImpl) writeMainRouteTable(ctx context.Context, accountID, vpc
if err != nil { if err != nil {
return false, fmt.Errorf("marshal main route table: %w", err) 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) { if errors.Is(err, jetstream.ErrKeyExists) {
return false, nil return false, nil
} }
@@ -1611,7 +1611,7 @@ func (s *VPCServiceImpl) findMainRouteTableID(ctx context.Context, accountID, vp
return "", fmt.Errorf("list rtb keys: %w", err) return "", fmt.Errorf("list rtb keys: %w", err)
} }
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) { if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue continue
} }
entry, err := s.rtbKV.Get(ctx, key) entry, err := s.rtbKV.Get(ctx, key)
@@ -1652,7 +1652,7 @@ func (s *VPCServiceImpl) getDefaultSubnet(ctx context.Context, accountID string)
} }
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey { if key == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(key, prefix) { 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) { 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) entry, err := s.subnetKV.Get(ctx, key)
if err != nil { if err != nil {
return nil, fmt.Errorf("subnet %s not found: %w", subnetId, err) 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{}{} seen := map[string]struct{}{}
for _, k := range keys { for _, k := range keys {
if k == kvutil.VersionKey { if k == kvstore.VersionKey {
continue continue
} }
entry, err := s.subnetKV.Get(ctx, k) entry, err := s.subnetKV.Get(ctx, k)
@@ -16,7 +16,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/bootstrap/config" "github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/zoneid" "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"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -2689,7 +2689,7 @@ func TestCreateSubnet_ZoneStoreFailureWritesNothingAndPublishesNothing(t *testin
require.ErrorIs(t, err, jetstream.ErrNoKeysFound) require.ErrorIs(t, err, jetstream.ErrNoKeysFound)
} }
for _, k := range keys { 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") assert.Zero(t, events.Load(), "no topology event may be published")
} }
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/mulgadc/spinifex/internal/testkit" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -111,7 +111,7 @@ func TestLoadOrCreateSigningKey_FailsWhenNoKeyDecrypts(t *testing.T) {
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "master key does not match") 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) require.NoError(t, err)
assert.Len(t, entries, 1, "a failed load must not mint a key alongside the unreadable one") assert.Len(t, entries, 1, "a failed load must not mint a key alongside the unreadable one")
} }
+3 -3
View File
@@ -21,7 +21,7 @@ import (
"time" "time"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam" 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" "github.com/nats-io/nats.go/jetstream"
) )
@@ -111,7 +111,7 @@ func openSigningBucket(ctx context.Context, js jetstream.JetStream, masterKey []
if len(masterKey) == 0 { if len(masterKey) == 0 {
return nil, errors.New("ecrauth: empty master key") 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 // 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 // load instead takes the gateway down on every node, permanently, over a key no
// live request needs. // 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) { 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) { if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil, nil, 0, fmt.Errorf("list signing keys: %w", err) return nil, nil, nil, 0, fmt.Errorf("list signing keys: %w", err)
} }
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"sync" "sync"
"time" "time"
"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/nats-io/nats.go/jetstream"
) )
@@ -179,7 +179,7 @@ func (s *KVMetaStore) bucket(ctx context.Context, accountID string) (jetstream.K
return kv, nil return kv, nil
} }
name := KVAccountBucket(accountID) name := KVAccountBucket(accountID)
kv, err := kvutil.GetOrCreateBucket(ctx, s.js, name, KVBucketAccountHistory) kv, err := kvstore.GetOrCreateBucket(ctx, s.js, name, KVBucketAccountHistory)
if err != nil { if err != nil {
return nil, fmt.Errorf("ecr: open account bucket %s: %w", name, err) return nil, fmt.Errorf("ecr: open account bucket %s: %w", name, err)
} }
+1 -1
View File
@@ -322,7 +322,7 @@ func TestReaper_Awsvpc_ReleasesENI(t *testing.T) {
rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout) rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec)) 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()) sc.reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
assert.Equal(t, 1, eni.releaseCalls) 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. // drained, services never relaunched, stopped tasks never pruned.
func TestSchedulerPasses_EnumerationFailureIsReported(t *testing.T) { func TestSchedulerPasses_EnumerationFailureIsReported(t *testing.T) {
svc, nc := newTestService(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) _, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
require.NoError(t, err) require.NoError(t, err)
@@ -49,7 +49,7 @@ func TestRegister_RestoresReapedInstanceToActive(t *testing.T) {
rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout) rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec)) 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") reaped := instanceStatus(t, svc, "web", "i-1")
require.Equal(t, InstanceStatusDraining, reaped.Status) require.Equal(t, InstanceStatusDraining, reaped.Status)
@@ -89,7 +89,7 @@ func TestReaper_ReleasesCapacityAndRecoversClean(t *testing.T) {
seeded.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout) seeded.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &seeded)) 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") reaped := instanceStatus(t, svc, "web", "i-1")
require.Equal(t, InstanceStatusDraining, reaped.Status) require.Equal(t, InstanceStatusDraining, reaped.Status)
+1 -1
View File
@@ -50,7 +50,7 @@ func schedulerRig(t *testing.T) (*Scheduler, *Service, jetstream.KeyValue) {
registerInstance(t, svc, "web", "i-1", 4096, 8192) registerInstance(t, svc, "web", "i-1", 4096, 8192)
kv, err := svc.bucket(t.Context(), testAccountID) kv, err := svc.bucket(t.Context(), testAccountID)
require.NoError(t, err) 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 // runOneTask places a task and returns its ID, read back from the record rather
+25 -45
View File
@@ -13,17 +13,19 @@ import (
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1" ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler" "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"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
) )
// Scheduler timing. The leader lease lives in spinifex-ecs-leader (60s TTL); the // KeyScheduler is the ECS scheduler's leader lease in the sync bucket.
// holder refreshes well inside that. The reaper marks an instance DRAINING after const KeyScheduler = "ecs.scheduler"
// three missed 30s heartbeats.
// Scheduler timing. A follower asks again at the lease's renewal rate. The
// reaper marks an instance DRAINING after three missed 30s heartbeats.
const ( const (
schedulerLeaderKey = "scheduler" schedulerLeaseTTL = 60 * time.Second
leaseRefresh = 20 * time.Second leaseRefresh = schedulerLeaseTTL / 3
reaperInterval = 30 * time.Second reaperInterval = 30 * time.Second
heartbeatTimeout = 90 * time.Second heartbeatTimeout = 90 * time.Second
stoppedReasonReaped = "ContainerInstance disconnected" stoppedReasonReaped = "ContainerInstance disconnected"
@@ -59,17 +61,14 @@ const (
reconcileResync = 5 * time.Minute reconcileResync = 5 * time.Minute
) )
// Scheduler is the per-daemon ECS control loop. A single leader (elected via the // Scheduler is the per-daemon ECS control loop. A single leader (elected on its
// shared leader bucket) owns the Layer-2 bus subscriptions and the heartbeat // lease) owns the Layer-2 bus subscriptions and the heartbeat
// reaper; losers idle and retry. Placement itself happens synchronously in the // reaper; losers idle and retry. Placement itself happens synchronously in the
// RunTask handler, so a brief leaderless gap never blocks RunTask. // RunTask handler, so a brief leaderless gap never blocks RunTask.
type Scheduler struct { type Scheduler struct {
nc *nats.Conn nc *nats.Conn
svc *Service svc *Service
holder string lease *kvsync.Lease
lease *kvlease.Lease
leaseErr error
// wake carries a bus message that changed something the service reconcile // wake carries a bus message that changed something the service reconcile
// reads. Fed by the task-state subscription and not by the heartbeat one: // reads. Fed by the task-state subscription and not by the heartbeat one:
@@ -89,47 +88,28 @@ type Scheduler struct {
subs []*nats.Subscription subs []*nats.Subscription
} }
// NewScheduler constructs a Scheduler. holder identifies this daemon in the lease. // NewSchedulerLease returns the ECS scheduler's leader lease for holder.
func NewScheduler(nc *nats.Conn, svc *Service, holder string) *Scheduler { func NewSchedulerLease(store kvsync.KVStore, holder string) *kvsync.Lease {
// One slot: it means "something the reconcile reads has changed", and a return kvsync.NewLease(store, KeyScheduler, holder, schedulerLeaseTTL)
// 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
} }
func (sc *Scheduler) leaderBucket(ctx context.Context) (jetstream.KeyValue, error) { // NewScheduler constructs a Scheduler that leads while it holds lease.
js, err := jetstream.New(sc.nc) func NewScheduler(nc *nats.Conn, svc *Service, lease *kvsync.Lease) *Scheduler {
if err != nil { // One slot: it means "something the reconcile reads has changed", and a
return nil, err // 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)}
return InitLeaderBucket(ctx, js)
} }
// Run drives the leadership + reaper loop until ctx is cancelled. It is intended // 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. // to run as a daemon-boot goroutine; panics are the caller's recover concern.
func (sc *Scheduler) Run(ctx context.Context) { 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{}) leaseDone := make(chan struct{})
go func() { go func() {
defer close(leaseDone) 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 // Two loops rather than four tickers, split by what wakes them. Services are
+110 -11
View File
@@ -6,7 +6,9 @@ package ecs
import ( import (
"context" "context"
"sync"
"testing" "testing"
"time"
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ecs" "github.com/aws/aws-sdk-go/service/ecs"
@@ -14,26 +16,67 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/mulgadc/spinifex/internal/testkit" "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 // TestScheduler_StandsDownWithoutIAM is the fleet outage this guards: a leader
// that cannot converge instance roles holds the lease, writes no sts:AssumeRole // that cannot converge instance roles holds the lease, writes no sts:AssumeRole
// grant, and every agent's credentials lapse an hour later. // grant, and every agent's credentials lapse an hour later.
func TestScheduler_StandsDownWithoutIAM(t *testing.T) { func TestScheduler_StandsDownWithoutIAM(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t) _, nc, _ := testutil.StartTestJetStream(t)
svc := NewService(nc, testRegion, "") svc := NewService(nc, testRegion, "")
sc := NewScheduler(nc, svc, "holder-a") sc := testScheduler(t, nc, svc, "holder-a")
require.NoError(t, sc.leaseErr)
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.False(t, sc.lease.Held())
assert.Zero(t, sc.subCount()) assert.Zero(t, sc.subCount())
// The key must be free, or a healthy node waits out the full TTL. // The key must be free, or a healthy node waits out the full TTL.
kv, err := InitLeaderBucket(t.Context(), js) requireLeaseKeyAbsent(t, nc, "a node that stood down left its key")
require.NoError(t, err)
_, err = kv.Get(t.Context(), schedulerLeaderKey)
require.Error(t, err)
} }
// TestConvergeInstanceRoles_RewritesProvisionedAccounts pins the pass that // TestConvergeInstanceRoles_RewritesProvisionedAccounts pins the pass that
@@ -49,7 +92,7 @@ func TestConvergeInstanceRoles_RewritesProvisionedAccounts(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
iam.RolePolicies[ecsInstanceRoleName] = `{"Version":"2012-10-17","Statement":[]}` 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())) require.NoError(t, sc.convergeInstanceRoles(t.Context()))
assert.Contains(t, iam.RolePolicies[ecsInstanceRoleName], "sts:AssumeRole") 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) _, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
require.NoError(t, err) require.NoError(t, err)
sc := NewScheduler(nc, svc, "holder-a") sc := testScheduler(t, nc, svc, "holder-a")
require.NoError(t, sc.convergeInstanceRoles(t.Context())) require.NoError(t, sc.convergeInstanceRoles(t.Context()))
assert.Zero(t, iam.CreateRoleCalls) 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. // caller that reaches the pass without going through leader election.
func TestConvergeInstanceRoles_NilIAMIsAnError(t *testing.T) { func TestConvergeInstanceRoles_NilIAMIsAnError(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(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()) err := sc.convergeInstanceRoles(t.Context())
require.Error(t, err) require.Error(t, err)
@@ -94,3 +137,59 @@ func TestECSInstanceRolePolicyDoc_GrantsAssumeRole(t *testing.T) {
assert.Contains(t, doc, `"ecs:*"`) assert.Contains(t, doc, `"ecs:*"`)
assert.Contains(t, doc, `"ecr:GetAuthorizationToken"`) 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")
}
+30 -25
View File
@@ -14,6 +14,7 @@ import (
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1" ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
"github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/internal/testkit"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" 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/nats-io/nats.go"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -571,7 +572,7 @@ func TestScheduler_ReapBucket_StopsStaleInstanceTasks(t *testing.T) {
rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout) rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec)) 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()) sc.reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
di, err := svc.DescribeContainerInstances(context.Background(), &ecs.DescribeContainerInstancesInput{ 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)) 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) { func TestScheduler_AcquireLease_SingleLeader(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t) _, nc, _ := testutil.StartTestJetStream(t)
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()}) svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
a := NewScheduler(nc, svc, "holder-a") a := testScheduler(t, nc, svc, "holder-a")
b := NewScheduler(nc, svc, "holder-b") b := testScheduler(t, nc, svc, "holder-b")
require.True(t, a.lease.TryAcquire(t.Context())) tok, err := a.lease.TryLock(t.Context())
assert.False(t, b.lease.TryAcquire(t.Context())) require.NoError(t, err)
assert.True(t, a.lease.TryAcquire(t.Context()), "a holder re-attempting keeps leadership") 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 // Leadership owns the Layer-2 bus subscriptions. This pins the wiring: gaining
// the lease subscribes, losing it unsubscribes — which is the regression this // the lease subscribes, losing it unsubscribes.
// conversion could introduce and the one thing kvlease cannot check itself.
func TestScheduler_LeadershipWiresBusSubscriptions(t *testing.T) { func TestScheduler_LeadershipWiresBusSubscriptions(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t) _, nc, _ := testutil.StartTestJetStream(t)
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()}) svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
sc := NewScheduler(nc, svc, "holder-a") sc := testScheduler(t, nc, svc, "holder-a")
require.NoError(t, sc.leaseErr)
require.Zero(t, sc.subCount(), "subscriptions must not exist before election") 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") 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") 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) { func TestScheduler_LoserHoldsNoSubscriptions(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t) _, nc, _ := testutil.StartTestJetStream(t)
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()}) svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
a := NewScheduler(nc, svc, "holder-a") a := testScheduler(t, nc, svc, "holder-a")
b := NewScheduler(nc, svc, "holder-b") b := testScheduler(t, nc, svc, "holder-b")
require.True(t, a.lease.TryAcquire(t.Context())) _, err := electOnce(t, a)
require.False(t, b.lease.TryAcquire(t.Context())) require.NoError(t, err)
_, err = b.lease.TryLock(t.Context())
require.ErrorAs(t, err, new(*kvsync.HeldError))
assert.Zero(t, b.subCount()) assert.Zero(t, b.subCount())
assert.Equal(t, 4, a.subCount()) assert.Equal(t, 4, a.subCount())
} }
func TestScheduler_RunReleasesLeaseOnShutdown(t *testing.T) { func TestScheduler_RunReleasesLeaseOnShutdown(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t) _, nc, _ := testutil.StartTestJetStream(t)
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()}) svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
sc := NewScheduler(nc, svc, "holder-a") sc := testScheduler(t, nc, svc, "holder-a")
require.NoError(t, sc.leaseErr)
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
done := make(chan struct{}) done := make(chan struct{})
@@ -643,10 +651,7 @@ func TestScheduler_RunReleasesLeaseOnShutdown(t *testing.T) {
cancel() cancel()
<-done <-done
kv, err := InitLeaderBucket(t.Context(), js) requireLeaseKeyAbsent(t, nc, "Run returned with the lease key still present")
require.NoError(t, err)
_, err = kv.Get(t.Context(), schedulerLeaderKey)
require.Error(t, err, "Run returned with the lease key still present")
} }
// --- GPU placement dimension (Epic C2) --- // --- GPU placement dimension (Epic C2) ---
+7 -26
View File
@@ -4,10 +4,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate" "github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
@@ -19,18 +17,11 @@ import (
// cluster, task-definition, task, service, and container-instance state. It is // cluster, task-definition, task, service, and container-instance state. It is
// created lazily on first cluster touch (no daemon-boot pre-creation) so accounts // 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). // 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 ( const (
KVBucketECSAccountPrefix = "ecs-account-" KVBucketECSAccountPrefix = "ecs-account-"
KVBucketECSAccountVersion = 1 KVBucketECSAccountVersion = 1
KVBucketECSAccountHistory = 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. // 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 return CapacityProvidersPrefix() + name
} }
// Store is the per-daemon ECS KV handle. Per-account and leader buckets are // Store is the per-daemon ECS KV handle. Per-account buckets are accessed via
// accessed via the package-level factories below. // the package-level factories below.
type Store struct { type Store struct {
nc *nats.Conn nc *nats.Conn
} }
// NewStore constructs a Store bound to the supplied NATS connection. It does not // NewStore constructs a Store bound to the supplied NATS connection. It does not
// touch JetStream — per-account buckets are created lazily by // touch JetStream — per-account buckets are created lazily by
// GetOrCreateAccountBucket and the leader bucket by InitLeaderBucket. // GetOrCreateAccountBucket.
func NewStore(nc *nats.Conn) (*Store, error) { func NewStore(nc *nats.Conn) (*Store, error) {
if nc == nil { if nc == nil {
return nil, errors.New("ecs store: nats connection is 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 { if err != nil {
return nil, fmt.Errorf("jetstream: %w", err) return nil, fmt.Errorf("jetstream: %w", err)
} }
names, err := kvutil.BucketNames(ctx, js) names, err := kvstore.BucketNames(ctx, js)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -176,7 +167,7 @@ func accountBuckets(ctx context.Context, nc *nats.Conn) ([]accountBucket, error)
// return the existing handle. // return the existing handle.
func GetOrCreateAccountBucket(ctx context.Context, js jetstream.JetStream, accountID string) (jetstream.KeyValue, error) { func GetOrCreateAccountBucket(ctx context.Context, js jetstream.JetStream, accountID string) (jetstream.KeyValue, error) {
bucket := AccountBucketName(accountID) bucket := AccountBucketName(accountID)
kv, err := kvutil.GetOrCreateBucket(ctx, js, bucket, KVBucketECSAccountHistory) kv, err := kvstore.GetOrCreateBucket(ctx, js, bucket, KVBucketECSAccountHistory)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create ECS per-account KV bucket %s: %w", bucket, err) 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 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,
})
}
-69
View File
@@ -1,13 +1,9 @@
package ecs package ecs
import ( import (
"encoding/json"
"strings"
"testing" "testing"
"time"
"github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/internal/testkit"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -46,71 +42,6 @@ func TestGetOrCreateAccountBucket_Idempotent(t *testing.T) {
assert.Equal(t, kv1.Bucket(), kv2.Bucket()) 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 // 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. // what the List* enumerations watch, so a drift here silently breaks listing.
func TestKeyPaths(t *testing.T) { func TestKeyPaths(t *testing.T) {
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"slices" "slices"
"strings" "strings"
"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/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 // casUpdate does a revision-checked read-modify-write. mutate returns true
// when a field changed. Returns ErrNotFound if absent. // 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) { 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 return mutate(r), nil
}) })
} }
+2 -2
View File
@@ -8,7 +8,7 @@ import (
"slices" "slices"
"strings" "strings"
"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/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 // casUpdate does a revision-checked read-modify-write. mutate returns true
// when a field changed. Returns ErrNotFound if absent. // 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) { 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 return mutate(r), nil
}) })
} }
@@ -14,6 +14,7 @@ import (
ekscluster "github.com/mulgadc/spinifex/spinifex/domains/eks/cluster" ekscluster "github.com/mulgadc/spinifex/spinifex/domains/eks/cluster"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats" 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/nats-io/nats.go"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -55,7 +56,7 @@ func TestEKSRequest_InternalAddonsWireBody(t *testing.T) {
ControlPlaneNodes: []ekscluster.ControlPlaneNode{{InstanceID: cpInstanceID}}, 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) require.NoError(t, err)
sub, err := nc.Subscribe("eks.ListStagedAddonManifests", func(m *nats.Msg) { sub, err := nc.Subscribe("eks.ListStagedAddonManifests", func(m *nats.Msg) {
acct := natsmsg.AccountIDFromMsg(m) acct := natsmsg.AccountIDFromMsg(m)
@@ -112,7 +113,7 @@ func TestEKSRequest_InternalWorkersWireBody(t *testing.T) {
require.NoError(t, ekscluster.PutNodegroupRecord(t.Context(), kv, rec)) 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) require.NoError(t, err)
sub, err := nc.Subscribe("eks.ListManagedWorkers", func(m *nats.Msg) { sub, err := nc.Subscribe("eks.ListManagedWorkers", func(m *nats.Msg) {
acct := natsmsg.AccountIDFromMsg(m) acct := natsmsg.AccountIDFromMsg(m)
@@ -16,8 +16,8 @@ import (
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1" ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler" "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/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry" "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
@@ -25,8 +25,7 @@ import (
) )
const ( 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 // 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 // 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. // 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. // ErrReconcilerLeaseLost is returned by Run when the leader lease is lost mid-loop.
var ErrReconcilerLeaseLost = errors.New("eks: reconciler lease lost") 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; // ErrReconcilerClusterDeleting is returned by Run when meta.Status is DELETING;
// DeleteCluster owns teardown so the reconciler exits. // DeleteCluster owns teardown so the reconciler exits.
var ErrReconcilerClusterDeleting = errors.New("eks: cluster deleting") var ErrReconcilerClusterDeleting = errors.New("eks: cluster deleting")
@@ -138,15 +144,12 @@ type HTTPDoer interface {
// ClusterReconciler drives one cluster's CREATING → ACTIVE → DELETING lifecycle // ClusterReconciler drives one cluster's CREATING → ACTIVE → DELETING lifecycle
// under a leader lease. One instance per (accountID, clusterName). // under a leader lease. One instance per (accountID, clusterName).
type ClusterReconciler struct { type ClusterReconciler struct {
leaderKV jetstream.KeyValue
acctKV jetstream.KeyValue acctKV jetstream.KeyValue
accountID string accountID string
clusterName string clusterName string
holderID string
healthURL string healthURL string
lease *kvlease.Lease lease *kvsync.Lease
leaseRefresh time.Duration
interval time.Duration interval time.Duration
healthTimeout time.Duration healthTimeout time.Duration
createTimeout 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 // 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 // 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. // 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 terminalMu sync.Mutex
terminal error terminal error
stopRun context.CancelFunc stopRun context.CancelFunc
@@ -226,11 +232,6 @@ func WithReconcileInterval(d time.Duration) ReconcilerOption {
return func(r *ClusterReconciler) { r.interval = d } 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. // WithHealthzTimeout overrides the per-probe HTTP timeout.
func WithHealthzTimeout(d time.Duration) ReconcilerOption { func WithHealthzTimeout(d time.Duration) ReconcilerOption {
return func(r *ClusterReconciler) { r.healthTimeout = d } 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 // NewClusterReconciler constructs a ClusterReconciler that leads while it holds
// fully-qualified probe target (e.g. https://nlb-dns:443/healthz) or empty // lease, the cluster's lease from ClusterLeases. healthURL is the probe target
// to skip /healthz probing (CREATING transitions on bootstrap state alone). // (e.g. https://nlb-dns:443/healthz) or empty to skip /healthz probing.
func NewClusterReconciler(leaderKV, acctKV jetstream.KeyValue, accountID, clusterName, holderID, healthURL string, opts ...ReconcilerOption) (*ClusterReconciler, error) { func NewClusterReconciler(lease *kvsync.Lease, acctKV jetstream.KeyValue, accountID, clusterName, healthURL string, opts ...ReconcilerOption) (*ClusterReconciler, error) {
if leaderKV == nil { if lease == nil {
return nil, errors.New("eks: NewClusterReconciler nil leaderKV") return nil, errors.New("eks: NewClusterReconciler nil lease")
} }
if acctKV == nil { if acctKV == nil {
return nil, errors.New("eks: NewClusterReconciler nil acctKV") return nil, errors.New("eks: NewClusterReconciler nil acctKV")
@@ -351,17 +352,12 @@ func NewClusterReconciler(leaderKV, acctKV jetstream.KeyValue, accountID, cluste
if clusterName == "" { if clusterName == "" {
return nil, errors.New("eks: NewClusterReconciler empty clusterName") return nil, errors.New("eks: NewClusterReconciler empty clusterName")
} }
if holderID == "" {
return nil, errors.New("eks: NewClusterReconciler empty holderID")
}
r := &ClusterReconciler{ r := &ClusterReconciler{
leaderKV: leaderKV,
acctKV: acctKV, acctKV: acctKV,
accountID: accountID, accountID: accountID,
clusterName: clusterName, clusterName: clusterName,
holderID: holderID,
healthURL: healthURL, healthURL: healthURL,
leaseRefresh: defaultReconcileLeaseRefresh, lease: lease,
interval: defaultReconcileInterval, interval: defaultReconcileInterval,
healthTimeout: defaultHealthzTimeout, healthTimeout: defaultHealthzTimeout,
createTimeout: defaultCreateTimeout, createTimeout: defaultCreateTimeout,
@@ -390,42 +386,23 @@ func NewClusterReconciler(leaderKV, acctKV jetstream.KeyValue, accountID, cluste
for _, o := range opts { for _, o := range opts {
o(r) 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 return r, nil
} }
func reconcilerLeaderKey(accountID, clusterName string) string { // AcquireLease takes the cluster's lease. Returns (release, true) on success;
return accountID + "/" + clusterName // (nil, false) when another holder, or another run in this process, has it.
}
// 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.
func (r *ClusterReconciler) AcquireLease(ctx context.Context) (func(), bool) { func (r *ClusterReconciler) AcquireLease(ctx context.Context) (func(), bool) {
if !r.lease.TryAcquire(ctx) { release, lost, ok := tryLease(ctx, r.lease)
return nil, false 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 // 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 { 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) runCtx, stop := context.WithCancel(ctx)
defer stop() defer stop()
r.stopRun = 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 // 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() { go func() {
select { select {
case <-runCtx.Done(): case <-runCtx.Done():
case <-r.lease.Lost(): case <-lost:
r.endRun(ErrReconcilerLeaseLost) r.endRun(ErrReconcilerLeaseLost)
} }
}() }()
@@ -21,14 +21,12 @@ func newStateReconcilerHarness(t *testing.T, opts ...ReconcilerOption) (*Cluster
t.Helper() t.Helper()
_, nc, _ := testutil.StartTestJetStream(t) _, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc) js := testutil.NewJetStream(t, nc)
leaderKV, err := InitLeaderBucket(t.Context(), js)
require.NoError(t, err)
acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, PutClusterMeta(t.Context(), acctKV, sampleClusterMeta("alpha"))) require.NoError(t, PutClusterMeta(t.Context(), acctKV, sampleClusterMeta("alpha")))
base := []ReconcilerOption{WithStateSource(nc, StateSubject(testAccountID, "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) require.NoError(t, err)
return r, nc, acctKV return r, nc, acctKV
} }
@@ -146,7 +144,6 @@ func TestClusterReconciler_StateReportSubscriptionDrivesActive(t *testing.T) {
t.Parallel() t.Parallel()
r, nc, acctKV := newStateReconcilerHarness(t, r, nc, acctKV := newStateReconcilerHarness(t,
WithReconcileInterval(10*time.Millisecond), WithReconcileInterval(10*time.Millisecond),
WithLeaseRefresh(10*time.Second),
) )
freshenClusterCreatedAt(t, acctKV) freshenClusterCreatedAt(t, acctKV)
seedBootstrapState(t, acctKV) seedBootstrapState(t, acctKV)
@@ -12,6 +12,8 @@ import (
"time" "time"
"github.com/mulgadc/spinifex/internal/testkit" "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/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -54,39 +56,47 @@ func onePass(t *testing.T, r *ClusterReconciler) error {
return err 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() t.Helper()
_, nc, _ := testutil.StartTestJetStream(t) _, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc) js := testutil.NewJetStream(t, nc)
leaderKV, err := InitLeaderBucket(t.Context(), js)
require.NoError(t, err)
acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, PutClusterMeta(t.Context(), acctKV, sampleClusterMeta("alpha"))) 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) 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) { func TestNewClusterReconciler_EmptyInputsRejected(t *testing.T) {
t.Parallel() t.Parallel()
_, nc, _ := testutil.StartTestJetStream(t) _, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc) js := testutil.NewJetStream(t, nc)
leaderKV, err := InitLeaderBucket(t.Context(), js)
require.NoError(t, err)
acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID) acctKV, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID)
require.NoError(t, err) 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) require.Error(t, err)
_, err = NewClusterReconciler(leaderKV, nil, testAccountID, "alpha", "h", "") _, err = NewClusterReconciler(lease, nil, testAccountID, "alpha", "")
require.Error(t, err) require.Error(t, err)
_, err = NewClusterReconciler(leaderKV, acctKV, "", "alpha", "h", "") _, err = NewClusterReconciler(lease, acctKV, "", "alpha", "")
require.Error(t, err) require.Error(t, err)
_, err = NewClusterReconciler(leaderKV, acctKV, testAccountID, "", "h", "") _, err = NewClusterReconciler(lease, acctKV, testAccountID, "", "")
require.Error(t, err)
_, err = NewClusterReconciler(leaderKV, acctKV, testAccountID, "alpha", "", "")
require.Error(t, err) require.Error(t, err)
} }
@@ -99,21 +109,21 @@ func TestClusterReconciler_AcquireLeaseFirstHolderWins(t *testing.T) {
require.NotNil(t, release) require.NotNil(t, release)
defer 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()) release2, ok2 := r.AcquireLease(t.Context())
assert.True(t, ok2) assert.False(t, ok2)
assert.NotNil(t, release2) assert.Nil(t, release2)
} }
func TestClusterReconciler_AcquireLeaseSecondHolderLoses(t *testing.T) { func TestClusterReconciler_AcquireLeaseSecondHolderLoses(t *testing.T) {
t.Parallel() t.Parallel()
r1, leaderKV, acctKV := newReconcilerHarness(t, "") r1, js, acctKV := newReconcilerHarness(t, "")
release, ok := r1.AcquireLease(t.Context()) release, ok := r1.AcquireLease(t.Context())
require.True(t, ok) require.True(t, ok)
defer release() 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) require.NoError(t, err)
release2, ok2 := r2.AcquireLease(t.Context()) release2, ok2 := r2.AcquireLease(t.Context())
assert.False(t, ok2) assert.False(t, ok2)
@@ -154,7 +164,6 @@ func TestClusterReconciler_CreatingTransitionsToActiveOnReadyAndHealthz(t *testi
"https://nlb.example/healthz", "https://nlb.example/healthz",
WithHTTPClient(stub), WithHTTPClient(stub),
WithReconcileInterval(10*time.Millisecond), WithReconcileInterval(10*time.Millisecond),
WithLeaseRefresh(10*time.Second),
) )
release, ok := r.AcquireLease(t.Context()) release, ok := r.AcquireLease(t.Context())
require.True(t, ok) require.True(t, ok)
@@ -193,7 +202,6 @@ func TestClusterReconciler_CreatingStaysWhenBootstrapMissing(t *testing.T) {
"https://nlb.example/healthz", "https://nlb.example/healthz",
WithHTTPClient(stub), WithHTTPClient(stub),
WithReconcileInterval(10*time.Millisecond), WithReconcileInterval(10*time.Millisecond),
WithLeaseRefresh(10*time.Second),
) )
release, ok := r.AcquireLease(t.Context()) release, ok := r.AcquireLease(t.Context())
require.True(t, ok) require.True(t, ok)
@@ -228,7 +236,6 @@ func TestClusterReconciler_CreatingStaysWhenJWKSUnverified(t *testing.T) {
"https://nlb.example/healthz", "https://nlb.example/healthz",
WithHTTPClient(stub), WithHTTPClient(stub),
WithReconcileInterval(10*time.Millisecond), WithReconcileInterval(10*time.Millisecond),
WithLeaseRefresh(10*time.Second),
) )
release, ok := r.AcquireLease(t.Context()) release, ok := r.AcquireLease(t.Context())
require.True(t, ok) require.True(t, ok)
@@ -262,7 +269,6 @@ func TestClusterReconciler_CreatingStaysWhenHealthzFails(t *testing.T) {
"https://nlb.example/healthz", "https://nlb.example/healthz",
WithHTTPClient(stub), WithHTTPClient(stub),
WithReconcileInterval(10*time.Millisecond), WithReconcileInterval(10*time.Millisecond),
WithLeaseRefresh(10*time.Second),
) )
release, ok := r.AcquireLease(t.Context()) release, ok := r.AcquireLease(t.Context())
require.True(t, ok) require.True(t, ok)
@@ -287,7 +293,6 @@ func TestClusterReconciler_CreatingTimesOutToFailed(t *testing.T) {
"https://nlb.example/healthz", "https://nlb.example/healthz",
WithHTTPClient(stub), WithHTTPClient(stub),
WithReconcileInterval(10*time.Millisecond), WithReconcileInterval(10*time.Millisecond),
WithLeaseRefresh(10*time.Second),
WithCreateTimeout(1*time.Millisecond), WithCreateTimeout(1*time.Millisecond),
) )
release, ok := r.AcquireLease(t.Context()) release, ok := r.AcquireLease(t.Context())
@@ -320,7 +325,6 @@ func TestClusterReconciler_CreatingWithinDeadlineStaysCreating(t *testing.T) {
"https://nlb.example/healthz", "https://nlb.example/healthz",
WithHTTPClient(stub), WithHTTPClient(stub),
WithReconcileInterval(10*time.Millisecond), WithReconcileInterval(10*time.Millisecond),
WithLeaseRefresh(10*time.Second),
WithCreateTimeout(time.Hour), WithCreateTimeout(time.Hour),
) )
release, ok := r.AcquireLease(t.Context()) release, ok := r.AcquireLease(t.Context())
@@ -345,7 +349,6 @@ func TestClusterReconciler_DeletingExitsLoop(t *testing.T) {
"https://nlb.example/healthz", "https://nlb.example/healthz",
WithHTTPClient(stub), WithHTTPClient(stub),
WithReconcileInterval(10*time.Millisecond), WithReconcileInterval(10*time.Millisecond),
WithLeaseRefresh(10*time.Second),
) )
release, ok := r.AcquireLease(t.Context()) release, ok := r.AcquireLease(t.Context())
require.True(t, ok) require.True(t, ok)
@@ -363,11 +366,10 @@ func TestClusterReconciler_DeletingExitsLoop(t *testing.T) {
func TestClusterReconciler_LostLeaseExitsLoop(t *testing.T) { func TestClusterReconciler_LostLeaseExitsLoop(t *testing.T) {
t.Parallel() t.Parallel()
stub := &stubHTTPDoer{status: http.StatusOK} stub := &stubHTTPDoer{status: http.StatusOK}
r, leaderKV, acctKV := newReconcilerHarness(t, r, js, acctKV := newReconcilerHarnessTTL(t, 300*time.Millisecond,
"https://nlb.example/healthz", "https://nlb.example/healthz",
WithHTTPClient(stub), WithHTTPClient(stub),
WithReconcileInterval(10*time.Second), WithReconcileInterval(10*time.Second),
WithLeaseRefresh(20*time.Millisecond),
) )
release, ok := r.AcquireLease(t.Context()) release, ok := r.AcquireLease(t.Context())
require.True(t, ok) require.True(t, ok)
@@ -381,16 +383,17 @@ func TestClusterReconciler_LostLeaseExitsLoop(t *testing.T) {
runErr := make(chan error, 1) runErr := make(chan error, 1)
go func() { runErr <- r.Run(ctx) }() go func() { runErr <- r.Run(ctx) }()
// Steal the lease in another holder's name. The renewal loses its CAS at the // Overwrite the key in another holder's name. The next renewal loses its
// stale revision, which closes Lost and exits Run. // CAS at the stale revision, which closes Lost and exits Run.
time.Sleep(40 * time.Millisecond) syncKV, err := kvstore.OpenSyncBucket(js).KV(t.Context())
_, err := leaderKV.Put(t.Context(), reconcilerLeaderKey(testAccountID, "alpha"), []byte("holder-2")) require.NoError(t, err)
_, err = syncKV.Put(t.Context(), KeyPrefixCluster+clusterLeaseName(testAccountID, "alpha"), []byte(`{"holder":"holder-2","generation":0}`))
require.NoError(t, err) require.NoError(t, err)
select { select {
case err := <-runErr: case err := <-runErr:
assert.ErrorIs(t, err, ErrReconcilerLeaseLost) 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") t.Fatal("Run did not exit after lease loss")
} }
} }
@@ -402,7 +405,6 @@ func TestClusterReconciler_ActiveProbesAndWarnsOnHealthzFail(t *testing.T) {
"https://nlb.example/healthz", "https://nlb.example/healthz",
WithHTTPClient(stub), WithHTTPClient(stub),
WithReconcileInterval(10*time.Millisecond), WithReconcileInterval(10*time.Millisecond),
WithLeaseRefresh(10*time.Second),
) )
release, ok := r.AcquireLease(t.Context()) release, ok := r.AcquireLease(t.Context())
require.True(t, ok) require.True(t, ok)
@@ -438,7 +440,6 @@ func TestClusterReconciler_ActiveHealthRecoversClearsIssue(t *testing.T) {
"https://nlb.example/healthz", "https://nlb.example/healthz",
WithHTTPClient(stub), WithHTTPClient(stub),
WithReconcileInterval(10*time.Millisecond), WithReconcileInterval(10*time.Millisecond),
WithLeaseRefresh(10*time.Second),
) )
release, ok := r.AcquireLease(t.Context()) release, ok := r.AcquireLease(t.Context())
require.True(t, ok) require.True(t, ok)
@@ -468,9 +469,111 @@ func TestClusterReconciler_ProbeHealthzEmptyURLNoop(t *testing.T) {
"", "",
WithHTTPClient(stub), WithHTTPClient(stub),
WithReconcileInterval(10*time.Millisecond), WithReconcileInterval(10*time.Millisecond),
WithLeaseRefresh(10*time.Second),
) )
require.NoError(t, r.probeHealthz(context.Background())) require.NoError(t, r.probeHealthz(context.Background()))
assert.EqualValues(t, 0, stub.calls.Load(), "no HTTP call when URL empty") 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" "strings"
"time" "time"
"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/nats-io/nats.go/jetstream"
) )
@@ -280,7 +280,7 @@ func casUpdateMeta(ctx context.Context, kv jetstream.KeyValue, name string, muta
if name == "" { if name == "" {
return errors.New("eks: casUpdateMeta empty 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, Attempts: maxClusterStateCASRetries,
NotFound: ErrClusterNotFound, NotFound: ErrClusterNotFound,
Exhausted: func(string, int) error { Exhausted: func(string, int) error {
@@ -435,7 +435,7 @@ func putIfGeneration(ctx context.Context, kv jetstream.KeyValue, meta *ClusterMe
if err == nil { if err == nil {
return nil return nil
} }
if !kvutil.IsRevisionConflict(err) { if !kvstore.IsRevisionConflict(err) {
return fmt.Errorf("kv write %s: %w", key, err) return fmt.Errorf("kv write %s: %w", key, err)
} }
} }
@@ -7,8 +7,7 @@ import (
"log/slog" "log/slog"
"time" "time"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry" telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
"github.com/nats-io/nats.go/jetstream" "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. // writer added refs to the record while it was tearing down.
const maxTeardownPasses = 5 const maxTeardownPasses = 5
// Teardown lease timing. TTL must match the leader bucket's TTL; vars for tests.
var (
teardownLeaseTTL = KVBucketEKSLeaderTTL
teardownLeaseRenew = defaultReconcileLeaseRefresh
)
var ( var (
errTeardownNotRecorded = errors.New("eks: cluster is not DELETING, teardown refused") errTeardownNotRecorded = errors.New("eks: cluster is not DELETING, teardown refused")
errTeardownLeaseLost = errors.New("eks: teardown lease lost") errTeardownLeaseLost = errors.New("eks: teardown lease lost")
@@ -35,45 +28,17 @@ var (
errLaunchInFlight = errors.New("eks: cluster create launch still running") 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. // teardownLease is a held teardown lease: lost closes if renewal loses the key.
type teardownLease struct { type teardownLease struct {
release func() release func()
lost <-chan struct{} lost <-chan struct{}
} }
// acquireTeardownLease takes the per-cluster teardown lease, renewing it on // acquireTeardownLease takes the per-cluster teardown lease, renewing it until
// ctx. The lease re-adopts a key its own holder already has, so it serialises // released. It refuses when another node holds it or another goroutine here does.
// nodes, not goroutines; startTeardown's in-process gate covers the latter. A
// nil leaderKV (single-node/test) skips gating.
func (s *EKSServiceImpl) acquireTeardownLease(ctx context.Context, accountID, clusterName string) (teardownLease, bool) { func (s *EKSServiceImpl) acquireTeardownLease(ctx context.Context, accountID, clusterName string) (teardownLease, bool) {
if s.leaderKV == nil { release, lost, ok := tryLease(ctx, s.leases.Get(teardownLeaseName(accountID, clusterName)))
return teardownLease{release: func() {}}, true return teardownLease{release: release, lost: lost}, ok
}
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
} }
// teardownPassTimeout bounds one converger run, so a step that hangs cannot // 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 var teardownPassTimeout = 15 * time.Minute
// holdTeardown claims the right to tear a cluster down on this node: the // 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 // in-process gate, which also bounds this node's teardowns, then the cross-node
// its own node holds, then the cross-node lease. It refuses, without waiting, // lease. It refuses, without waiting, when either is held, or, if capped, when
// when either is held, or, if capped, when the node is at teardownConcurrency. // the node is at teardownConcurrency.
func (s *EKSServiceImpl) holdTeardown(accountID, name string, capped bool) (release func(), lost <-chan struct{}, ok bool) { func (s *EKSServiceImpl) holdTeardown(accountID, name string, capped bool) (release func(retire bool), lost <-chan struct{}, ok bool) {
key := teardownLeaderKey(accountID, name) key := teardownLeaseName(accountID, name)
s.teardownMu.Lock() s.teardownMu.Lock()
if s.teardownRunning == nil { if s.teardownRunning == nil {
s.teardownRunning = map[string]struct{}{} s.teardownRunning = map[string]struct{}{}
@@ -110,7 +75,15 @@ func (s *EKSServiceImpl) holdTeardown(accountID, name string, capped bool) (rele
leave() leave()
return nil, nil, false 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 // startTeardown hands one DELETING cluster to a background converger and
@@ -122,7 +95,8 @@ func (s *EKSServiceImpl) startTeardown(accountID, name string) bool {
return false return false
} }
s.teardownWG.Go(func() { s.teardownWG.Go(func() {
defer release() deleted := false
defer func() { release(deleted) }()
ctx, cancel := context.WithTimeout(s.bgCtx, teardownPassTimeout) ctx, cancel := context.WithTimeout(s.bgCtx, teardownPassTimeout)
defer cancel() defer cancel()
started := time.Now() started := time.Now()
@@ -131,6 +105,7 @@ func (s *EKSServiceImpl) startTeardown(accountID, name string) bool {
"cluster", name, "account", accountID, "err", err) "cluster", name, "account", accountID, "err", err)
return return
} }
deleted = true
slog.Info("eks: cluster teardown complete", "cluster", name, "account", accountID, slog.Info("eks: cluster teardown complete", "cluster", name, "account", accountID,
"duration_ms", telemetry.Millis(time.Since(started))) "duration_ms", telemetry.Millis(time.Since(started)))
}) })
@@ -211,6 +186,9 @@ func (s *EKSServiceImpl) convergeClusterDeletion(ctx context.Context, accountID,
return terr 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) done, err := s.removeClusterRecord(ctx, acctKV, name, meta)
if err != nil { if err != nil {
if !errors.Is(err, errNodegroupsAttached) && !errors.Is(err, errTeardownNotRecorded) { 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") 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, Attempts: maxClusterStateCASRetries,
NotFound: ErrClusterNotFound, NotFound: ErrClusterNotFound,
}, func(m *ClusterMeta) error { }, func(m *ClusterMeta) error {
@@ -12,6 +12,8 @@ import (
"github.com/aws/aws-sdk-go/service/eks" "github.com/aws/aws-sdk-go/service/eks"
"github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/internal/testkit"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" 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/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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") 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 // TestS8_EKSTeardownLeaseOutlivesTheRequest: the teardown lease renews on its
// service's context, so cancelling the request context mid-teardown does not // own, so cancelling the request context mid-teardown does not let the lease
// let the lease expire and a second node start a duplicate teardown. // lapse and a second node start a duplicate teardown.
func TestS8_EKSTeardownLeaseOutlivesTheRequest(t *testing.T) { func TestS8_EKSTeardownLeaseOutlivesTheRequest(t *testing.T) {
f := newDeleteClusterFixture(t, "alpha") f := newDeleteClusterFixture(t, "alpha")
_, nc, _ := testutil.StartTestJetStream(t) // A short TTL keeps the wait brief while leaving several renewal chances
js := testutil.NewJetStream(t, nc) // inside each TTL window.
// A short TTL with frequent renewals keeps the wait brief while leaving
// several renewal chances inside each TTL window.
const ttl = 400 * time.Millisecond const ttl = 400 * time.Millisecond
short, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: "eks-teardown-short-ttl", TTL: ttl}) js := testutil.NewJetStream(t, f.svc.deps.NATSConn)
require.NoError(t, err) f.svc.leases = kvsync.NewLeaseGroup(kvstore.OpenSyncBucket(js), KeyPrefixCluster, "node-1", ttl)
f.svc.leaderKV = short other := kvsync.NewLeaseGroup(kvstore.OpenSyncBucket(js), KeyPrefixCluster, "node-2", ttl).
origTTL, origRenew := teardownLeaseTTL, teardownLeaseRenew Get(teardownLeaseName(testAccountID, "alpha"))
teardownLeaseTTL, teardownLeaseRenew = ttl, 50*time.Millisecond
t.Cleanup(func() { teardownLeaseTTL, teardownLeaseRenew = origTTL, origRenew })
entered, release := blockTerminate(t, f.inst) entered, release := blockTerminate(t, f.inst)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -155,19 +153,22 @@ func TestS8_EKSTeardownLeaseOutlivesTheRequest(t *testing.T) {
waitEntered(t, entered) waitEntered(t, entered)
cancel() 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) time.Sleep(ttl * 5 / 2)
_, err = short.Create(t.Context(), teardownLeaderKey(testAccountID, "alpha"), []byte("node-2")) _, err = other.TryLock(t.Context())
require.ErrorIs(t, err, jetstream.ErrKeyExists, require.ErrorAs(t, err, new(*kvsync.HeldError),
"the running teardown must still hold its lease after the request context is gone") "the running teardown must still hold its lease after the request context is gone")
release() release()
f.svc.WaitTeardowns() f.svc.WaitTeardowns()
} }
// TestS8_EKSKickAndSameNodeSweepRunOneTeardown: the teardown lease re-adopts a // TestS8_EKSKickAndSameNodeSweepRunOneTeardown: a same-node reaper sweep must
// key its own node holds, so only the in-process gate stops a same-node reaper // not start a second teardown beside the request's. Each member must be
// sweep from starting a second teardown beside the request's. Each member must // terminated exactly once.
// be terminated exactly once.
func TestS8_EKSKickAndSameNodeSweepRunOneTeardown(t *testing.T) { func TestS8_EKSKickAndSameNodeSweepRunOneTeardown(t *testing.T) {
t.Parallel() t.Parallel()
f := newDeleteClusterFixture(t, "alpha") f := newDeleteClusterFixture(t, "alpha")
@@ -180,7 +180,7 @@ func TestEKSBootstrapRunRemovesItsOwnEntryOnExit(t *testing.T) {
eventually(t, func() bool { eventually(t, func() bool {
f.svc.bootstrapMu.Lock() f.svc.bootstrapMu.Lock()
defer f.svc.bootstrapMu.Unlock() defer f.svc.bootstrapMu.Unlock()
_, ok := f.svc.bootstraps[reconcilerLeaderKey(testAccountID, "alpha")] _, ok := f.svc.bootstraps[clusterLeaseName(testAccountID, "alpha")]
return !ok return !ok
}, "an exited bootstrap must remove its own entry") }, "an exited bootstrap must remove its own entry")
} }
@@ -252,8 +252,7 @@ func TestS2_EKSFailedClusterReclaimRunsUnderTheTeardownLease(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
meta.Status = ClusterStatusFailed meta.Status = ClusterStatusFailed
require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta)) require.NoError(t, PutClusterMeta(t.Context(), f.kv, meta))
_, err = f.svc.leaderKV.Create(t.Context(), teardownLeaderKey(testAccountID, "alpha"), []byte("node-2")) holdElsewhere(t, f.svc, teardownLeaseName(testAccountID, "alpha"))
require.NoError(t, err)
_, err = f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "") _, err = f.svc.CreateCluster(context.Background(), createInput("alpha"), testAccountID, "")
code, _, _ := awserrors.ResolveErrorDetail(err) code, _, _ := awserrors.ResolveErrorDetail(err)
@@ -15,6 +15,8 @@ import (
"github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/systeminstance" "github.com/mulgadc/spinifex/spinifex/domains/ec2/systeminstance"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "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"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -83,6 +85,7 @@ func newEKSServiceFixture(t *testing.T) *eksServiceFixture {
GatewayBaseURL: "https://gw.local:9999", GatewayBaseURL: "https://gw.local:9999",
Region: "us-east-1", Region: "us-east-1",
HolderID: "node-1", HolderID: "node-1",
Leases: NewClusterLeases(kvstore.OpenSyncBucket(js), "node-1"),
SystemGatewayURL: "https://gw.local:9999", SystemGatewayURL: "https://gw.local:9999",
SystemAccessKey: "AKIAEXAMPLE", SystemAccessKey: "AKIAEXAMPLE",
SystemSecretKey: "s3cr3t-key", SystemSecretKey: "s3cr3t-key",
@@ -371,6 +374,33 @@ func TestDeleteCluster_EgressEIPReleaseRealErrorLeavesMeta(t *testing.T) {
assert.NotEmpty(t, meta.EgressEIPAllocationID, "alloc ID must remain for retry") 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 // TestDeleteClusterLeaseHeldElsewhereLeavesTeardownToTheHolder: when another
// node holds the teardown lease, DeleteCluster still records DELETING and // 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 // 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() t.Parallel()
f := newDeleteClusterFixture(t, "alpha") f := newDeleteClusterFixture(t, "alpha")
_, err := f.svc.leaderKV.Create(t.Context(), teardownLeaderKey(testAccountID, "alpha"), []byte("node-2")) holdElsewhere(t, f.svc, teardownLeaseName(testAccountID, "alpha"))
require.NoError(t, err)
deleteAndSettle(t, f.svc, "alpha") deleteAndSettle(t, f.svc, "alpha")
@@ -399,8 +428,73 @@ func TestDeleteClusterTeardownReleasesItsLease(t *testing.T) {
deleteAndSettle(t, f.svc, "alpha") deleteAndSettle(t, f.svc, "alpha")
_, getErr := f.svc.leaderKV.Get(t.Context(), teardownLeaderKey(testAccountID, "alpha")) assert.False(t, leaseKeyHeld(t, f.svc, teardownLeaseName(testAccountID, "alpha")),
assert.ErrorIs(t, getErr, jetstream.ErrKeyNotFound, "the teardown lease must be released after the teardown") "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) { func TestTeardownLeaseReleaseSurvivesCallerCancellation(t *testing.T) {
@@ -413,8 +507,7 @@ func TestTeardownLeaseReleaseSurvivesCallerCancellation(t *testing.T) {
cancel() cancel()
lease.release() lease.release()
_, err := f.svc.leaderKV.Get(t.Context(), teardownLeaderKey(testAccountID, "alpha")) assert.False(t, leaseKeyHeld(t, f.svc, teardownLeaseName(testAccountID, "alpha")))
require.ErrorIs(t, err, jetstream.ErrKeyNotFound)
} }
// TestDeleteCluster_ManagedCPVPCReclaimsCustomerVPCSGs guards the contract: in // TestDeleteCluster_ManagedCPVPCReclaimsCustomerVPCSGs guards the contract: in
@@ -62,8 +62,7 @@ func TestDeletingReaperLeavesAHeldTeardownAlone(t *testing.T) {
t.Parallel() t.Parallel()
f := newDeleteClusterFixture(t, "beta") f := newDeleteClusterFixture(t, "beta")
markDeleting(t, f, "beta", time.Second) markDeleting(t, f, "beta", time.Second)
_, err := f.svc.leaderKV.Create(t.Context(), teardownLeaderKey(testAccountID, "beta"), []byte("node-2")) holdElsewhere(t, f.svc, teardownLeaseName(testAccountID, "beta"))
require.NoError(t, err)
assert.Equal(t, 0, sweepAndSettle(t, f)) 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 // startNodegroupDelete runs one converger pass in the background and reports
// whether it started; a pass already running here or on another node refuses. // 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 { 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 { if !ok {
return false return false
} }
s.teardownWG.Go(func() { s.teardownWG.Go(func() {
defer release() deleted := false
defer func() { release(deleted) }()
ctx, cancel := context.WithTimeout(s.bgCtx, teardownPassTimeout) ctx, cancel := context.WithTimeout(s.bgCtx, teardownPassTimeout)
defer cancel() defer cancel()
if err := s.convergeNodegroupDeletion(ctx, accountID, kv, cluster, ng); err != nil { if err := s.convergeNodegroupDeletion(ctx, accountID, kv, cluster, ng); err != nil {
slog.Warn("eks: nodegroup delete incomplete, the record stays DELETING for retry", slog.Warn("eks: nodegroup delete incomplete, the record stays DELETING for retry",
"cluster", cluster, "nodegroup", ng, "account", accountID, "err", err) "cluster", cluster, "nodegroup", ng, "account", accountID, "err", err)
return
} }
deleted = true
}) })
return true return true
} }
+48
View File
@@ -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) requireErrorCode(t, err, awserrors.ErrorEKSResourceNotFound)
assert.NotContains(t, fixtureIAM(t, f).Profiles, rec.InstanceProfileName) 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" "errors"
"log/slog" "log/slog"
"sync" "sync"
"time"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
"github.com/nats-io/nats.go/jetstream" "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). // ReconcilerRegistry tracks active per-cluster reconciler goroutines; one entry per (accountID, clusterName).
type ReconcilerRegistry struct { type ReconcilerRegistry struct {
mu sync.Mutex mu sync.Mutex
@@ -64,9 +62,6 @@ func (r *ReconcilerRegistry) Spawn(parent context.Context, accountID, clusterNam
r.mu.Unlock() r.mu.Unlock()
cancel() cancel()
close(done) close(done)
if errors.Is(err, ErrLeaseHeld) {
return nil
}
return err return err
} }
@@ -151,33 +146,67 @@ func registryKey(accountID, clusterName string) string {
return accountID + "/" + clusterName return accountID + "/" + clusterName
} }
// RunClusterReconciler is the production SpawnReconcilerFn: constructs a ClusterReconciler, // RunClusterReconciler contends for the cluster's lease and drives Run while it
// acquires the lease, and drives Run in a goroutine. Returns ErrLeaseHeld if another node wins. // 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( func RunClusterReconciler(
ctx context.Context, ctx context.Context,
leaderKV, acctKV jetstream.KeyValue, lease *kvsync.Lease, acctKV jetstream.KeyValue,
accountID, clusterName, holderID, healthURL string, accountID, clusterName, healthURL string,
opts ...ReconcilerOption, opts ...ReconcilerOption,
) (func(), <-chan struct{}, error) { ) (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 { if err != nil {
return nil, nil, err return nil, nil, err
} }
release, ok := r.AcquireLease(ctx) runCtx, cancel := context.WithCancel(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.
finished := make(chan struct{}) finished := make(chan struct{})
go func() { go func() {
defer close(finished) defer close(finished)
if runErr := r.Run(ctx); runErr != nil && !errors.Is(runErr, context.Canceled) { for {
slog.Info("RunClusterReconciler: Run exited", if release, ok := r.AcquireLease(runCtx); ok {
"accountID", accountID, "cluster", clusterName, "err", runErr) 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
}
} }
@@ -63,34 +63,6 @@ func TestReconcilerRegistry_SpawnFnErrorRemovesEntry(t *testing.T) {
assert.False(t, reg.Has("111122223333", "alpha"), "entry must not linger after spawn failure") assert.False(t, reg.Has("111122223333", "alpha"), "entry must not linger after spawn failure")
} }
func TestReconcilerRegistry_LeaseHeldDropsEntryAndRetries(t *testing.T) {
t.Parallel()
reg := NewReconcilerRegistry()
var spawnCalls atomic.Int32
leaseHeld := atomic.Bool{}
leaseHeld.Store(true)
fn := func(ctx context.Context, _, _ string) (func(), <-chan struct{}, error) {
spawnCalls.Add(1)
if leaseHeld.Load() {
return nil, nil, ErrLeaseHeld
}
go func() { <-ctx.Done() }()
return func() {}, nil, nil
}
// Lease held elsewhere: Spawn is benign (nil), records no phantom holder.
require.NoError(t, reg.Spawn(t.Context(), "111122223333", "alpha", fn))
assert.False(t, reg.Has("111122223333", "alpha"), "lease-held must not leave a holder")
// Holder's TTL lapses; a later Spawn re-invokes fn and now takes over —
// no daemon restart needed.
leaseHeld.Store(false)
require.NoError(t, reg.Spawn(t.Context(), "111122223333", "alpha", fn))
assert.True(t, reg.Has("111122223333", "alpha"))
assert.EqualValues(t, 2, spawnCalls.Load(), "second Spawn must re-attempt after lease-held")
reg.StopAll()
}
// TestReconcilerRegistry_SelfExitDropsEntryAndRespawns covers a reconciler that // TestReconcilerRegistry_SelfExitDropsEntryAndRespawns covers a reconciler that
// exits on its own — a terminal cluster state or a lost lease — with no Stop() // exits on its own — a terminal cluster state or a lost lease — with no Stop()
// to cancel its ctx. The registry must drop the entry (and release the lease) so // to cancel its ctx. The registry must drop the entry (and release the lease) so
+15 -19
View File
@@ -33,7 +33,8 @@ import (
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging" "github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/idempotency" "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/mulgadc/spinifex/spinifex/foundation/state/kvsync"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore" "github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
@@ -55,6 +56,9 @@ type EKSServiceDeps struct {
Region string Region string
HolderID string HolderID string
// Leases is the per-cluster lease group, from NewClusterLeases.
Leases *kvsync.LeaseGroup
// InternalSuffix is the AWS-parity internal DNS suffix (e.g. spinifex.internal) // InternalSuffix is the AWS-parity internal DNS suffix (e.g. spinifex.internal)
// used to compose the worker's ECR registry host. // used to compose the worker's ECR registry host.
InternalSuffix string InternalSuffix string
@@ -262,7 +266,7 @@ type csiVolumeReclaimer interface {
// EKSServiceImpl is the daemon-side EKSService implementation. // EKSServiceImpl is the daemon-side EKSService implementation.
type EKSServiceImpl struct { type EKSServiceImpl struct {
deps EKSServiceDeps deps EKSServiceDeps
leaderKV jetstream.KeyValue leases *kvsync.LeaseGroup
registry *ReconcilerRegistry registry *ReconcilerRegistry
owner *access.Owner owner *access.Owner
@@ -340,7 +344,7 @@ const (
defaultLaunchStaleAfter = 90 * time.Second defaultLaunchStaleAfter = 90 * time.Second
) )
// NewEKSServiceImpl initialises EKSServiceImpl, wiring the leader KV and reconciler registry. // NewEKSServiceImpl initialises EKSServiceImpl, wiring the lease group and reconciler registry.
func NewEKSServiceImpl(deps EKSServiceDeps) (*EKSServiceImpl, error) { func NewEKSServiceImpl(deps EKSServiceDeps) (*EKSServiceImpl, error) {
if err := addon.ValidateCatalog(); err != nil { if err := addon.ValidateCatalog(); err != nil {
return nil, fmt.Errorf("eks: validate add-on catalog: %w", err) return nil, fmt.Errorf("eks: validate add-on catalog: %w", err)
@@ -348,21 +352,13 @@ func NewEKSServiceImpl(deps EKSServiceDeps) (*EKSServiceImpl, error) {
if deps.NATSConn == nil { if deps.NATSConn == nil {
return nil, errors.New("eks: NewEKSServiceImpl nil NATSConn") return nil, errors.New("eks: NewEKSServiceImpl nil NATSConn")
} }
js, err := jetstream.New(deps.NATSConn) if deps.Leases == nil {
if err != nil { return nil, errors.New("eks: NewEKSServiceImpl nil Leases")
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
} }
// The leader bucket outlives every request, so its open is bounded by the
// service's own background context rather than a caller's.
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
leaderKV, err := InitLeaderBucket(ctx, js)
if err != nil {
cancel()
return nil, err
}
return &EKSServiceImpl{ return &EKSServiceImpl{
deps: deps, deps: deps,
leaderKV: leaderKV, leases: deps.Leases,
registry: NewReconcilerRegistry(), registry: NewReconcilerRegistry(),
owner: access.New(deps.Region), owner: access.New(deps.Region),
bgCtx: ctx, bgCtx: ctx,
@@ -1384,7 +1380,7 @@ func (s *EKSServiceImpl) claimClusterName(ctx context.Context, accountID string,
if !held { if !held {
return errors.New(awserrors.ErrorEKSResourceInUse) return errors.New(awserrors.ErrorEKSResourceInUse)
} }
defer release() defer release(false)
} }
var oldRefs *ClusterMeta var oldRefs *ClusterMeta
reclaimed := false reclaimed := false
@@ -2138,7 +2134,7 @@ func (s *EKSServiceImpl) casUpdateNodegroupTags(ctx context.Context, accountID,
if err != nil { if err != nil {
return err return err
} }
_, err = kvutil.Update(ctx, acctKV, NodegroupKey(cluster, ng), kvutil.CASConfig{ _, err = kvstore.Update(ctx, acctKV, NodegroupKey(cluster, ng), kvstore.CASConfig{
Attempts: ngCASMaxRetries, Attempts: ngCASMaxRetries,
NotFound: errors.New(awserrors.ErrorEKSResourceNotFound), NotFound: errors.New(awserrors.ErrorEKSResourceNotFound),
Exhausted: func(string, int) error { return errors.New(awserrors.ErrorServerInternal) }, Exhausted: func(string, int) error { return errors.New(awserrors.ErrorServerInternal) },
@@ -2368,7 +2364,7 @@ func (s *EKSServiceImpl) spawnBootstrap(accountID, clusterName string, kv jetstr
return return
} }
ctx, cancel := context.WithCancel(s.bgCtx) ctx, cancel := context.WithCancel(s.bgCtx)
key := reconcilerLeaderKey(accountID, clusterName) key := clusterLeaseName(accountID, clusterName)
entry := &bootstrapRun{cancel: cancel} entry := &bootstrapRun{cancel: cancel}
s.bootstrapMu.Lock() s.bootstrapMu.Lock()
if s.bootstraps == nil { if s.bootstraps == nil {
@@ -2413,7 +2409,7 @@ type bootstrapRun struct {
// stopBootstrap cancels this process's bootstrap subscriber for a cluster, so // stopBootstrap cancels this process's bootstrap subscriber for a cluster, so
// a cluster being torn down stops collecting artifacts into its prefix. // a cluster being torn down stops collecting artifacts into its prefix.
func (s *EKSServiceImpl) stopBootstrap(accountID, clusterName string) { func (s *EKSServiceImpl) stopBootstrap(accountID, clusterName string) {
key := reconcilerLeaderKey(accountID, clusterName) key := clusterLeaseName(accountID, clusterName)
s.bootstrapMu.Lock() s.bootstrapMu.Lock()
run := s.bootstraps[key] run := s.bootstraps[key]
delete(s.bootstraps, key) delete(s.bootstraps, key)
@@ -2454,7 +2450,7 @@ func (s *EKSServiceImpl) spawnReconciler(accountID, clusterName string, _ *Clust
opts = append(opts, WithEtcdResetRecovery(0, 0, 0)) opts = append(opts, WithEtcdResetRecovery(0, 0, 0))
} }
spawn := func(ctx context.Context, _, _ string) (func(), <-chan struct{}, error) { spawn := func(ctx context.Context, _, _ string) (func(), <-chan struct{}, error) {
return RunClusterReconciler(ctx, s.leaderKV, acctKV, accountID, clusterName, s.deps.HolderID, "", opts...) return RunClusterReconciler(ctx, s.leases.Get(clusterLeaseName(accountID, clusterName)), acctKV, accountID, clusterName, "", opts...)
} }
if err := s.registry.Spawn(s.bgCtx, accountID, clusterName, spawn); err != nil { if err := s.registry.Spawn(s.bgCtx, accountID, clusterName, spawn); err != nil {
slog.Error("spawnReconciler: registry spawn", "cluster", clusterName, "err", err) slog.Error("spawnReconciler: registry spawn", "cluster", clusterName, "err", err)
@@ -12,6 +12,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/domains/eks/access" "github.com/mulgadc/spinifex/spinifex/domains/eks/access"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam" iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors" "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -20,12 +21,20 @@ import (
func setupTestService(t *testing.T) *EKSServiceImpl { func setupTestService(t *testing.T) *EKSServiceImpl {
t.Helper() t.Helper()
_, nc, _ := testutil.StartTestJetStream(t) _, nc, js := testutil.StartTestJetStream(t)
svc, err := NewEKSServiceImpl(EKSServiceDeps{NATSConn: nc}) svc, err := NewEKSServiceImpl(EKSServiceDeps{NATSConn: nc, Leases: NewClusterLeases(kvstore.OpenSyncBucket(js), "test")})
require.NoError(t, err) require.NoError(t, err)
return svc return svc
} }
// A service without the daemon's lease group would contend on keys no other
// owner sees, so construction refuses it.
func TestNewEKSServiceImpl_RequiresLeases(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
_, err := NewEKSServiceImpl(EKSServiceDeps{NATSConn: nc})
require.ErrorContains(t, err, "nil Leases")
}
// TestEKSServiceImpl_ClusterLifecycleShimMode covers the four lifecycle // TestEKSServiceImpl_ClusterLifecycleShimMode covers the four lifecycle
// methods when the service is constructed with only NATS wiring (the path the // methods when the service is constructed with only NATS wiring (the path the
// daemon-handler routing test uses): orchestration deps // daemon-handler routing test uses): orchestration deps
+5 -26
View File
@@ -5,11 +5,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate" "github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
@@ -20,10 +17,6 @@ import (
// Per-account bucket "eks-account-{accountID}" holds all customer-visible // Per-account bucket "eks-account-{accountID}" holds all customer-visible
// cluster state. It is created lazily on first cluster touch (no daemon-boot // cluster state. It is created lazily on first cluster touch (no daemon-boot
// pre-creation) so accounts without any EKS clusters never grow a bucket. // pre-creation) so accounts without any EKS clusters never grow a bucket.
//
// Shared leader bucket "spinifex-eks-leader" holds 60s-TTL CAS locks
// keyed by "{accountID}/{clusterName}" for the per-cluster reconciler
// leader election. Created once at daemon boot.
const ( const (
KVBucketEKSAccountPrefix = "eks-account-" KVBucketEKSAccountPrefix = "eks-account-"
KVBucketEKSAccountVersion = 1 KVBucketEKSAccountVersion = 1
@@ -34,10 +27,6 @@ const (
// survives a DeleteCluster. Keep this decoupled from the schema version so a // survives a DeleteCluster. Keep this decoupled from the schema version so a
// future version bump can't silently widen history and resurrect ciphertext. // future version bump can't silently widen history and resurrect ciphertext.
KVBucketEKSAccountHistory = 1 KVBucketEKSAccountHistory = 1
KVBucketEKSLeader = "spinifex-eks-leader"
KVBucketEKSLeaderVersion = 1
KVBucketEKSLeaderTTL = 60 * time.Second
) )
// Key-path helpers for the per-account bucket. The layout matches the // Key-path helpers for the per-account bucket. The layout matches the
@@ -124,15 +113,15 @@ func RecoveryDirectiveKey(cluster, instanceID string) string {
return RecoveryPrefix(cluster) + instanceID return RecoveryPrefix(cluster) + instanceID
} }
// Store is the per-daemon EKS KV handle. Per-account and leader buckets are // Store is the per-daemon EKS KV handle. Per-account buckets are accessed via
// accessed via the package-level factories below. // the package-level factories below.
type Store struct { type Store struct {
nc *nats.Conn nc *nats.Conn
} }
// NewStore constructs a Store bound to the supplied NATS connection. It does // NewStore constructs a Store bound to the supplied NATS connection. It does
// not touch JetStream — per-account buckets are created lazily by // 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) { func NewStore(nc *nats.Conn) (*Store, error) {
if nc == nil { if nc == nil {
return nil, errors.New("eks store: nats connection is nil") return nil, errors.New("eks store: nats connection is nil")
@@ -157,7 +146,7 @@ func accountBucketNames(ctx context.Context, nc *nats.Conn) ([]string, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("jetstream: %w", err) return nil, fmt.Errorf("jetstream: %w", err)
} }
all, err := kvutil.BucketNames(ctx, js) all, err := kvstore.BucketNames(ctx, js)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -196,7 +185,7 @@ func AccountWatchBuckets(ctx context.Context, nc *nats.Conn) ([]*kvstore.Bucket,
// existing handle. // existing handle.
func GetOrCreateAccountBucket(ctx context.Context, js jetstream.JetStream, accountID string) (jetstream.KeyValue, error) { func GetOrCreateAccountBucket(ctx context.Context, js jetstream.JetStream, accountID string) (jetstream.KeyValue, error) {
bucket := AccountBucketName(accountID) bucket := AccountBucketName(accountID)
kv, err := kvutil.GetOrCreateBucket(ctx, js, bucket, KVBucketEKSAccountHistory) kv, err := kvstore.GetOrCreateBucket(ctx, js, bucket, KVBucketEKSAccountHistory)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create EKS per-account KV bucket %s: %w", bucket, err) return nil, fmt.Errorf("failed to create EKS per-account KV bucket %s: %w", bucket, err)
} }
@@ -205,13 +194,3 @@ func GetOrCreateAccountBucket(ctx context.Context, js jetstream.JetStream, accou
} }
return kv, nil return kv, nil
} }
// InitLeaderBucket creates (or attaches to) the shared spinifex-eks-leader
// bucket used for per-cluster reconciler leader-lease CAS locks.
func InitLeaderBucket(ctx context.Context, js jetstream.JetStream) (jetstream.KeyValue, error) {
return kvlease.OpenBucket(ctx, js, kvlease.BucketConfig{
Name: KVBucketEKSLeader,
TTL: KVBucketEKSLeaderTTL,
Version: KVBucketEKSLeaderVersion,
})
}
@@ -49,40 +49,6 @@ func TestGetOrCreateAccountBucket_ReplicasClamped(t *testing.T) {
assert.Equal(t, 1, si.Config.Replicas) assert.Equal(t, 1, si.Config.Replicas)
} }
func TestInitLeaderBucket_Idempotent(t *testing.T) {
t.Parallel()
_, 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, KVBucketEKSLeader, kv1.Bucket())
kv2, err := InitLeaderBucket(t.Context(), js)
require.NoError(t, err)
require.NotNil(t, kv2)
assert.Equal(t, KVBucketEKSLeader, kv2.Bucket())
}
// TestInitLeaderBucket_ReplicasClamped confirms the leader bucket is created
// with the requested replica count, clamped to a minimum of 1.
func TestInitLeaderBucket_ReplicasClamped(t *testing.T) {
t.Parallel()
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
kv, err := InitLeaderBucket(t.Context(), js)
require.NoError(t, err)
require.NotNil(t, kv)
stream, err := js.Stream(t.Context(), "KV_"+KVBucketEKSLeader)
require.NoError(t, err)
si, err := stream.Info(t.Context())
require.NoError(t, err)
assert.Equal(t, 1, si.Config.Replicas)
}
func TestKeyPaths_MatchQ2Spec(t *testing.T) { func TestKeyPaths_MatchQ2Spec(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -6,7 +6,7 @@ import (
"github.com/mulgadc/spinifex/internal/testkit" "github.com/mulgadc/spinifex/internal/testkit"
ekscluster "github.com/mulgadc/spinifex/spinifex/domains/eks/cluster" ekscluster "github.com/mulgadc/spinifex/spinifex/domains/eks/cluster"
"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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -23,7 +23,7 @@ func TestAccountWatchBuckets_ReturnsOnePerAccountBucket(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
_, err = ekscluster.GetOrCreateAccountBucket(t.Context(), js, "222222222222") _, err = ekscluster.GetOrCreateAccountBucket(t.Context(), js, "222222222222")
require.NoError(t, err) require.NoError(t, err)
_, err = kvutil.GetOrCreateBucket(t.Context(), js, "something-else", 1) _, err = kvstore.GetOrCreateBucket(t.Context(), js, "something-else", 1)
require.NoError(t, err) require.NoError(t, err)
buckets, err := ekscluster.AccountWatchBuckets(t.Context(), nc) buckets, err := ekscluster.AccountWatchBuckets(t.Context(), nc)
+5 -6
View File
@@ -11,7 +11,6 @@ import (
"time" "time"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore" "github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate" "github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
@@ -35,7 +34,7 @@ const (
maxLBNameClaimRetries = 5 maxLBNameClaimRetries = 5
// targetGroupCASAttempts bounds a target-group read-modify-write. It sits // targetGroupCASAttempts bounds a target-group read-modify-write. It sits
// above the kvutil default because Terraform registers targets at its own // above the kvstore default because Terraform registers targets at its own
// parallelism (10 by default), every one contending for the same record. // parallelism (10 by default), every one contending for the same record.
targetGroupCASAttempts = 12 targetGroupCASAttempts = 12
@@ -90,7 +89,7 @@ func NewStore(ctx context.Context, nc *nats.Conn) (*Store, error) {
return nil, fmt.Errorf("failed to get JetStream context: %w", err) return nil, fmt.Errorf("failed to get JetStream context: %w", err)
} }
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketELBv2, 1) kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketELBv2, 1)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketELBv2, err) return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketELBv2, err)
} }
@@ -189,7 +188,7 @@ var ErrLoadBalancerNotFound = errors.New("elbv2: load balancer not found")
// concurrency: a writer that loses the race re-reads and retries instead of // concurrency: a writer that loses the race re-reads and retries instead of
// overwriting the winner. mutate returns false to commit nothing. // overwriting the winner. mutate returns false to commit nothing.
func (s *Store) UpdateLoadBalancer(ctx context.Context, lbID string, mutate func(*LoadBalancerRecord) (bool, error)) (*LoadBalancerRecord, error) { func (s *Store) UpdateLoadBalancer(ctx context.Context, lbID string, mutate func(*LoadBalancerRecord) (bool, error)) (*LoadBalancerRecord, error) {
return kvutil.Update(ctx, s.kv, KeyPrefixLB+lbID, kvutil.CASConfig{ return kvstore.Update(ctx, s.kv, KeyPrefixLB+lbID, kvstore.CASConfig{
Attempts: targetGroupCASAttempts, Attempts: targetGroupCASAttempts,
NotFound: ErrLoadBalancerNotFound, NotFound: ErrLoadBalancerNotFound,
Exhausted: func(_ string, attempts int) error { Exhausted: func(_ string, attempts int) error {
@@ -294,7 +293,7 @@ var ErrTargetGroupNotFound = errors.New("elbv2: target group not found")
// concurrency: a writer that loses the race re-reads and retries instead of // concurrency: a writer that loses the race re-reads and retries instead of
// overwriting the winner. mutate returns false to commit nothing. // overwriting the winner. mutate returns false to commit nothing.
func (s *Store) UpdateTargetGroup(ctx context.Context, tgID string, mutate func(*TargetGroupRecord) (bool, error)) (*TargetGroupRecord, error) { func (s *Store) UpdateTargetGroup(ctx context.Context, tgID string, mutate func(*TargetGroupRecord) (bool, error)) (*TargetGroupRecord, error) {
return kvutil.Update(ctx, s.kv, KeyPrefixTG+tgID, kvutil.CASConfig{ return kvstore.Update(ctx, s.kv, KeyPrefixTG+tgID, kvstore.CASConfig{
Attempts: targetGroupCASAttempts, Attempts: targetGroupCASAttempts,
NotFound: ErrTargetGroupNotFound, NotFound: ErrTargetGroupNotFound,
Exhausted: func(_ string, attempts int) error { Exhausted: func(_ string, attempts int) error {
@@ -652,7 +651,7 @@ func listByPrefixOpt[T any](ctx context.Context, kv jetstream.KeyValue, prefix s
var result []*T var result []*T
for _, key := range keys { for _, key := range keys {
if key == kvutil.VersionKey { if key == kvstore.VersionKey {
continue continue
} }
if !strings.HasPrefix(key, prefix) { if !strings.HasPrefix(key, prefix) {
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"fmt" "fmt"
"time" "time"
"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/nats-io/nats.go/jetstream"
) )
@@ -41,7 +41,7 @@ type AccountNameIndex struct {
// NewAccountNameIndex opens (or creates) the reservation bucket at the // NewAccountNameIndex opens (or creates) the reservation bucket at the
// cluster's replica count. // cluster's replica count.
func NewAccountNameIndex(ctx context.Context, js jetstream.JetStream) (*AccountNameIndex, error) { func NewAccountNameIndex(ctx context.Context, js jetstream.JetStream) (*AccountNameIndex, error) {
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketAccountNames, 5) kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketAccountNames, 5)
if err != nil { if err != nil {
return nil, fmt.Errorf("init account names bucket: %w", err) return nil, fmt.Errorf("init account names bucket: %w", err)
} }

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