Compare commits

..

31 Commits

Author SHA1 Message Date
brynmailer-mdc 0425d32c9e docs: name the missing store record among the UEFI launch refusals 2026-11-05 18:03:07 +10:00
brynmailer-mdc 785945c367 fix: release UEFI claims over clean copies and recover lost writers
The write-back reaper unlocks a claim this node holds over a stopped
instance's clean copy, so a failed unlock at stop or a restart that left no
writer no longer blocks starts elsewhere. It also adopts a running guest
whose reconnect could not start a writer. A launch that fails after QEMU
started settles its store as a stop does once that QEMU has exited.
2026-11-05 17:23:41 +10:00
brynmailer-mdc 0d0c83964f fix: never mark a store deleting that a refused seed did not create
A seed refused because a store already exists under the instance's ID left
RunInstances' failure path to mark that store deleting. The instance now
records that its seed has not created the store, and terminate leaves such a
store alone.
2026-11-05 17:23:41 +10:00
brynmailer-mdc 5aed9c7e29 chore: separate embedded fields in the UEFI write-back test fakes 2026-11-05 17:23:40 +10:00
brynmailer-mdc 5be80f13c4 fix: decide every UEFI write-back skip from vars.fd and leader-read records
A stop, a drop and the reaper compared only the events the writer had
consumed, so a final guest write still queued at the stop was left behind an
unlocked claim. seeded.json and the writer now carry the SHA-256 of the
region the pointer holds, and each skip compares vars.fd with it. A writer
that cannot watch vars.fd polls it every 10 s instead of waiting for the stop.

An owed copy is dropped only on proof another node owns the store: a claim
at a later generation, or a fenced or deleting record. MovePointer and the
prune read the record from the stream leader, and an older claim generation
is a lagging read that keeps the copy.
2026-11-05 17:23:40 +10:00
brynmailer-mdc 9e6b781dc2 feat: write a UEFI instance's variable store back on write and at stop
A running UEFI guest's vars.fd is written back as a new object and the store
record's pointer moved under the instance's claim generation. The stop writes
back between QEMU's exit and the unmount and then unlocks the claim; a failed
write-back keeps the claim and marks seeded.json owed for the write-back
reaper, which retries it, or drops it when another node owns the store.
Superseded objects are pruned, keeping the pointed object and anything under
10 minutes old.
2026-11-05 17:23:40 +10:00
brynmailer-mdc 156d674ec0 feat: seed a UEFI instance's variable store at RunInstances instead of an EFI volume
RunInstances no longer creates a <root>-efi volume or appends an EFI
request. The launching node takes the instance's absent store claim,
creates the store record live at the claim's generation, puts its VARS
template gzip-compressed at uefi/<instance-id>/<uuid> with source=template,
and moves the pointer to it by the fenced write. It keeps the region as
vars.fd beside seeded.json, so the launch boots it without a get.

Seeding refuses an ID whose store record or claim already exists and
changes neither. A failed RunInstances terminates the instance, which
sets its store record to deleting and now releases the claim this node
holds, so the store reaper removes the store.

The EFI request paths are gone from buildDrives and the launch's in-use
marking; a UEFI instance that still carries an EFI request is refused
before anything changes.
2026-11-05 17:23:01 +10:00
brynmailer-mdc c826cbd158 fix: confirm a stale or absent UEFI holder heartbeat with the stream leader before takeover 2026-11-05 17:22:21 +10:00
brynmailer-mdc 82b3b55d70 fix: check UEFI firmware before the claim and never overwrite a copy the claim guards
A start checks the pointer's firmware before it touches the claim, reuses
vars.fd whenever seeded.json names the claim's generation, and refuses as
retryable, keeping the file and the claim, when seeded.json cannot be read.
A rollback releases the claim only when QEMU never started and no local
copy may owe a write-back. An EFI-volume instance reads no store record,
and a pointer move to the current key is still confirmed by CAS.
2026-11-05 17:22:20 +10:00
brynmailer-mdc c864bfbc84 fix: keep a UEFI claim guarding a local copy and refuse on an undated heartbeat
A launch that fails after adopting its claim keeps it while seeded.json
names the claim's generation, or cannot be read. A heartbeat that exists but
cannot be dated refuses the takeover. A stamp at the record's own
generation is written by CAS, so a follower's stale pointer is never booted.
2026-11-05 17:22:20 +10:00
brynmailer-mdc c9c6dae176 fix: refuse a start whose UEFI store a live node holds as InsufficientInstanceCapacity 2026-11-05 17:22:20 +10:00
brynmailer-mdc c99ff34d53 feat: claim the UEFI variable store and fence its pointer by generation
The store record now carries the boot pointer and the claim generation that
may move it. A launch claims the store through kvsync, takes it over only once
the holder's heartbeat is LivenessWindow old, stamps the record with its
generation, and boots from the pointer it read after the stamp. A record above
the claim's generation refuses the start as retryable and changes nothing.
The store reaper removes the claim before it closes the record.
2026-11-05 17:22:20 +10:00
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
327 changed files with 10463 additions and 7120 deletions
+2 -3
View File
@@ -90,7 +90,6 @@ go_build:
LAMBDA_TESTLINK_DIR := ./bin/lambda-testlink
LAMBDA_TESTLINK_PKG := github.com/mulgadc/spinifex/spinifex/runtime/daemon
LAMBDA_TESTLINK_PIN := $(LAMBDA_TESTLINK_PKG).lambdaHardKillSHA256
LAMBDA_TESTLINK_GW := github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw
LAMBDA_DIGEST_RE := ^[0-9a-f]{64}$$
build-lambda-testlink: override export LAMBDA_IMAGE_DIGEST := $(value LAMBDA_IMAGE_DIGEST)
build-lambda-testlink:
@@ -105,8 +104,8 @@ build-lambda-testlink:
@digest=$$(sha256sum $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so | cut -d' ' -f1); \
test $${#digest} -eq 64 || { echo "initializer digest missing"; exit 1; }; \
echo "initializer sha256 $$digest"; \
pin="-X $(LAMBDA_TESTLINK_PIN)=$$digest -X $(LAMBDA_TESTLINK_PKG).lambdaImageDigest=$$LAMBDA_IMAGE_DIGEST -X $(LAMBDA_TESTLINK_GW).lambdaImageDigest=$$LAMBDA_IMAGE_DIGEST"; \
LAMBDA_TESTLINK_INITIALIZER=$(abspath $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so) go test -count=1 -tags lambdatestlink -ldflags "$$pin" -run '^TestLambda(HardKillPin|ImageDigestPin)$$' ./spinifex/runtime/daemon/ ./spinifex/runtime/roles/awsgw/ && \
pin="-X $(LAMBDA_TESTLINK_PIN)=$$digest -X $(LAMBDA_TESTLINK_PKG).lambdaImageDigest=$$LAMBDA_IMAGE_DIGEST"; \
LAMBDA_TESTLINK_INITIALIZER=$(abspath $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so) go test -count=1 -tags lambdatestlink -ldflags "$$pin" -run '^TestLambda(HardKillPin|ImageDigestPin)$$' ./spinifex/runtime/daemon/ && \
go build $(GO_BUILD_MOD) -tags lambdatestlink -ldflags "$(subst -s -w ,,$(LDFLAGS)) $$pin" -o $(LAMBDA_TESTLINK_DIR).tmp/$(GO_PROJECT_NAME) cmd/spinifex/main.go
@mkdir -p $(LAMBDA_TESTLINK_DIR)
mv $(LAMBDA_TESTLINK_DIR).tmp/hardkill.so $(LAMBDA_TESTLINK_DIR).tmp/$(GO_PROJECT_NAME) $(LAMBDA_TESTLINK_DIR)/
+1
View File
@@ -17,6 +17,7 @@ var allowlist = []allowance{
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/instance", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/routetable", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/snapshot", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/volume", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "operator/accountlifecycle", To: "domains/ec2/awsapi/vpc", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "agents/ecs/agent", To: "domains/ecs", Debt: "agents/ecs/agent", Reason: "assignment/state wire types from the ECS implementation; needs a guest/controller contract"},
+25 -1
View File
@@ -1,6 +1,9 @@
package clusterv1
import "time"
import (
"encoding/json"
"time"
)
// ClusterStateBucket is the KV bucket holding each daemon's heartbeat alongside
// other cluster-wide state.
@@ -11,6 +14,27 @@ func HeartbeatKey(node string) string {
return "heartbeat." + node
}
// LivenessWindow is how recently a node must have heartbeated for a reader
// deciding on a takeover to count it live. It is six heartbeats, compared
// across node clocks, where NTP skew is small against it.
const LivenessWindow = 60 * time.Second
// LiveAt reports whether a heartbeat written at written still vouches for its
// node at now: it does while it is under LivenessWindow old.
func LiveAt(written, now time.Time) bool {
return now.Sub(written) < LivenessWindow
}
// HeartbeatWritten dates a stored heartbeat value. One that cannot be decoded
// or dated vouches for nothing, which is an answer rather than a fault.
func HeartbeatWritten(data []byte) (time.Time, bool) {
var beat HeartbeatTimestamp
if json.Unmarshal(data, &beat) != nil {
return time.Time{}, false
}
return beat.Time()
}
// HeartbeatTimestamp is the read side of a heartbeat for node liveness. It
// decodes only the timestamp, so a reader does not depend on the capacity
// figures the daemon writes beside it.
+20
View File
@@ -33,3 +33,23 @@ func TestHeartbeatTimestamp_UndatedBeatVouchesForNothing(t *testing.T) {
assert.False(t, ok, "timestamp %q", ts)
}
}
// A beat is live strictly under the window, so one exactly the window old is
// the first a takeover may treat as stopped.
func TestLiveAt_IsStrictlyUnderTheWindow(t *testing.T) {
now := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)
assert.True(t, clusterv1.LiveAt(now.Add(-clusterv1.LivenessWindow+time.Nanosecond), now))
assert.False(t, clusterv1.LiveAt(now.Add(-clusterv1.LivenessWindow), now))
assert.True(t, clusterv1.LiveAt(now.Add(time.Second), now), "a beat from a clock running ahead is live")
}
func TestHeartbeatWritten_DatesAStoredBeatAndRefusesAnUndecodableOne(t *testing.T) {
written, ok := clusterv1.HeartbeatWritten([]byte(`{"node":"node-a","timestamp":"2026-10-30T01:02:03Z"}`))
require.True(t, ok)
assert.True(t, written.Equal(time.Date(2026, 10, 30, 1, 2, 3, 0, time.UTC)))
for _, raw := range []string{"not json", `{"node":"node-a"}`} {
_, ok := clusterv1.HeartbeatWritten([]byte(raw))
assert.False(t, ok, raw)
}
}
-8
View File
@@ -22,15 +22,7 @@ Tenant code may be able to reach the listener, but it cannot open the agent's co
For each generation the executor creates ephemeral self-signed host and guest certificates whose single URI SAN is `spinifex-lambda://<Region>/<account>/<function ID>/<environment ID>/<generation>/<role>`, with role `host` or `guest`.
Each end validates the peer certificate against the one pinned peer certificate and separately requires that URI SAN with the opposite role; hostname verification is not relied on.
ALPN carries the L-GUEST major version, `spinifex-lambda-guest/1`, and an unsupported version fails the handshake. Builds with the `lambdatestlink` tag offer only `spinifex-lambda-testlink/2` on both host and guest, whose first frame carries the execution-role credential, so a test-link end and a release end fail the handshake; release builds are unchanged.
In the same builds, the gateway reaches the host daemon on the local abstract socket `spinifex-lambda-testlink-control/3`, and each request carries the runtime-image digest the gateway was built with; the daemon refuses a missing or different digest before starting the attempt, and a gateway and daemon on different socket names never connect.
On that socket a request's raw payload is at most 6 MiB, checked by the gateway before encoding and by the daemon after decoding.
Its encoded frame is at most the base64 length of 6 MiB plus 64 KiB of metadata headroom and 64 bytes of field slack, and a receiver checks the frame length before allocating; metadata has no separate bound within the frame.
The credential travels as its own frame of at most 8 KiB, and the reply has the same payload and frame bounds as the request.
The daemon holds at most two connections at once, from accept to close, and leaves the rest unaccepted in the listen backlog.
Under TLS 1.3 the guest's handshake can complete before the host refuses its certificate, so the guest treats the connection as accepted only after the host's first message.
In the same builds, the function owner moves a created function with stored code from `Pending` to `Active` once this node's daemon serves the pinned image and the cluster configuration has exactly one node; a function with environment variables or a code copy over the test-link bound stays `Pending`, and `StateReason` names the unmet condition.
Release builds never leave `Pending`.
The Mulga deploy role installs a test-link build with `deploy_lambda_testlink=true`; this profile does not meet Q-199 fail-closed revocation, and its guests take management addresses whose record can expire (mulga-0hsub).
**Bootstrap material:** the executor passes these fw_cfg entries under `opt/spinifex/lambda/`, and the agent reads them as root and unloads `qemu_fw_cfg` before user code starts.
+3 -3
View File
@@ -111,9 +111,9 @@ Daemons (`spinifex/runtime/daemon/daemon.go`) subscribe to NATS topics and handl
When a daemon handles `RunInstances`:
1. **Resource Check**: Validates CPU/memory availability via `ResourceManager`
2. **Volume Generation**: Creates boot and EFI volumes via Viperblock
A UEFI instance whose record carries a variable store pointer has no EFI volume: its `vars.fd` is fetched from the object under `uefi/` in the configured Predastore bucket that the pointer names, recorded in the `spinifex-uefi-stores` JetStream bucket (or reused when `seeded.json` on this node already names the same object) after a firmware architecture and VARS size check, and a record with both or neither is refused.
RunInstances still creates the EFI volume, nothing writes `vars.fd` back to the object store yet, and a terminated instance's store is removed by a cluster-wide reaper.
2. **Volume Generation**: Creates the boot volume through the EBS provider and, for a UEFI guest, seeds its variable store as an object under `uefi/<instance-id>/` with a store record in `spinifex-uefi-stores` that points at it
At launch a UEFI instance's `vars.fd` is fetched from the object its store pointer names (or reused when `seeded.json` on this node already names the same object) after a firmware architecture and VARS size check, and a UEFI instance with no live store record naming a pointer, or one that still carries an EFI volume request, is refused.
The store is written back to the object store on write and at stop, and a terminated instance's store is removed by a cluster-wide reaper.
3. **Volume Mount**: Publishes each volume through the EBS provider contract (`PublishVolume` on `ebs.provider.v1.{node}.mount`), receives its export
4. **QEMU Launch**: Builds and executes QEMU command with NBD-backed disks
5. **QMP Monitoring**: Establishes QEMU Machine Protocol connection for VM management
@@ -321,19 +321,6 @@ func (s *TestStream) HostSend(req Request) error {
return s.write(frame{Type: typeInvoke, Request: &req})
}
// CheckInvokeFits encodes req as HostSend would and returns ErrFrameTooLarge
// when the frame exceeds the bound, so a caller can refuse before launch.
func CheckInvokeFits(req Request) error {
b, err := json.Marshal(frame{Type: typeInvoke, Request: &req})
if err != nil {
return err
}
if len(b) > maxFrame {
return ErrFrameTooLarge
}
return nil
}
// HostShutdown writes a shutdown frame, as the host side of the carrier.
func (s *TestStream) HostShutdown() error {
return s.write(frame{Type: typeShutdown})
+1 -2
View File
@@ -14,7 +14,6 @@ import (
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
@@ -184,7 +183,7 @@ func NewStore(ctx context.Context, nc *nats.Conn, masterKey []byte) (*Store, err
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketACM, KVBucketACMVersion)
kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketACM, KVBucketACMVersion)
if err != nil {
return nil, err
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"time"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
"golang.org/x/time/rate"
)
@@ -171,7 +171,7 @@ func (s *Service) RunBedrockRPMSync(ctx context.Context, js jetstream.JetStream)
if s == nil || !s.limits.RequestsPerMinuteEnabled {
return
}
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketBedrockRPMUsage, 1)
kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketBedrockRPMUsage, 1)
if err != nil {
slog.Warn("bedrock rpm sync: open usage bucket failed, sync disabled", "err", err)
return
+3 -3
View File
@@ -9,7 +9,7 @@ import (
"time"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -187,7 +187,7 @@ func (s *Service) PutAccountQuota(ctx context.Context, accountID string, over Ov
over.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
over.UpdatedBy = updatedBy
_, err := kvutil.Update(ctx, s.overrides, accountID, kvutil.CASConfig{
_, err := kvstore.Update(ctx, s.overrides, accountID, kvstore.CASConfig{
CreateIfAbsent: true,
Attempts: overrideMaxAttempts,
Exhausted: func(string, int) error { return errOverridesContended },
@@ -230,7 +230,7 @@ func (s *Service) clearAccountQuota(ctx context.Context, accountID string) error
return nil
case errors.Is(err, jetstream.ErrKeyNotFound):
return nil
case kvutil.IsRevisionConflict(err):
case kvstore.IsRevisionConflict(err):
// A concurrent write landed between the read and the guarded
// delete; retry against whatever revision now holds.
continue
+4 -4
View File
@@ -22,10 +22,10 @@ const ReconcileInterval = 30 * time.Second
// dimension; the live-counted dimensions need no stored state.
const KVBucketAccountUsage = "spinifex-account-usage"
// KVBucketQuotaReconcile is the leader-lock bucket for the gateway quota
// reconcile. It is deliberately distinct from vpcd's network-reconcile lock so
// the two independent loops never share one mutex and block each other.
const KVBucketQuotaReconcile = "spinifex-quota-reconcile"
// KeyReconcile is the sync-bucket leader lease for the gateway quota reconcile.
// It is deliberately distinct from vpcd's network-reconcile lease so the two
// independent loops never share one mutex and block each other.
const KeyReconcile = "quota.reconcile"
// KVBucketAccountQuota holds the per-account limit overrides, keyed by
// accountID. It is separate from the accounts bucket so a quota write never
+3 -3
View File
@@ -8,7 +8,7 @@ import (
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -73,7 +73,7 @@ func (s *Service) sealBucket(ctx context.Context) (jetstream.KeyValue, error) {
slog.ErrorContext(ctx, "Quota override seal has no JetStream client wired to open the override bucket")
return nil, errors.New(awserrors.ErrorInternalError)
}
kv, err := kvutil.GetOrCreateBucket(ctx, s.js, KVBucketAccountQuota, 1)
kv, err := kvstore.GetOrCreateBucket(ctx, s.js, KVBucketAccountQuota, 1)
if err != nil {
slog.ErrorContext(ctx, "Failed to open quota override bucket for seal", "err", err)
return nil, errors.New(awserrors.ErrorInternalError)
@@ -136,7 +136,7 @@ func (s *Service) SealAccountOverrides(ctx context.Context, accountID string) (*
switch {
case writeErr == nil:
return &OverrideSeal{Revision: newRev}, nil
case kvutil.IsRevisionConflict(writeErr):
case kvstore.IsRevisionConflict(writeErr):
continue
default:
slog.ErrorContext(ctx, "Failed to write sealed quota override record", "accountID", accountID, "err", writeErr)
+7 -7
View File
@@ -8,7 +8,7 @@ import (
"time"
nsconfig "github.com/mulgadc/northstar/pkg/config"
"github.com/nats-io/nats.go"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
)
@@ -48,7 +48,7 @@ func (w *Writer) PlatformDelegation(name string) (servers []string, zone string,
// Delegate writes, or with remove withdraws, an NS set in the base zone that
// delegates each name to this cluster's nameservers. It takes the zone-writer
// election first, so it never races the reconcile's own writes to the zone.
func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, names []string, remove bool) (*ChangeResult, error) {
func (w *Writer) Delegate(ctx context.Context, lease *kvsync.Lease, names []string, remove bool) (*ChangeResult, error) {
if !w.Enabled() {
return nil, errors.New("northstar is not configured on this node")
}
@@ -77,7 +77,7 @@ func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, nam
if len(batch.Changes) == 0 {
return &ChangeResult{}, nil
}
release, err := w.awaitWriterElection(ctx, nc, holder)
release, err := w.awaitWriterElection(ctx, lease)
if err != nil {
return nil, err
}
@@ -85,19 +85,19 @@ func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, nam
return w.ApplyBatch(batch)
}
func (w *Writer) awaitWriterElection(ctx context.Context, nc *nats.Conn, holder string) (func(), error) {
if nc == nil {
func (w *Writer) awaitWriterElection(ctx context.Context, lease *kvsync.Lease) (func(), error) {
if lease == nil {
return func() {}, nil
}
tick := time.NewTicker(delegateLockPoll)
defer tick.Stop()
for {
if release, ok := reconcilelock.AcquireLeader(ctx, nc, KVBucketDNSReconcile, holder); ok {
if release, ok := reconcilelock.AcquireLeader(ctx, lease); ok {
return release, nil
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("another node is writing the zone: %w", ctx.Err())
return nil, fmt.Errorf("another writer has the zone: %w", ctx.Err())
case <-tick.C:
}
}
+33 -10
View File
@@ -14,7 +14,7 @@ import (
func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
_, err := w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net", "B.r53e2e.spx3.net."}, false)
_, err := w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net", "B.r53e2e.spx3.net."}, false)
require.NoError(t, err)
for _, name := range []string{"a.r53e2e.spx3.net.", "b.r53e2e.spx3.net"} {
@@ -29,7 +29,7 @@ func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
assert.True(t, ok, "a name below the base domain is decided by the base zone")
assert.Empty(t, servers)
_, err = w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, true)
_, err = w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net"}, true)
require.NoError(t, err)
servers, _, _, err = w.PlatformDelegation("a.r53e2e.spx3.net")
require.NoError(t, err)
@@ -43,11 +43,11 @@ func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
// servers written while the cluster published a larger set.
func TestDelegateRemoveWithdrawsServersFromAnEarlierSet(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
_, err := w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, false)
_, err := w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net"}, false)
require.NoError(t, err)
w.nameservers = w.nameservers[:1]
_, err = w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, true)
_, err = w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net"}, true)
require.NoError(t, err)
servers, _, _, err := w.PlatformDelegation("a.r53e2e.spx3.net")
require.NoError(t, err)
@@ -66,7 +66,7 @@ func TestPlatformDelegationIgnoresNamesOutsideTheBaseDomain(t *testing.T) {
func TestDelegateRefusesPlatformAndForeignNames(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
for _, name := range []string{"ns1.spx3.net", "x.elb.spx3.net", "compute.spx3.net", "example.com", "spx3.net"} {
_, err := w.Delegate(t.Context(), nil, "node1", []string{"ok.spx3.net", name}, false)
_, err := w.Delegate(t.Context(), nil, []string{"ok.spx3.net", name}, false)
require.Error(t, err, name)
}
servers, _, _, err := w.PlatformDelegation("ok.spx3.net")
@@ -76,22 +76,45 @@ func TestDelegateRefusesPlatformAndForeignNames(t *testing.T) {
func TestDelegateWaitsForTheZoneWriterElection(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
_, nc, _ := testutil.StartTestJetStream(t)
_, _, js := testutil.StartTestJetStream(t)
prev := delegateLockPoll
delegateLockPoll = 10 * time.Millisecond
t.Cleanup(func() { delegateLockPoll = prev })
release, ok := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "node2")
release, ok := reconcilelock.AcquireLeader(t.Context(), dnsLease(js, "node2"))
require.True(t, ok)
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
_, err := w.Delegate(ctx, nc, "node1", []string{"a.spx3.net"}, false)
require.ErrorContains(t, err, "another node is writing the zone")
node1 := dnsLease(js, "node1")
_, err := w.Delegate(ctx, node1, []string{"a.spx3.net"}, false)
require.ErrorContains(t, err, "another writer has the zone")
release()
_, err = w.Delegate(t.Context(), nc, "node1", []string{"a.spx3.net"}, false)
_, err = w.Delegate(t.Context(), node1, []string{"a.spx3.net"}, false)
require.NoError(t, err)
servers, _, _, err := w.PlatformDelegation("a.spx3.net")
require.NoError(t, err)
assert.Len(t, servers, 2)
}
// The reconcile pass and a delegation on one node share the DNS lease, so a
// delegation waits for this node's own pass as it would for another node's.
func TestDelegateWaitsForThisNodesOwnReconcilePass(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
_, _, js := testutil.StartTestJetStream(t)
prev := delegateLockPoll
delegateLockPoll = 10 * time.Millisecond
t.Cleanup(func() { delegateLockPoll = prev })
shared := dnsLease(js, "node1")
release, ok := reconcilelock.AcquireLeader(t.Context(), shared)
require.True(t, ok, "the reconcile pass holds the lease")
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
_, err := w.Delegate(ctx, shared, []string{"a.spx3.net"}, false)
require.ErrorContains(t, err, "another writer has the zone")
release()
_, err = w.Delegate(t.Context(), shared, []string{"a.spx3.net"}, false)
require.NoError(t, err)
}
+22 -19
View File
@@ -11,7 +11,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler"
"github.com/nats-io/nats.go"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
)
// DefaultReconcileInterval is how often the drift backstop rebuilds managed
@@ -33,6 +33,12 @@ const DefaultReconcileInterval = reconciler.DefaultResync
// costs a cycle for nothing.
const DefaultComputeTimeout = 2 * time.Minute
// NewReconcileLease returns the zone writer's election lease, shared by the
// drift reconcile and Delegate so the two never write the zone together.
func NewReconcileLease(store kvsync.KVStore, holder string) *kvsync.Lease {
return reconcilelock.NewLeaderLease(store, KeyReconcile, holder)
}
// DesiredFunc returns the full desired managed record set built from the live
// resource inventory across all tenants. The daemon supplies it by enumerating
// instances, load balancers, and EKS clusters.
@@ -102,7 +108,8 @@ type Reconciler struct {
// lands in. Distinct from both baseDomain and internalDomain, so pruning it
// needs its own read the same way internalDomain does for EC2.
serviceZone string
nc *nats.Conn
// lease elects the one node that writes the zones; nil skips the election.
lease *kvsync.Lease
// writer applies the converged batch. The reconcile owns the zone object on
// the node that won the election, so the batch never leaves this process.
writer *Writer
@@ -111,7 +118,6 @@ type Reconciler struct {
// computeTimeout bounds how long one pass spends building the desired set.
// A field rather than a constant so a test can shrink it.
computeTimeout time.Duration
holder string
sources []reconciler.Source
}
@@ -121,18 +127,15 @@ type Reconciler struct {
// changes should wake the loop. They are optional: with none supplied the loop
// falls back to the interval alone, which is the behaviour that predates the
// watch.
func NewReconciler(cfg *config.Config, cluster *config.ClusterConfig, nc *nats.Conn, writer *Writer, desired DesiredFunc, sources ...reconciler.Source) *Reconciler {
func NewReconciler(cfg *config.Config, cluster *config.ClusterConfig, lease *kvsync.Lease, writer *Writer, desired DesiredFunc, sources ...reconciler.Source) *Reconciler {
r := &Reconciler{
nc: nc,
lease: lease,
writer: writer,
desired: desired,
interval: DefaultReconcileInterval,
computeTimeout: DefaultComputeTimeout,
sources: sources,
}
if cfg != nil {
r.holder = cfg.Node
}
zoneCfg, ok := zoneS3Config(cfg)
if !ok || desired == nil || !writer.Enabled() {
return r
@@ -166,25 +169,24 @@ func (r *Reconciler) Run(ctx context.Context) {
// No revisit deadline: the desired set is a function of the buckets
// being watched, so a change is the only reason to run early.
Reconcile: func(ctx context.Context) (time.Duration, error) {
r.reconcileOnce(ctx)
return 0, nil
return r.reconcileOnce(ctx), nil
},
Resync: r.interval,
})
}
// reconcileOnce computes the converging batch and applies it, on whichever node
// wins this cycle's election. The read that finds the stale records and the
// wins this cycle's election, and returns the election's revisit when it lost. The read that finds the stale records and the
// write that removes them now happen in one process, so a record re-added
// between them is no longer deleted by a batch that predates it.
func (r *Reconciler) reconcileOnce(ctx context.Context) {
func (r *Reconciler) reconcileOnce(ctx context.Context) time.Duration {
if !r.enabled {
return
return 0
}
if r.nc != nil {
release, elected := reconcilelock.AcquireLeader(ctx, r.nc, KVBucketDNSReconcile, r.holder)
if r.lease != nil {
release, retry, elected := reconcilelock.ElectPass(ctx, r.lease)
if !elected {
return
return retry
}
defer release()
}
@@ -193,18 +195,19 @@ func (r *Reconciler) reconcileOnce(ctx context.Context) {
// A corrupt zone is handled in readZone; anything reaching here left the
// desired state unapplied, so surface it rather than burying it in WARN.
slog.Error("dns reconcile: compute batch failed, retrying next cycle", "error", err)
return
return 0
}
if len(batch) == 0 {
return
return 0
}
slog.Debug("dns reconcile: converging", "changes", len(batch))
res, err := r.writer.ApplyBatch(&ChangeBatch{Changes: batch})
if err != nil {
slog.Warn("dns reconcile: apply failed, retrying next cycle", "changes", len(batch), "error", err)
return
return 0
}
slog.Debug("dns reconcile: converged", "changes", res.Applied, "zones", res.Zones)
return 0
}
// computeBatchBounded runs computeBatch with a deadline, so a pass that never
+21 -12
View File
@@ -8,41 +8,50 @@ import (
"github.com/mulgadc/spinifex/internal/testkit"
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
"github.com/nats-io/nats.go"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// electingReconcilerFor is reconcilerFor with a live connection, so a pass runs
// the election instead of skipping it.
func electingReconcilerFor(w *Writer, nc *nats.Conn, holder string, desired func() DesiredSet) *Reconciler {
// electingReconcilerFor is reconcilerFor with a lease, so a pass runs the
// election instead of skipping it.
func electingReconcilerFor(w *Writer, lease *kvsync.Lease, desired func() DesiredSet) *Reconciler {
r := reconcilerFor(w, desired)
r.nc = nc
r.holder = holder
r.lease = lease
return r
}
// dnsLease returns holder's DNS lease on a store of its own, standing in for
// a separate process.
func dnsLease(js jetstream.JetStream, holder string) *kvsync.Lease {
return NewReconcileLease(kvstore.OpenSyncBucket(js), holder)
}
// The zone lock is gone, and what replaces it is that a zone has one writer:
// the node holding the reconcile lease. A node that loses the election must
// therefore not touch the object at all — if it did, two nodes could be
// read-modify-writing the same zone with nothing serialising them.
func TestReconcileOnce_ALoserOfTheElectionDoesNotWriteTheZone(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
_, _, js := testutil.StartTestJetStream(t)
w, objects := newTestWriter(t)
before := objects[testBase+".toml"]
require.NotEmpty(t, before)
// A peer holds the lease for the whole pass, so this node cannot win it.
release, elected := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "peer")
release, elected := reconcilelock.AcquireLeader(t.Context(), dnsLease(js, "peer"))
require.True(t, elected, "the peer has to hold the lease or this test proves nothing")
r := electingReconcilerFor(w, nc, "node1", func() DesiredSet {
r := electingReconcilerFor(w, dnsLease(js, "node1"), func() DesiredSet {
return DesiredSet{Changes: []Change{upsert("lb-1.elb."+testBase, "10.200.1.9")}}
})
r.reconcileOnce(t.Context())
revisit := r.reconcileOnce(t.Context())
assert.Equal(t, before, objects[testBase+".toml"],
"a node that did not win the election must leave the zone object exactly as it found it")
assert.Positive(t, revisit, "the loser asks to retry so a dead holder is taken over")
assert.LessOrEqual(t, revisit, reconcilelock.LeaderTTL/3, "the retry comes within a renewal interval")
release()
}
@@ -50,10 +59,10 @@ func TestReconcileOnce_ALoserOfTheElectionDoesNotWriteTheZone(t *testing.T) {
// The counterpart, so the test above is not passing because the pass was broken
// for some other reason: with the lease free, the same reconciler writes.
func TestReconcileOnce_TheWinnerOfTheElectionWritesTheZone(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
_, _, js := testutil.StartTestJetStream(t)
w, objects := newTestWriter(t)
r := electingReconcilerFor(w, nc, "node1", func() DesiredSet {
r := electingReconcilerFor(w, dnsLease(js, "node1"), func() DesiredSet {
return DesiredSet{Changes: []Change{upsert("lb-1.elb."+testBase, "10.200.1.9")}}
})
r.reconcileOnce(t.Context())
+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,
// and cancelling ctx must still let the loop return.
func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
_, _, js := testutil.StartTestJetStream(t)
endpoint, _ := fakeS3(t, "northstar")
var passes atomic.Int64
@@ -235,8 +235,7 @@ func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1",
AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET",
},
nc: nc,
holder: "test-node",
lease: dnsLease(js, "test-node"),
desired: func() DesiredSet {
passes.Add(1)
return DesiredSet{}
@@ -268,7 +267,7 @@ func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
// acquirable afterwards, not merely on the call returning, since it is the
// leadership and not the pass that the rest of the cluster waits on.
func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
_, _, js := testutil.StartTestJetStream(t)
endpoint, _ := fakeS3(t, "northstar")
blocked := make(chan struct{})
@@ -281,8 +280,7 @@ func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1",
AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET",
},
nc: nc,
holder: "stuck-node",
lease: dnsLease(js, "stuck-node"),
computeTimeout: 100 * time.Millisecond,
desired: func() DesiredSet {
<-blocked
@@ -302,7 +300,7 @@ func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
t.Fatal("reconcileOnce never returned: a stuck pass still holds the lease forever")
}
release, elected := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "next-node")
release, elected := reconcilelock.AcquireLeader(t.Context(), dnsLease(js, "next-node"))
require.True(t, elected, "another node must be able to take the next cycle")
release()
}
+3 -3
View File
@@ -11,9 +11,9 @@ const (
// PUT so every northstar instance reloads just that zone immediately, instead
// of waiting for the next S3 sync poll. No queue group: all servers consume.
SubjectZoneReload = "dns.zone.reload"
// KVBucketDNSReconcile elects the one node that writes the zones for a drift
// cycle. Its own bucket, so the election never blocks another reconcile loop.
KVBucketDNSReconcile = "spinifex-dns-reconcile"
// KeyReconcile elects the one node that writes the zones for a drift cycle.
// Its own key, so the election never blocks another reconcile loop.
KeyReconcile = "dns.reconcile"
// PrivateZone is the fixed AWS-parity private DNS zone (IMDS synthHostname).
PrivateZone = "compute.internal"
// DefaultTTL is applied when a change omits a TTL.
+2 -2
View File
@@ -8,7 +8,7 @@ import (
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -109,7 +109,7 @@ func (s *AccountSettingsServiceImpl) SealAccount(ctx context.Context, accountID
switch {
case writeErr == nil:
return &AccountSettingsSeal{Revision: newRev}, nil
case kvutil.IsRevisionConflict(writeErr):
case kvstore.IsRevisionConflict(writeErr):
continue
default:
slog.ErrorContext(ctx, "Failed to write sealed account settings record", "accountID", accountID, "err", writeErr)
+7 -7
View File
@@ -13,7 +13,7 @@ import (
ebsencryption "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/encryption"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -57,7 +57,7 @@ func NewAccountSettingsServiceImplWithNATS(ctx context.Context, cfg *config.Conf
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
settingsKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketAccountSettings, 10)
settingsKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketAccountSettings, 10)
if err != nil {
return nil, fmt.Errorf("failed to create account settings KV bucket: %w", err)
}
@@ -115,13 +115,13 @@ func mapSettingsError(ctx context.Context, accountID string, err error) error {
return errors.New(awserrors.ErrorBlocked)
case errors.Is(err, errSettingsContended):
slog.ErrorContext(ctx, "Account settings CAS retries exhausted under contention", "accountID", accountID)
case errors.Is(err, kvutil.ErrRead):
case errors.Is(err, kvstore.ErrRead):
slog.ErrorContext(ctx, "Failed to read account settings from KV", "accountID", accountID, "err", err)
case errors.Is(err, kvutil.ErrDecode):
case errors.Is(err, kvstore.ErrDecode):
slog.ErrorContext(ctx, "Corrupt account settings record in KV", "accountID", accountID, "err", err)
case errors.Is(err, kvutil.ErrEncode):
case errors.Is(err, kvstore.ErrEncode):
slog.ErrorContext(ctx, "Failed to marshal account settings record", "accountID", accountID, "err", err)
case errors.Is(err, kvutil.ErrWrite):
case errors.Is(err, kvstore.ErrWrite):
slog.ErrorContext(ctx, "Failed to write account settings to KV", "accountID", accountID, "err", err)
default:
slog.ErrorContext(ctx, "Account settings operation failed", "accountID", accountID, "err", err)
@@ -133,7 +133,7 @@ func mapSettingsError(ctx context.Context, accountID string, err error) error {
// changed is always true, so every success is a leader-accepted write over an
// active value; a seal landing between the read and the write fails the CAS.
func (s *AccountSettingsServiceImpl) mutateSettings(ctx context.Context, accountID string, set func(*AccountSettingsRecord)) error {
_, err := kvutil.Update(ctx, s.settingsKV, settingsKey(accountID), kvutil.CASConfig{
_, err := kvstore.Update(ctx, s.settingsKV, settingsKey(accountID), kvstore.CASConfig{
CreateIfAbsent: true,
Exhausted: func(string, int) error { return errSettingsContended },
}, func(rec *AccountSettingsRecord) (bool, error) {
@@ -1,155 +0,0 @@
package accountcleanup
import (
"context"
"log/slog"
"slices"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
)
// ReasonRecordRemains marks a resource whose record the owner still holds.
const ReasonRecordRemains = "record-remains"
// ComputeRemainder reads what compute the account still holds, changing nothing.
type ComputeRemainder interface {
ComputeRemaining(ctx context.Context, accountID string) (*Outcome, error)
}
// StorageRemainder reads what volumes and tag residue the account still holds,
// changing nothing.
type StorageRemainder interface {
StorageRemaining(ctx context.Context, accountID string) (*Outcome, error)
}
var (
_ ComputeRemainder = (*ComputeCleanup)(nil)
_ StorageRemainder = (*StorageCleanup)(nil)
)
func validRemainderAccount(accountID string) error {
if accountID == "" {
return awserrors.Errorf(awserrors.ErrorMissingParameter, "The request must specify an account ID.")
}
if !awsidentifiers.IsAccountID(accountID) {
return awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "The account ID must be 12 digits.")
}
return nil
}
// ComputeRemaining lists every instance the account still holds and every spot
// request that can still launch. A read that does not complete is an incomplete
// reason, never a shorter list. The result is a snapshot, not evidence of absence.
func (c *ComputeCleanup) ComputeRemaining(ctx context.Context, accountID string) (*Outcome, error) {
if err := validRemainderAccount(accountID); err != nil {
return nil, err
}
if c == nil || c.spot == nil || c.instances == nil || c.teardown == nil {
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "Compute cleanup is not fully wired.")
}
res := &Outcome{AccountID: accountID}
defer res.finish()
scan, err := c.spot.ScanAccount(ctx, accountID)
if err != nil {
slog.WarnContext(ctx, "ComputeRemaining: spot scan failed", "account_id", accountID, "err", err)
res.incomplete(ReasonSpotInventoryIncomplete)
} else {
for _, r := range slices.Concat(scan.Active, scan.Terminal) {
if !terminalSpotState(r.State) {
res.Items = append(res.Items, Item{ResourceID: r.RequestID, Kind: KindSpotRequest, State: r.State, Disposition: DispositionResidue, Reason: ReasonRecordRemains})
}
}
}
inventory, err := c.instances.AccountInstances(ctx, accountID)
if err != nil || !inventoryUsable(inventory) {
slog.WarnContext(ctx, "ComputeRemaining: instance inventory incomplete", "account_id", accountID, "err", err)
res.incomplete(ReasonInstanceInventoryIncomplete)
} else {
for _, reservation := range inventory.Reservations {
for _, in := range reservation.Instances {
state := ""
if in.State != nil {
state = aws.StringValue(in.State.Name)
}
res.Items = append(res.Items, Item{ResourceID: aws.StringValue(in.InstanceId), Kind: KindInstance, State: state, Disposition: DispositionResidue, Reason: ReasonRecordRemains})
}
}
}
outstanding, err := c.teardown.AccountTeardownOutstanding(accountID)
if err != nil {
slog.WarnContext(ctx, "ComputeRemaining: teardown unread", "account_id", accountID, "err", err)
res.incomplete(ReasonTeardownUnread)
} else {
for _, o := range outstanding {
if !res.hasInstance(o.InstanceID) {
res.Items = append(res.Items, Item{ResourceID: o.InstanceID, Kind: KindInstance, State: o.State, Disposition: DispositionResidue, Reason: ReasonTerminating})
}
}
}
return res, nil
}
// StorageRemaining lists every volume record the account still holds, deleting
// ones included, and the tag entries of resources whose owner holds no record.
func (c *StorageCleanup) StorageRemaining(ctx context.Context, accountID string) (*Outcome, error) {
if err := validRemainderAccount(accountID); err != nil {
return nil, err
}
if c == nil || c.volumes == nil || c.instances == nil || c.teardown == nil || c.spot == nil || c.tags == nil {
return nil, awserrors.Errorf(awserrors.ErrorServerInternal, "Storage cleanup is not fully wired.")
}
res := &Outcome{AccountID: accountID}
defer res.finish()
volumes, err := c.volumes.VolumeInventory(ctx, accountID)
if err != nil {
slog.WarnContext(ctx, "StorageRemaining: volume inventory incomplete", "account_id", accountID, "err", err)
res.incomplete(ReasonVolumeInventoryIncomplete)
}
for _, v := range volumes {
reason := ReasonRecordRemains
if v.State == ec2.VolumeStateDeleting {
reason = ReasonVolumeDeleting
}
res.Items = append(res.Items, Item{ResourceID: v.VolumeID, Kind: KindVolume, State: v.State, Disposition: DispositionResidue, Reason: reason})
}
live, err := c.instances.AccountInstances(ctx, accountID)
if err != nil || !inventoryUsable(live) {
slog.WarnContext(ctx, "StorageRemaining: no complete instance view", "account_id", accountID, "err", err)
res.incomplete(ReasonHolderProofIncomplete)
return res, nil
}
outstanding, err := c.teardown.AccountTeardownOutstanding(accountID)
if err != nil {
slog.WarnContext(ctx, "StorageRemaining: teardown unread", "account_id", accountID, "err", err)
res.incomplete(ReasonHolderProofIncomplete)
return res, nil
}
tagged, err := c.tags.ScanAccountResourceIDs(ctx, accountID, storageTagPrefixes...)
if err != nil {
slog.WarnContext(ctx, "StorageRemaining: tag index read failed", "account_id", accountID, "err", err)
res.incomplete(ReasonTagIndexUnread)
return res, nil
}
owned, resolved := c.ownedResourceIDs(ctx, accountID, live, outstanding)
for _, id := range tagged {
switch {
case !resolved[tagPrefix(id)]:
res.incomplete(ReasonTagOwnerUnresolved)
res.Items = append(res.Items, Item{ResourceID: id, Kind: KindTag, Disposition: DispositionResidue, Reason: ReasonOwnerRecordsUnresolved})
case owned[id]:
// The resource itself is reported; its tags are not residue.
default:
res.Items = append(res.Items, Item{ResourceID: id, Kind: KindTag, Disposition: DispositionResidue, Reason: ReasonRecordRemains})
}
}
return res, nil
}
@@ -1,97 +0,0 @@
package accountcleanup
import (
"errors"
"testing"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountinventory"
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
ec2spotinstance "github.com/mulgadc/spinifex/spinifex/domains/ec2/spotinstance"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestComputeRemainingReportsEveryHeldInstanceAndOpenSpot(t *testing.T) {
r := newComputeRig()
r.spot.scans = []*ec2spotinstance.AccountScan{{
Active: []ec2spotinstance.AccountSpotRequest{{RequestID: "sir-0aaaaaaaaaaaaaaa1", State: ec2.SpotInstanceStateOpen}},
Terminal: []ec2spotinstance.AccountSpotRequest{{RequestID: "sir-0aaaaaaaaaaaaaaa2", State: ec2.SpotInstanceStateClosed}},
}}
r.teardown.outstanding = []ec2instance.OutstandingInstance{{InstanceID: "i-0aaaaaaaaaaaaaa99", State: ec2.InstanceStateNameTerminated}}
res, err := r.cleanup().ComputeRemaining(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, StatusIncomplete, res.Status)
assert.NotNil(t, itemFor(res, runningID))
assert.NotNil(t, itemFor(res, stoppedID))
assert.NotNil(t, itemFor(res, "i-0aaaaaaaaaaaaaa99"))
assert.NotNil(t, itemFor(res, "sir-0aaaaaaaaaaaaaaa1"))
assert.Nil(t, itemFor(res, "sir-0aaaaaaaaaaaaaaa2"), "a closed request launches nothing")
assert.Empty(t, r.terminator.terminated, "a re-read changes nothing")
}
func TestComputeRemainingCleanWhenNothingHeld(t *testing.T) {
r := newComputeRig()
r.inventory.out = listing(nil)
res, err := r.cleanup().ComputeRemaining(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, StatusClean, res.Status)
}
func TestComputeRemainingUnreadStoreIsIncompleteNeverClean(t *testing.T) {
for name, mutate := range map[string]func(*computeRig){
"instances": func(r *computeRig) { r.inventory.out, r.inventory.err = nil, errors.New("node silent") },
"spot": func(r *computeRig) { r.spot.scanErr = errors.New("spot unreadable") },
"teardown": func(r *computeRig) { r.teardown.err = errors.New("no responder") },
} {
t.Run(name, func(t *testing.T) {
r := newComputeRig()
r.inventory.out = listing(nil)
mutate(r)
res, err := r.cleanup().ComputeRemaining(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, StatusIncomplete, res.Status)
assert.NotEmpty(t, res.IncompleteReasons)
})
}
}
func TestStorageRemainingReportsVolumeRecordsAndTagResidue(t *testing.T) {
r := newStorageRig()
r.volumes.vols = []accountinventory.VolumeItem{
volume(freeVol, ec2.VolumeStateAvailable, ""),
volume("vol-0aaaaaaaaaaaaaaa2", ec2.VolumeStateDeleting, ""),
}
r.tags.ids = []string{"i-0aaaaaaaaaaaaaaaf", freeVol}
res, err := r.cleanup().StorageRemaining(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, StatusIncomplete, res.Status)
assert.Equal(t, ReasonRecordRemains, itemFor(res, freeVol).Reason)
assert.Equal(t, ReasonVolumeDeleting, itemFor(res, "vol-0aaaaaaaaaaaaaaa2").Reason)
assert.Equal(t, KindTag, itemFor(res, "i-0aaaaaaaaaaaaaaaf").Kind, "tags of a resource with no record are residue")
assert.Empty(t, r.deleter.deleted, "a re-read deletes nothing")
assert.Empty(t, r.tags.deleted, "a re-read clears no tag")
}
func TestStorageRemainingUnreadStoreIsIncompleteNeverClean(t *testing.T) {
for name, mutate := range map[string]func(*storageRig){
"volumes": func(r *storageRig) { r.volumes.failAt = -1 },
"instances": func(r *storageRig) { r.instances.out, r.instances.err = nil, errors.New("node silent") },
"teardown": func(r *storageRig) { r.teardown.err = errors.New("no responder") },
"tags": func(r *storageRig) { r.tags.scanErr = errors.New("tag index unreadable") },
} {
t.Run(name, func(t *testing.T) {
r := newStorageRig()
r.volumes.vols = nil
mutate(r)
res, err := r.cleanup().StorageRemaining(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, StatusIncomplete, res.Status)
assert.NotEmpty(t, res.IncompleteReasons)
})
}
}
@@ -1,28 +0,0 @@
package instance
import (
"context"
"fmt"
"time"
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/nats-io/nats.go"
)
// AccountTeardownOutstandingSubject serves the instances of an account whose
// teardown is not complete, from the shared record store.
const AccountTeardownOutstandingSubject = "ec2.AccountTeardownOutstanding"
var accountTeardownOutstandingTimeout = 15 * time.Second
// AccountTeardownOutstanding reads the account's unfinished instance teardowns.
// No responder, a timeout or an error reply is an error, never an empty list.
func AccountTeardownOutstanding(ctx context.Context, natsConn *nats.Conn, accountID string) ([]ec2instance.OutstandingInstance, error) {
out, err := natsmsg.NATSRequest[ec2instance.TeardownOutstandingOutput](ctx, natsConn, AccountTeardownOutstandingSubject,
&ec2instance.TeardownOutstandingInput{}, accountTeardownOutstandingTimeout, accountID)
if err != nil {
return nil, fmt.Errorf("read outstanding teardown: %w", err)
}
return out.Instances, nil
}
@@ -1,88 +0,0 @@
package instance
import (
"context"
"encoding/json"
"testing"
"time"
ec2instance "github.com/mulgadc/spinifex/spinifex/domains/ec2/instance"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func serveTeardownOutstanding(t *testing.T, nc *nats.Conn, reply []byte) {
t.Helper()
_, err := nc.Subscribe(AccountTeardownOutstandingSubject, func(msg *nats.Msg) { _ = msg.Respond(reply) })
require.NoError(t, err)
require.NoError(t, nc.Flush())
}
func TestAccountTeardownOutstanding_ReturnsTheServedInstances(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
reply, err := json.Marshal(ec2instance.TeardownOutstandingOutput{Instances: []ec2instance.OutstandingInstance{{InstanceID: "i-1"}}})
require.NoError(t, err)
serveTeardownOutstanding(t, nc, reply)
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, "i-1", got[0].InstanceID)
}
// A read that cannot be answered must never look like an empty list, because an
// empty list lets the storage cleanup believe no QEMU still holds a volume.
func TestAccountTeardownOutstanding_UnansweredReadIsAnError(t *testing.T) {
t.Parallel()
t.Run("no responder", func(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
require.Error(t, err)
assert.Nil(t, got)
assert.ErrorIs(t, err, nats.ErrNoResponders)
})
t.Run("error reply", func(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
serveTeardownOutstanding(t, nc, awserrors.GenerateErrorPayload(awserrors.ErrorServerInternal))
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
require.Error(t, err)
assert.Nil(t, got)
})
t.Run("unreadable reply", func(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
serveTeardownOutstanding(t, nc, []byte("not json"))
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
require.Error(t, err)
assert.Nil(t, got)
})
}
// Not parallel: it shortens the package timeout.
func TestAccountTeardownOutstanding_TimeoutIsAnError(t *testing.T) {
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe(AccountTeardownOutstandingSubject, func(*nats.Msg) {})
require.NoError(t, err)
require.NoError(t, nc.Flush())
previous := accountTeardownOutstandingTimeout
accountTeardownOutstandingTimeout = 200 * time.Millisecond
t.Cleanup(func() { accountTeardownOutstandingTimeout = previous })
got, err := AccountTeardownOutstanding(context.Background(), nc, "123456789012")
require.Error(t, err)
assert.Nil(t, got)
}
+7 -7
View File
@@ -19,7 +19,7 @@ import (
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -56,7 +56,7 @@ func NewEgressOnlyIGWServiceImplWithNATS(ctx context.Context, cfg *config.Config
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
eigwKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketEgressOnlyIGW, 10)
eigwKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketEgressOnlyIGW, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketEgressOnlyIGW, err)
}
@@ -65,7 +65,7 @@ func NewEgressOnlyIGWServiceImplWithNATS(ctx context.Context, cfg *config.Config
}
// Get or create VPC KV bucket for cross-resource ownership validation
vpcKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
vpcKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
if err != nil {
return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err)
}
@@ -90,7 +90,7 @@ func (s *EgressOnlyIGWServiceImpl) CreateEgressOnlyInternetGateway(ctx context.C
slog.ErrorContext(ctx, "VPC KV unavailable, cannot verify VPC ownership")
return nil, errors.New(awserrors.ErrorServerInternal)
}
if _, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, *input.VpcId)); err != nil {
if _, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, *input.VpcId)); err != nil {
slog.WarnContext(ctx, "CreateEgressOnlyInternetGateway: VPC not found for account", "vpcId", *input.VpcId, "accountID", accountID)
return nil, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", *input.VpcId)
}
@@ -109,7 +109,7 @@ func (s *EgressOnlyIGWServiceImpl) CreateEgressOnlyInternetGateway(ctx context.C
if err != nil {
return nil, fmt.Errorf("failed to marshal Egress-only IGW record: %w", err)
}
if _, err := s.eigwKV.Put(ctx, kvutil.AccountKey(accountID, eigwID), data); err != nil {
if _, err := s.eigwKV.Put(ctx, kvstore.AccountKey(accountID, eigwID), data); err != nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
@@ -127,7 +127,7 @@ func (s *EgressOnlyIGWServiceImpl) DeleteEgressOnlyInternetGateway(ctx context.C
}
eigwID := *input.EgressOnlyInternetGatewayId
key := kvutil.AccountKey(accountID, eigwID)
key := kvstore.AccountKey(accountID, eigwID)
// Verify the EIGW exists before deleting
if _, err := s.eigwKV.Get(ctx, key); err != nil {
@@ -178,7 +178,7 @@ func (s *EgressOnlyIGWServiceImpl) DescribeEgressOnlyInternetGateways(ctx contex
}
for _, key := range keys {
if key == kvutil.VersionKey {
if key == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(key, prefix) {
@@ -9,7 +9,7 @@ import (
"github.com/mulgadc/spinifex/internal/testkit"
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -23,7 +23,7 @@ func setupTestEIGWService(t *testing.T) *EgressOnlyIGWServiceImpl {
// Create VPC KV bucket and register test VPCs so fail-closed ownership checks pass
vpcEntries := map[string][]byte{}
for _, vpcID := range []string{"vpc-test123", "vpc-tagged", "vpc-tagged2"} {
vpcEntries[kvutil.AccountKey(testAccountID, vpcID)] = []byte(`{"vpc_id":"` + vpcID + `","state":"available"}`)
vpcEntries[kvstore.AccountKey(testAccountID, vpcID)] = []byte(`{"vpc_id":"` + vpcID + `","state":"available"}`)
}
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, vpcEntries)
@@ -312,7 +312,7 @@ func TestCreateEgressOnlyInternetGateway_CrossAccountVPCRejected(t *testing.T) {
// Create VPC KV bucket with a VPC owned by testAccountID
vpcID := "vpc-alpha456"
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, map[string][]byte{
kvutil.AccountKey(testAccountID, vpcID): []byte(`{"vpc_id":"vpc-alpha456","state":"available"}`),
kvstore.AccountKey(testAccountID, vpcID): []byte(`{"vpc_id":"vpc-alpha456","state":"available"}`),
})
var err error
@@ -12,7 +12,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/idempotency"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -36,7 +36,7 @@ func (s *EIPServiceImpl) allocateOnce(ctx context.Context, accountID string, all
if key == "" || s.idemKV == nil {
return alloc()
}
kvKey := kvutil.AccountKey(accountID, idempotencyKeyHash(key))
kvKey := kvstore.AccountKey(accountID, idempotencyKeyHash(key))
if out := s.cachedAllocation(ctx, kvKey); out != nil {
slog.WarnContext(ctx, "AllocateAddress: returning first result for a retried request",
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/domains/network/projection"
"github.com/mulgadc/spinifex/spinifex/domains/network/topology"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -80,7 +80,7 @@ func (s *EIPServiceImpl) AllocationExists(ctx context.Context, allocationID stri
}
suffix := "." + allocationID
for _, k := range keys {
if k != kvutil.VersionKey && strings.HasSuffix(k, suffix) {
if k != kvstore.VersionKey && strings.HasSuffix(k, suffix) {
return true, nil
}
}
@@ -98,7 +98,7 @@ func (s *EIPServiceImpl) findByAllocationIDAnyAccount(ctx context.Context, alloc
suffix := "." + allocationID
for _, k := range keys {
if k == kvutil.VersionKey || !strings.HasSuffix(k, suffix) {
if k == kvstore.VersionKey || !strings.HasSuffix(k, suffix) {
continue
}
entry, err := s.eipKV.Get(ctx, k)
+12 -12
View File
@@ -20,7 +20,7 @@ import (
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -66,7 +66,7 @@ func NewEIPServiceImpl(ctx context.Context, natsConn *nats.Conn, externalIPAM *e
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
eipKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketEIPs, 10)
eipKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketEIPs, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketEIPs, err)
}
@@ -76,7 +76,7 @@ func NewEIPServiceImpl(ctx context.Context, natsConn *nats.Conn, externalIPAM *e
// TTL'd, so a failure to create it is not worth refusing to serve EIPs over:
// a nil bucket only costs post-completion dedupe.
idemKV, err := kvutil.GetOrCreateBucketWithTTL(ctx, js, KVBucketEIPIdempotency, 1, eipIdempotencyTTL)
idemKV, err := kvstore.GetOrCreateBucketWithTTL(ctx, js, KVBucketEIPIdempotency, 1, eipIdempotencyTTL)
if err != nil {
slog.Warn("EIP service: idempotency bucket unavailable; a retried AllocateAddress may allocate twice",
"bucket", KVBucketEIPIdempotency, "err", err)
@@ -146,7 +146,7 @@ func (s *EIPServiceImpl) allocateAddress(ctx context.Context, input *ec2.Allocat
if err != nil {
return nil, fmt.Errorf("failed to marshal EIP record: %w", err)
}
if _, err := s.eipKV.Put(ctx, kvutil.AccountKey(accountID, allocID), data); err != nil {
if _, err := s.eipKV.Put(ctx, kvstore.AccountKey(accountID, allocID), data); err != nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
@@ -167,7 +167,7 @@ func (s *EIPServiceImpl) ReleaseAddress(ctx context.Context, input *ec2.ReleaseA
}
allocID := *input.AllocationId
key := kvutil.AccountKey(accountID, allocID)
key := kvstore.AccountKey(accountID, allocID)
entry, err := s.eipKV.Get(ctx, key)
if err != nil {
@@ -206,7 +206,7 @@ func (s *EIPServiceImpl) AssociateAddress(ctx context.Context, input *ec2.Associ
}
allocID := *input.AllocationId
key := kvutil.AccountKey(accountID, allocID)
key := kvstore.AccountKey(accountID, allocID)
entry, err := s.eipKV.Get(ctx, key)
if err != nil {
@@ -536,7 +536,7 @@ func (s *EIPServiceImpl) findByENI(ctx context.Context, accountID, eniID string)
if err := ctx.Err(); err != nil {
return nil, err
}
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue
}
entry, err := s.eipKV.Get(ctx, k)
@@ -590,7 +590,7 @@ func (s *EIPServiceImpl) DescribeAddresses(ctx context.Context, input *ec2.Descr
var addresses []*ec2.Address
for _, k := range keys {
if k == kvutil.VersionKey {
if k == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(k, prefix) {
@@ -699,7 +699,7 @@ func (s *EIPServiceImpl) DescribeAddressesAttribute(ctx context.Context, input *
if id == nil {
continue
}
key := kvutil.AccountKey(accountID, *id)
key := kvstore.AccountKey(accountID, *id)
entry, err := s.eipKV.Get(ctx, key)
if err != nil {
continue // not found, skip
@@ -722,7 +722,7 @@ func (s *EIPServiceImpl) DescribeAddressesAttribute(ctx context.Context, input *
return nil, errors.New(awserrors.ErrorServerInternal)
}
for _, k := range keys {
if k == kvutil.VersionKey {
if k == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(k, prefix) {
@@ -819,7 +819,7 @@ func (s *EIPServiceImpl) findByAssociationID(ctx context.Context, accountID, ass
}
for _, k := range keys {
if k == kvutil.VersionKey {
if k == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(k, prefix) {
@@ -857,7 +857,7 @@ func (s *EIPServiceImpl) AssociatedPublicIPForInstance(ctx context.Context, acco
return "", false
}
for _, k := range keys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue
}
entry, err := s.eipKV.Get(ctx, k)
+15 -15
View File
@@ -22,7 +22,7 @@ import (
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -93,7 +93,7 @@ func NewIGWServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
igwKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketIGW, 10)
igwKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketIGW, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketIGW, err)
}
@@ -102,13 +102,13 @@ func NewIGWServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
}
// Get or create VPC KV bucket for cross-resource ownership validation
vpcKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
vpcKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
if err != nil {
return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err)
}
// Read EIP associations so detach can refuse while addresses are mapped.
eipKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2eip.KVBucketEIPs, 10)
eipKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2eip.KVBucketEIPs, 10)
if err != nil {
return nil, fmt.Errorf("failed to get EIP KV bucket: %w", err)
}
@@ -142,7 +142,7 @@ func (s *IGWServiceImpl) createIGW(ctx context.Context, input *ec2.CreateInterne
if err != nil {
return nil, fmt.Errorf("failed to marshal IGW record: %w", err)
}
if _, err := s.igwKV.Put(ctx, kvutil.AccountKey(accountID, igwID), data); err != nil {
if _, err := s.igwKV.Put(ctx, kvstore.AccountKey(accountID, igwID), data); err != nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
@@ -160,7 +160,7 @@ func (s *IGWServiceImpl) DeleteInternetGateway(ctx context.Context, input *ec2.D
}
igwID := *input.InternetGatewayId
key := kvutil.AccountKey(accountID, igwID)
key := kvstore.AccountKey(accountID, igwID)
entry, err := s.igwKV.Get(ctx, key)
if err != nil {
@@ -236,7 +236,7 @@ func (s *IGWServiceImpl) DescribeInternetGateways(ctx context.Context, input *ec
foundIDs := make(map[string]bool)
for _, key := range keys {
if key == kvutil.VersionKey {
if key == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(key, prefix) {
@@ -326,7 +326,7 @@ func (s *IGWServiceImpl) AttachInternetGateway(ctx context.Context, input *ec2.A
igwID := *input.InternetGatewayId
vpcID := *input.VpcId
key := kvutil.AccountKey(accountID, igwID)
key := kvstore.AccountKey(accountID, igwID)
entry, err := s.igwKV.Get(ctx, key)
if err != nil {
@@ -347,7 +347,7 @@ func (s *IGWServiceImpl) AttachInternetGateway(ctx context.Context, input *ec2.A
slog.ErrorContext(ctx, "VPC KV unavailable, cannot verify VPC ownership")
return nil, errors.New(awserrors.ErrorServerInternal)
}
if _, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID)); err != nil {
if _, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, vpcID)); err != nil {
slog.WarnContext(ctx, "AttachInternetGateway: VPC not found for account", "vpcId", vpcID, "accountID", accountID)
return nil, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID)
}
@@ -388,7 +388,7 @@ func (s *IGWServiceImpl) DetachInternetGateway(ctx context.Context, input *ec2.D
igwID := *input.InternetGatewayId
vpcID := *input.VpcId
key := kvutil.AccountKey(accountID, igwID)
key := kvstore.AccountKey(accountID, igwID)
entry, err := s.igwKV.Get(ctx, key)
if err != nil {
@@ -449,7 +449,7 @@ func (s *IGWServiceImpl) DetachInternetGateway(ctx context.Context, input *ec2.D
// associated into vpcID, whether to an interface or a NAT gateway. The listing
// is filtered server-side to the account's keys.
func (s *IGWServiceImpl) vpcHasMappedAddress(ctx context.Context, accountID, vpcID string) (bool, error) {
lister, err := s.eipKV.ListKeysFiltered(ctx, kvutil.AccountKey(accountID, "*"))
lister, err := s.eipKV.ListKeysFiltered(ctx, kvstore.AccountKey(accountID, "*"))
if err != nil {
return false, err
}
@@ -493,11 +493,11 @@ func (s *IGWServiceImpl) CreateAttachedInternetGateway(ctx context.Context, acco
if s.vpcKV == nil {
return false, errors.New(awserrors.ErrorServerInternal)
}
if _, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID)); err != nil {
if _, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, vpcID)); err != nil {
return false, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID)
}
key := kvutil.AccountKey(accountID, igwID)
key := kvstore.AccountKey(accountID, igwID)
data, err := json.Marshal(IGWRecord{
InternetGatewayId: igwID,
VpcId: vpcID,
@@ -558,7 +558,7 @@ func (s *IGWServiceImpl) AttachmentIntent(ctx context.Context, accountID, vpcID
}
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.igwKV.Get(ctx, key)
@@ -599,7 +599,7 @@ func (s *IGWServiceImpl) AttachmentIntent(ctx context.Context, accountID, vpcID
// Writing only on the pending transition keeps a pass from waking the drift loop.
func MarkAttached(ctx context.Context, kv jetstream.KeyValue, recordKey, vpcID string) error {
confirmed := false
record, err := kvutil.Update(ctx, kv, recordKey, kvutil.CASConfig{}, func(r *IGWRecord) (bool, error) {
record, err := kvstore.Update(ctx, kv, recordKey, kvstore.CASConfig{}, func(r *IGWRecord) (bool, error) {
// Reset per attempt: a retried CAS may see a record another writer has
// already moved out of pending.
confirmed = r.VpcId == vpcID && r.AttachState == AttachStatePending
@@ -13,7 +13,7 @@ import (
ec2eip "github.com/mulgadc/spinifex/spinifex/domains/ec2/eip"
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -55,7 +55,7 @@ func setupTestIGWService(t *testing.T) (*IGWServiceImpl, *nats.Conn) {
// Create VPC KV bucket and register test VPCs so fail-closed ownership checks pass
vpcEntries := map[string][]byte{}
for _, vpcID := range []string{"vpc-test123", "vpc-other", "vpc-lifecycle", "vpc-event-test"} {
vpcEntries[kvutil.AccountKey(testAccountID, vpcID)] = []byte(`{"vpc_id":"` + vpcID + `","state":"available"}`)
vpcEntries[kvstore.AccountKey(testAccountID, vpcID)] = []byte(`{"vpc_id":"` + vpcID + `","state":"available"}`)
}
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, vpcEntries)
@@ -68,7 +68,7 @@ func setupTestIGWService(t *testing.T) (*IGWServiceImpl, *nats.Conn) {
// gateway come up. Without it an attach stays pending and reports no attachment.
func confirmAttach(t *testing.T, svc *IGWServiceImpl, igwID, vpcID string) {
t.Helper()
require.NoError(t, MarkAttached(context.Background(), svc.igwKV, kvutil.AccountKey(testAccountID, igwID), vpcID))
require.NoError(t, MarkAttached(context.Background(), svc.igwKV, kvstore.AccountKey(testAccountID, igwID), vpcID))
}
func createTestIGW(t *testing.T, svc *IGWServiceImpl) string {
@@ -331,7 +331,7 @@ func TestDescribeInternetGateways_LegacyRecordReportsAttachment(t *testing.T) {
svc, _ := setupTestIGWService(t)
igwID := createTestIGW(t, svc)
key := kvutil.AccountKey(testAccountID, igwID)
key := kvstore.AccountKey(testAccountID, igwID)
entry, err := svc.igwKV.Get(context.Background(), key)
require.NoError(t, err)
var record IGWRecord
@@ -398,7 +398,7 @@ func TestMarkAttached_NoRewriteWhenAlreadyAttached(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
key := kvutil.AccountKey(testAccountID, igwID)
key := kvstore.AccountKey(testAccountID, igwID)
confirmAttach(t, svc, igwID, "vpc-test123")
first, err := svc.igwKV.Get(context.Background(), key)
require.NoError(t, err)
@@ -605,7 +605,7 @@ func TestDetachInternetGateway_MappedPublicAddress(t *testing.T) {
putEIP := func(rec ec2eip.EIPRecord) {
data, err := json.Marshal(rec)
require.NoError(t, err)
_, err = svc.eipKV.Put(context.Background(), kvutil.AccountKey(testAccountID, rec.AllocationId), data)
_, err = svc.eipKV.Put(context.Background(), kvstore.AccountKey(testAccountID, rec.AllocationId), data)
require.NoError(t, err)
}
putEIP(ec2eip.EIPRecord{AllocationId: "eipalloc-other", AssociationId: "eipassoc-other", VpcId: "vpc-other", State: "associated"})
@@ -614,7 +614,7 @@ func TestDetachInternetGateway_MappedPublicAddress(t *testing.T) {
// Another account's address naming the same VPC ID is not this account's dependency.
foreign, err := json.Marshal(ec2eip.EIPRecord{AllocationId: "eipalloc-foreign", AssociationId: "eipassoc-foreign", VpcId: "vpc-test123", State: "associated"})
require.NoError(t, err)
_, err = svc.eipKV.Put(context.Background(), kvutil.AccountKey("999999999999", "eipalloc-foreign"), foreign)
_, err = svc.eipKV.Put(context.Background(), kvstore.AccountKey("999999999999", "eipalloc-foreign"), foreign)
require.NoError(t, err)
detach := func() error {
@@ -828,7 +828,7 @@ func TestAttachInternetGateway_CrossAccountVPCRejected(t *testing.T) {
require.NoError(t, err)
vpcID := "vpc-alpha123"
_, err = vpcKV.Put(t.Context(), kvutil.AccountKey(testAccountID, vpcID), []byte(`{"vpc_id":"vpc-alpha123","state":"available"}`))
_, err = vpcKV.Put(t.Context(), kvstore.AccountKey(testAccountID, vpcID), []byte(`{"vpc_id":"vpc-alpha123","state":"available"}`))
require.NoError(t, err)
// Refresh service to pick up the VPC KV bucket
@@ -1,126 +0,0 @@
package instance
import (
"bytes"
"context"
"testing"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// varsTemplateBytes stands in for a firmware VARS template. The real one is
// 540,672 bytes, but nothing here depends on the size beyond it being exact.
func varsTemplateBytes() []byte { return bytes.Repeat([]byte{0xAB}, 4096) }
func providerEFIService(t *testing.T, provider ebsprovider.EBSProvider) *InstanceServiceImpl {
t.Helper()
return providerRootVolumeService(t, provider, rootVolumeAMILoader(), objectstore.NewMemoryObjectStore())
}
// TestCreateEFIVolume_Provider_SeedsFromTemplate is the core of the EFI
// decouple: the variable store is allocated through the provider with the
// template as seed bytes, so no control-plane engine is built for it.
func TestCreateEFIVolume_Provider_SeedsFromTemplate(t *testing.T) {
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{VolumeSeeding: true})
svc := providerEFIService(t, provider)
template := varsTemplateBytes()
require.NoError(t, svc.createEFIVolumeViaProvider(context.Background(), "vol-efiseed001-efi", template))
volume, err := provider.GetVolume(context.Background(), ebsprovider.GetVolumeRequest{
Versioned: ebsprovider.NewVersioned(), VolumeID: "vol-efiseed001-efi",
})
require.NoError(t, err)
assert.Equal(t, int64(len(template)), volume.CapacityBytes,
"the store must be sized byte-exactly to the VARS template")
seed, ok := provider.SeedData("vol-efiseed001-efi")
require.True(t, ok)
assert.Equal(t, template, seed, "the template must reach the provider as seed bytes")
}
// A provider that rounds capacity to a GiB boundary produces a VARS region
// pflash refuses, which would surface as an unbootable guest rather than a
// failed launch. The mismatch has to fail the launch instead.
func TestCreateEFIVolume_Provider_CapacityMismatchFails(t *testing.T) {
provider := &roundingProvider{
EBSProvider: ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{VolumeSeeding: true}),
}
svc := providerEFIService(t, provider)
err := svc.createEFIVolumeViaProvider(context.Background(), "vol-efiround01-efi", varsTemplateBytes())
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
// A relaunch reissues the same create. Reseeding would overwrite the BootOrder
// the guest wrote on its first boot, so the second call must be a no-op.
func TestCreateEFIVolume_Provider_RepeatDoesNotReseed(t *testing.T) {
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{VolumeSeeding: true})
svc := providerEFIService(t, provider)
ctx := context.Background()
template := varsTemplateBytes()
require.NoError(t, svc.createEFIVolumeViaProvider(ctx, "vol-efirepeat1-efi", template))
require.NoError(t, svc.createEFIVolumeViaProvider(ctx, "vol-efirepeat1-efi", template))
seed, ok := provider.SeedData("vol-efirepeat1-efi")
require.True(t, ok)
assert.Equal(t, template, seed)
}
// A seed above the wire bound must fail the launch with an error naming the
// limit, not reach NATS and fail as an oversized publish.
func TestCreateEFIVolume_Provider_OversizedTemplateFails(t *testing.T) {
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{VolumeSeeding: true})
svc := providerEFIService(t, provider)
err := svc.createEFIVolumeViaProvider(context.Background(), "vol-efitoobig1-efi", make([]byte, ebsprovider.MaxSeedBytes+1))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
// TestPrepareEFIVolume_Provider_RegistersEBSRequest covers the whole function
// against this host's real firmware, including the name the VM launches with.
func TestPrepareEFIVolume_Provider_RegistersEBSRequest(t *testing.T) {
if _, _, _, err := vm.FirmwarePaths("x86_64"); err != nil {
t.Skipf("no UEFI firmware on this host: %v", err)
}
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{VolumeSeeding: true})
svc := providerEFIService(t, provider)
instance := &vm.VM{}
require.NoError(t, svc.prepareEFIVolume(context.Background(), "vol-efiprepare001", instance, "x86_64"))
instance.EBSRequests.Mu.Lock()
defer instance.EBSRequests.Mu.Unlock()
require.Len(t, instance.EBSRequests.Requests, 1)
assert.Equal(t, "vol-efiprepare001-efi", instance.EBSRequests.Requests[0].Name)
assert.True(t, instance.EBSRequests.Requests[0].EFI)
assert.False(t, instance.EBSRequests.Requests[0].Boot)
seed, ok := provider.SeedData("vol-efiprepare001-efi")
require.True(t, ok)
assert.NotEmpty(t, seed, "the store must be seeded from this host's VARS template")
}
// roundingProvider reports a GiB-rounded capacity the way a provider with no
// sub-GiB volume granularity would.
type roundingProvider struct {
ebsprovider.EBSProvider
}
func (p *roundingProvider) CreateVolume(ctx context.Context, req ebsprovider.CreateVolumeRequest) (*ebsprovider.Volume, error) {
volume, err := p.EBSProvider.CreateVolume(ctx, req)
if err != nil {
return nil, err
}
volume.CapacityBytes = 1 << 30
return volume, nil
}
+15 -87
View File
@@ -10,7 +10,6 @@ import (
"github.com/mulgadc/spinifex/spinifex/domains/ec2/tagmirror"
"log/slog"
"maps"
"os"
"slices"
"sort"
"strconv"
@@ -1265,6 +1264,10 @@ func (s *InstanceServiceImpl) StartInstance(ctx context.Context, instance *vm.VM
slog.WarnContext(ctx, "StartInstance: vmMgr.Start failed", "instanceId", command.ID, "err", err)
return refusal
}
if errors.Is(err, vm.ErrUEFIClaimHeld) {
slog.WarnContext(ctx, "StartInstance: vmMgr.Start failed", "instanceId", command.ID, "err", err)
return errInsufficientCapacity
}
slog.ErrorContext(ctx, "StartInstance: vmMgr.Start failed", "instanceId", command.ID, "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
@@ -1612,18 +1615,14 @@ func (s *InstanceServiceImpl) GenerateVolumes(ctx context.Context, input *ec2.Ru
return nil, err
}
// Step 2: Create EFI variable store (only when the AMI is UEFI; BIOS
// guests must not allocate an orphan VARS volume).
if instance.BootMode == "uefi" || instance.BootMode == "uefi-preferred" {
arch := instanceArchitecture(s.instanceTypes[*input.InstanceType])
err = s.prepareEFIVolume(ctx, imageId, instance, arch)
if err != nil {
return nil, err
}
// Step 2: Seed the UEFI variable store, which is not a volume. A failure
// terminates the instance, and terminate deletes whatever store it left.
if err := s.vmMgr.SeedUEFIVarStore(ctx, instance); err != nil {
slog.ErrorContext(ctx, "Failed to seed the UEFI variable store", "instanceId", instance.ID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
// The EFI store is internal, so it is not reported; every volume the
// request named is.
// Every volume the request named is reported.
volumeInfos := []VolumeInfo{
{
VolumeId: imageId,
@@ -1885,81 +1884,6 @@ func rootVolumeIOPS(requested int) int {
return requested
}
// prepareEFIVolume creates the per-VM EFI variable store, sized exactly to the
// firmware VARS template (pflash requires byte-exact size) and seeded from it.
// arch is "x86_64" | "arm64".
func (s *InstanceServiceImpl) prepareEFIVolume(ctx context.Context, volumeID string, instance *vm.VM, arch string) error {
codePath, varsTemplate, varsSize, err := vm.FirmwarePaths(arch)
if err != nil {
slog.ErrorContext(ctx, "UEFI firmware not installed on this host", "arch", arch, "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
template, err := os.ReadFile(varsTemplate)
if err != nil {
slog.ErrorContext(ctx, "Failed to read VARS template", "path", varsTemplate, "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
if int64(len(template)) != varsSize {
slog.ErrorContext(ctx, "VARS template size mismatch between stat and read", "path", varsTemplate, "statSize", varsSize, "readSize", len(template))
return errors.New(awserrors.ErrorServerInternal)
}
slog.InfoContext(ctx, "Preparing EFI variable store", "arch", arch, "firmwarePath", codePath, "varsTemplate", varsTemplate, "size", varsSize)
efiVolumeName := fmt.Sprintf("%s-efi", volumeID)
if s.ebsProvider == nil {
slog.ErrorContext(ctx, "no EBS provider configured", "op", "prepareEFIVolume")
return errors.New(awserrors.ErrorServerInternal)
}
if err := s.createEFIVolumeViaProvider(ctx, efiVolumeName, template); err != nil {
return err
}
instance.EBSRequests.Mu.Lock()
instance.EBSRequests.Requests = append(instance.EBSRequests.Requests, viperblocklegacyv1.EBSRequest{
Name: efiVolumeName,
Boot: false,
EFI: true,
})
instance.EBSRequests.Mu.Unlock()
return nil
}
// createEFIVolumeViaProvider allocates the EFI variable store through the
// provider, seeding it with this node's firmware VARS template. The bytes
// travel with the request so the region matches the firmware that will run.
func (s *InstanceServiceImpl) createEFIVolumeViaProvider(ctx context.Context, efiVolumeName string, template []byte) error {
slog.InfoContext(ctx, "Creating EFI variable store via EBS provider", "name", efiVolumeName, "seedBytes", len(template))
// The store is internal to the launch, never attachable and filtered out of
// DescribeVolumes, so it gets no ebsmetadata document the way a root volume
// does. Its lifecycle is the root volume's DeleteVolume.
created, err := s.ebsProvider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{
Versioned: ebsprovider.NewVersioned(),
VolumeID: efiVolumeName,
CapacityRange: ebsprovider.CapacityRange{RequiredBytes: int64(len(template))},
AvailabilityZone: s.config.AZ,
SeedData: template,
})
if err != nil {
slog.ErrorContext(ctx, "Provider EFI volume creation failed", "name", efiVolumeName, "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
if created == nil {
slog.ErrorContext(ctx, "Provider returned no volume for the EFI variable store", "name", efiVolumeName)
return errors.New(awserrors.ErrorServerInternal)
}
// pflash rejects a VARS region that is not byte-exact, so a provider that
// rounded the capacity up to a GiB boundary yields a guest that cannot boot.
if created.CapacityBytes != int64(len(template)) {
slog.ErrorContext(ctx, "Provider EFI volume capacity does not match the VARS template",
"name", efiVolumeName, "wantBytes", len(template), "gotBytes", created.CapacityBytes)
return errors.New(awserrors.ErrorServerInternal)
}
return nil
}
// checkImageArchitecture refuses an AMI whose architecture the instance type
// does not support, as AWS does. An unrecorded architecture on either side is
// not a mismatch.
@@ -2760,7 +2684,7 @@ func (s *InstanceServiceImpl) capacityRefusal(ctx context.Context, instance *vm.
func (s *InstanceServiceImpl) launchClaimedInstance(ctx context.Context, instance *vm.VM, undo func()) error {
// Before anything is stamped or claimed, so the record handed back is the
// one that was claimed.
if err := vm.CheckUEFIVarStore(instance); err != nil {
if err := s.vmMgr.CheckUEFIVarStore(ctx, instance); err != nil {
slog.ErrorContext(ctx, "launchClaimedInstance: refusing to launch a UEFI instance without exactly one variable store",
"instanceId", instance.ID, "err", err)
undo()
@@ -2893,6 +2817,10 @@ func (s *InstanceServiceImpl) launchClaimedInstance(ctx context.Context, instanc
if refusal := volumeHeldElsewhereError(err); refusal != nil {
return refusal
}
// A live node holds the variable store, so another node may take it.
if errors.Is(err, vm.ErrUEFIClaimHeld) {
return s.capacityRefusal(ctx, instance)
}
return errors.New(awserrors.ErrorServerInternal)
}
@@ -1,33 +1,12 @@
package instance
import "context"
// OutstandingInstance is an instance of an account that is not yet fully torn
// down, with the state its record carries.
type OutstandingInstance struct {
InstanceID string `json:"instance_id"`
InstanceID string
// State is the record's lifecycle state, such as running or terminated.
State string `json:"state"`
State string
// Teardown is the per-dependent progress (done, pending or failed) the
// record carries. It is empty when no dependent has been recorded.
Teardown map[string]string `json:"teardown,omitempty"`
}
// TeardownOutstandingInput is the empty request of the outstanding-teardown
// read; the account comes from the message header.
type TeardownOutstandingInput struct{}
// TeardownOutstandingOutput carries every instance not yet fully torn down.
type TeardownOutstandingOutput struct {
Instances []OutstandingInstance `json:"instances"`
}
// AccountTeardownOutstandingRPC serves AccountTeardownOutstanding over the
// message bus, so a process without the instance record store can read it.
func (s *InstanceServiceImpl) AccountTeardownOutstandingRPC(_ context.Context, _ *TeardownOutstandingInput, accountID string) (*TeardownOutstandingOutput, error) {
outstanding, err := s.AccountTeardownOutstanding(accountID)
if err != nil {
return nil, err
}
return &TeardownOutstandingOutput{Instances: outstanding}, nil
Teardown map[string]string
}
@@ -0,0 +1,122 @@
package instance
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
ebsprovider "github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testSeedInstance = "i-0a1b2c3d4e5f60718"
type seedInstanceTypes map[string]vm.InstanceTypeSpec
func (r seedInstanceTypes) Resolve(name string) (vm.InstanceTypeSpec, bool) {
spec, ok := r[name]
return spec, ok
}
// uefiSeedingService is an instance service whose VM manager seeds stores into
// a real JetStream's store records and claims and an in-memory object store,
// under a stand-in x86_64 firmware.
func uefiSeedingService(t *testing.T) (*InstanceServiceImpl, *ebsprovider.MemoryProvider, *uefistore.Store, *objectstore.MemoryObjectStore) {
t.Helper()
fwDir := t.TempDir()
code := filepath.Join(fwDir, "OVMF_CODE.fd")
vars := filepath.Join(fwDir, "OVMF_VARS.fd")
require.NoError(t, os.WriteFile(code, []byte("code"), 0o600))
require.NoError(t, os.WriteFile(vars, make([]byte, 4096), 0o600))
orig, had := vm.FirmwarePathCandidates["x86_64"]
vm.FirmwarePathCandidates["x86_64"] = []vm.FirmwareCandidate{{Code: code, VarsTemplate: vars}}
t.Cleanup(func() {
if had {
vm.FirmwarePathCandidates["x86_64"] = orig
} else {
delete(vm.FirmwarePathCandidates, "x86_64")
}
})
_, _, js := testutil.StartTestJetStream(t)
records := uefistore.NewStore(js)
require.NoError(t, records.Open(t.Context()))
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{CrashConsistentSnapshot: true, VolumeEnumeration: true})
seedProviderSnapshot(t, provider, "vol-origin", "snap-source")
objects := objectstore.NewMemoryObjectStore()
svc := providerRootVolumeService(t, provider, rootVolumeAMILoader(), objects)
svc.vmMgr = vm.NewManagerWithDeps(vm.Deps{
NodeID: "node-a",
InstanceTypes: seedInstanceTypes{"t3.nano": {Architecture: "x86_64", VCPUs: 1, MemoryMiB: 512}},
UEFIVarStore: vm.UEFIVarStoreDeps{
Objects: objects, Bucket: testRootBucket, DataDir: t.TempDir(), Records: records,
Claims: uefistore.NewClaims(kvstore.OpenSyncBucket(js), "node-a"),
},
})
return svc, provider, records, objects
}
func uefiLaunch() (*ec2.RunInstancesInput, *vm.VM) {
input := &ec2.RunInstancesInput{ImageId: aws.String("ami-1"), InstanceType: aws.String("t3.nano")}
return input, &vm.VM{ID: testSeedInstance, AccountID: testRootAccount, InstanceType: "t3.nano", BootMode: "uefi"}
}
// A UEFI launch seeds its store as an object and a store record, and creates
// no <root>-efi volume and no EFI request.
func TestGenerateVolumes_UEFILaunchSeedsAStoreAndCreatesNoEFIVolume(t *testing.T) {
svc, provider, records, objects := uefiSeedingService(t)
input, instance := uefiLaunch()
infos, err := svc.GenerateVolumes(t.Context(), input, instance)
require.NoError(t, err)
require.Len(t, infos, 1, "only the root volume is reported")
instance.EBSRequests.Mu.Lock()
for _, req := range instance.EBSRequests.Requests {
assert.False(t, req.EFI, "no EFI request is appended")
assert.False(t, strings.HasSuffix(req.Name, "-efi"), req.Name)
}
instance.EBSRequests.Mu.Unlock()
listed, err := provider.ListVolumes(t.Context(), ebsprovider.ListVolumesRequest{Versioned: ebsprovider.NewVersioned()})
require.NoError(t, err)
for _, v := range listed.Volumes {
assert.False(t, strings.HasSuffix(v.ID, "-efi"), "no EFI volume is created: %s", v.ID)
}
rec, _, err := records.Get(t.Context(), testSeedInstance)
require.NoError(t, err)
assert.Equal(t, uefistore.StateLive, rec.State)
require.NotNil(t, rec.Pointer)
keys, err := uefistore.ListObjects(t.Context(), objects, testRootBucket, testSeedInstance)
require.NoError(t, err)
assert.Equal(t, []string{rec.Pointer.Key}, keys, "the pointer names the one template object")
}
// A seed that cannot be written fails the launch as InternalError, and no EFI
// volume is created in its place.
func TestGenerateVolumes_FailedSeedIsInternalError(t *testing.T) {
svc, provider, _, _ := uefiSeedingService(t)
svc.vmMgr = vm.NewManager()
input, instance := uefiLaunch()
_, err := svc.GenerateVolumes(t.Context(), input, instance)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
listed, err := provider.ListVolumes(t.Context(), ebsprovider.ListVolumesRequest{Versioned: ebsprovider.NewVersioned()})
require.NoError(t, err)
for _, v := range listed.Volumes {
assert.False(t, strings.HasSuffix(v.ID, "-efi"), v.ID)
}
}
@@ -10,7 +10,6 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -26,9 +25,9 @@ func (s *Store) Exists(ctx context.Context, instanceID string) (bool, error) {
return s.kv.Exists(ctx, instanceID)
}
// MarkDeleting records that instanceID's store is to be deleted, and drops any
// write-back still owed. It reports false with no error when the instance has
// no store record. Only a terminate or an operator's forget may call it.
// MarkDeleting records that instanceID's store is to be deleted. It reports
// false with no error when the instance has no store record. Only a terminate
// or an operator's forget may call it.
func (s *Store) MarkDeleting(ctx context.Context, instanceID string) (bool, error) {
for range markAttempts {
rec, rev, err := s.Get(ctx, instanceID)
@@ -42,7 +41,6 @@ func (s *Store) MarkDeleting(ctx context.Context, instanceID string) (bool, erro
return true, nil
}
rec.State = StateDeleting
rec.WriteBack = nil
_, err = s.CompareAndSet(ctx, rec, rev)
if err == nil {
return true, nil
@@ -72,7 +70,7 @@ func (s *Store) InstanceIDs(ctx context.Context) ([]string, error) {
var ids []string
for key := range lister.Keys() {
if key != kvutil.VersionKey {
if key != kvstore.VersionKey {
ids = append(ids, key)
}
}
@@ -37,7 +37,7 @@ func keysUnder(t *testing.T, objects objectstore.ObjectStore, prefix string) []s
}
// Deletion is final: a writer holding the revision of a deleting record, such
// as a write-back retry, must not bring the store back to live.
// as a late pointer move, must not bring the store back to live.
func TestCompareAndSet_RefusesDeletingToLive(t *testing.T) {
s, _ := openStore(t)
rev, err := s.Create(t.Context(), liveRecord())
@@ -47,8 +47,7 @@ func TestCompareAndSet_RefusesDeletingToLive(t *testing.T) {
rev, err = s.CompareAndSet(t.Context(), deleting, rev)
require.NoError(t, err)
revived := liveRecord()
revived.WriteBack = writeBack()
revived := pointedRecord()
_, err = s.CompareAndSet(t.Context(), revived, rev)
require.ErrorIs(t, err, uefistore.ErrDeleting)
@@ -58,24 +57,9 @@ func TestCompareAndSet_RefusesDeletingToLive(t *testing.T) {
assert.Equal(t, uefistore.StateDeleting, got.State)
}
// A write-back owed on a deleting store would put an object behind the delete.
func TestCompareAndSet_RefusesDeletingWithAWriteBack(t *testing.T) {
func TestMarkDeleting_MovesLiveToDeletingAndKeepsWhatItNames(t *testing.T) {
s, _ := openStore(t)
rev, err := s.Create(t.Context(), liveRecord())
require.NoError(t, err)
rec := liveRecord()
rec.State = uefistore.StateDeleting
rec.WriteBack = writeBack()
_, err = s.CompareAndSet(t.Context(), rec, rev)
require.ErrorIs(t, err, uefistore.ErrInvalidRecord)
}
func TestMarkDeleting_MovesLiveToDeletingAndDropsTheWriteBack(t *testing.T) {
s, _ := openStore(t)
rec := liveRecord()
rec.WriteBack = writeBack()
_, err := s.Create(t.Context(), rec)
_, err := s.Create(t.Context(), pointedRecord())
require.NoError(t, err)
has, err := s.MarkDeleting(t.Context(), instanceID)
@@ -85,8 +69,8 @@ func TestMarkDeleting_MovesLiveToDeletingAndDropsTheWriteBack(t *testing.T) {
got, rev, err := s.Get(t.Context(), instanceID)
require.NoError(t, err)
assert.Equal(t, uefistore.StateDeleting, got.State)
assert.Nil(t, got.WriteBack)
assert.Equal(t, "111122223333", got.AccountID)
assert.Equal(t, pointer("b"), got.Pointer, "the pointer stays readable until the prefix is gone")
has, err = s.MarkDeleting(t.Context(), instanceID)
require.NoError(t, err)
@@ -0,0 +1,93 @@
package uefistore
import (
"context"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
)
// ClaimPrefix is the kvsync prefix of every store's claim. An instance's claim
// is ClaimPrefix+instanceID, and names the node that may hold variable changes
// no object has yet.
const ClaimPrefix = "uefi."
// NewClaims returns node's group of store claims over store. The daemon builds
// it once per process, as kvsync requires of every prefix.
func NewClaims(store kvsync.KVStore, node string) *kvsync.ClaimGroup {
return kvsync.NewClaimGroup(store, ClaimPrefix, node)
}
// ErrFenced is a stamp or pointer move refused because the record carries a
// claim generation other than the caller's: a later acquisition has stamped
// it, so the caller no longer owns the store and must change nothing.
var ErrFenced = errors.New("uefistore: store record is fenced to another claim generation")
// fenceAttempts bounds the read-then-write loops of Stamp and MovePointer. A
// lost race is re-read, so a concurrent stamp is seen and refused, not retried.
const fenceAttempts = 5
// Stamp raises instanceID's record to generation, the claim token the caller
// has just acquired or adopted, and returns the record it stamped, whose
// pointer the caller boots from. One above generation is ErrFenced, and one
// deleting is ErrDeleting. A record already at generation is still written by
// CAS, because a follower can answer the read with an older pointer.
func (s *Store) Stamp(ctx context.Context, instanceID string, generation uint64) (*UEFIStoreRecord, error) {
if generation == 0 {
return nil, fmt.Errorf("uefistore: stamp %s: generation 0 is no claim", instanceID)
}
for range fenceAttempts {
rec, rev, err := s.Get(ctx, instanceID)
if err != nil {
return nil, err
}
switch {
case rec.State == StateDeleting:
return nil, fmt.Errorf("%w: %s", ErrDeleting, instanceID)
case rec.Generation > generation:
return nil, fmt.Errorf("%w: %s is at generation %d, above the claim's %d", ErrFenced, instanceID, rec.Generation, generation)
}
rec.Generation = generation
_, err = s.CompareAndSet(ctx, rec, rev)
if err == nil {
return rec, nil
}
if !errors.Is(err, kvstore.ErrConflict) {
return nil, err
}
}
return nil, fmt.Errorf("uefistore: stamp %s: %w after %d attempts", instanceID, kvstore.ErrConflict, fenceAttempts)
}
// MovePointer points instanceID's record at ref only while the record is at
// generation. A record at any other generation is ErrFenced and is left
// unchanged. The leader answers the read, because a caller drops its copy on
// ErrFenced or ErrDeleting, and the CAS keeps a lost race from succeeding.
func (s *Store) MovePointer(ctx context.Context, instanceID string, generation uint64, ref UEFIVarStoreRef) error {
if err := ref.validate(instanceID); err != nil {
return fmt.Errorf("%w: move %s: %w", ErrInvalidRecord, instanceID, err)
}
for range fenceAttempts {
rec, rev, err := s.LeaderGet(ctx, instanceID)
if err != nil {
return err
}
switch {
case rec.State == StateDeleting:
return fmt.Errorf("%w: %s", ErrDeleting, instanceID)
case rec.Generation != generation:
return fmt.Errorf("%w: %s is at generation %d, and the writer holds %d", ErrFenced, instanceID, rec.Generation, generation)
}
rec.Pointer = &ref
_, err = s.CompareAndSet(ctx, rec, rev)
if err == nil {
return nil
}
if !errors.Is(err, kvstore.ErrConflict) {
return err
}
}
return fmt.Errorf("uefistore: move %s: %w after %d attempts", instanceID, kvstore.ErrConflict, fenceAttempts)
}
@@ -0,0 +1,213 @@
package uefistore_test
import (
"testing"
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func revisionOf(t *testing.T, s *uefistore.Store) uint64 {
t.Helper()
_, rev, err := s.Get(t.Context(), instanceID)
require.NoError(t, err)
return rev
}
func TestStamp_RaisesTheGenerationAndReturnsThePointerToBootFrom(t *testing.T) {
s, _ := openStore(t)
_, err := s.Create(t.Context(), pointedRecord())
require.NoError(t, err)
rec, err := s.Stamp(t.Context(), instanceID, 9)
require.NoError(t, err)
assert.Equal(t, uint64(9), rec.Generation)
assert.Equal(t, pointer("b"), rec.Pointer)
got, _, err := s.Get(t.Context(), instanceID)
require.NoError(t, err)
assert.Equal(t, rec, got, "the returned record is the one stored")
}
// A daemon restart adopts its claim at the same generation. The stamp still
// writes by CAS, so a pointer read from a lagging follower fails the write and
// is read again rather than booted from.
func TestStamp_AtTheRecordsGenerationConfirmsTheReadByCAS(t *testing.T) {
s, _ := openStore(t)
_, err := s.Create(t.Context(), pointedRecord())
require.NoError(t, err)
before := revisionOf(t, s)
rec, err := s.Stamp(t.Context(), instanceID, 7)
require.NoError(t, err)
assert.Equal(t, pointer("b"), rec.Pointer)
assert.Equal(t, uint64(7), rec.Generation)
assert.Greater(t, revisionOf(t, s), before, "the read must be confirmed by a revision-checked write")
}
func TestStamp_RefusesAndChangesNothing(t *testing.T) {
tests := []struct {
name string
deleting bool
generation uint64
want error
}{
{name: "a later acquisition stamped it", generation: 6, want: uefistore.ErrFenced},
{name: "the store is being deleted", deleting: true, generation: 8, want: uefistore.ErrDeleting},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s, _ := openStore(t)
rev, err := s.Create(t.Context(), pointedRecord())
require.NoError(t, err)
if tt.deleting {
_, err = s.MarkDeleting(t.Context(), instanceID)
require.NoError(t, err)
rev = revisionOf(t, s)
}
_, err = s.Stamp(t.Context(), instanceID, tt.generation)
require.ErrorIs(t, err, tt.want)
assert.Equal(t, rev, revisionOf(t, s))
})
}
}
func TestStamp_MissingRecordIsNotFound(t *testing.T) {
s, _ := openStore(t)
_, err := s.Stamp(t.Context(), instanceID, 1)
require.ErrorIs(t, err, kvstore.ErrNotFound)
}
// The record keeps only the current version, so each move replaces the
// pointer and names nothing it replaced.
func TestMovePointer_ReplacesThePointer(t *testing.T) {
s, _ := openStore(t)
_, err := s.Create(t.Context(), pointedRecord())
require.NoError(t, err)
for _, name := range []string{"c", "d"} {
require.NoError(t, s.MovePointer(t.Context(), instanceID, 7, *pointer(name)))
}
got, _, err := s.Get(t.Context(), instanceID)
require.NoError(t, err)
assert.Equal(t, pointer("d"), got.Pointer)
assert.Equal(t, uint64(7), got.Generation)
}
// A move to the key the record already names is still a CAS at the revision
// read, so a fenced writer that read a lagging follower cannot see success.
func TestMovePointer_ToTheCurrentKeyConfirmsTheReadByCAS(t *testing.T) {
s, _ := openStore(t)
_, err := s.Create(t.Context(), pointedRecord())
require.NoError(t, err)
before := revisionOf(t, s)
require.NoError(t, s.MovePointer(t.Context(), instanceID, 7, *pointer("b")))
assert.Greater(t, revisionOf(t, s), before)
}
func TestMovePointer_RefusesAndChangesNothing(t *testing.T) {
tests := []struct {
name string
deleting bool
generation uint64
ref *uefistore.UEFIVarStoreRef
want error
}{
{name: "an earlier generation", generation: 6, ref: pointer("c"), want: uefistore.ErrFenced},
{name: "a later generation than the stamp", generation: 8, ref: pointer("c"), want: uefistore.ErrFenced},
{name: "a deleting store", deleting: true, generation: 7, ref: pointer("c"), want: uefistore.ErrDeleting},
{name: "a key under another instance", generation: 7,
ref: &uefistore.UEFIVarStoreRef{Key: uefistore.KeyPrefix("i-0fedcba9876543210") + "c", Firmware: pointer("c").Firmware, WriterNode: "n"},
want: uefistore.ErrInvalidRecord},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s, _ := openStore(t)
_, err := s.Create(t.Context(), pointedRecord())
require.NoError(t, err)
if tt.deleting {
_, err = s.MarkDeleting(t.Context(), instanceID)
require.NoError(t, err)
}
rev := revisionOf(t, s)
err = s.MovePointer(t.Context(), instanceID, tt.generation, *tt.ref)
require.ErrorIs(t, err, tt.want)
got, gotRev, err := s.Get(t.Context(), instanceID)
require.NoError(t, err)
assert.Equal(t, rev, gotRev)
assert.Equal(t, pointer("b"), got.Pointer)
})
}
}
// claimedStore is a store record and a claim group per node over one
// JetStream, as two daemons see them.
type claimedStore struct {
records *uefistore.Store
a, b *kvsync.Claim
}
func newClaimedStore(t *testing.T) *claimedStore {
t.Helper()
_, _, js := testutil.StartTestJetStream(t)
records := uefistore.NewStore(js)
require.NoError(t, records.Open(t.Context()))
_, err := records.Create(t.Context(), liveRecord())
require.NoError(t, err)
return &claimedStore{
records: records,
a: uefistore.NewClaims(kvstore.OpenSyncBucket(js), "node-a").Get(instanceID),
b: uefistore.NewClaims(kvstore.OpenSyncBucket(js), "node-b").Get(instanceID),
}
}
// A node partitioned long enough to be taken over is fenced by the new
// owner's stamp: its pointer move fails and the record keeps what it had.
func TestFence_TheOldHoldersMoveFailsAfterTheNewOwnersStamp(t *testing.T) {
c := newClaimedStore(t)
first, err := c.a.TryLock(t.Context())
require.NoError(t, err)
_, err = c.records.Stamp(t.Context(), instanceID, first.Generation)
require.NoError(t, err)
require.NoError(t, c.records.MovePointer(t.Context(), instanceID, first.Generation, *pointer("b")))
second, err := c.b.TakeOver(t.Context(), first)
require.NoError(t, err)
_, err = c.records.Stamp(t.Context(), instanceID, second.Generation)
require.NoError(t, err)
rev := revisionOf(t, c.records)
err = c.records.MovePointer(t.Context(), instanceID, first.Generation, *pointer("late"))
require.ErrorIs(t, err, uefistore.ErrFenced)
got, gotRev, err := c.records.Get(t.Context(), instanceID)
require.NoError(t, err)
assert.Equal(t, rev, gotRev)
assert.Equal(t, pointer("b"), got.Pointer)
assert.Equal(t, second.Generation, got.Generation)
}
// The new owner reads the pointer after it stamps, so a move the old holder
// landed between the takeover and the stamp is the one it boots from.
func TestFence_AMoveBetweenTakeoverAndStampIsThePointerTheNewOwnerBoots(t *testing.T) {
c := newClaimedStore(t)
first, err := c.a.TryLock(t.Context())
require.NoError(t, err)
_, err = c.records.Stamp(t.Context(), instanceID, first.Generation)
require.NoError(t, err)
second, err := c.b.TakeOver(t.Context(), first)
require.NoError(t, err)
require.NoError(t, c.records.MovePointer(t.Context(), instanceID, first.Generation, *pointer("landed")))
rec, err := c.records.Stamp(t.Context(), instanceID, second.Generation)
require.NoError(t, err)
assert.Equal(t, keyOf("landed"), rec.Pointer.Key)
}
@@ -0,0 +1,121 @@
package uefistore
import (
"bytes"
"compress/gzip"
"context"
"crypto/rand"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
)
const (
// SourceTemplate is a store seeded from the launching node's VARS template.
SourceTemplate = "template"
// SourceWriteBack is a store written back from a node's live copy.
SourceWriteBack = "write-back"
// PutTimeout bounds one put of a store object. The object is a fixed-size
// region, so a put that takes longer is a wedged object store.
PutTimeout = 10 * time.Second
)
// ErrInvalidObject is a store object whose key, region or metadata cannot be
// written as the owner's store.
var ErrInvalidObject = errors.New("uefistore: invalid store object")
// ObjectMeta is what a store object records about itself, as x-amz-meta-
// entries, so its owner, writer and firmware are known from the object alone.
type ObjectMeta struct {
OwnerID string
WriterNode string
Firmware FirmwareIdentity
CodePath string
Source string
}
// Metadata returns m as object metadata, keyed by the owner's kind.
func (m ObjectMeta) Metadata() (map[string]*string, error) {
var ownerKey string
switch {
case strings.HasPrefix(m.OwnerID, "i-"):
ownerKey = "instance-id"
case strings.HasPrefix(m.OwnerID, "ami-"):
ownerKey = "image-id"
default:
return nil, fmt.Errorf("%w: owner %q is neither an instance nor an image", ErrInvalidObject, m.OwnerID)
}
switch {
case m.WriterNode == "":
return nil, fmt.Errorf("%w: %s names no writer node", ErrInvalidObject, m.OwnerID)
case m.Source == "":
return nil, fmt.Errorf("%w: %s names no source", ErrInvalidObject, m.OwnerID)
case m.Firmware.Arch == "" || m.Firmware.VarsSize <= 0 || m.Firmware.CodeSHA256 == "":
return nil, fmt.Errorf("%w: %s has an incomplete firmware identity", ErrInvalidObject, m.OwnerID)
}
return map[string]*string{
ownerKey: aws.String(m.OwnerID),
"writer-node": aws.String(m.WriterNode),
"firmware-arch": aws.String(m.Firmware.Arch),
"firmware-vars-size": aws.String(strconv.FormatInt(m.Firmware.VarsSize, 10)),
"firmware-code-sha256": aws.String(m.Firmware.CodeSHA256),
"firmware-code-path": aws.String(m.CodePath),
"source": aws.String(m.Source),
}, nil
}
// NewObjectKey returns a fresh key under ownerID's prefix, named by a random
// version 4 UUID, so no write ever replaces an object a record points at.
func NewObjectKey(ownerID string) (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generate UEFI store key for %s: %w", ownerID, err)
}
b[6] = b[6]&0x0f | 0x40
b[8] = b[8]&0x3f | 0x80
return fmt.Sprintf("%s%x-%x-%x-%x-%x", KeyPrefix(ownerID), b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
// PutObject writes region gzip-compressed to key with meta, within
// PutTimeout. The region must be exactly the firmware's VARS size, because
// pflash refuses any other.
func PutObject(ctx context.Context, objects objectstore.ObjectStore, bucket, key string, region []byte, meta ObjectMeta) error {
if !ownsKey(meta.OwnerID, key) {
return fmt.Errorf("%w: key %q is not under %s", ErrInvalidObject, key, KeyPrefix(meta.OwnerID))
}
if int64(len(region)) != meta.Firmware.VarsSize {
return fmt.Errorf("%w: %s is a %d-byte region under firmware with a %d-byte VARS region",
ErrInvalidObject, key, len(region), meta.Firmware.VarsSize)
}
md, err := meta.Metadata()
if err != nil {
return err
}
var body bytes.Buffer
zw := gzip.NewWriter(&body)
if _, err := zw.Write(region); err != nil {
return fmt.Errorf("compress UEFI store %s: %w", key, err)
}
if err := zw.Close(); err != nil {
return fmt.Errorf("compress UEFI store %s: %w", key, err)
}
putCtx, cancel := context.WithTimeout(ctx, PutTimeout)
defer cancel()
if _, err := objects.PutObject(putCtx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: bytes.NewReader(body.Bytes()),
Metadata: md,
}); err != nil {
return fmt.Errorf("put UEFI store %s: %w", key, err)
}
return nil
}
@@ -0,0 +1,164 @@
package uefistore_test
import (
"bytes"
"compress/gzip"
"context"
"errors"
"io"
"regexp"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testBucket = "instances"
func testMeta(owner string) uefistore.ObjectMeta {
return uefistore.ObjectMeta{
OwnerID: owner,
WriterNode: "node-1",
Firmware: uefistore.FirmwareIdentity{Arch: "arm64", VarsSize: 1 << 16, CodeSHA256: "abc123"},
CodePath: "/usr/share/AAVMF/AAVMF_CODE.fd",
Source: uefistore.SourceTemplate,
}
}
var uuidV4 = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
// Every key is a fresh version 4 UUID directly under the owner's prefix, so
// a write never lands on an object a record already points at.
func TestNewObjectKey_IsAFreshUUIDUnderTheOwnersPrefix(t *testing.T) {
seen := map[string]bool{}
for range 64 {
key, err := uefistore.NewObjectKey(instanceID)
require.NoError(t, err)
name, ok := bytes.CutPrefix([]byte(key), []byte(uefistore.KeyPrefix(instanceID)))
require.True(t, ok, key)
assert.Regexp(t, uuidV4, string(name))
assert.False(t, seen[key], "key %s repeated", key)
seen[key] = true
}
}
func TestObjectMeta_Metadata_NamesTheOwnerByKind(t *testing.T) {
md, err := testMeta(instanceID).Metadata()
require.NoError(t, err)
assert.Equal(t, map[string]string{
"instance-id": instanceID,
"writer-node": "node-1",
"firmware-arch": "arm64",
"firmware-vars-size": "65536",
"firmware-code-sha256": "abc123",
"firmware-code-path": "/usr/share/AAVMF/AAVMF_CODE.fd",
"source": "template",
}, aws.StringValueMap(md))
img := testMeta("ami-0123456789abcdef0")
img.Source = "image:ami-0fedcba9876543210"
md, err = img.Metadata()
require.NoError(t, err)
assert.Equal(t, "ami-0123456789abcdef0", aws.StringValue(md["image-id"]))
assert.NotContains(t, md, "instance-id")
assert.Equal(t, "image:ami-0fedcba9876543210", aws.StringValue(md["source"]))
}
func TestObjectMeta_Metadata_RefusesAnIncompleteObject(t *testing.T) {
tests := map[string]func(*uefistore.ObjectMeta){
"unknown owner": func(m *uefistore.ObjectMeta) { m.OwnerID = "vol-1" },
"no writer": func(m *uefistore.ObjectMeta) { m.WriterNode = "" },
"no source": func(m *uefistore.ObjectMeta) { m.Source = "" },
"no arch": func(m *uefistore.ObjectMeta) { m.Firmware.Arch = "" },
"no vars size": func(m *uefistore.ObjectMeta) { m.Firmware.VarsSize = 0 },
"no code sha256": func(m *uefistore.ObjectMeta) { m.Firmware.CodeSHA256 = "" },
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
m := testMeta(instanceID)
mutate(&m)
_, err := m.Metadata()
require.ErrorIs(t, err, uefistore.ErrInvalidObject)
})
}
}
// The 64 MiB arm64 region is mostly erased flash, so it is stored compressed
// and decompresses to the exact region.
func TestPutObject_StoresTheRegionCompressed(t *testing.T) {
objects := objectstore.NewMemoryObjectStore()
meta := testMeta(instanceID)
region := bytes.Repeat([]byte{0xff}, int(meta.Firmware.VarsSize))
copy(region, "BootOrder")
key, err := uefistore.NewObjectKey(instanceID)
require.NoError(t, err)
require.NoError(t, uefistore.PutObject(t.Context(), objects, testBucket, key, region, meta))
out, err := objects.GetObject(t.Context(), &s3.GetObjectInput{Bucket: aws.String(testBucket), Key: aws.String(key)})
require.NoError(t, err)
body, err := io.ReadAll(out.Body)
require.NoError(t, err)
assert.Less(t, len(body), len(region)/10, "the erased region compresses")
gz, err := gzip.NewReader(bytes.NewReader(body))
require.NoError(t, err)
got, err := io.ReadAll(gz)
require.NoError(t, err)
assert.Equal(t, region, got)
}
// pflash needs a byte-exact region, and a key outside the owner's prefix is
// one no store record owns; either is refused before anything is put.
func TestPutObject_RefusesWhatTheOwnerCannotBoot(t *testing.T) {
meta := testMeta(instanceID)
good, err := uefistore.NewObjectKey(instanceID)
require.NoError(t, err)
other, err := uefistore.NewObjectKey("i-0fedcba9876543210")
require.NoError(t, err)
tests := []struct {
name string
key string
region []byte
}{
{name: "short region", key: good, region: make([]byte, meta.Firmware.VarsSize-1)},
{name: "long region", key: good, region: make([]byte, meta.Firmware.VarsSize+1)},
{name: "another owner's key", key: other, region: make([]byte, meta.Firmware.VarsSize)},
{name: "nested key", key: uefistore.KeyPrefix(instanceID) + "a/b", region: make([]byte, meta.Firmware.VarsSize)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
objects := objectstore.NewMemoryObjectStore()
err := uefistore.PutObject(t.Context(), objects, testBucket, tt.key, tt.region, meta)
require.ErrorIs(t, err, uefistore.ErrInvalidObject)
out, listErr := objects.ListObjectsV2(t.Context(), &s3.ListObjectsV2Input{Bucket: aws.String(testBucket)})
require.NoError(t, listErr)
assert.Empty(t, out.Contents)
})
}
}
type failingPut struct {
*objectstore.MemoryObjectStore
}
func (failingPut) PutObject(ctx context.Context, _ *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
if _, ok := ctx.Deadline(); !ok {
return nil, errors.New("put had no deadline")
}
return nil, errors.New("predastore unreachable")
}
// A put runs under a deadline, so a wedged object store fails the write
// rather than holding the caller, and its error reaches the caller.
func TestPutObject_IsBoundedAndReportsAFailedPut(t *testing.T) {
key, err := uefistore.NewObjectKey(instanceID)
require.NoError(t, err)
meta := testMeta(instanceID)
err = uefistore.PutObject(t.Context(), failingPut{objectstore.NewMemoryObjectStore()}, testBucket, key, make([]byte, meta.Firmware.VarsSize), meta)
require.Error(t, err)
assert.Contains(t, err.Error(), "predastore unreachable")
}
@@ -0,0 +1,73 @@
package uefistore
import (
"context"
"errors"
"fmt"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
)
// PruneMinAge is how old an object must be before a prune may delete it, so a
// write-back whose pointer move has not landed yet never loses its object.
const PruneMinAge = 10 * time.Minute
// PruneResult is what one prune of a prefix did.
type PruneResult struct {
Deleted int
// Young counts objects other than keep that were too new to delete, so the
// caller prunes the prefix again later.
Young int
}
// PruneObjects deletes every object under ownerID's prefix except keep that was
// last modified before cutoff. keep must be the key the store record points at;
// an empty or foreign keep deletes nothing. An object with no modification time
// counts as young.
func PruneObjects(ctx context.Context, objects objectstore.ObjectStore, bucket, ownerID, keep string, cutoff time.Time) (PruneResult, error) {
var res PruneResult
if err := checkOwnedKey(ownerID, keep); err != nil {
return res, fmt.Errorf("uefistore: refusing to prune %s: %w", ownerID, err)
}
contents, _, err := objectstore.ListAll(ctx, objects, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(KeyPrefix(ownerID)),
})
if err != nil {
return res, fmt.Errorf("uefistore: list %s: %w", KeyPrefix(ownerID), err)
}
for _, obj := range contents {
key := aws.StringValue(obj.Key)
if key == keep {
continue
}
if obj.LastModified == nil || !obj.LastModified.Before(cutoff) {
res.Young++
continue
}
_, err := objects.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)})
if err != nil && !objectstore.IsNoSuchKeyError(err) {
return res, fmt.Errorf("uefistore: delete %s: %w", key, err)
}
res.Deleted++
}
return res, nil
}
// checkOwnedKey fails a key that is empty or not one object under ownerID's
// prefix.
func checkOwnedKey(ownerID, key string) error {
if !validOwner(ownerID) {
return fmt.Errorf("%q is not an instance or image ID", ownerID)
}
if key == "" {
return errors.New("no key to keep")
}
if !ownsKey(ownerID, key) {
return fmt.Errorf("%s is not under %s", key, KeyPrefix(ownerID))
}
return nil
}
@@ -0,0 +1,84 @@
package uefistore_test
import (
"context"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// datedStore lists each object with the modification time the test set; a
// key it has none for is listed with none.
type datedStore struct {
*objectstore.MemoryObjectStore
modified map[string]time.Time
}
func (s *datedStore) ListObjectsV2(ctx context.Context, in *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
out, err := s.MemoryObjectStore.ListObjectsV2(ctx, in)
if err != nil {
return nil, err
}
for _, obj := range out.Contents {
obj.LastModified = nil
if at, ok := s.modified[aws.StringValue(obj.Key)]; ok {
obj.LastModified = aws.Time(at)
}
}
return out, nil
}
// A prune keeps the key it is told to and every object not older than the
// cutoff, counting those it kept for age, and an undated object counts as
// young (ADR-0003:S7).
func TestS7_PruneObjectsKeepsThePointerAndEveryYoungObject(t *testing.T) {
const owner = "i-0123456789abcdef0"
prefix := uefistore.KeyPrefix(owner)
cutoff := time.Unix(1700000000, 0)
keep, stale, young, undated, other := prefix+"keep", prefix+"stale", prefix+"young", prefix+"undated", uefistore.KeyPrefix("i-0fedcba9876543210")+"stale"
store := &datedStore{MemoryObjectStore: objectstore.NewMemoryObjectStore(), modified: map[string]time.Time{
keep: cutoff.Add(-time.Hour),
stale: cutoff.Add(-time.Second),
young: cutoff,
other: cutoff.Add(-time.Hour),
}}
for _, k := range []string{keep, stale, young, undated, other} {
putObject(t, store, k)
}
res, err := uefistore.PruneObjects(t.Context(), store, bucket, owner, keep, cutoff)
require.NoError(t, err)
assert.Equal(t, uefistore.PruneResult{Deleted: 1, Young: 2}, res)
assert.ElementsMatch(t, []string{keep, young, undated}, keysUnder(t, store, prefix),
"ADR-0003:S7: a prune must keep the pointed object and every object younger than the cutoff")
assert.Equal(t, []string{other}, keysUnder(t, store, uefistore.KeyPrefix("i-0fedcba9876543210")), "another owner's prefix is untouched")
}
// With no key to keep, or one outside the owner's prefix, a prune cannot tell
// the pointer from a leftover, so it deletes nothing (ADR-0003:S7).
func TestS7_PruneObjectsWithoutAKeyToKeepDeletesNothing(t *testing.T) {
const owner = "i-0123456789abcdef0"
prefix := uefistore.KeyPrefix(owner)
store := &datedStore{MemoryObjectStore: objectstore.NewMemoryObjectStore(), modified: map[string]time.Time{
prefix + "stale": time.Unix(1, 0),
}}
putObject(t, store, prefix+"stale")
for _, keep := range []string{"", uefistore.KeyPrefix("i-0fedcba9876543210") + "stale", prefix + "nested/key"} {
_, err := uefistore.PruneObjects(t.Context(), store, bucket, owner, keep, time.Now())
require.Error(t, err, keep)
}
_, err := uefistore.PruneObjects(t.Context(), store, bucket, "i-*", prefix+"stale", time.Now())
require.Error(t, err)
assert.Equal(t, []string{prefix + "stale"}, keysUnder(t, store, prefix),
"ADR-0003:S7: a prune with no valid pointer to keep must delete nothing")
}
@@ -1,6 +1,6 @@
// Package uefistore holds the types that describe an instance's UEFI variable
// store and the bucket of store records that carry its deletion and write-back
// obligations past the life of the instance record.
// store, the bucket of store records that carry its pointer and deletion
// obligation past the life of the instance record, and the claim that fences it.
package uefistore
import (
@@ -53,22 +53,27 @@ type FirmwareIdentity struct {
// UEFIStoreRecord is the durable owner of one instance's variable store
// objects. It outlives the instance record, so an obligation recorded here is
// not lost when a terminated record expires.
//
// Pointer names the only version of the store the record keeps, the object the
// instance boots from, and only a writer whose claim token has generation
// Generation may move it.
type UEFIStoreRecord struct {
SchemaVersion uint16 `json:"schema_version"`
InstanceID string `json:"instance_id"`
AccountID string `json:"account_id"`
State string `json:"state"`
WriteBack *UEFIWriteBack `json:"write_back,omitempty"`
SchemaVersion uint16 `json:"schema_version"`
InstanceID string `json:"instance_id"`
AccountID string `json:"account_id"`
State string `json:"state"`
Generation uint64 `json:"generation"`
Pointer *UEFIVarStoreRef `json:"pointer,omitempty"`
}
// UEFIWriteBack is a write-back that failed and is still owed. Key is the
// object every retry puts to, so retries leave no extra objects; SeededFrom is
// the pointer Node seeded from, which a retry must still find on the record.
type UEFIWriteBack struct {
Node string `json:"node"`
Key string `json:"key"`
SeededFrom string `json:"seeded_from"`
Since time.Time `json:"since"`
// UEFIVarStoreRef names the store object an instance launches from, with the
// firmware it was written under, so a launch can check it without reading the
// object's metadata.
type UEFIVarStoreRef struct {
Key string `json:"key"`
Firmware FirmwareIdentity `json:"firmware"`
WriterNode string `json:"writer_node"`
WrittenAt time.Time `json:"written_at"`
}
// KeyPrefix is the object-store prefix every store object of ownerID (an
@@ -83,8 +88,8 @@ func ownsKey(ownerID, key string) bool {
return ok && name != "" && !strings.Contains(name, "/")
}
// validate checks rec against key. A write-back key outside the instance's own
// prefix is refused because a superseded obligation deletes that key.
// validate checks rec against key. The pointer must name one object directly
// under the instance's own prefix, because a deletion acts on it.
func (rec *UEFIStoreRecord) validate(key string) error {
switch {
case rec.SchemaVersion > RecordSchemaVersion:
@@ -98,29 +103,32 @@ func (rec *UEFIStoreRecord) validate(key string) error {
return fmt.Errorf("%w: instance_id %s stored at key %s", ErrInvalidRecord, rec.InstanceID, key)
case rec.State != StateLive && rec.State != StateDeleting:
return fmt.Errorf("%w: %s has state %q", ErrInvalidRecord, key, rec.State)
case rec.State == StateDeleting && rec.WriteBack != nil:
// A write-back retry puts an object, which would land behind the delete.
return fmt.Errorf("%w: %s is deleting with a write-back still owed", ErrInvalidRecord, key)
}
if wb := rec.WriteBack; wb != nil {
switch {
case wb.Node == "":
return fmt.Errorf("%w: %s write-back names no node", ErrInvalidRecord, key)
case wb.Since.IsZero():
return fmt.Errorf("%w: %s write-back has no since", ErrInvalidRecord, key)
case !ownsKey(rec.InstanceID, wb.Key):
return fmt.Errorf("%w: %s write-back key %q is not under %s", ErrInvalidRecord, key, wb.Key, KeyPrefix(rec.InstanceID))
case !ownsKey(rec.InstanceID, wb.SeededFrom):
return fmt.Errorf("%w: %s write-back seeded_from %q is not under %s", ErrInvalidRecord, key, wb.SeededFrom, KeyPrefix(rec.InstanceID))
if p := rec.Pointer; p != nil {
if err := p.validate(rec.InstanceID); err != nil {
return fmt.Errorf("%w: %s pointer: %w", ErrInvalidRecord, key, err)
}
}
return nil
}
// validate checks ref as a pointer of instanceID's store.
func (ref *UEFIVarStoreRef) validate(instanceID string) error {
switch {
case !ownsKey(instanceID, ref.Key):
return fmt.Errorf("key %q is not under %s", ref.Key, KeyPrefix(instanceID))
case ref.Firmware.Arch == "" || ref.Firmware.VarsSize <= 0:
return fmt.Errorf("key %q names no firmware arch and VARS size", ref.Key)
case ref.WriterNode == "":
return fmt.Errorf("key %q names no writer node", ref.Key)
}
return nil
}
func bucketConfig() kvstore.Config {
return kvstore.Config{
Name: BucketName,
Description: "Spinifex UEFI variable store records (deletion and write-back obligations)",
Description: "Spinifex UEFI variable store records (pointers and deletion obligations)",
History: 1,
// Left false: a lost bucket has lost obligations, and an empty
// recreated one would report every store as having none.
@@ -172,6 +180,19 @@ func (s *Store) Get(ctx context.Context, instanceID string) (*UEFIStoreRecord, u
return rec, rev, nil
}
// LeaderGet is Get answered by the stream leader, for a caller that deletes
// objects by what the record does not point at.
func (s *Store) LeaderGet(ctx context.Context, instanceID string) (*UEFIStoreRecord, uint64, error) {
rec, rev, err := s.kv.LeaderGet(ctx, instanceID)
if err != nil {
return nil, 0, err
}
if err := rec.validate(instanceID); err != nil {
return nil, 0, err
}
return rec, rev, nil
}
// Create writes a new record, which must be live: a store is recorded before
// its first object is written. An existing record is kvstore.ErrExists. Every
// write stamps rec with RecordSchemaVersion.
@@ -11,7 +11,6 @@ import (
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -31,15 +30,25 @@ func liveRecord() *uefistore.UEFIStoreRecord {
return &uefistore.UEFIStoreRecord{InstanceID: instanceID, AccountID: "111122223333", State: uefistore.StateLive}
}
func writeBack() *uefistore.UEFIWriteBack {
return &uefistore.UEFIWriteBack{
Node: "node-2",
Key: uefistore.KeyPrefix(instanceID) + "b",
SeededFrom: uefistore.KeyPrefix(instanceID) + "a",
Since: time.Date(2026, 11, 3, 1, 2, 3, 0, time.UTC),
func pointer(name string) *uefistore.UEFIVarStoreRef {
return &uefistore.UEFIVarStoreRef{
Key: keyOf(name),
Firmware: uefistore.FirmwareIdentity{Arch: "x86_64", VarsSize: 540672, CodeSHA256: "c0de"},
WriterNode: "node-2",
WrittenAt: time.Date(2026, 11, 3, 1, 2, 3, 0, time.UTC),
}
}
func keyOf(name string) string { return uefistore.KeyPrefix(instanceID) + name }
// pointedRecord is a live record stamped at generation 7 that points at "b".
func pointedRecord() *uefistore.UEFIStoreRecord {
rec := liveRecord()
rec.Generation = 7
rec.Pointer = pointer("b")
return rec
}
func rawPut(t *testing.T, js jetstream.JetStream, key, body string) {
t.Helper()
kv, err := js.KeyValue(t.Context(), uefistore.BucketName)
@@ -60,15 +69,14 @@ func TestOpen_CreatesBucketWithNoTTLAndStampsVersion(t *testing.T) {
assert.Equal(t, int64(1), status.History())
assert.Zero(t, status.TTL(), "a TTL would expire deletion obligations before they are settled")
v, err := kvutil.ReadVersion(t.Context(), kv)
v, err := kvstore.ReadVersion(t.Context(), kv)
require.NoError(t, err)
assert.Equal(t, uefistore.BucketVersion, v)
}
func TestCreate_RoundTripsAndStampsSchemaVersion(t *testing.T) {
s, _ := openStore(t)
rec := liveRecord()
rec.WriteBack = writeBack()
rec := pointedRecord()
rev, err := s.Create(t.Context(), rec)
require.NoError(t, err)
@@ -83,9 +91,7 @@ func TestCreate_RoundTripsAndStampsSchemaVersion(t *testing.T) {
// The wire names are what every release reads, so they are pinned.
func TestRecord_WireShape(t *testing.T) {
s, js := openStore(t)
rec := liveRecord()
rec.WriteBack = writeBack()
_, err := s.Create(t.Context(), rec)
_, err := s.Create(t.Context(), pointedRecord())
require.NoError(t, err)
kv, err := js.KeyValue(t.Context(), uefistore.BucketName)
@@ -97,11 +103,12 @@ func TestRecord_WireShape(t *testing.T) {
"instance_id": "i-0123456789abcdef0",
"account_id": "111122223333",
"state": "live",
"write_back": {
"node": "node-2",
"generation": 7,
"pointer": {
"key": "uefi/i-0123456789abcdef0/b",
"seeded_from": "uefi/i-0123456789abcdef0/a",
"since": "2026-11-03T01:02:03Z"
"firmware": {"arch": "x86_64", "vars_size": 540672, "code_sha256": "c0de"},
"writer_node": "node-2",
"written_at": "2026-11-03T01:02:03Z"
}
}`, string(entry.Value()))
}
@@ -145,8 +152,7 @@ func TestCompareAndSet_StaleRevisionConflictsAndWritesNothing(t *testing.T) {
first, err := s.Create(t.Context(), liveRecord())
require.NoError(t, err)
moved := liveRecord()
moved.WriteBack = writeBack()
moved := pointedRecord()
second, err := s.CompareAndSet(t.Context(), moved, first)
require.NoError(t, err)
assert.Greater(t, second, first)
@@ -160,7 +166,7 @@ func TestCompareAndSet_StaleRevisionConflictsAndWritesNothing(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, second, rev)
assert.Equal(t, uefistore.StateLive, got.State)
assert.Equal(t, writeBack(), got.WriteBack)
assert.Equal(t, pointer("b"), got.Pointer)
}
// Two actors that read the same revision race to change it; exactly one may
@@ -180,12 +186,11 @@ func TestCompareAndSet_ConcurrentWritersFromOneReadHaveOneWinner(t *testing.T) {
)
for i := range writers {
wg.Go(func() {
rec := liveRecord()
rec.WriteBack = writeBack()
rec.WriteBack.Node = fmt.Sprintf("node-%d", i)
rec := pointedRecord()
rec.Pointer.WriterNode = fmt.Sprintf("node-%d", i)
if _, err := s.CompareAndSet(context.Background(), rec, rev); err == nil {
mu.Lock()
wins = append(wins, rec.WriteBack.Node)
wins = append(wins, rec.Pointer.WriterNode)
mu.Unlock()
} else {
assert.ErrorIs(t, err, kvstore.ErrConflict)
@@ -197,7 +202,7 @@ func TestCompareAndSet_ConcurrentWritersFromOneReadHaveOneWinner(t *testing.T) {
require.Len(t, wins, 1)
got, _, err := s.Get(t.Context(), instanceID)
require.NoError(t, err)
assert.Equal(t, wins[0], got.WriteBack.Node)
assert.Equal(t, wins[0], got.Pointer.WriterNode)
}
func TestCompareAndDelete_StaleRevisionKeepsRecord(t *testing.T) {
@@ -218,29 +223,20 @@ func TestCompareAndDelete_StaleRevisionKeepsRecord(t *testing.T) {
require.ErrorIs(t, err, kvstore.ErrNotFound)
}
// A superseded obligation deletes its recorded key, so a key outside the
// A deletion acts on the key the record points at, so a key outside the
// instance's own prefix must never reach the bucket.
func TestCompareAndSet_RefusesInvalidRecords(t *testing.T) {
other := uefistore.KeyPrefix("i-0fedcba9876543210") + "b"
cases := map[string]func(*uefistore.UEFIStoreRecord){
"unknown state": func(r *uefistore.UEFIStoreRecord) { r.State = "gone" },
"not an instance ID": func(r *uefistore.UEFIStoreRecord) { r.InstanceID = "ami-0123456789abcdef0" },
"write-back with no node": func(r *uefistore.UEFIStoreRecord) { r.WriteBack.Node = "" },
"write-back with no since": func(r *uefistore.UEFIStoreRecord) { r.WriteBack.Since = time.Time{} },
"write-back key under another instance": func(r *uefistore.UEFIStoreRecord) {
r.WriteBack.Key = uefistore.KeyPrefix("i-0fedcba9876543210") + "b"
},
"write-back key under an image": func(r *uefistore.UEFIStoreRecord) {
r.WriteBack.Key = uefistore.KeyPrefix("ami-0123456789abcdef0") + "b"
},
"write-back key is the prefix itself": func(r *uefistore.UEFIStoreRecord) {
r.WriteBack.Key = uefistore.KeyPrefix(instanceID)
},
"write-back key nested below the prefix": func(r *uefistore.UEFIStoreRecord) {
r.WriteBack.Key = uefistore.KeyPrefix(instanceID) + "x/y"
},
"write-back seeded from another instance": func(r *uefistore.UEFIStoreRecord) {
r.WriteBack.SeededFrom = uefistore.KeyPrefix("i-0fedcba9876543210") + "a"
},
"unknown state": func(r *uefistore.UEFIStoreRecord) { r.State = "gone" },
"not an instance ID": func(r *uefistore.UEFIStoreRecord) { r.InstanceID = "ami-0123456789abcdef0" },
"pointer under another instance": func(r *uefistore.UEFIStoreRecord) { r.Pointer.Key = other },
"pointer under an image": func(r *uefistore.UEFIStoreRecord) { r.Pointer.Key = uefistore.KeyPrefix("ami-0123456789abcdef0") + "b" },
"pointer is the prefix itself": func(r *uefistore.UEFIStoreRecord) { r.Pointer.Key = uefistore.KeyPrefix(instanceID) },
"pointer nested below the prefix": func(r *uefistore.UEFIStoreRecord) { r.Pointer.Key = keyOf("x/y") },
"pointer with no firmware arch": func(r *uefistore.UEFIStoreRecord) { r.Pointer.Firmware.Arch = "" },
"pointer with no VARS size": func(r *uefistore.UEFIStoreRecord) { r.Pointer.Firmware.VarsSize = 0 },
"pointer with no writer node": func(r *uefistore.UEFIStoreRecord) { r.Pointer.WriterNode = "" },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
@@ -248,8 +244,7 @@ func TestCompareAndSet_RefusesInvalidRecords(t *testing.T) {
rev, err := s.Create(t.Context(), liveRecord())
require.NoError(t, err)
rec := liveRecord()
rec.WriteBack = writeBack()
rec := pointedRecord()
mutate(rec)
_, err = s.CompareAndSet(t.Context(), rec, rev)
require.ErrorIs(t, err, uefistore.ErrInvalidRecord)
@@ -257,7 +252,7 @@ func TestCompareAndSet_RefusesInvalidRecords(t *testing.T) {
got, gotRev, err := s.Get(t.Context(), instanceID)
require.NoError(t, err)
assert.Equal(t, rev, gotRev, "an invalid record must not be written")
assert.Nil(t, got.WriteBack)
assert.Nil(t, got.Pointer)
})
}
}
@@ -266,11 +261,11 @@ func TestCompareAndSet_RefusesInvalidRecords(t *testing.T) {
// is reported rather than decoded into something a reaper would act on.
func TestGet_RefusesRecordsItCannotTrust(t *testing.T) {
cases := map[string]string{
"newer schema": `{"schema_version":2,"instance_id":"i-0123456789abcdef0","state":"live"}`,
"no schema": `{"instance_id":"i-0123456789abcdef0","state":"live"}`,
"another instance": `{"schema_version":1,"instance_id":"i-0fedcba9876543210","state":"deleting"}`,
"unknown state": `{"schema_version":1,"instance_id":"i-0123456789abcdef0","state":"purged"}`,
"foreign write-back": `{"schema_version":1,"instance_id":"i-0123456789abcdef0","state":"live","write_back":{"node":"n","key":"uefi/i-0fedcba9876543210/b","seeded_from":"uefi/i-0123456789abcdef0/a","since":"2026-11-03T00:00:00Z"}}`,
"newer schema": `{"schema_version":2,"instance_id":"i-0123456789abcdef0","state":"live"}`,
"no schema": `{"instance_id":"i-0123456789abcdef0","state":"live"}`,
"another instance": `{"schema_version":1,"instance_id":"i-0fedcba9876543210","state":"deleting"}`,
"unknown state": `{"schema_version":1,"instance_id":"i-0123456789abcdef0","state":"purged"}`,
"foreign pointer": `{"schema_version":1,"instance_id":"i-0123456789abcdef0","state":"live","generation":3,"pointer":{"key":"uefi/i-0fedcba9876543210/b","firmware":{"arch":"x86_64","vars_size":540672},"writer_node":"n"}}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
@@ -102,3 +102,38 @@ func TestStartPaths_ReportAVolumeHeldElsewhereByCode(t *testing.T) {
assert.Equal(t, awserrors.ErrorIncorrectState, awserrors.ValidErrorCodeFromError(err))
})
}
// uefiClaimHeldMounter fails the launch the way a start does while a live
// node holds the instance's UEFI variable store.
type uefiClaimHeldMounter struct{ raceVolumeMounter }
func (uefiClaimHeldMounter) Mount(context.Context, *vm.VM) error {
return fmt.Errorf("%w: node-a holds i-uefi: %w", vm.ErrUEFIClaimHeld, vm.ErrMountRetryable)
}
// A held UEFI store is a capacity refusal on both start paths, so the caller
// tries another node rather than reporting a fault.
func TestStartPaths_ReportAHeldUEFIStoreAsCapacity(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
types, _ := defaultPrepareInstanceTypes()
t.Run("claimed relaunch", func(t *testing.T) {
mgr := vm.NewManager()
mgr.SetDeps(vm.Deps{NodeID: "node-b", VolumeMounter: uefiClaimHeldMounter{}})
svc := &InstanceServiceImpl{resourceMgr: &fakeResourceCapacityProvider{instanceTypes: types}, vmMgr: mgr}
err := svc.launchClaimedInstance(t.Context(), &vm.VM{ID: "i-uefi-claim", AccountID: "acc", InstanceType: "t3.micro", Status: vm.StatePending}, func() {})
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, awserrors.ValidErrorCodeFromError(err))
})
t.Run("live owner", func(t *testing.T) {
const id = "i-uefi-live"
mgr := mgrWith(map[string]*vm.VM{id: {ID: id, Status: vm.StateStopped, InstanceType: "t3.micro"}})
mgr.SetDeps(vm.Deps{NodeID: "node-b", VolumeMounter: uefiClaimHeldMounter{}})
v, _ := mgr.Get(id)
svc := &InstanceServiceImpl{resourceMgr: &fakeResourceCapacityProvider{instanceTypes: types}, vmMgr: mgr}
err := svc.StartInstance(t.Context(), v, ec2v1.EC2InstanceCommand{ID: id})
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, awserrors.ValidErrorCodeFromError(err))
})
}
@@ -9,7 +9,7 @@ import (
"strings"
"time"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -33,7 +33,7 @@ type AccountCleanup struct {
// Unreadable headers, their bodies and unrecognised entries are left for the
// owner scan to report. A listing failure is returned and nothing is removed.
func (s *LaunchTemplateServiceImpl) CleanupAccountResidue(ctx context.Context, accountID string, settledBefore time.Time) (*AccountCleanup, error) {
keys, err := kvutil.Keys(ctx, s.kv)
keys, err := kvstore.Keys(ctx, s.kv)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list launch-template keys: %w", err)
}
@@ -145,7 +145,7 @@ type AccountTemplateDeletes struct {
// it cannot read or decode is left for the owner scan to report. A listing
// failure is returned and nothing is deleted.
func (s *LaunchTemplateServiceImpl) DeleteLiveTemplates(ctx context.Context, accountID string, referenced func(id, name string) string) (*AccountTemplateDeletes, error) {
keys, err := kvutil.Keys(ctx, s.kv)
keys, err := kvstore.Keys(ctx, s.kv)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list launch-template keys: %w", err)
}
@@ -9,7 +9,7 @@ import (
"strconv"
"strings"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -37,7 +37,7 @@ type AccountScan struct {
// Headers and name-index entries are read; version bodies are classified from
// their keys alone, so template data and user data are never loaded.
func (s *LaunchTemplateServiceImpl) ScanAccount(ctx context.Context, accountID string) (*AccountScan, error) {
keys, err := kvutil.Keys(ctx, s.kv)
keys, err := kvstore.Keys(ctx, s.kv)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list launch-template keys: %w", err)
}
@@ -11,7 +11,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit/readonlystore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -45,7 +45,7 @@ func TestScanAccountClassifiesLaunchTemplateRecords(t *testing.T) {
// A header removed alone, as a delete interrupted after it leaves the rest.
deleted := createWithUserData(t, svc, "deleted")
inner := svc.kv
require.NoError(t, inner.Delete(t.Context(), kvutil.AccountKey(testAccountID, deleted)))
require.NoError(t, inner.Delete(t.Context(), kvstore.AccountKey(testAccountID, deleted)))
// A create whose header write and clean-up both fail.
svc.kv = &faultyKV{KeyValue: inner,
@@ -59,16 +59,16 @@ func TestScanAccountClassifiesLaunchTemplateRecords(t *testing.T) {
require.Error(t, err)
svc.kv = inner
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0badbadbadbadbad0"), "{not json")
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0badbadbadbadbad0.v1"), `{"version_number":1}`)
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0mismatch00000000"), `{"launch_template_id":"lt-0other"}`)
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0unreadable000000"), `{"launch_template_id":"lt-0unreadable000000"}`)
putRaw(t, inner, kvstore.AccountKey(testAccountID, "lt-0badbadbadbadbad0"), "{not json")
putRaw(t, inner, kvstore.AccountKey(testAccountID, "lt-0badbadbadbadbad0.v1"), `{"version_number":1}`)
putRaw(t, inner, kvstore.AccountKey(testAccountID, "lt-0mismatch00000000"), `{"launch_template_id":"lt-0other"}`)
putRaw(t, inner, kvstore.AccountKey(testAccountID, "lt-0unreadable000000"), `{"launch_template_id":"lt-0unreadable000000"}`)
putRaw(t, inner, nameKey(testAccountID, "unreadable"), "lt-0unreadable000000")
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0bad.vx"), "{}")
putRaw(t, inner, kvstore.AccountKey(testAccountID, "lt-0bad.vx"), "{}")
recorder := &readRecordingKV{KeyValue: readonlystore.NewKV(t, inner)}
svc.kv = &faultyKV{KeyValue: recorder, failGet: func(key string) bool {
return key == kvutil.AccountKey(testAccountID, "lt-0unreadable000000")
return key == kvstore.AccountKey(testAccountID, "lt-0unreadable000000")
}}
scan, err := svc.ScanAccount(context.Background(), testAccountID)
require.NoError(t, err)
@@ -16,7 +16,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -159,7 +159,7 @@ func TestCurrentBehaviour_AccountTermination_LaunchTemplateStorageLayout(t *test
header := testAccountID + "." + id
name := testAccountID + ".name.776562283129" // hex of "web(1)"
assert.Equal(t, sortedKeys(header, header+".v1", header+".v2", name, kvutil.VersionKey), storedKeys(t, svc.kv))
assert.Equal(t, sortedKeys(header, header+".v1", header+".v2", name, kvstore.VersionKey), storedKeys(t, svc.kv))
entry, err := svc.kv.Get(t.Context(), name)
require.NoError(t, err)
@@ -223,13 +223,13 @@ func TestAccountTermination_DeleteLaunchTemplateKeepsTheTemplateWhenAStepFails(t
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
svc.kv = inner
assert.Equal(t, sortedKeys(append(tc.left(header, nameIdx), kvutil.VersionKey)...), storedKeys(t, inner))
assert.Equal(t, sortedKeys(append(tc.left(header, nameIdx), kvstore.VersionKey)...), storedKeys(t, inner))
assert.Equal(t, []string{id}, describedTemplateIDs(t, svc, testAccountID))
out, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
require.NoError(t, err)
assert.Equal(t, id, aws.StringValue(out.LaunchTemplate.LaunchTemplateId))
assert.Equal(t, []string{kvutil.VersionKey}, storedKeys(t, inner))
assert.Equal(t, []string{kvstore.VersionKey}, storedKeys(t, inner))
assert.Empty(t, describedTemplateIDs(t, svc, testAccountID))
})
}
@@ -260,7 +260,7 @@ func TestAccountTermination_DeleteLaunchTemplateRestartsWhenTheHeaderChanges(t *
_, err = svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
require.NoError(t, err)
assert.True(t, raced)
assert.Equal(t, []string{kvutil.VersionKey}, storedKeys(t, inner))
assert.Equal(t, []string{kvstore.VersionKey}, storedKeys(t, inner))
}
// A create that fails after claiming the name removes what it wrote, so no
@@ -287,7 +287,7 @@ func TestAccountTermination_FailedCreateRemovesWhatItWrote(t *testing.T) {
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
svc.kv = inner
assert.Equal(t, []string{kvutil.VersionKey}, storedKeys(t, inner))
assert.Equal(t, []string{kvstore.VersionKey}, storedKeys(t, inner))
retried := createTemplate(t, svc, "web", "t3.micro")
assert.Equal(t, []string{aws.StringValue(retried.LaunchTemplateId)}, describedTemplateIDs(t, svc, testAccountID))
})
@@ -299,7 +299,7 @@ func TestAccountTermination_FailedCreateRemovesWhatItWrote(t *testing.T) {
// failure is an internal error, never a missing template.
func TestCurrentBehaviour_AccountTermination_DescribeLaunchTemplatesHidesUnreadableHeaders(t *testing.T) {
const brokenID = "lt-0badbadbadbadbad0"
brokenHeader := kvutil.AccountKey(testAccountID, brokenID)
brokenHeader := kvstore.AccountKey(testAccountID, brokenID)
tests := []struct {
name string
seed func(t *testing.T, svc *LaunchTemplateServiceImpl)
@@ -350,7 +350,7 @@ func TestAccountCleanup_KeepsTemplatesAndRemovesSettledResidue(t *testing.T) {
require.NoError(t, err)
orphanBody := versionKey(testAccountID, "lt-0orphan000000000", 1)
orphanName := nameKey(testAccountID, "gone")
brokenHeader := kvutil.AccountKey(testAccountID, "lt-0broken000000000")
brokenHeader := kvstore.AccountKey(testAccountID, "lt-0broken000000000")
brokenBody := versionKey(testAccountID, "lt-0broken000000000", 1)
brokenName := nameKey(testAccountID, "broken")
for key, value := range map[string]string{
@@ -384,7 +384,7 @@ func TestAccountCleanup_KeepsTemplatesAndRemovesSettledResidue(t *testing.T) {
func TestAccountCleanup_InterruptedTemplateDeleteIsLeftToThePublicRetry(t *testing.T) {
svc := setupTestService(t)
id := createWithUserData(t, svc, "web")
header := kvutil.AccountKey(testAccountID, id)
header := kvstore.AccountKey(testAccountID, id)
kv := svc.kv
svc.kv = &faultyKV{KeyValue: kv, failDelete: func(key string) bool { return key == header }}
_, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{LaunchTemplateId: aws.String(id)}, testAccountID)
@@ -19,7 +19,7 @@ import (
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -64,7 +64,7 @@ func NewLaunchTemplateServiceImplWithNATS(ctx context.Context, cfg *config.Confi
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketLaunchTemplates, 10)
kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketLaunchTemplates, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketLaunchTemplates, err)
}
@@ -87,24 +87,24 @@ func NewLaunchTemplateServiceImplWithNATS(ctx context.Context, cfg *config.Confi
// Launch template names allow characters such as parentheses that are not valid
// in NATS KV keys, so the name must not be used in its raw form here.
func nameKey(accountID, name string) string {
return kvutil.AccountKey(accountID, "name."+hex.EncodeToString([]byte(name)))
return kvstore.AccountKey(accountID, "name."+hex.EncodeToString([]byte(name)))
}
// versionKey returns a version-body key: account.lt-<id>.v<n>.
func versionKey(accountID, ltID string, n int64) string {
return kvutil.AccountKey(accountID, ltID+".v"+strconv.FormatInt(n, 10))
return kvstore.AccountKey(accountID, ltID+".v"+strconv.FormatInt(n, 10))
}
// versionPrefix returns the scan prefix for a template's version bodies.
func versionPrefix(accountID, ltID string) string {
return kvutil.AccountKey(accountID, ltID+".v")
return kvstore.AccountKey(accountID, ltID+".v")
}
// --- record load helpers ---
// getHeaderByID reads a header and its KV entry (for CAS) by launch template id.
func (s *LaunchTemplateServiceImpl) getHeaderByID(ctx context.Context, accountID, ltID string) (*LaunchTemplateHeader, jetstream.KeyValueEntry, error) {
entry, err := s.kv.Get(ctx, kvutil.AccountKey(accountID, ltID))
entry, err := s.kv.Get(ctx, kvstore.AccountKey(accountID, ltID))
if err != nil {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateIdNotFound)
@@ -386,7 +386,7 @@ func (s *LaunchTemplateServiceImpl) claimName(ctx context.Context, accountID, na
// header means the name is taken; any other read error (transient fault, or a
// concurrent in-flight create whose header is not yet written) fails closed so
// the name is never stolen from a possibly-live template.
_, herr := s.kv.Get(ctx, kvutil.AccountKey(accountID, string(entry.Value())))
_, herr := s.kv.Get(ctx, kvstore.AccountKey(accountID, string(entry.Value())))
switch {
case herr == nil:
return errNameInUse()
@@ -404,7 +404,7 @@ func (s *LaunchTemplateServiceImpl) putHeader(ctx context.Context, accountID str
if err != nil {
return errors.New(awserrors.ErrorServerInternal)
}
if _, err := s.kv.Put(ctx, kvutil.AccountKey(accountID, h.LaunchTemplateId), data); err != nil {
if _, err := s.kv.Put(ctx, kvstore.AccountKey(accountID, h.LaunchTemplateId), data); err != nil {
return errors.New(awserrors.ErrorServerInternal)
}
return nil
@@ -510,13 +510,13 @@ func (s *LaunchTemplateServiceImpl) commitVersion(ctx context.Context, accountID
// re-reading on a conflict. A non-nil check vets each re-read header and
// stops the commit with its error.
func (s *LaunchTemplateServiceImpl) commitHeader(ctx context.Context, op, accountID, ltID string, entry jetstream.KeyValueEntry, check func(*LaunchTemplateHeader) error) error {
key := kvutil.AccountKey(accountID, ltID)
key := kvstore.AccountKey(accountID, ltID)
for range maxVersionRetries {
_, err := s.kv.Update(ctx, key, entry.Value(), entry.Revision())
if err == nil {
return nil
}
if !kvutil.IsRevisionConflict(err) {
if !kvstore.IsRevisionConflict(err) {
slog.ErrorContext(ctx, op+": header commit failed", "launchTemplateId", ltID, "err", err)
return err
}
@@ -590,7 +590,7 @@ func (s *LaunchTemplateServiceImpl) ModifyLaunchTemplate(ctx context.Context, in
// header deleted meanwhile is NotFound for the identifier the request used.
func (s *LaunchTemplateServiceImpl) setDefaultVersion(ctx context.Context, accountID string, input *ec2.ModifyLaunchTemplateInput, header *LaunchTemplateHeader, entry jetstream.KeyValueEntry, sel string) (*LaunchTemplateHeader, error) {
notFound := fmt.Sprintf("The launch template version %s is not found for the specified launch template.", sel)
key := kvutil.AccountKey(accountID, header.LaunchTemplateId)
key := kvstore.AccountKey(accountID, header.LaunchTemplateId)
for range maxVersionRetries {
n, err := s.resolveVersionNumber(ctx, accountID, header, sel)
if err != nil {
@@ -611,7 +611,7 @@ func (s *LaunchTemplateServiceImpl) setDefaultVersion(ctx context.Context, accou
if err == nil {
return header, nil
}
if !kvutil.IsRevisionConflict(err) {
if !kvstore.IsRevisionConflict(err) {
slog.ErrorContext(ctx, "ModifyLaunchTemplate: header update failed", "launchTemplateId", header.LaunchTemplateId, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
@@ -662,7 +662,7 @@ const maxHeaderDeleteAttempts = 3
// through the public delete; a header changed in between restarts the sweep.
func (s *LaunchTemplateServiceImpl) deleteTemplate(ctx context.Context, accountID string, header *LaunchTemplateHeader, entry jetstream.KeyValueEntry) error {
ltID := header.LaunchTemplateId
headerKey := kvutil.AccountKey(accountID, ltID)
headerKey := kvstore.AccountKey(accountID, ltID)
for attempt := 1; ; attempt++ {
if err := s.deleteVersionBodies(ctx, accountID, ltID); err != nil {
return err
@@ -824,7 +824,7 @@ func (s *LaunchTemplateServiceImpl) commitVersionDeletes(ctx context.Context, ac
}
// A template delete that removed the header before the restore has already
// swept its bodies, so the restored ones would be left headerless.
if _, gerr := s.kv.Get(ctx, kvutil.AccountKey(accountID, ltID)); errors.Is(gerr, jetstream.ErrKeyNotFound) {
if _, gerr := s.kv.Get(ctx, kvstore.AccountKey(accountID, ltID)); errors.Is(gerr, jetstream.ErrKeyNotFound) {
for n := range deleted {
if derr := s.kv.Delete(ctx, versionKey(accountID, ltID, n)); derr != nil {
slog.ErrorContext(ctx, "DeleteLaunchTemplateVersions: failed to remove a restored version", "launchTemplateId", ltID, "version", n, "err", derr)
@@ -9,7 +9,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -116,7 +116,7 @@ func TestCreateLaunchTemplate_OrphanNameReclaim(t *testing.T) {
lt := createTemplate(t, svc, "orphan", "t3.micro")
// Simulate a crash after the name claim but with the header lost.
require.NoError(t, svc.kv.Delete(t.Context(), kvutil.AccountKey(testAccountID, aws.StringValue(lt.LaunchTemplateId))))
require.NoError(t, svc.kv.Delete(t.Context(), kvstore.AccountKey(testAccountID, aws.StringValue(lt.LaunchTemplateId))))
// The name index still points at the now-orphaned id; create must reclaim it.
out, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
@@ -10,7 +10,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -38,7 +38,7 @@ func TestAccountTermination_VersionCreateRacingADeleteLeavesNoBody(t *testing.T)
t.Run(pause, func(t *testing.T) {
svc := setupTestService(t)
id := createWithUserData(t, svc, "web")
header := kvutil.AccountKey(testAccountID, id)
header := kvstore.AccountKey(testAccountID, id)
raced := false
race := func(key string, at string) {
if raced || pause != at {
@@ -71,7 +71,7 @@ func TestAccountTermination_DeleteRacingACommittedVersionRemovesIt(t *testing.T)
svc := setupTestService(t)
id := createWithUserData(t, svc, "web")
inner := svc.kv
header := kvutil.AccountKey(testAccountID, id)
header := kvstore.AccountKey(testAccountID, id)
raced := false
svc.kv = &faultyKV{KeyValue: inner, beforeDelete: func(key string) {
if key != header || raced {
@@ -95,7 +95,7 @@ func TestAccountTermination_FailedVersionCommitRemovesTheBody(t *testing.T) {
svc := setupTestService(t)
id := createWithUserData(t, svc, "web")
before := accountKeys(t, svc.kv, testAccountID)
header := kvutil.AccountKey(testAccountID, id)
header := kvstore.AccountKey(testAccountID, id)
svc.kv = &faultyKV{KeyValue: svc.kv, failUpdate: func(key string) bool { return key == header }}
_, err := svc.CreateLaunchTemplateVersion(context.Background(), newVersionInput(id), testAccountID)
@@ -110,7 +110,7 @@ func TestAccountTermination_VersionCommitKeepsAConcurrentHeaderChange(t *testing
svc := setupTestService(t)
id := createWithUserData(t, svc, "web")
inner := svc.kv
header := kvutil.AccountKey(testAccountID, id)
header := kvstore.AccountKey(testAccountID, id)
raced := false
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
if key != header || raced {
@@ -215,10 +215,10 @@ func TestAccountTemplateReferences_FailsClosed(t *testing.T) {
require.NoError(t, err)
},
"header unreadable": func(t *testing.T, svc *LaunchTemplateServiceImpl, id string) {
svc.kv = &faultyKV{KeyValue: svc.kv, failGet: func(key string) bool { return key == kvutil.AccountKey(testAccountID, id) }}
svc.kv = &faultyKV{KeyValue: svc.kv, failGet: func(key string) bool { return key == kvstore.AccountKey(testAccountID, id) }}
},
"header undecodable": func(t *testing.T, svc *LaunchTemplateServiceImpl, _ string) {
_, err := svc.kv.Put(t.Context(), kvutil.AccountKey(testAccountID, "lt-0broken000000000"), []byte("{not json"))
_, err := svc.kv.Put(t.Context(), kvstore.AccountKey(testAccountID, "lt-0broken000000000"), []byte("{not json"))
require.NoError(t, err)
},
}
@@ -241,7 +241,7 @@ func TestModifyLaunchTemplate_RacingADeleteIsNotFound(t *testing.T) {
t.Run(map[bool]string{false: "by-id", true: "by-name"}[byName], func(t *testing.T) {
svc := setupTestService(t)
id := createWithUserData(t, svc, "web")
header := kvutil.AccountKey(testAccountID, id)
header := kvstore.AccountKey(testAccountID, id)
raced := false
svc.kv = &faultyKV{KeyValue: svc.kv, beforeUpdate: func(key string) {
if key == header && !raced {
@@ -275,7 +275,7 @@ func TestModifyLaunchTemplate_RetriesAConcurrentHeaderChange(t *testing.T) {
_, err := svc.CreateLaunchTemplateVersion(context.Background(), newVersionInput(id), testAccountID) // v3
require.NoError(t, err)
inner := svc.kv
header := kvutil.AccountKey(testAccountID, id)
header := kvstore.AccountKey(testAccountID, id)
raced := false
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
if key != header || raced {
@@ -306,7 +306,7 @@ func TestModifyLaunchTemplate_ConflictRetriesAreBounded(t *testing.T) {
svc := setupTestService(t)
id := createWithUserData(t, svc, "web")
inner := svc.kv
header := kvutil.AccountKey(testAccountID, id)
header := kvstore.AccountKey(testAccountID, id)
attempts := 0
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
if key != header {
@@ -333,7 +333,7 @@ func TestModifyLaunchTemplate_ConflictReReadFailureIsInternal(t *testing.T) {
svc := setupTestService(t)
id := createWithUserData(t, svc, "web")
inner := svc.kv
header := kvutil.AccountKey(testAccountID, id)
header := kvstore.AccountKey(testAccountID, id)
conflicted := false
svc.kv = &faultyKV{
KeyValue: inner,
@@ -410,7 +410,7 @@ func TestDefaultChangeRacingAVersionDeleteFailsNotFound(t *testing.T) {
svc := setupTestService(t)
id := createWithUserData(t, svc, "web")
inner := svc.kv
header := kvutil.AccountKey(testAccountID, id)
header := kvstore.AccountKey(testAccountID, id)
raced := false
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
if key != header || raced {
@@ -460,7 +460,7 @@ func TestFailedVersionDeleteCommitRestoresTheBodies(t *testing.T) {
svc := setupTestService(t)
id := createWithUserData(t, svc, "web")
before := accountKeys(t, svc.kv, testAccountID)
header := kvutil.AccountKey(testAccountID, id)
header := kvstore.AccountKey(testAccountID, id)
svc.kv = &faultyKV{KeyValue: svc.kv, failUpdate: func(key string) bool { return key == header }}
_, err := deleteVersions(svc, id, "2")
@@ -474,7 +474,7 @@ func TestFailedVersionDeleteCommitRestoresTheBodies(t *testing.T) {
func TestVersionDeleteRacingATemplateDeleteLeavesNothing(t *testing.T) {
svc := setupTestService(t)
id := createWithUserData(t, svc, "web")
header := kvutil.AccountKey(testAccountID, id)
header := kvstore.AccountKey(testAccountID, id)
raced := false
svc.kv = &faultyKV{KeyValue: svc.kv, beforeUpdate: func(key string) {
if key != header || raced {
@@ -531,7 +531,7 @@ func TestDefaultChangeRetriesAfterAConcurrentVersionCreate(t *testing.T) {
svc := setupTestService(t)
id := createWithUserData(t, svc, "web")
inner := svc.kv
header := kvutil.AccountKey(testAccountID, id)
header := kvstore.AccountKey(testAccountID, id)
raced := false
svc.kv = &faultyKV{KeyValue: inner, beforeUpdate: func(key string) {
if key != header || raced {
@@ -8,7 +8,7 @@ import (
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -27,7 +27,7 @@ type TemplateReferences struct {
// header or body it cannot read or decode, is an error, because the unread
// record may name the resource. Headerless bodies resolve from no launch.
func (s *LaunchTemplateServiceImpl) AccountTemplateReferences(ctx context.Context, accountID string) (*TemplateReferences, error) {
keys, err := kvutil.Keys(ctx, s.kv)
keys, err := kvstore.Keys(ctx, s.kv)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list launch-template keys: %w", err)
}
@@ -10,7 +10,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
ec2eip "github.com/mulgadc/spinifex/spinifex/domains/ec2/eip"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -42,7 +42,7 @@ func TestRLC3_NatGatewayDeletesWhileRouted(t *testing.T) {
natgwID := createTestNatGateway(t, svc)
testutil.SeedKV(t, js, kvBucketRouteTables, map[string][]byte{
kvutil.AccountKey(testAccountID, "rtb-routed"): fmt.Appendf(nil,
kvstore.AccountKey(testAccountID, "rtb-routed"): fmt.Appendf(nil,
`{"route_table_id":"rtb-routed","vpc_id":"vpc-test1","routes":[{"destination_cidr_block":"0.0.0.0/0","nat_gateway_id":%q}]}`, natgwID),
})
@@ -93,7 +93,7 @@ func createTestNatGateway(t *testing.T, svc *NatGatewayServiceImpl) string {
func readEIP(t *testing.T, svc *NatGatewayServiceImpl, allocID string) ec2eip.EIPRecord {
t.Helper()
entry, err := svc.eipKV.Get(t.Context(), kvutil.AccountKey(testAccountID, allocID))
entry, err := svc.eipKV.Get(t.Context(), kvstore.AccountKey(testAccountID, allocID))
require.NoError(t, err)
var eip ec2eip.EIPRecord
require.NoError(t, json.Unmarshal(entry.Value(), &eip))
+17 -17
View File
@@ -20,7 +20,7 @@ import (
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -45,7 +45,7 @@ func NewNatGatewayServiceImplWithNATS(ctx context.Context, natsConn *nats.Conn)
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
natgwKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketNatGateways, 10)
natgwKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketNatGateways, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketNatGateways, err)
}
@@ -61,17 +61,17 @@ func NewNatGatewayServiceImplWithNATS(ctx context.Context, natsConn *nats.Conn)
return nil, fmt.Errorf("migrate %s: %w", KVBucketDeletedNatGateways, err)
}
eipKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2eip.KVBucketEIPs, 10)
eipKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2eip.KVBucketEIPs, 10)
if err != nil {
return nil, fmt.Errorf("failed to get EIP KV bucket: %w", err)
}
subnetKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketSubnets, 10)
subnetKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketSubnets, 10)
if err != nil {
return nil, fmt.Errorf("failed to get subnet KV bucket: %w", err)
}
vpcKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
vpcKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
if err != nil {
return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err)
}
@@ -92,7 +92,7 @@ func NewNatGatewayServiceImplWithNATS(ctx context.Context, natsConn *nats.Conn)
// for as long as a client may still ask about it.
func getOrCreateDeletedBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) {
// Terraform polls DescribeNatGateways after delete and expects state=deleted.
return kvutil.GetOrCreateBucketWithOptions(ctx, js, kvutil.BucketOptions{
return kvstore.GetOrCreateBucketWithOptions(ctx, js, kvstore.BucketOptions{
Name: KVBucketDeletedNatGateways,
Description: "Deleted NAT Gateways (auto-expire after 1 hour)",
History: 1,
@@ -113,7 +113,7 @@ func (s *NatGatewayServiceImpl) CreateNatGateway(ctx context.Context, input *ec2
allocID := *input.AllocationId
// Validate subnet exists and get its VPC
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.AccountKey(accountID, subnetID))
subnetEntry, err := s.subnetKV.Get(ctx, kvstore.AccountKey(accountID, subnetID))
if err != nil {
return nil, errors.New(awserrors.ErrorInvalidSubnetIDNotFound)
}
@@ -123,7 +123,7 @@ func (s *NatGatewayServiceImpl) CreateNatGateway(ctx context.Context, input *ec2
}
// Validate EIP exists and is not already associated
eipEntry, err := s.eipKV.Get(ctx, kvutil.AccountKey(accountID, allocID))
eipEntry, err := s.eipKV.Get(ctx, kvstore.AccountKey(accountID, allocID))
if err != nil {
return nil, errors.New(awserrors.ErrorInvalidAllocationIDNotFound)
}
@@ -152,7 +152,7 @@ func (s *NatGatewayServiceImpl) CreateNatGateway(ctx context.Context, input *ec2
if err != nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
if _, err := s.natgwKV.Put(ctx, kvutil.AccountKey(accountID, natgwID), data); err != nil {
if _, err := s.natgwKV.Put(ctx, kvstore.AccountKey(accountID, natgwID), data); err != nil {
slog.ErrorContext(ctx, "Failed to store NAT Gateway", "natGatewayId", natgwID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
@@ -165,7 +165,7 @@ func (s *NatGatewayServiceImpl) CreateNatGateway(ctx context.Context, input *ec2
eipRecord.VpcId = subnetRecord.VpcId
eipRecord.State = "associated"
if eipData, err := json.Marshal(eipRecord); err == nil {
if _, err := s.eipKV.Update(ctx, kvutil.AccountKey(accountID, allocID), eipData, eipEntry.Revision()); err != nil {
if _, err := s.eipKV.Update(ctx, kvstore.AccountKey(accountID, allocID), eipData, eipEntry.Revision()); err != nil {
slog.WarnContext(ctx, "CreateNatGateway: failed to mark EIP associated", "natGatewayId", natgwID, "allocationId", allocID, "err", err)
}
}
@@ -185,7 +185,7 @@ func (s *NatGatewayServiceImpl) DeleteNatGateway(ctx context.Context, input *ec2
}
natgwID := *input.NatGatewayId
key := kvutil.AccountKey(accountID, natgwID)
key := kvstore.AccountKey(accountID, natgwID)
entry, err := s.natgwKV.Get(ctx, key)
if err != nil {
@@ -255,7 +255,7 @@ func (s *NatGatewayServiceImpl) publishDeleteEventsForNatGateway(ctx context.Con
slog.WarnContext(ctx, "DeleteNatGateway: JetStream setup failed, SNAT teardown deferred to route delete", "natGatewayId", record.NatGatewayId, "err", err)
return
}
rtbKV, err := kvutil.GetOrCreateBucket(ctx, js, kvBucketRouteTables, 10)
rtbKV, err := kvstore.GetOrCreateBucket(ctx, js, kvBucketRouteTables, 10)
if err != nil {
slog.WarnContext(ctx, "DeleteNatGateway: route-table scan failed, SNAT teardown deferred to route delete", "natGatewayId", record.NatGatewayId, "err", err)
return
@@ -266,7 +266,7 @@ func (s *NatGatewayServiceImpl) publishDeleteEventsForNatGateway(ctx context.Con
}
prefix := accountID + "."
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := rtbKV.Get(ctx, key)
@@ -302,7 +302,7 @@ func (s *NatGatewayServiceImpl) publishDeleteEventsForNatGateway(ctx context.Con
if assoc.SubnetId == "" {
continue
}
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.AccountKey(accountID, assoc.SubnetId))
subnetEntry, err := s.subnetKV.Get(ctx, kvstore.AccountKey(accountID, assoc.SubnetId))
if err != nil {
continue
}
@@ -322,7 +322,7 @@ func (s *NatGatewayServiceImpl) disassociateEIP(ctx context.Context, record *Nat
if record.AllocationId == "" {
return
}
key := kvutil.AccountKey(accountID, record.AllocationId)
key := kvstore.AccountKey(accountID, record.AllocationId)
entry, err := s.eipKV.Get(ctx, key)
if err != nil {
if !errors.Is(err, jetstream.ErrKeyNotFound) {
@@ -380,7 +380,7 @@ func (s *NatGatewayServiceImpl) DescribeNatGateways(ctx context.Context, input *
foundIDs := make(map[string]bool)
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
@@ -415,7 +415,7 @@ func (s *NatGatewayServiceImpl) DescribeNatGateways(ctx context.Context, input *
if foundIDs[id] {
continue
}
key := kvutil.AccountKey(accountID, id)
key := kvstore.AccountKey(accountID, id)
entry, err := s.deletedNatgwKV.Get(ctx, key)
if err != nil {
if errors.Is(err, jetstream.ErrKeyNotFound) {
@@ -11,7 +11,7 @@ import (
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -31,18 +31,18 @@ func setupTestServiceJS(t *testing.T) (*NatGatewayServiceImpl, jetstream.JetStre
// Seed VPC KV
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, map[string][]byte{
kvutil.AccountKey(testAccountID, "vpc-test1"): []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`),
kvstore.AccountKey(testAccountID, "vpc-test1"): []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`),
})
// Seed subnet KV (public subnet)
testutil.SeedKV(t, js, ec2vpc.KVBucketSubnets, map[string][]byte{
kvutil.AccountKey(testAccountID, "subnet-pub1"): []byte(`{"subnet_id":"subnet-pub1","vpc_id":"vpc-test1","cidr_block":"10.0.1.0/24","state":"available","map_public_ip_on_launch":true}`),
kvstore.AccountKey(testAccountID, "subnet-pub1"): []byte(`{"subnet_id":"subnet-pub1","vpc_id":"vpc-test1","cidr_block":"10.0.1.0/24","state":"available","map_public_ip_on_launch":true}`),
})
// Seed EIP KV
testutil.SeedKV(t, js, ec2eip.KVBucketEIPs, map[string][]byte{
kvutil.AccountKey(testAccountID, "eipalloc-test1"): []byte(`{"allocation_id":"eipalloc-test1","public_ip":"203.0.113.50","state":"allocated"}`),
kvutil.AccountKey(testAccountID, "eipalloc-used"): []byte(`{"allocation_id":"eipalloc-used","public_ip":"203.0.113.51","state":"associated","association_id":"eipassoc-xxx"}`),
kvstore.AccountKey(testAccountID, "eipalloc-test1"): []byte(`{"allocation_id":"eipalloc-test1","public_ip":"203.0.113.50","state":"allocated"}`),
kvstore.AccountKey(testAccountID, "eipalloc-used"): []byte(`{"allocation_id":"eipalloc-used","public_ip":"203.0.113.51","state":"associated","association_id":"eipassoc-xxx"}`),
})
svc, err := NewNatGatewayServiceImplWithNATS(t.Context(), nc)
@@ -170,7 +170,7 @@ func TestDeleteNatGateway(t *testing.T) {
assert.Equal(t, natgwID, *deleteOut.NatGatewayId)
// Should be gone
_, err = svc.natgwKV.Get(t.Context(), kvutil.AccountKey(testAccountID, natgwID))
_, err = svc.natgwKV.Get(t.Context(), kvstore.AccountKey(testAccountID, natgwID))
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound)
}
@@ -8,7 +8,7 @@ import (
"log/slog"
"strings"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -39,7 +39,7 @@ type AccountCleanup struct {
// referenced returns a reason is kept; unreadable records are left for the owner scan to report. A
// listing failure is returned and nothing is deleted.
func (s *PlacementGroupServiceImpl) CleanupAccount(ctx context.Context, accountID string, referenced func(groupID, groupName string) string) (*AccountCleanup, error) {
keys, err := kvutil.Keys(ctx, s.kv)
keys, err := kvstore.Keys(ctx, s.kv)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list placement-group keys: %w", err)
}
@@ -47,7 +47,7 @@ func (s *PlacementGroupServiceImpl) CleanupAccount(ctx context.Context, accountI
prefix := accountID + "."
out := &AccountCleanup{Referenced: map[string]string{}}
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.kv.Get(ctx, key)
@@ -8,7 +8,7 @@ import (
"log/slog"
"strings"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -34,7 +34,7 @@ type AccountScan struct {
// A listing failure is returned as an error; per-record read failures are
// counted. The bucket's schema-version key belongs to no account.
func (s *PlacementGroupServiceImpl) ScanAccount(ctx context.Context, accountID string) (*AccountScan, error) {
keys, err := kvutil.Keys(ctx, s.kv)
keys, err := kvstore.Keys(ctx, s.kv)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list placement-group keys: %w", err)
}
@@ -42,7 +42,7 @@ func (s *PlacementGroupServiceImpl) ScanAccount(ctx context.Context, accountID s
prefix := accountID + "."
scan := &AccountScan{}
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.kv.Get(ctx, key)
@@ -11,7 +11,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit/readonlystore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -42,14 +42,14 @@ func TestScanAccountClassifiesPlacementGroupRecords(t *testing.T) {
GroupName: "held", NodeInstances: map[string][]string{"node-2": {"i-0bbb", "i-0aaa"}, "node-3": {"i-0aaa"}},
}, testAccountID)
require.NoError(t, err)
_, err = svc.kv.Put(t.Context(), kvutil.AccountKey(testAccountID, "undecodable"), []byte("{not json"))
_, err = svc.kv.Put(t.Context(), kvstore.AccountKey(testAccountID, "undecodable"), []byte("{not json"))
require.NoError(t, err)
_, err = svc.kv.Put(t.Context(), kvutil.AccountKey(testAccountID, "anonymous"), []byte(`{"group_name":"anonymous"}`))
_, err = svc.kv.Put(t.Context(), kvstore.AccountKey(testAccountID, "anonymous"), []byte(`{"group_name":"anonymous"}`))
require.NoError(t, err)
createTestGroup(t, svc, "unreadable", ec2.PlacementStrategySpread)
require.Contains(t, bucketKeys(t, svc.kv), kvutil.VersionKey)
require.Contains(t, bucketKeys(t, svc.kv), kvstore.VersionKey)
svc.kv = &hookedKV{KeyValue: readonlystore.NewKV(t, svc.kv), failGet: kvutil.AccountKey(testAccountID, "unreadable")}
svc.kv = &hookedKV{KeyValue: readonlystore.NewKV(t, svc.kv), failGet: kvstore.AccountKey(testAccountID, "unreadable")}
scan, err := svc.ScanAccount(context.Background(), testAccountID)
require.NoError(t, err)
@@ -14,7 +14,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -84,7 +84,7 @@ func TestCurrentBehaviour_AccountTermination_PlacementGroupStorageLayout(t *test
}, testAccountID)
require.NoError(t, err)
assert.Equal(t, []string{testAccountID + ".spread-a", kvutil.VersionKey}, bucketKeys(t, svc.kv))
assert.Equal(t, []string{testAccountID + ".spread-a", kvstore.VersionKey}, bucketKeys(t, svc.kv))
entry, err := svc.kv.Get(t.Context(), testAccountID+".spread-a")
require.NoError(t, err)
@@ -100,7 +100,7 @@ func TestCurrentBehaviour_AccountTermination_PlacementGroupStorageLayout(t *test
// a record cannot be removed through DeletePlacementGroup either, and a read
// failure is an internal error, never an unknown group.
func TestCurrentBehaviour_AccountTermination_DescribePlacementGroupsHidesUnreadableRecords(t *testing.T) {
brokenKey := kvutil.AccountKey(testAccountID, "broken")
brokenKey := kvstore.AccountKey(testAccountID, "broken")
tests := []struct {
name string
seed func(t *testing.T, svc *PlacementGroupServiceImpl)
@@ -139,7 +139,7 @@ func TestCurrentBehaviour_AccountTermination_DescribePlacementGroupsHidesUnreada
func TestAccountTermination_DeletePlacementGroupLosesToAConcurrentMember(t *testing.T) {
svc := setupTestService(t)
createTestGroup(t, svc, "cluster-a", ec2.PlacementStrategyCluster)
key := kvutil.AccountKey(testAccountID, "cluster-a")
key := kvstore.AccountKey(testAccountID, "cluster-a")
launcher := &PlacementGroupServiceImpl{kv: svc.kv, region: svc.region}
var reserveErr, finalizeErr error
@@ -222,7 +222,7 @@ func TestAccountCleanup_DeletesEmptyGroupsAndBlocksOnMembers(t *testing.T) {
empty := createTestGroup(t, svc, "empty", ec2.PlacementStrategySpread)
held := createTestGroup(t, svc, "held", ec2.PlacementStrategyCluster)
require.NoError(t, addMember(t, svc, "held", "i-0123456789abcdef0"))
_, err := svc.kv.Put(t.Context(), kvutil.AccountKey(testAccountID, "broken"), []byte("{not json"))
_, err := svc.kv.Put(t.Context(), kvstore.AccountKey(testAccountID, "broken"), []byte("{not json"))
require.NoError(t, err)
other := "999988887777"
_, err = svc.CreatePlacementGroup(context.Background(), &ec2.CreatePlacementGroupInput{
@@ -238,7 +238,7 @@ func TestAccountCleanup_DeletesEmptyGroupsAndBlocksOnMembers(t *testing.T) {
assert.Equal(t, []string{"held"}, describedGroupNames(t, svc, testAccountID))
assert.Equal(t, []string{"theirs"}, describedGroupNames(t, svc, other))
_, err = svc.kv.Get(t.Context(), kvutil.AccountKey(testAccountID, "broken"))
_, err = svc.kv.Get(t.Context(), kvstore.AccountKey(testAccountID, "broken"))
require.NoError(t, err, "an unreadable record is never deleted")
}
@@ -247,7 +247,7 @@ func TestAccountCleanup_DeletesEmptyGroupsAndBlocksOnMembers(t *testing.T) {
func TestAccountCleanup_ConcurrentMemberBlocksTheDelete(t *testing.T) {
svc := setupTestService(t)
group := createTestGroup(t, svc, "cluster-a", ec2.PlacementStrategyCluster)
key := kvutil.AccountKey(testAccountID, "cluster-a")
key := kvstore.AccountKey(testAccountID, "cluster-a")
launcher := &PlacementGroupServiceImpl{kv: svc.kv, region: svc.region}
var memberErr error
// Read 1 is the cleanup's listing read; read 2 is the delete's check.
@@ -17,7 +17,7 @@ import (
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -87,7 +87,7 @@ func NewPlacementGroupServiceImplWithNATS(ctx context.Context, cfg *config.Confi
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketPlacementGroups, 10)
kv, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketPlacementGroups, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketPlacementGroups, err)
}
@@ -137,7 +137,7 @@ func (s *PlacementGroupServiceImpl) CreatePlacementGroup(ctx context.Context, in
}
groupName := *input.GroupName
key := kvutil.AccountKey(accountID, groupName)
key := kvstore.AccountKey(accountID, groupName)
groupID := awsidentifiers.GenerateResourceID("pg")
record := PlacementGroupRecord{
@@ -175,7 +175,7 @@ func (s *PlacementGroupServiceImpl) DeletePlacementGroup(ctx context.Context, in
}
groupName := *input.GroupName
if _, err := s.deleteGroup(ctx, accountID, kvutil.AccountKey(accountID, groupName)); err != nil {
if _, err := s.deleteGroup(ctx, accountID, kvstore.AccountKey(accountID, groupName)); err != nil {
if errors.Is(err, errPGAbsent) {
// Lower case, unlike DescribePlacementGroups' message for the same code.
return nil, awserrors.Errorf(awserrors.ErrorInvalidPlacementGroupUnknown, "The placement group '%s' is unknown.", groupName)
@@ -194,7 +194,7 @@ func (s *PlacementGroupServiceImpl) DeletePlacementGroup(ctx context.Context, in
// The returned count is the members seen by the last check.
func (s *PlacementGroupServiceImpl) deleteGroup(ctx context.Context, accountID, key string) (int, error) {
members := 0
record, err := kvutil.DeleteIf(ctx, s.kv, key, casConfig(), func(record *PlacementGroupRecord) error {
record, err := kvstore.DeleteIf(ctx, s.kv, key, casConfig(), func(record *PlacementGroupRecord) error {
members = memberCount(record.NodeInstances)
if members > 0 {
return errPGInUse
@@ -238,7 +238,7 @@ func (s *PlacementGroupServiceImpl) DescribePlacementGroups(ctx context.Context,
var groups []*ec2.PlacementGroup
for _, k := range keys {
if k == kvutil.VersionKey {
if k == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(k, prefix) {
@@ -341,7 +341,7 @@ func pgMatchesFilters(record *PlacementGroupRecord, filters map[string][]string)
const maxCASRetries = 5
// errPGAbsent and errPGContended mark the two kvutil outcomes that map to
// errPGAbsent and errPGContended mark the two kvstore outcomes that map to
// specific AWS errors rather than a generic internal failure, and neither
// escapes this file. errPGInUse is the AWS error itself.
var (
@@ -350,8 +350,8 @@ var (
errPGInUse = errors.New(awserrors.ErrorInvalidPlacementGroupInUse)
)
// mapCASError turns a kvutil failure into the AWS error the caller returns.
// A non-kvutil error came from mutate and is already an AWS code.
// mapCASError turns a kvstore failure into the AWS error the caller returns.
// A non-kvstore error came from mutate and is already an AWS code.
func mapCASError(ctx context.Context, op, groupName, accountID string, err error) error {
switch {
case errors.Is(err, errPGAbsent):
@@ -359,16 +359,16 @@ func mapCASError(ctx context.Context, op, groupName, accountID string, err error
case errors.Is(err, errPGContended):
slog.ErrorContext(ctx, "Placement group CAS retries exhausted under contention",
"op", op, "groupName", groupName, "accountID", accountID)
case errors.Is(err, kvutil.ErrRead):
case errors.Is(err, kvstore.ErrRead):
slog.ErrorContext(ctx, "Failed to read placement group from KV",
"op", op, "groupName", groupName, "accountID", accountID, "err", err)
case errors.Is(err, kvutil.ErrDecode):
case errors.Is(err, kvstore.ErrDecode):
slog.ErrorContext(ctx, "Corrupt placement group record in KV",
"op", op, "groupName", groupName, "accountID", accountID, "err", err)
case errors.Is(err, kvutil.ErrEncode):
case errors.Is(err, kvstore.ErrEncode):
slog.ErrorContext(ctx, "Failed to marshal placement group record",
"op", op, "groupName", groupName, "accountID", accountID, "err", err)
case errors.Is(err, kvutil.ErrWrite):
case errors.Is(err, kvstore.ErrWrite):
slog.ErrorContext(ctx, "Failed to write placement group to KV",
"op", op, "groupName", groupName, "accountID", accountID, "err", err)
default:
@@ -378,8 +378,8 @@ func mapCASError(ctx context.Context, op, groupName, accountID string, err error
}
// casConfig is the shared CAS policy for every placement group mutation.
func casConfig() kvutil.CASConfig {
return kvutil.CASConfig{
func casConfig() kvstore.CASConfig {
return kvstore.CASConfig{
Attempts: maxCASRetries,
NotFound: errPGAbsent,
Exhausted: func(string, int) error { return errPGContended },
@@ -394,7 +394,7 @@ func (s *PlacementGroupServiceImpl) ReserveSpreadNodes(ctx context.Context, inpu
}
var selected []string
_, err := kvutil.Update(ctx, s.kv, kvutil.AccountKey(accountID, input.GroupName), casConfig(),
_, err := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
func(record *PlacementGroupRecord) (bool, error) {
if record.State != ec2.PlacementGroupStateAvailable {
return false, errors.New(awserrors.ErrorInvalidPlacementGroupUnknown)
@@ -437,7 +437,7 @@ func (s *PlacementGroupServiceImpl) FinalizeSpreadInstances(ctx context.Context,
return nil, errors.New(awserrors.ErrorMissingParameter)
}
_, err := kvutil.Update(ctx, s.kv, kvutil.AccountKey(accountID, input.GroupName), casConfig(),
_, err := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
func(record *PlacementGroupRecord) (bool, error) {
maps.Copy(record.NodeInstances, input.NodeInstances)
return true, nil
@@ -462,7 +462,7 @@ func (s *PlacementGroupServiceImpl) ReleaseSpreadNodes(ctx context.Context, inpu
releaseSet[n] = true
}
_, err := kvutil.Update(ctx, s.kv, kvutil.AccountKey(accountID, input.GroupName), casConfig(),
_, err := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
func(record *PlacementGroupRecord) (bool, error) {
changed := false
for node := range releaseSet {
@@ -490,7 +490,7 @@ func (s *PlacementGroupServiceImpl) RemoveInstance(ctx context.Context, input *R
return nil, errors.New(awserrors.ErrorMissingParameter)
}
_, err := kvutil.Update(ctx, s.kv, kvutil.AccountKey(accountID, input.GroupName), casConfig(),
_, err := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
func(record *PlacementGroupRecord) (bool, error) {
instances, exists := record.NodeInstances[input.NodeName]
if !exists {
@@ -533,7 +533,7 @@ func (s *PlacementGroupServiceImpl) ReserveClusterNode(ctx context.Context, inpu
}
var targetNode string
_, err := kvutil.Update(ctx, s.kv, kvutil.AccountKey(accountID, input.GroupName), casConfig(),
_, err := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
func(record *PlacementGroupRecord) (bool, error) {
if record.State != ec2.PlacementGroupStateAvailable {
return false, errors.New(awserrors.ErrorInvalidPlacementGroupUnknown)
@@ -570,7 +570,7 @@ func (s *PlacementGroupServiceImpl) FinalizeClusterInstances(ctx context.Context
return nil, errors.New(awserrors.ErrorMissingParameter)
}
_, err := kvutil.Update(ctx, s.kv, kvutil.AccountKey(accountID, input.GroupName), casConfig(),
_, err := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
func(record *PlacementGroupRecord) (bool, error) {
// Append rather than replace: a cluster group can have several launches in flight at once.
for node, ids := range input.NodeInstances {
@@ -10,7 +10,7 @@ import (
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -39,7 +39,7 @@ func createTestGroup(t *testing.T, svc *PlacementGroupServiceImpl, name, strateg
// readRecord returns the stored record for assertions.
func readRecord(t *testing.T, svc *PlacementGroupServiceImpl, groupName string) *PlacementGroupRecord {
t.Helper()
entry, err := svc.kv.Get(t.Context(), kvutil.AccountKey(testAccountID, groupName))
entry, err := svc.kv.Get(t.Context(), kvstore.AccountKey(testAccountID, groupName))
require.NoError(t, err)
var record PlacementGroupRecord
require.NoError(t, json.Unmarshal(entry.Value(), &record))
@@ -50,7 +50,7 @@ func readRecord(t *testing.T, svc *PlacementGroupServiceImpl, groupName string)
// starting state the public API cannot produce.
func seedRecord(t *testing.T, svc *PlacementGroupServiceImpl, groupName string, mutate func(*PlacementGroupRecord)) {
t.Helper()
_, err := kvutil.Update(t.Context(), svc.kv, kvutil.AccountKey(testAccountID, groupName), kvutil.CASConfig{},
_, err := kvstore.Update(t.Context(), svc.kv, kvstore.AccountKey(testAccountID, groupName), kvstore.CASConfig{},
func(record *PlacementGroupRecord) (bool, error) {
mutate(record)
return true, nil
+29 -29
View File
@@ -25,7 +25,7 @@ import (
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -52,7 +52,7 @@ func NewRouteTableServiceImplWithNATS(ctx context.Context, cfg *config.Config, n
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
rtbKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketRouteTables, 10)
rtbKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketRouteTables, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketRouteTables, err)
}
@@ -60,22 +60,22 @@ func NewRouteTableServiceImplWithNATS(ctx context.Context, cfg *config.Config, n
return nil, fmt.Errorf("migrate %s: %w", KVBucketRouteTables, err)
}
vpcKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
vpcKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
if err != nil {
return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err)
}
igwKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2igw.KVBucketIGW, 10)
igwKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2igw.KVBucketIGW, 10)
if err != nil {
return nil, fmt.Errorf("failed to get IGW KV bucket: %w", err)
}
subnetKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketSubnets, 10)
subnetKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketSubnets, 10)
if err != nil {
return nil, fmt.Errorf("failed to get subnet KV bucket: %w", err)
}
natgwKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2natgw.KVBucketNatGateways, 10)
natgwKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2natgw.KVBucketNatGateways, 10)
if err != nil {
return nil, fmt.Errorf("failed to get NAT Gateway KV bucket: %w", err)
}
@@ -95,7 +95,7 @@ func NewRouteTableServiceImplWithNATS(ctx context.Context, cfg *config.Config, n
// getRouteTable retrieves a route table record from KV.
func (s *RouteTableServiceImpl) getRouteTable(ctx context.Context, accountID, rtbID string) (*RouteTableRecord, error) {
entry, err := s.rtbKV.Get(ctx, kvutil.AccountKey(accountID, rtbID))
entry, err := s.rtbKV.Get(ctx, kvstore.AccountKey(accountID, rtbID))
if err != nil {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, rtbNotFoundError(rtbID)
@@ -118,7 +118,7 @@ func (s *RouteTableServiceImpl) putRouteTable(ctx context.Context, accountID str
slog.ErrorContext(ctx, "Failed to marshal route table record", "routeTableId", record.RouteTableId, "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
if _, err := s.rtbKV.Put(ctx, kvutil.AccountKey(accountID, record.RouteTableId), data); err != nil {
if _, err := s.rtbKV.Put(ctx, kvstore.AccountKey(accountID, record.RouteTableId), data); err != nil {
slog.ErrorContext(ctx, "Failed to write route table to KV", "routeTableId", record.RouteTableId, "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
@@ -130,7 +130,7 @@ func (s *RouteTableServiceImpl) putRouteTable(ctx context.Context, accountID str
// parallel AssociateRouteTable calls Terraform fans out per route table.
const rtbCASMaxRetries = 16
// errRTBAbsent and errRTBContended mark the two kvutil outcomes that map to
// errRTBAbsent and errRTBContended mark the two kvstore outcomes that map to
// specific AWS errors rather than a generic internal failure. Neither
// escapes this file.
var (
@@ -143,7 +143,7 @@ var (
// several subnets with one route table at once. mutate reports whether
// it changed the record; a false return commits nothing.
func (s *RouteTableServiceImpl) mutateRouteTableCAS(ctx context.Context, accountID, rtbID string, mutate func(*RouteTableRecord) (bool, error)) error {
_, err := kvutil.Update(ctx, s.rtbKV, kvutil.AccountKey(accountID, rtbID), kvutil.CASConfig{
_, err := kvstore.Update(ctx, s.rtbKV, kvstore.AccountKey(accountID, rtbID), kvstore.CASConfig{
Attempts: rtbCASMaxRetries,
NotFound: errRTBAbsent,
Exhausted: func(string, int) error { return errRTBContended },
@@ -157,13 +157,13 @@ func (s *RouteTableServiceImpl) mutateRouteTableCAS(ctx context.Context, account
// Contended rather than broken.
slog.ErrorContext(ctx, "Route table CAS retries exhausted under contention",
"routeTableId", rtbID, "attempts", rtbCASMaxRetries)
case errors.Is(err, kvutil.ErrRead):
case errors.Is(err, kvstore.ErrRead):
slog.ErrorContext(ctx, "Failed to read route table from KV", "routeTableId", rtbID, "err", err)
case errors.Is(err, kvutil.ErrDecode):
case errors.Is(err, kvstore.ErrDecode):
slog.ErrorContext(ctx, "Corrupt route table record in KV", "routeTableId", rtbID, "err", err)
case errors.Is(err, kvutil.ErrEncode):
case errors.Is(err, kvstore.ErrEncode):
slog.ErrorContext(ctx, "Failed to marshal route table record", "routeTableId", rtbID, "err", err)
case errors.Is(err, kvutil.ErrWrite):
case errors.Is(err, kvstore.ErrWrite):
slog.ErrorContext(ctx, "Failed to write route table to KV", "routeTableId", rtbID, "err", err)
default:
return err
@@ -173,7 +173,7 @@ func (s *RouteTableServiceImpl) mutateRouteTableCAS(ctx context.Context, account
// getVPCCidr looks up a VPC's CIDR block from the VPC KV bucket.
func (s *RouteTableServiceImpl) getVPCCidr(ctx context.Context, accountID, vpcID string) (string, error) {
entry, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID))
entry, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, vpcID))
if err != nil {
return "", awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID)
}
@@ -229,7 +229,7 @@ func (s *RouteTableServiceImpl) subnetsImplicitlyOnMainRT(ctx context.Context, a
}
var implicit []string
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.subnetKV.Get(ctx, key)
@@ -387,7 +387,7 @@ func (s *RouteTableServiceImpl) allSubnetsForVPC(ctx context.Context, accountID,
}
var subnets []string
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.subnetKV.Get(ctx, key)
@@ -455,7 +455,7 @@ func (s *RouteTableServiceImpl) allRouteTablesForVPC(ctx context.Context, accoun
var results []RouteTableRecord
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.rtbKV.Get(ctx, key)
@@ -577,7 +577,7 @@ func (s *RouteTableServiceImpl) DeleteRouteTable(ctx context.Context, input *ec2
}
}
if err := s.rtbKV.Delete(ctx, kvutil.AccountKey(accountID, rtbID)); err != nil {
if err := s.rtbKV.Delete(ctx, kvstore.AccountKey(accountID, rtbID)); err != nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
@@ -633,7 +633,7 @@ func (s *RouteTableServiceImpl) DescribeRouteTables(ctx context.Context, input *
foundIDs := make(map[string]bool)
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
@@ -740,7 +740,7 @@ func (s *RouteTableServiceImpl) CreateRoute(ctx context.Context, input *ec2.Crea
case input.GatewayId != nil && *input.GatewayId != "":
igwID := *input.GatewayId
// Verify IGW exists and is attached to the same VPC
igwEntry, err := s.igwKV.Get(ctx, kvutil.AccountKey(accountID, igwID))
igwEntry, err := s.igwKV.Get(ctx, kvstore.AccountKey(accountID, igwID))
if err != nil {
return nil, gatewayNotFoundError(igwID)
}
@@ -770,7 +770,7 @@ func (s *RouteTableServiceImpl) CreateRoute(ctx context.Context, input *ec2.Crea
case input.NatGatewayId != nil && *input.NatGatewayId != "":
natgwID := *input.NatGatewayId
// Verify NAT GW exists and belongs to the same VPC
natgwEntry, err := s.natgwKV.Get(ctx, kvutil.AccountKey(accountID, natgwID))
natgwEntry, err := s.natgwKV.Get(ctx, kvstore.AccountKey(accountID, natgwID))
if err != nil {
return nil, errors.New(awserrors.ErrorInvalidNatGatewayIDNotFound)
}
@@ -924,7 +924,7 @@ func (s *RouteTableServiceImpl) ReplaceRoute(ctx context.Context, input *ec2.Rep
igwID := *input.GatewayId
// AWS reports a missing gateway ahead of a missing route.
igwEntry, err := s.igwKV.Get(ctx, kvutil.AccountKey(accountID, igwID))
igwEntry, err := s.igwKV.Get(ctx, kvstore.AccountKey(accountID, igwID))
if err != nil {
return nil, gatewayNotFoundError(igwID)
}
@@ -983,7 +983,7 @@ func (s *RouteTableServiceImpl) AssociateRouteTable(ctx context.Context, input *
}
// Verify subnet exists and belongs to the same VPC
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.AccountKey(accountID, subnetID))
subnetEntry, err := s.subnetKV.Get(ctx, kvstore.AccountKey(accountID, subnetID))
if err != nil {
return nil, errors.New(awserrors.ErrorInvalidSubnetIDNotFound)
}
@@ -1077,7 +1077,7 @@ func (s *RouteTableServiceImpl) DisassociateRouteTable(ctx context.Context, inpu
}
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
@@ -1168,7 +1168,7 @@ func (s *RouteTableServiceImpl) ReplaceRouteTableAssociation(ctx context.Context
}
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
@@ -1369,7 +1369,7 @@ func (s *RouteTableServiceImpl) publishNatGatewayDeleteEvents(ctx context.Contex
if s.natsConn == nil {
return
}
natgwEntry, err := s.natgwKV.Get(ctx, kvutil.AccountKey(accountID, natgwID))
natgwEntry, err := s.natgwKV.Get(ctx, kvstore.AccountKey(accountID, natgwID))
if err != nil {
slog.WarnContext(ctx, "NAT GW event: natgw lookup failed", "topic", networkv1.NATGatewayDeleteSubject, "natGatewayId", natgwID, "err", err)
return
@@ -1417,7 +1417,7 @@ func (s *RouteTableServiceImpl) publishNatGatewayEventsForAssociation(ctx contex
if r.NatGatewayId == "" {
continue
}
natgwEntry, err := s.natgwKV.Get(ctx, kvutil.AccountKey(accountID, r.NatGatewayId))
natgwEntry, err := s.natgwKV.Get(ctx, kvstore.AccountKey(accountID, r.NatGatewayId))
if err != nil {
slog.WarnContext(ctx, "NAT GW event: natgw lookup failed", "topic", topic, "natGatewayId", r.NatGatewayId, "err", err)
continue
@@ -1442,7 +1442,7 @@ func (s *RouteTableServiceImpl) publishNatGatewayEventForSubnet(ctx context.Cont
if s.natsConn == nil {
return
}
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.AccountKey(accountID, subnetID))
subnetEntry, err := s.subnetKV.Get(ctx, kvstore.AccountKey(accountID, subnetID))
if err != nil {
slog.WarnContext(ctx, "NAT GW event: subnet lookup failed", "topic", topic, "subnetId", subnetID, "err", err)
return
@@ -13,7 +13,7 @@ import (
ec2natgw "github.com/mulgadc/spinifex/spinifex/domains/ec2/natgw"
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -28,23 +28,23 @@ func setupTestService(t *testing.T) *RouteTableServiceImpl {
// Seed VPC KV
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, map[string][]byte{
kvutil.AccountKey(testAccountID, "vpc-test1"): []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`),
kvstore.AccountKey(testAccountID, "vpc-test1"): []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`),
})
// Seed IGW KV (attached to vpc-test1)
testutil.SeedKV(t, js, ec2igw.KVBucketIGW, map[string][]byte{
kvutil.AccountKey(testAccountID, "igw-test1"): []byte(`{"internet_gateway_id":"igw-test1","vpc_id":"vpc-test1","state":"available"}`),
kvstore.AccountKey(testAccountID, "igw-test1"): []byte(`{"internet_gateway_id":"igw-test1","vpc_id":"vpc-test1","state":"available"}`),
})
// Seed subnet KV
testutil.SeedKV(t, js, ec2vpc.KVBucketSubnets, map[string][]byte{
kvutil.AccountKey(testAccountID, "subnet-test1"): []byte(`{"subnet_id":"subnet-test1","vpc_id":"vpc-test1","cidr_block":"10.0.1.0/24","state":"available"}`),
kvutil.AccountKey(testAccountID, "subnet-priv1"): []byte(`{"subnet_id":"subnet-priv1","vpc_id":"vpc-test1","cidr_block":"10.0.11.0/24","state":"available"}`),
kvstore.AccountKey(testAccountID, "subnet-test1"): []byte(`{"subnet_id":"subnet-test1","vpc_id":"vpc-test1","cidr_block":"10.0.1.0/24","state":"available"}`),
kvstore.AccountKey(testAccountID, "subnet-priv1"): []byte(`{"subnet_id":"subnet-priv1","vpc_id":"vpc-test1","cidr_block":"10.0.11.0/24","state":"available"}`),
})
// Seed NAT Gateway KV
testutil.SeedKV(t, js, ec2natgw.KVBucketNatGateways, map[string][]byte{
kvutil.AccountKey(testAccountID, "nat-test1"): []byte(`{"nat_gateway_id":"nat-test1","vpc_id":"vpc-test1","subnet_id":"subnet-test1","public_ip":"192.168.1.100","state":"available"}`),
kvstore.AccountKey(testAccountID, "nat-test1"): []byte(`{"nat_gateway_id":"nat-test1","vpc_id":"vpc-test1","subnet_id":"subnet-test1","public_ip":"192.168.1.100","state":"available"}`),
})
svc, err := NewRouteTableServiceImplWithNATS(t.Context(), nil, nc)
@@ -10,7 +10,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/nats-io/nats.go/jetstream"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
)
// AccountSpotRequest is one persisted request in an account scan.
@@ -54,7 +54,7 @@ func scanBucket(ctx context.Context, kv jetstream.KeyValue, accountID string) ([
prefix := accountID + "."
var out []AccountSpotRequest
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := kv.Get(ctx, key)
@@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
)
func TestScanAccountListsActiveAndTerminalForOneAccount(t *testing.T) {
@@ -35,7 +35,7 @@ func TestScanAccountListsActiveAndTerminalForOneAccount(t *testing.T) {
func TestScanAccountFailsOnAnUndecodableRecord(t *testing.T) {
svc := setupTestService(t)
putSIRs(t, svc, makeSIR("sir-aaa", "i-aaa"))
_, err := svc.activeKV.Put(t.Context(), kvutil.AccountKey(testAccountID, "sir-bad"), []byte("{not json"))
_, err := svc.activeKV.Put(t.Context(), kvstore.AccountKey(testAccountID, "sir-bad"), []byte("{not json"))
require.NoError(t, err)
scan, err := svc.ScanAccount(t.Context(), testAccountID)
@@ -45,7 +45,7 @@ func TestScanAccountFailsOnAnUndecodableRecord(t *testing.T) {
func TestScanAccountFailsOnARecordWithoutARequest(t *testing.T) {
svc := setupTestService(t)
_, err := svc.terminalKV.Put(t.Context(), kvutil.AccountKey(testAccountID, "sir-empty"), []byte(`{"account_id":"x"}`))
_, err := svc.terminalKV.Put(t.Context(), kvstore.AccountKey(testAccountID, "sir-empty"), []byte(`{"account_id":"x"}`))
require.NoError(t, err)
_, err = svc.ScanAccount(t.Context(), testAccountID)
@@ -76,7 +76,7 @@ func (k failingGetKV) Get(ctx context.Context, key string) (jetstream.KeyValueEn
func TestScanAccountFailsWhenARecordCannotBeRead(t *testing.T) {
svc := setupTestService(t)
putSIRs(t, svc, makeSIR("sir-aaa", "i-aaa"), makeSIR("sir-bbb", "i-bbb"))
svc.activeKV = failingGetKV{KeyValue: svc.activeKV, key: kvutil.AccountKey(testAccountID, "sir-bbb")}
svc.activeKV = failingGetKV{KeyValue: svc.activeKV, key: kvstore.AccountKey(testAccountID, "sir-bbb")}
scan, err := svc.ScanAccount(t.Context(), testAccountID)
require.Error(t, err)
@@ -8,7 +8,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -24,14 +24,14 @@ type LaunchReferences struct {
// account. It is complete or an error: an unlistable bucket or an unreadable
// request is an error, because the unread request may name the resource.
func (s *SpotInstanceServiceImpl) AccountLaunchReferences(ctx context.Context, accountID string) (*LaunchReferences, error) {
keys, err := kvutil.Keys(ctx, s.activeKV)
keys, err := kvstore.Keys(ctx, s.activeKV)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list spot requests: %w", err)
}
prefix := accountID + "."
out := &LaunchReferences{}
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
record, err := s.readRecord(ctx, s.activeKV, key)
@@ -5,7 +5,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -41,7 +41,7 @@ func TestAccountLaunchReferences_ReadsOpenAndActiveRequests(t *testing.T) {
func TestAccountLaunchReferences_UnreadableRequestFailsTheCheck(t *testing.T) {
svc := setupTestService(t)
putSIRs(t, svc, makeSIR("sir-active01", "i-0000000000000001"))
_, err := svc.activeKV.Put(t.Context(), kvutil.AccountKey(testAccountID, "sir-broken01"), []byte("{not json"))
_, err := svc.activeKV.Put(t.Context(), kvstore.AccountKey(testAccountID, "sir-broken01"), []byte("{not json"))
require.NoError(t, err)
_, err = svc.AccountLaunchReferences(t.Context(), testAccountID)
@@ -15,7 +15,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -41,7 +41,7 @@ func NewSpotInstanceServiceImplWithNATS(ctx context.Context, cfg *config.Config,
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
activeKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketSpotRequests, 10)
activeKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketSpotRequests, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketSpotRequests, err)
}
@@ -71,7 +71,7 @@ func NewSpotInstanceServiceImplWithNATS(ctx context.Context, cfg *config.Config,
// getOrCreateTerminalBucket opens the terminal bucket without changing its
// existing replica or placement configuration, creating it only when absent.
func getOrCreateTerminalBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) {
return kvutil.GetOrCreateBucketWithOptions(ctx, js, kvutil.BucketOptions{
return kvstore.GetOrCreateBucketWithOptions(ctx, js, kvstore.BucketOptions{
Name: KVBucketSpotRequestsTerminal,
Description: "Terminal Spot Instance Requests (auto-expire after 1 hour)",
History: 1,
@@ -91,7 +91,7 @@ func (s *SpotInstanceServiceImpl) PutSpotInstanceRequests(ctx context.Context, i
if err != nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
if _, err := s.activeKV.Put(ctx, kvutil.AccountKey(accountID, sirID), data); err != nil {
if _, err := s.activeKV.Put(ctx, kvstore.AccountKey(accountID, sirID), data); err != nil {
slog.ErrorContext(ctx, "PutSpotInstanceRequests: KV put failed", "sirId", sirID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
@@ -171,7 +171,7 @@ func (s *SpotInstanceServiceImpl) CancelSpotInstanceRequests(ctx context.Context
cancelled := make([]*ec2.CancelledSpotInstanceRequest, 0, len(input.SpotInstanceRequestIds))
for _, idPtr := range input.SpotInstanceRequestIds {
sirID := aws.StringValue(idPtr)
key := kvutil.AccountKey(accountID, sirID)
key := kvstore.AccountKey(accountID, sirID)
record, err := s.readRecord(ctx, s.activeKV, key)
if err == nil {
@@ -213,7 +213,7 @@ func (s *SpotInstanceServiceImpl) CloseForInstance(ctx context.Context, instance
prefix := accountID + "."
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
record, err := s.readRecord(ctx, s.activeKV, key)
@@ -251,7 +251,7 @@ func (s *SpotInstanceServiceImpl) listRequests(ctx context.Context, kv jetstream
prefix := accountID + "."
var requests []*ec2.SpotInstanceRequest
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
record, err := s.readRecord(ctx, kv, key)
@@ -6,7 +6,7 @@ import (
"sync"
"testing"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -39,7 +39,7 @@ func (k *interleavingKV) Get(ctx context.Context, key string) (jetstream.KeyValu
// its write both survive alongside its own tag.
func TestCurrentBehaviour_Tagging_RecordMirrorKeepsInterleavedWrites(t *testing.T) {
const acct, id = "123456789012", "sg-0123456789abcdef0"
key := kvutil.AccountKey(acct, id)
key := kvstore.AccountKey(acct, id)
seed, err := json.Marshal(tagMirrorRecord{Name: "original", Tags: map[string]string{}})
require.NoError(t, err)
kv := seedKV(t, "tagmirror-cb", map[string][]byte{key: seed})
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -83,8 +83,8 @@ var errRecordAbsent = errors.New("tagmirror: record absent")
// applying mut and committing at the read revision, retrying on conflict so a
// concurrent write is never reverted. An absent resource is skipped.
func UpdateKVRecordTags[R any](ctx context.Context, kv jetstream.KeyValue, accountID, resourceID string, mut func(*R)) error {
key := kvutil.AccountKey(accountID, resourceID)
_, err := kvutil.Update(ctx, kv, key, kvutil.CASConfig{NotFound: errRecordAbsent}, func(rec *R) (bool, error) {
key := kvstore.AccountKey(accountID, resourceID)
_, err := kvstore.Update(ctx, kv, key, kvstore.CASConfig{NotFound: errRecordAbsent}, func(rec *R) (bool, error) {
mut(rec)
return true, nil
})
@@ -8,7 +8,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -75,15 +75,15 @@ func TestUpdateKVRecordTags(t *testing.T) {
rec, err := json.Marshal(&tagMirrorRecord{Name: "r1", Tags: map[string]string{"a": "1"}})
require.NoError(t, err)
kv := seedKV(t, "tagmirror-test", map[string][]byte{
kvutil.AccountKey("acct", "res-1"): rec,
kvutil.AccountKey("acct", "res-bad"): []byte("not-json"),
kvstore.AccountKey("acct", "res-1"): rec,
kvstore.AccountKey("acct", "res-bad"): []byte("not-json"),
})
t.Run("mutates and persists", func(t *testing.T) {
require.NoError(t, UpdateKVRecordTags(t.Context(), kv, "acct", "res-1", func(r *tagMirrorRecord) {
r.Tags["b"] = "2"
}))
entry, err := kv.Get(t.Context(), kvutil.AccountKey("acct", "res-1"))
entry, err := kv.Get(t.Context(), kvstore.AccountKey("acct", "res-1"))
require.NoError(t, err)
var got tagMirrorRecord
require.NoError(t, json.Unmarshal(entry.Value(), &got))
@@ -110,14 +110,14 @@ func TestMirrorKVRecordTags(t *testing.T) {
nilTags, err := json.Marshal(&tagMirrorRecord{})
require.NoError(t, err)
kv := seedKV(t, "tagmirror-mirror-test", map[string][]byte{
kvutil.AccountKey("acct", "vol-1"): withTags,
kvutil.AccountKey("acct", "vol-2"): nilTags,
kvstore.AccountKey("acct", "vol-1"): withTags,
kvstore.AccountKey("acct", "vol-2"): nilTags,
})
tagsOf := func(r *tagMirrorRecord) *map[string]string { return &r.Tags }
readTags := func(t *testing.T, id string) map[string]string {
entry, err := kv.Get(t.Context(), kvutil.AccountKey("acct", id))
entry, err := kv.Get(t.Context(), kvstore.AccountKey("acct", id))
require.NoError(t, err)
var got tagMirrorRecord
require.NoError(t, json.Unmarshal(entry.Value(), &got))
@@ -150,7 +150,7 @@ func TestMirrorKVRecordTags(t *testing.T) {
// must never be touched even though it exists in this bucket.
otherRec, err := json.Marshal(&tagMirrorRecord{Tags: map[string]string{"x": "y"}})
require.NoError(t, err)
_, err = kv.Put(t.Context(), kvutil.AccountKey("acct", "snap-other"), otherRec)
_, err = kv.Put(t.Context(), kvstore.AccountKey("acct", "snap-other"), otherRec)
require.NoError(t, err)
require.NoError(t, MirrorKVRecordTags(t.Context(), kv, "acct", "vol-", resources, tagsOf,
MergeTagsMut(&ec2.CreateTagsInput{Tags: []*ec2.Tag{
+2 -2
View File
@@ -23,7 +23,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -274,7 +274,7 @@ func (s *TagsServiceImpl) CreateTags(ctx context.Context, input *ec2.CreateTagsI
func (s *TagsServiceImpl) taggedResourceIDs(ctx context.Context, accountID string) ([]string, error) {
seen := make(map[string]struct{})
keys, err := kvutil.Keys(ctx, s.kv)
keys, err := kvstore.Keys(ctx, s.kv)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, fmt.Errorf("list tag keys: %w", err)
}
+4 -4
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"strings"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -46,7 +46,7 @@ func resourceIDFromKVKey(key, accountID string) (string, bool) {
// GetOrCreateTagsBucket opens the tag bucket, creating it at the cluster's
// replica count if this is the first node to reach it.
func GetOrCreateTagsBucket(ctx context.Context, js jetstream.KeyValueManager) (jetstream.KeyValue, error) {
return kvutil.GetOrCreateBucket(ctx, js, KVBucketTags, kvTagsHistory)
return kvstore.GetOrCreateBucket(ctx, js, KVBucketTags, kvTagsHistory)
}
// readKVTags returns a resource's tags from the bucket, and whether the key
@@ -76,7 +76,7 @@ func (s *TagsServiceImpl) readKVTags(ctx context.Context, accountID, resourceID
// before the store moved.
func (s *TagsServiceImpl) mutateTags(ctx context.Context, accountID, resourceID string, mutate func(map[string]string)) error {
key := tagsKVKey(accountID, resourceID)
_, err := kvutil.Update(ctx, s.kv, key, kvutil.CASConfig{
_, err := kvstore.Update(ctx, s.kv, key, kvstore.CASConfig{
Attempts: tagCASAttempts,
CreateIfAbsent: true,
Exhausted: func(key string, attempts int) error {
@@ -104,7 +104,7 @@ func (s *TagsServiceImpl) PutResourceTags(ctx context.Context, accountID, resour
if err != nil {
return err
}
return kvutil.Put(ctx, s.kv, tagsKVKey(accountID, resourceID), kvutil.CASConfig{
return kvstore.Put(ctx, s.kv, tagsKVKey(accountID, resourceID), kvstore.CASConfig{
Attempts: tagCASAttempts,
CreateIfAbsent: true,
}, data)
+6 -6
View File
@@ -12,7 +12,7 @@ import (
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -105,7 +105,7 @@ func (s *VPCServiceImpl) ensureDefaultVPC(ctx context.Context, accountID string,
return claim.info(), nil
}
if _, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, claim.VpcId)); err == nil {
if _, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, claim.VpcId)); err == nil {
return claim.info(), nil
} else if !errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, fmt.Errorf("read default VPC %s: %w", claim.VpcId, err)
@@ -233,7 +233,7 @@ func oldestAccountRecord[T any](ctx context.Context, kv jetstream.KeyValue, acco
var bestID string
var bestCreated time.Time
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := kv.Get(ctx, key)
@@ -262,7 +262,7 @@ func oldestAccountRecord[T any](ctx context.Context, kv jetstream.KeyValue, acco
// marks the claim complete. Records are written with Create, so only the caller
// that actually wrote one announces it to vpcd.
func (s *VPCServiceImpl) buildDefaultVPC(ctx context.Context, accountID string, claim *defaultVPCClaim) error {
created, err := createRecord(ctx, s.vpcKV, kvutil.AccountKey(accountID, claim.VpcId), VPCRecord{
created, err := createRecord(ctx, s.vpcKV, kvstore.AccountKey(accountID, claim.VpcId), VPCRecord{
VpcId: claim.VpcId,
CidrBlock: claim.Cidr,
State: "available",
@@ -284,7 +284,7 @@ func (s *VPCServiceImpl) buildDefaultVPC(ctx context.Context, accountID string,
if s.config != nil && s.config.AZ != "" {
az = s.config.AZ
}
created, err = createRecord(ctx, s.subnetKV, kvutil.AccountKey(accountID, claim.SubnetId), SubnetRecord{
created, err = createRecord(ctx, s.subnetKV, kvstore.AccountKey(accountID, claim.SubnetId), SubnetRecord{
SubnetId: claim.SubnetId,
VpcId: claim.VpcId,
CidrBlock: claim.SubnetCidr,
@@ -320,7 +320,7 @@ func (s *VPCServiceImpl) buildDefaultVPC(ctx context.Context, accountID string,
}
}
if _, err := kvutil.Update(ctx, s.defaultsKV, accountID, kvutil.CASConfig{}, func(c *defaultVPCClaim) (bool, error) {
if _, err := kvstore.Update(ctx, s.defaultsKV, accountID, kvstore.CASConfig{}, func(c *defaultVPCClaim) (bool, error) {
if c.Complete || c.VpcId != claim.VpcId {
return false, nil
}
+4 -4
View File
@@ -13,7 +13,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit"
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
@@ -149,12 +149,12 @@ func TestEnsureDefaultVPC_AdoptsOldestExistingDefault(t *testing.T) {
const accountID = "000000000051"
created := time.Date(2026, 9, 11, 14, 13, 40, 0, time.UTC)
for i, id := range []string{"vpc-0000000000000newer", "vpc-0000000000000older"} {
putRecord(t, js, ec2vpc.KVBucketVPCs, kvutil.AccountKey(accountID, id), ec2vpc.VPCRecord{
putRecord(t, js, ec2vpc.KVBucketVPCs, kvstore.AccountKey(accountID, id), ec2vpc.VPCRecord{
VpcId: id, CidrBlock: ec2vpc.DefaultVPCCidr, IsDefault: true, VNI: int64(200 + i),
CreatedAt: created.Add(time.Duration(1-i) * time.Minute),
})
}
putRecord(t, js, ec2vpc.KVBucketSubnets, kvutil.AccountKey(accountID, "subnet-00000000000older"), ec2vpc.SubnetRecord{
putRecord(t, js, ec2vpc.KVBucketSubnets, kvstore.AccountKey(accountID, "subnet-00000000000older"), ec2vpc.SubnetRecord{
SubnetId: "subnet-00000000000older", VpcId: "vpc-0000000000000older", CidrBlock: ec2vpc.DefaultSubnetCidr,
IsDefault: true, CreatedAt: created,
})
@@ -183,7 +183,7 @@ func TestEnsureDefaultVPC_FinishesHalfBuiltClaim(t *testing.T) {
"route_table_id": "rtb-00000000000half", "internet_gateway_id": "igw-00000000000half",
"cidr": ec2vpc.DefaultVPCCidr, "subnet_cidr": ec2vpc.DefaultSubnetCidr, "vni": 4242,
})
putRecord(t, js, ec2vpc.KVBucketVPCs, kvutil.AccountKey(accountID, "vpc-00000000000000half"), ec2vpc.VPCRecord{
putRecord(t, js, ec2vpc.KVBucketVPCs, kvstore.AccountKey(accountID, "vpc-00000000000000half"), ec2vpc.VPCRecord{
VpcId: "vpc-00000000000000half", CidrBlock: ec2vpc.DefaultVPCCidr, IsDefault: true, VNI: 4242,
})
+18 -18
View File
@@ -23,7 +23,7 @@ import (
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -118,7 +118,7 @@ func (s *VPCServiceImpl) CreateNetworkInterface(ctx context.Context, input *ec2.
subnetId := *input.SubnetId
// Verify subnet exists and belongs to this account
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.AccountKey(accountID, subnetId))
subnetEntry, err := s.subnetKV.Get(ctx, kvstore.AccountKey(accountID, subnetId))
if err != nil {
return nil, errors.New(awserrors.ErrorInvalidSubnetIDNotFound)
}
@@ -205,7 +205,7 @@ func (s *VPCServiceImpl) CreateNetworkInterface(ctx context.Context, input *ec2.
if err != nil {
return nil, fmt.Errorf("failed to marshal ENI record: %w", err)
}
if _, err := s.eniKV.Put(ctx, kvutil.AccountKey(accountID, eniId), data); err != nil {
if _, err := s.eniKV.Put(ctx, kvstore.AccountKey(accountID, eniId), data); err != nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
@@ -251,7 +251,7 @@ func (s *VPCServiceImpl) ForceDeleteInstanceENI(ctx context.Context, accountID,
}
func (s *VPCServiceImpl) deleteNetworkInterface(ctx context.Context, eniId, accountID string, force bool) (*ec2.DeleteNetworkInterfaceOutput, error) {
key := kvutil.AccountKey(accountID, eniId)
key := kvstore.AccountKey(accountID, eniId)
// Get the ENI record
entry, err := s.eniKV.Get(ctx, key)
@@ -346,7 +346,7 @@ func (s *VPCServiceImpl) releaseENISideEffects(ctx context.Context, eniId, accou
// deletion that did not happen.
func (s *VPCServiceImpl) DetachAndDeleteENI(ctx context.Context, accountID, eniID string, force bool) (deleted bool, err error) {
const maxAttempts = 3
key := kvutil.AccountKey(accountID, eniID)
key := kvstore.AccountKey(accountID, eniID)
var lastErr error
for range maxAttempts {
@@ -458,7 +458,7 @@ func (s *VPCServiceImpl) ModifyNetworkInterfaceAttribute(ctx context.Context, in
}
eniId := *input.NetworkInterfaceId
key := kvutil.AccountKey(accountID, eniId)
key := kvstore.AccountKey(accountID, eniId)
entry, err := s.eniKV.Get(ctx, key)
if err != nil {
@@ -569,7 +569,7 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(ctx context.Context, input *e
}
for _, key := range keys {
if key == kvutil.VersionKey {
if key == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(key, prefix) {
@@ -707,7 +707,7 @@ func (s *VPCServiceImpl) AttachENIWithOwner(accountID, eniId, instanceId string,
func (s *VPCServiceImpl) attachENI(ctx context.Context, accountID, eniId, instanceId string, deviceIndex int64, instanceOwnerID string) (string, error) {
attachmentId := awsidentifiers.GenerateResourceID("eni-attach")
err := s.casENI(ctx, kvutil.AccountKey(accountID, eniId), eniId, func(record *ENIRecord) error {
err := s.casENI(ctx, kvstore.AccountKey(accountID, eniId), eniId, func(record *ENIRecord) error {
if record.Status == "in-use" {
return errors.New(awserrors.ErrorInvalidNetworkInterfaceInUse)
}
@@ -737,7 +737,7 @@ func (s *VPCServiceImpl) attachENI(ctx context.Context, accountID, eniId, instan
// DetachENI marks an ENI as detached from an instance (internal use by TerminateInstances).
// accountID scopes the lookup to the correct KV key.
func (s *VPCServiceImpl) DetachENI(ctx context.Context, accountID, eniId string) error {
key := kvutil.AccountKey(accountID, eniId)
key := kvstore.AccountKey(accountID, eniId)
entry, err := s.eniKV.Get(ctx, key)
if err != nil {
return eniNotFoundError(eniId)
@@ -773,7 +773,7 @@ func (s *VPCServiceImpl) GetENIRecord(accountID, eniId string) (ENIRecord, error
}
func (s *VPCServiceImpl) getENIRecord(ctx context.Context, accountID, eniId string) (ENIRecord, error) {
key := kvutil.AccountKey(accountID, eniId)
key := kvstore.AccountKey(accountID, eniId)
entry, err := s.eniKV.Get(ctx, key)
if err != nil {
return ENIRecord{}, eniNotFoundError(eniId)
@@ -793,7 +793,7 @@ func (s *VPCServiceImpl) UpdateENI(accountID, eniId string, fn func(*ENIRecord))
}
func (s *VPCServiceImpl) updateENI(ctx context.Context, accountID, eniId string, fn func(*ENIRecord)) error {
return s.casENI(ctx, kvutil.AccountKey(accountID, eniId), eniId, func(r *ENIRecord) error {
return s.casENI(ctx, kvstore.AccountKey(accountID, eniId), eniId, func(r *ENIRecord) error {
fn(r)
return nil
})
@@ -876,7 +876,7 @@ func (s *VPCServiceImpl) listInstanceENIs(ctx context.Context, accountID, instan
prefix := accountID + "."
var out []ENIRecord
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.eniKV.Get(ctx, key)
@@ -938,7 +938,7 @@ func (s *VPCServiceImpl) scanENIs(ctx context.Context, match func(*ENIRecord) bo
var out []AccountENI
for _, key := range keys {
if key == kvutil.VersionKey {
if key == kvstore.VersionKey {
continue
}
accountID, _, found := strings.Cut(key, ".")
@@ -1004,7 +1004,7 @@ func (s *VPCServiceImpl) findENIByAttachment(ctx context.Context, accountID, att
}
prefix := accountID + "."
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.eniKV.Get(ctx, key)
@@ -1028,7 +1028,7 @@ func (s *VPCServiceImpl) UpdateENIPublicIP(accountID, eniId, publicIP, poolName
}
func (s *VPCServiceImpl) updateENIPublicIP(ctx context.Context, accountID, eniId, publicIP, poolName string) error {
key := kvutil.AccountKey(accountID, eniId)
key := kvstore.AccountKey(accountID, eniId)
entry, err := s.eniKV.Get(ctx, key)
if err != nil {
return fmt.Errorf("ENI %s not found: %w", eniId, err)
@@ -1063,7 +1063,7 @@ func (s *VPCServiceImpl) fillSGNames(ctx context.Context, accountID string, ids
continue
}
names[id] = ""
entry, err := s.sgKV.Get(ctx, kvutil.AccountKey(accountID, id))
entry, err := s.sgKV.Get(ctx, kvstore.AccountKey(accountID, id))
if err != nil {
slog.WarnContext(ctx, "fillSGNames: SG read failed", "groupId", id, "err", err)
continue
@@ -1080,7 +1080,7 @@ func (s *VPCServiceImpl) fillSGNames(ctx context.Context, accountID string, ids
// vpcDNSHostnames reports whether the VPC has enableDnsHostnames on. An
// unreadable VPC reads as off, so its interfaces go unnamed.
func (s *VPCServiceImpl) vpcDNSHostnames(ctx context.Context, accountID, vpcID string) bool {
entry, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID))
entry, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, vpcID))
if err != nil {
slog.WarnContext(ctx, "vpcDNSHostnames: VPC read failed", "vpcId", vpcID, "err", err)
return false
@@ -1238,7 +1238,7 @@ func (s *VPCServiceImpl) validateSGAttachment(ctx context.Context, accountID str
// Each SG must exist in the caller's account and belong to the same VPC.
for _, sgId := range sgIds {
sgEntry, err := s.sgKV.Get(ctx, kvutil.AccountKey(accountID, sgId))
sgEntry, err := s.sgKV.Get(ctx, kvstore.AccountKey(accountID, sgId))
if err != nil {
return sgNotFoundError(sgId)
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/domains/network/projection"
"github.com/mulgadc/spinifex/spinifex/domains/network/topology"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -69,7 +69,7 @@ func (s *VPCServiceImpl) findENIAnyAccount(ctx context.Context, eniID string) (*
suffix := "." + eniID
for _, k := range keys {
if k == kvutil.VersionKey || !strings.HasSuffix(k, suffix) {
if k == kvstore.VersionKey || !strings.HasSuffix(k, suffix) {
continue
}
entry, err := s.eniKV.Get(ctx, k)
@@ -7,7 +7,7 @@ import (
"testing"
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -30,7 +30,7 @@ func newStaleReadENI(t *testing.T) (*VPCServiceImpl, string, func() ENIRecord) {
// Reads the stored record directly: the wrapped bucket would serve the
// check itself from a lagging replica.
stored := func() ENIRecord {
entry, err := inner.Get(context.Background(), kvutil.AccountKey(testAccountID, eniID))
entry, err := inner.Get(context.Background(), kvstore.AccountKey(testAccountID, eniID))
require.NoError(t, err)
var rec ENIRecord
require.NoError(t, json.Unmarshal(entry.Value(), &rec))
@@ -114,7 +114,7 @@ func TestCASENIGivesUpAfterBoundedConflicts(t *testing.T) {
t.Parallel()
svc, eniID, _ := newStaleReadENI(t)
conflicts := 0
err := svc.casENI(t.Context(), kvutil.AccountKey(testAccountID, eniID), eniID, func(*ENIRecord) error {
err := svc.casENI(t.Context(), kvstore.AccountKey(testAccountID, eniID), eniID, func(*ENIRecord) error {
conflicts++
// Move the record under the next compare-and-swap, as a competing writer does.
return svc.UpdateENI(testAccountID, eniID, func(r *ENIRecord) { r.Description = "other writer" })
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"strings"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -42,7 +42,7 @@ func keyWithSuffixExists(ctx context.Context, kv jetstream.KeyValue, resourceID
}
suffix := "." + resourceID
for _, k := range keys {
if k != kvutil.VersionKey && strings.HasSuffix(k, suffix) {
if k != kvstore.VersionKey && strings.HasSuffix(k, suffix) {
return true, nil
}
}
+20 -20
View File
@@ -23,7 +23,7 @@ import (
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -245,7 +245,7 @@ func (s *VPCServiceImpl) CreateSecurityGroup(ctx context.Context, input *ec2.Cre
}
sgsInVPC := 0
for _, k := range sgKeys {
if k == kvutil.VersionKey {
if k == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(k, prefix) {
@@ -305,7 +305,7 @@ func (s *VPCServiceImpl) CreateSecurityGroup(ctx context.Context, input *ec2.Cre
if err != nil {
return nil, fmt.Errorf("failed to marshal security group record: %w", err)
}
if _, err := s.sgKV.Put(ctx, kvutil.AccountKey(accountID, groupId), data); err != nil {
if _, err := s.sgKV.Put(ctx, kvstore.AccountKey(accountID, groupId), data); err != nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
@@ -331,7 +331,7 @@ func (s *VPCServiceImpl) DeleteSecurityGroup(ctx context.Context, input *ec2.Del
}
groupId := *input.GroupId
key := kvutil.AccountKey(accountID, groupId)
key := kvstore.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(ctx, key)
if err != nil {
@@ -380,7 +380,7 @@ func (s *VPCServiceImpl) validateSGRuleReferences(ctx context.Context, accountID
if r.SourceSG == "" {
continue
}
entry, err := s.sgKV.Get(ctx, kvutil.AccountKey(accountID, r.SourceSG))
entry, err := s.sgKV.Get(ctx, kvstore.AccountKey(accountID, r.SourceSG))
if err != nil {
return sgNotFoundError(r.SourceSG)
}
@@ -406,7 +406,7 @@ func (s *VPCServiceImpl) checkSGDependencies(ctx context.Context, accountID, gro
return errors.New(awserrors.ErrorServerInternal)
}
for _, k := range eniKeys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue
}
entry, err := s.eniKV.Get(ctx, k)
@@ -435,7 +435,7 @@ func (s *VPCServiceImpl) checkSGDependencies(ctx context.Context, accountID, gro
return errors.New(awserrors.ErrorServerInternal)
}
for _, k := range sgKeys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue
}
entry, err := s.sgKV.Get(ctx, k)
@@ -561,7 +561,7 @@ func (s *VPCServiceImpl) DescribeSecurityGroups(ctx context.Context, input *ec2.
}
for _, key := range keys {
if key == kvutil.VersionKey {
if key == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(key, prefix) {
@@ -713,7 +713,7 @@ func (s *VPCServiceImpl) GetSecurityGroupsForVpc(ctx context.Context, input *ec2
groups := []*ec2.SecurityGroupForVpc{}
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.sgKV.Get(ctx, key)
@@ -844,7 +844,7 @@ func (s *VPCServiceImpl) DescribeSecurityGroupRules(ctx context.Context, input *
emitted := make(map[string]bool)
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.sgKV.Get(ctx, key)
@@ -945,7 +945,7 @@ func (s *VPCServiceImpl) updateSGRuleTags(ctx context.Context, accountID, ruleID
}
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.sgKV.Get(ctx, key)
@@ -1056,7 +1056,7 @@ func (s *VPCServiceImpl) AuthorizeSecurityGroupIngress(ctx context.Context, inpu
}
groupId := *input.GroupId
key := kvutil.AccountKey(accountID, groupId)
key := kvstore.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(ctx, key)
if err != nil {
@@ -1134,7 +1134,7 @@ func (s *VPCServiceImpl) AuthorizeSecurityGroupEgress(ctx context.Context, input
}
groupId := *input.GroupId
key := kvutil.AccountKey(accountID, groupId)
key := kvstore.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(ctx, key)
if err != nil {
@@ -1212,7 +1212,7 @@ func (s *VPCServiceImpl) RevokeSecurityGroupIngress(ctx context.Context, input *
}
groupId := *input.GroupId
key := kvutil.AccountKey(accountID, groupId)
key := kvstore.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(ctx, key)
if err != nil {
@@ -1277,7 +1277,7 @@ func (s *VPCServiceImpl) RevokeSecurityGroupEgress(ctx context.Context, input *e
}
groupId := *input.GroupId
key := kvutil.AccountKey(accountID, groupId)
key := kvstore.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(ctx, key)
if err != nil {
@@ -1397,7 +1397,7 @@ func (s *VPCServiceImpl) updateSGRuleDescriptions(ctx context.Context, accountID
return errors.New(awserrors.ErrorInvalidParameterCombination)
}
key := kvutil.AccountKey(accountID, req.groupId)
key := kvstore.AccountKey(accountID, req.groupId)
entry, err := s.sgKV.Get(ctx, key)
if err != nil {
return sgNotFoundError(req.groupId)
@@ -1581,7 +1581,7 @@ func (s *VPCServiceImpl) ModifySecurityGroupRules(ctx context.Context, input *ec
}
groupId := *input.GroupId
key := kvutil.AccountKey(accountID, groupId)
key := kvstore.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(ctx, key)
if err != nil {
@@ -2158,7 +2158,7 @@ func (s *VPCServiceImpl) storeDefaultSecurityGroup(ctx context.Context, accountI
if err != nil {
return nil, false, fmt.Errorf("marshal default security group: %w", err)
}
if _, err := s.sgKV.Create(ctx, kvutil.AccountKey(accountID, groupId), data); err != nil {
if _, err := s.sgKV.Create(ctx, kvstore.AccountKey(accountID, groupId), data); err != nil {
if errors.Is(err, jetstream.ErrKeyExists) {
return record, false, nil
}
@@ -2182,7 +2182,7 @@ func (s *VPCServiceImpl) announceDefaultSecurityGroup(record *SecurityGroupRecor
// CannotDelete guard for default SGs. Used by DeleteVpc to cascade-delete the
// per-VPC default SG. Surfaces vpcd tear-down failures to the caller.
func (s *VPCServiceImpl) deleteSecurityGroupInternal(ctx context.Context, accountID, groupId string) error {
key := kvutil.AccountKey(accountID, groupId)
key := kvstore.AccountKey(accountID, groupId)
entry, err := s.sgKV.Get(ctx, key)
if err != nil {
return fmt.Errorf("read default security group: %w", err)
@@ -2219,7 +2219,7 @@ func (s *VPCServiceImpl) findDefaultSGForVPC(ctx context.Context, accountID, vpc
return "", err
}
for _, k := range keys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue
}
entry, err := s.sgKV.Get(ctx, k)
@@ -15,7 +15,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -62,7 +62,7 @@ func tcpPeerRequest(port int64, peer string) *ec2.SecurityGroupRuleRequest {
// modify wrote nothing.
func storedSGRecord(t *testing.T, svc *VPCServiceImpl, sgID string) []byte {
t.Helper()
entry, err := svc.sgKV.Get(t.Context(), kvutil.AccountKey(testAccountID, sgID))
entry, err := svc.sgKV.Get(t.Context(), kvstore.AccountKey(testAccountID, sgID))
require.NoError(t, err)
return entry.Value()
}
@@ -689,7 +689,7 @@ func TestRevokeSecurityGroupIngress_InvertedPortRangeStillRevocable(t *testing.T
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
sgID := createTestSG(t, svc, vpcID, "stale-inverted-ports")
key := kvutil.AccountKey(testAccountID, sgID)
key := kvstore.AccountKey(testAccountID, sgID)
var rec SecurityGroupRecord
require.NoError(t, json.Unmarshal(storedSGRecord(t, svc, sgID), &rec))
rec.IngressRules = append(rec.IngressRules, SGRule{
@@ -13,7 +13,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -35,7 +35,7 @@ func plantIngressRules(t *testing.T, svc *VPCServiceImpl, sgID string, rules ...
rec.IngressRules = append(rec.IngressRules, rules...)
data, err := json.Marshal(rec)
require.NoError(t, err)
_, err = svc.sgKV.Put(t.Context(), kvutil.AccountKey(testAccountID, sgID), data)
_, err = svc.sgKV.Put(t.Context(), kvstore.AccountKey(testAccountID, sgID), data)
require.NoError(t, err)
}
@@ -13,7 +13,7 @@ import (
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -1524,7 +1524,7 @@ func findDefaultSGInVPC(t *testing.T, svc *VPCServiceImpl, vpcID string) string
require.NoError(t, err)
var matches []string
for _, k := range keys {
if k == kvutil.VersionKey {
if k == kvstore.VersionKey {
continue
}
entry, err := svc.sgKV.Get(t.Context(), k)
@@ -2122,7 +2122,7 @@ func seedAvailableVPC(t *testing.T, svc *VPCServiceImpl, vpcID string) {
rec := VPCRecord{VpcId: vpcID, CidrBlock: "10.0.0.0/16", State: "available"}
data, err := json.Marshal(rec)
require.NoError(t, err)
_, err = svc.vpcKV.Put(t.Context(), kvutil.AccountKey(testAccountID, vpcID), data)
_, err = svc.vpcKV.Put(t.Context(), kvstore.AccountKey(testAccountID, vpcID), data)
require.NoError(t, err)
}
@@ -2359,7 +2359,7 @@ func TestAuthorizeSecurityGroupIngress_AssignsRuleIDs(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
entry, err := svc.sgKV.Get(t.Context(), kvutil.AccountKey(testAccountID, sgID))
entry, err := svc.sgKV.Get(t.Context(), kvstore.AccountKey(testAccountID, sgID))
require.NoError(t, err)
var record SecurityGroupRecord
require.NoError(t, json.Unmarshal(entry.Value(), &record))
+35 -35
View File
@@ -26,7 +26,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/zoneid"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -173,7 +173,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
return nil, fmt.Errorf("create JetStream client: %w", err)
}
vpcKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketVPCs, 10)
vpcKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketVPCs, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketVPCs, err)
}
@@ -181,7 +181,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
return nil, fmt.Errorf("migrate %s: %w", KVBucketVPCs, err)
}
subnetKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketSubnets, 10)
subnetKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketSubnets, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketSubnets, err)
}
@@ -189,7 +189,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
return nil, fmt.Errorf("migrate %s: %w", KVBucketSubnets, err)
}
vniKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketVNICounter, 10)
vniKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketVNICounter, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketVNICounter, err)
}
@@ -197,7 +197,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
return nil, fmt.Errorf("migrate %s: %w", KVBucketVNICounter, err)
}
eniKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketENIs, 10)
eniKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketENIs, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketENIs, err)
}
@@ -205,7 +205,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
return nil, fmt.Errorf("migrate %s: %w", KVBucketENIs, err)
}
sgKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketSecurityGroups, 10)
sgKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketSecurityGroups, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketSecurityGroups, err)
}
@@ -213,7 +213,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
return nil, fmt.Errorf("migrate %s: %w", KVBucketSecurityGroups, err)
}
defaultsKV, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketDefaultVPCs, 10)
defaultsKV, err := kvstore.GetOrCreateBucket(ctx, js, KVBucketDefaultVPCs, 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketDefaultVPCs, err)
}
@@ -221,18 +221,18 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
return nil, fmt.Errorf("migrate %s: %w", KVBucketDefaultVPCs, err)
}
rtbKV, err := kvutil.GetOrCreateBucket(ctx, js, "spinifex-vpc-route-tables", 10)
rtbKV, err := kvstore.GetOrCreateBucket(ctx, js, "spinifex-vpc-route-tables", 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket spinifex-vpc-route-tables: %w", err)
}
// Named literally, like the route table bucket above: the IGW package
// imports this one, so its constant cannot be referenced here.
igwKV, err := kvutil.GetOrCreateBucket(ctx, js, "spinifex-igw", 10)
igwKV, err := kvstore.GetOrCreateBucket(ctx, js, "spinifex-igw", 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket spinifex-igw: %w", err)
}
eigwKV, err := kvutil.GetOrCreateBucket(ctx, js, "spinifex-eigw", 10)
eigwKV, err := kvstore.GetOrCreateBucket(ctx, js, "spinifex-eigw", 10)
if err != nil {
return nil, fmt.Errorf("failed to create KV bucket spinifex-eigw: %w", err)
}
@@ -268,7 +268,7 @@ func NewVPCServiceImplWithNATS(ctx context.Context, cfg *config.Config, natsConn
// nextVNI allocates the next VNI using atomic increment on the NATS KV counter.
func (s *VPCServiceImpl) nextVNI(ctx context.Context) (int64, error) {
var allocated int64
_, err := kvutil.Update(ctx, s.vniKV, vniCounterKey, kvutil.CASConfig{CreateIfAbsent: true, Attempts: vniCASAttempts},
_, err := kvstore.Update(ctx, s.vniKV, vniCounterKey, kvstore.CASConfig{CreateIfAbsent: true, Attempts: vniCASAttempts},
func(next *int64) (bool, error) {
if *next == 0 {
*next = vniStart
@@ -331,7 +331,7 @@ func (s *VPCServiceImpl) CreateVpc(ctx context.Context, input *ec2.CreateVpcInpu
if err != nil {
return nil, fmt.Errorf("failed to marshal VPC record: %w", err)
}
if _, err := s.vpcKV.Put(ctx, kvutil.AccountKey(accountID, vpcID), data); err != nil {
if _, err := s.vpcKV.Put(ctx, kvstore.AccountKey(accountID, vpcID), data); err != nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
@@ -366,7 +366,7 @@ func (s *VPCServiceImpl) CreateVpc(ctx context.Context, input *ec2.CreateVpcInpu
// requireVPCExists returns InvalidVpcID.NotFound if the VPC doesn't exist for
// this account.
func (s *VPCServiceImpl) requireVPCExists(ctx context.Context, accountID, vpcId string) error {
if _, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcId)); err != nil {
if _, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, vpcId)); err != nil {
return vpcNotFoundError(vpcId)
}
return nil
@@ -379,7 +379,7 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu
}
vpcID := *input.VpcId
key := kvutil.AccountKey(accountID, vpcID)
key := kvstore.AccountKey(accountID, vpcID)
if _, err := s.vpcKV.Get(ctx, key); err != nil {
// AWS-faithful: an absent VPC is NotFound (the tofu/SDK provider
@@ -398,7 +398,7 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu
return nil, errors.New(awserrors.ErrorServerInternal)
}
for _, k := range subnetKeys {
if k == kvutil.VersionKey {
if k == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(k, prefix) {
@@ -435,7 +435,7 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu
return nil, errors.New(awserrors.ErrorServerInternal)
}
for _, k := range sgKeys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue
}
entry, err := s.sgKV.Get(ctx, k)
@@ -472,7 +472,7 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu
return nil, errors.New(awserrors.ErrorServerInternal)
}
for _, k := range rtbKeys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue
}
entry, err := s.rtbKV.Get(ctx, k)
@@ -533,7 +533,7 @@ func (s *VPCServiceImpl) DeleteVpc(ctx context.Context, input *ec2.DeleteVpcInpu
return nil, errors.New(awserrors.ErrorServerInternal)
}
if rtbID != "" {
if err := s.rtbKV.Delete(ctx, kvutil.AccountKey(accountID, rtbID)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
if err := s.rtbKV.Delete(ctx, kvstore.AccountKey(accountID, rtbID)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
slog.ErrorContext(ctx, "DeleteVpc: main route table reap failed", "vpcId", vpcID, "routeTableId", rtbID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
@@ -574,7 +574,7 @@ func rejectAttachedGateway(ctx context.Context, kv jetstream.KeyValue, accountID
}
for _, k := range keys {
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
if k == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
continue
}
entry, err := kv.Get(ctx, k)
@@ -653,7 +653,7 @@ func (s *VPCServiceImpl) DescribeVpcs(ctx context.Context, input *ec2.DescribeVp
}
for _, key := range keys {
if key == kvutil.VersionKey {
if key == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(key, prefix) {
@@ -723,7 +723,7 @@ func (s *VPCServiceImpl) CreateSubnet(ctx context.Context, input *ec2.CreateSubn
}
// Verify VPC exists and belongs to this account
vpcEntry, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID))
vpcEntry, err := s.vpcKV.Get(ctx, kvstore.AccountKey(accountID, vpcID))
if err != nil {
return nil, vpcNotFoundError(vpcID)
}
@@ -757,7 +757,7 @@ func (s *VPCServiceImpl) CreateSubnet(ctx context.Context, input *ec2.CreateSubn
}
for _, k := range subnetKeys {
if k == kvutil.VersionKey {
if k == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(k, prefix) {
@@ -822,7 +822,7 @@ func (s *VPCServiceImpl) CreateSubnet(ctx context.Context, input *ec2.CreateSubn
if err != nil {
return nil, fmt.Errorf("failed to marshal subnet record: %w", err)
}
if _, err := s.subnetKV.Put(ctx, kvutil.AccountKey(accountID, subnetID), data); err != nil {
if _, err := s.subnetKV.Put(ctx, kvstore.AccountKey(accountID, subnetID), data); err != nil {
return nil, errors.New(awserrors.ErrorServerInternal)
}
@@ -843,7 +843,7 @@ func (s *VPCServiceImpl) DeleteSubnet(ctx context.Context, input *ec2.DeleteSubn
}
subnetID := *input.SubnetId
key := kvutil.AccountKey(accountID, subnetID)
key := kvstore.AccountKey(accountID, subnetID)
// Read subnet record before deletion (needed for vpcd event)
subnetEntry, err := s.subnetKV.Get(ctx, key)
@@ -902,7 +902,7 @@ func (s *VPCServiceImpl) checkSubnetResidents(ctx context.Context, accountID, su
}
prefix := accountID + "."
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.eniKV.Get(ctx, key)
@@ -950,7 +950,7 @@ func (s *VPCServiceImpl) clearRouteTableAssociationsForSubnet(ctx context.Contex
}
prefix := accountID + "."
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.rtbKV.Get(ctx, key)
@@ -1035,7 +1035,7 @@ func (s *VPCServiceImpl) DescribeSubnets(ctx context.Context, input *ec2.Describ
}
for _, key := range keys {
if key == kvutil.VersionKey {
if key == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(key, prefix) {
@@ -1386,7 +1386,7 @@ func (s *VPCServiceImpl) ModifySubnetAttribute(ctx context.Context, input *ec2.M
}
subnetID := *input.SubnetId
key := kvutil.AccountKey(accountID, subnetID)
key := kvstore.AccountKey(accountID, subnetID)
entry, err := s.subnetKV.Get(ctx, key)
if err != nil {
@@ -1438,7 +1438,7 @@ func (s *VPCServiceImpl) ModifyVpcAttribute(ctx context.Context, input *ec2.Modi
}
vpcID := *input.VpcId
key := kvutil.AccountKey(accountID, vpcID)
key := kvstore.AccountKey(accountID, vpcID)
entry, err := s.vpcKV.Get(ctx, key)
if err != nil {
@@ -1486,7 +1486,7 @@ func (s *VPCServiceImpl) DescribeVpcAttribute(ctx context.Context, input *ec2.De
}
vpcID := *input.VpcId
key := kvutil.AccountKey(accountID, vpcID)
key := kvstore.AccountKey(accountID, vpcID)
entry, err := s.vpcKV.Get(ctx, key)
if err != nil {
@@ -1587,7 +1587,7 @@ func (s *VPCServiceImpl) writeMainRouteTable(ctx context.Context, accountID, vpc
if err != nil {
return false, fmt.Errorf("marshal main route table: %w", err)
}
if _, err := s.rtbKV.Create(ctx, kvutil.AccountKey(accountID, rtbID), data); err != nil {
if _, err := s.rtbKV.Create(ctx, kvstore.AccountKey(accountID, rtbID), data); err != nil {
if errors.Is(err, jetstream.ErrKeyExists) {
return false, nil
}
@@ -1611,7 +1611,7 @@ func (s *VPCServiceImpl) findMainRouteTableID(ctx context.Context, accountID, vp
return "", fmt.Errorf("list rtb keys: %w", err)
}
for _, key := range keys {
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
continue
}
entry, err := s.rtbKV.Get(ctx, key)
@@ -1652,7 +1652,7 @@ func (s *VPCServiceImpl) getDefaultSubnet(ctx context.Context, accountID string)
}
for _, key := range keys {
if key == kvutil.VersionKey {
if key == kvstore.VersionKey {
continue
}
if !strings.HasPrefix(key, prefix) {
@@ -1680,7 +1680,7 @@ func (s *VPCServiceImpl) GetSubnet(accountID, subnetId string) (*SubnetRecord, e
}
func (s *VPCServiceImpl) getSubnet(ctx context.Context, accountID, subnetId string) (*SubnetRecord, error) {
key := kvutil.AccountKey(accountID, subnetId)
key := kvstore.AccountKey(accountID, subnetId)
entry, err := s.subnetKV.Get(ctx, key)
if err != nil {
return nil, fmt.Errorf("subnet %s not found: %w", subnetId, err)
@@ -1726,7 +1726,7 @@ func (s *VPCServiceImpl) SubnetAvailabilityZones(ctx context.Context) ([]string,
}
seen := map[string]struct{}{}
for _, k := range keys {
if k == kvutil.VersionKey {
if k == kvstore.VersionKey {
continue
}
entry, err := s.subnetKV.Get(ctx, k)
@@ -16,7 +16,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/zoneid"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
@@ -2689,7 +2689,7 @@ func TestCreateSubnet_ZoneStoreFailureWritesNothingAndPublishesNothing(t *testin
require.ErrorIs(t, err, jetstream.ErrNoKeysFound)
}
for _, k := range keys {
assert.Equal(t, kvutil.VersionKey, k, "no subnet record may exist")
assert.Equal(t, kvstore.VersionKey, k, "no subnet record may exist")
}
assert.Zero(t, events.Load(), "no topology event may be published")
}
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -111,7 +111,7 @@ func TestLoadOrCreateSigningKey_FailsWhenNoKeyDecrypts(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "master key does not match")
entries, err := kvutil.Keys(t.Context(), kv)
entries, err := kvstore.Keys(t.Context(), kv)
require.NoError(t, err)
assert.Len(t, entries, 1, "a failed load must not mint a key alongside the unreadable one")
}
+3 -3
View File
@@ -21,7 +21,7 @@ import (
"time"
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -111,7 +111,7 @@ func openSigningBucket(ctx context.Context, js jetstream.JetStream, masterKey []
if len(masterKey) == 0 {
return nil, errors.New("ecrauth: empty master key")
}
return kvutil.GetOrCreateBucket(ctx, js, SigningBucket, signingKeyHistory)
return kvstore.GetOrCreateBucket(ctx, js, SigningBucket, signingKeyHistory)
}
// reloadKeys reads every stored signing key, returning the verify set, each
@@ -125,7 +125,7 @@ func openSigningBucket(ctx context.Context, js jetstream.JetStream, masterKey []
// load instead takes the gateway down on every node, permanently, over a key no
// live request needs.
func reloadKeys(ctx context.Context, kv jetstream.KeyValue, masterKey []byte) (active *SigningKey, verify map[string]*ecdsa.PublicKey, metas []keyMeta, undecodable int, err error) {
names, err := kvutil.Keys(ctx, kv)
names, err := kvstore.Keys(ctx, kv)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil, nil, 0, fmt.Errorf("list signing keys: %w", err)
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"sync"
"time"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -179,7 +179,7 @@ func (s *KVMetaStore) bucket(ctx context.Context, accountID string) (jetstream.K
return kv, nil
}
name := KVAccountBucket(accountID)
kv, err := kvutil.GetOrCreateBucket(ctx, s.js, name, KVBucketAccountHistory)
kv, err := kvstore.GetOrCreateBucket(ctx, s.js, name, KVBucketAccountHistory)
if err != nil {
return nil, fmt.Errorf("ecr: open account bucket %s: %w", name, err)
}
+1 -1
View File
@@ -322,7 +322,7 @@ func TestReaper_Awsvpc_ReleasesENI(t *testing.T) {
rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec))
sc := NewScheduler(nc, svc, "test-holder")
sc := testScheduler(t, nc, svc, "test-holder")
sc.reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
assert.Equal(t, 1, eni.releaseCalls)
@@ -27,7 +27,7 @@ func dropRevisit(pass func(context.Context) (time.Duration, error)) func(context
// drained, services never relaunched, stopped tasks never pruned.
func TestSchedulerPasses_EnumerationFailureIsReported(t *testing.T) {
svc, nc := newTestService(t)
sc := NewScheduler(nc, svc, "test-holder")
sc := testScheduler(t, nc, svc, "test-holder")
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
require.NoError(t, err)
@@ -49,7 +49,7 @@ func TestRegister_RestoresReapedInstanceToActive(t *testing.T) {
rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec))
NewScheduler(nc, svc, "test-holder").reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
testScheduler(t, nc, svc, "test-holder").reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
reaped := instanceStatus(t, svc, "web", "i-1")
require.Equal(t, InstanceStatusDraining, reaped.Status)
@@ -89,7 +89,7 @@ func TestReaper_ReleasesCapacityAndRecoversClean(t *testing.T) {
seeded.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &seeded))
NewScheduler(nc, svc, "test-holder").reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
testScheduler(t, nc, svc, "test-holder").reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
reaped := instanceStatus(t, svc, "web", "i-1")
require.Equal(t, InstanceStatusDraining, reaped.Status)
+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)
kv, err := svc.bucket(t.Context(), testAccountID)
require.NoError(t, err)
return NewScheduler(nc, svc, "test-holder"), svc, kv
return testScheduler(t, nc, svc, "test-holder"), svc, kv
}
// runOneTask places a task and returns its ID, read back from the record rather
+25 -45
View File
@@ -13,17 +13,19 @@ import (
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// Scheduler timing. The leader lease lives in spinifex-ecs-leader (60s TTL); the
// holder refreshes well inside that. The reaper marks an instance DRAINING after
// three missed 30s heartbeats.
// KeyScheduler is the ECS scheduler's leader lease in the sync bucket.
const KeyScheduler = "ecs.scheduler"
// Scheduler timing. A follower asks again at the lease's renewal rate. The
// reaper marks an instance DRAINING after three missed 30s heartbeats.
const (
schedulerLeaderKey = "scheduler"
leaseRefresh = 20 * time.Second
schedulerLeaseTTL = 60 * time.Second
leaseRefresh = schedulerLeaseTTL / 3
reaperInterval = 30 * time.Second
heartbeatTimeout = 90 * time.Second
stoppedReasonReaped = "ContainerInstance disconnected"
@@ -59,17 +61,14 @@ const (
reconcileResync = 5 * time.Minute
)
// Scheduler is the per-daemon ECS control loop. A single leader (elected via the
// shared leader bucket) owns the Layer-2 bus subscriptions and the heartbeat
// Scheduler is the per-daemon ECS control loop. A single leader (elected on its
// lease) owns the Layer-2 bus subscriptions and the heartbeat
// reaper; losers idle and retry. Placement itself happens synchronously in the
// RunTask handler, so a brief leaderless gap never blocks RunTask.
type Scheduler struct {
nc *nats.Conn
svc *Service
holder string
lease *kvlease.Lease
leaseErr error
nc *nats.Conn
svc *Service
lease *kvsync.Lease
// wake carries a bus message that changed something the service reconcile
// reads. Fed by the task-state subscription and not by the heartbeat one:
@@ -89,47 +88,28 @@ type Scheduler struct {
subs []*nats.Subscription
}
// NewScheduler constructs a Scheduler. holder identifies this daemon in the lease.
func NewScheduler(nc *nats.Conn, svc *Service, holder string) *Scheduler {
// One slot: it means "something the reconcile reads has changed", and a
// second arriving before the pass runs says nothing the first did not.
sc := &Scheduler{nc: nc, svc: svc, holder: holder, wake: make(chan struct{}, 1)}
sc.lease, sc.leaseErr = kvlease.New(kvlease.Config{
Name: "ecs/scheduler",
Bucket: sc.leaderBucket,
Key: schedulerLeaderKey,
Holder: holder,
TTL: KVBucketECSLeaderTTL,
Renew: leaseRefresh,
Retry: leaseRefresh,
// The leader owns the Layer-2 bus subscriptions and the instance-role
// converge pass. A node that can do neither stands down rather than hold
// the lease and process nothing.
OnGained: sc.onGained,
OnLost: sc.unsubscribeBus,
})
return sc
// NewSchedulerLease returns the ECS scheduler's leader lease for holder.
func NewSchedulerLease(store kvsync.KVStore, holder string) *kvsync.Lease {
return kvsync.NewLease(store, KeyScheduler, holder, schedulerLeaseTTL)
}
func (sc *Scheduler) leaderBucket(ctx context.Context) (jetstream.KeyValue, error) {
js, err := jetstream.New(sc.nc)
if err != nil {
return nil, err
}
return InitLeaderBucket(ctx, js)
// NewScheduler constructs a Scheduler that leads while it holds lease.
func NewScheduler(nc *nats.Conn, svc *Service, lease *kvsync.Lease) *Scheduler {
// One slot: it means "something the reconcile reads has changed", and a
// second arriving before the pass runs says nothing the first did not.
return &Scheduler{nc: nc, svc: svc, lease: lease, wake: make(chan struct{}, 1)}
}
// Run drives the leadership + reaper loop until ctx is cancelled. It is intended
// to run as a daemon-boot goroutine; panics are the caller's recover concern.
func (sc *Scheduler) Run(ctx context.Context) {
if sc.leaseErr != nil {
slog.ErrorContext(ctx, "ECS scheduler: lease config invalid", "holder", sc.holder, "err", sc.leaseErr)
return
}
leaseDone := make(chan struct{})
go func() {
defer close(leaseDone)
sc.lease.Run(ctx)
// The leader owns the Layer-2 bus subscriptions and the instance-role
// converge pass. A node that can do neither stands down rather than hold
// the lease and process nothing.
kvsync.RunElection(ctx, sc.lease, sc.onGained, sc.unsubscribeBus)
}()
// Two loops rather than four tickers, split by what wakes them. Services are
+110 -11
View File
@@ -6,7 +6,9 @@ package ecs
import (
"context"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ecs"
@@ -14,26 +16,67 @@ import (
"github.com/stretchr/testify/require"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ecs/taskdefinition"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
"github.com/nats-io/nats.go"
)
// testScheduler is holder's scheduler on a sync bucket of its own, as a
// separate daemon's would be.
func testScheduler(t *testing.T, nc *nats.Conn, svc *Service, holder string) *Scheduler {
t.Helper()
return NewScheduler(nc, svc, NewSchedulerLease(kvstore.OpenSyncBucket(testutil.NewJetStream(t, nc)), holder))
}
// electOnce runs sc's election with the scheduler's own callbacks and returns
// what its first onGained returned. stop ends the election and waits for it.
func electOnce(t *testing.T, sc *Scheduler) (stop func(), gainedErr error) {
t.Helper()
ctx, cancel := context.WithCancel(t.Context())
gained := make(chan error, 1)
done := make(chan struct{})
go func() {
defer close(done)
kvsync.RunElection(ctx, sc.lease, func(ctx context.Context) error {
err := sc.onGained(ctx)
gained <- err
return err
}, sc.unsubscribeBus)
}()
stop = func() { cancel(); <-done }
t.Cleanup(stop)
select {
case gainedErr = <-gained:
case <-time.After(10 * time.Second):
t.Fatal("the election never gained the lease")
}
return sync.OnceFunc(stop), gainedErr
}
// requireLeaseKeyAbsent fails unless no node holds the scheduler's key.
func requireLeaseKeyAbsent(t *testing.T, nc *nats.Conn, msg string) {
t.Helper()
_, _, err := kvstore.OpenSyncBucket(testutil.NewJetStream(t, nc)).Load(t.Context(), KeyScheduler)
require.ErrorIs(t, err, kvsync.ErrNotFound, msg)
}
// TestScheduler_StandsDownWithoutIAM is the fleet outage this guards: a leader
// that cannot converge instance roles holds the lease, writes no sts:AssumeRole
// grant, and every agent's credentials lapse an hour later.
func TestScheduler_StandsDownWithoutIAM(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
_, nc, _ := testutil.StartTestJetStream(t)
svc := NewService(nc, testRegion, "")
sc := NewScheduler(nc, svc, "holder-a")
require.NoError(t, sc.leaseErr)
sc := testScheduler(t, nc, svc, "holder-a")
assert.False(t, sc.lease.TryAcquire(t.Context()), "a node without IAM must refuse the lease")
stop, err := electOnce(t, sc)
require.Error(t, err, "a node without IAM must refuse the lease")
stop()
assert.False(t, sc.lease.Held())
assert.Zero(t, sc.subCount())
// The key must be free, or a healthy node waits out the full TTL.
kv, err := InitLeaderBucket(t.Context(), js)
require.NoError(t, err)
_, err = kv.Get(t.Context(), schedulerLeaderKey)
require.Error(t, err)
requireLeaseKeyAbsent(t, nc, "a node that stood down left its key")
}
// TestConvergeInstanceRoles_RewritesProvisionedAccounts pins the pass that
@@ -49,7 +92,7 @@ func TestConvergeInstanceRoles_RewritesProvisionedAccounts(t *testing.T) {
require.NoError(t, err)
iam.RolePolicies[ecsInstanceRoleName] = `{"Version":"2012-10-17","Statement":[]}`
sc := NewScheduler(nc, svc, "holder-a")
sc := testScheduler(t, nc, svc, "holder-a")
require.NoError(t, sc.convergeInstanceRoles(t.Context()))
assert.Contains(t, iam.RolePolicies[ecsInstanceRoleName], "sts:AssumeRole")
@@ -65,7 +108,7 @@ func TestConvergeInstanceRoles_SkipsAccountsWithoutTheRole(t *testing.T) {
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
require.NoError(t, err)
sc := NewScheduler(nc, svc, "holder-a")
sc := testScheduler(t, nc, svc, "holder-a")
require.NoError(t, sc.convergeInstanceRoles(t.Context()))
assert.Zero(t, iam.CreateRoleCalls)
@@ -77,7 +120,7 @@ func TestConvergeInstanceRoles_SkipsAccountsWithoutTheRole(t *testing.T) {
// caller that reaches the pass without going through leader election.
func TestConvergeInstanceRoles_NilIAMIsAnError(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
sc := NewScheduler(nc, NewService(nc, testRegion, ""), "holder-a")
sc := testScheduler(t, nc, NewService(nc, testRegion, ""), "holder-a")
err := sc.convergeInstanceRoles(t.Context())
require.Error(t, err)
@@ -94,3 +137,59 @@ func TestECSInstanceRolePolicyDoc_GrantsAssumeRole(t *testing.T) {
assert.Contains(t, doc, `"ecs:*"`)
assert.Contains(t, doc, `"ecr:GetAuthorizationToken"`)
}
// containerInstanceStatus is i-1's status in the web cluster.
func containerInstanceStatus(t *testing.T, svc *Service) string {
t.Helper()
di, err := svc.DescribeContainerInstances(context.Background(), &ecs.DescribeContainerInstancesInput{
Cluster: aws.String("web"), ContainerInstances: []*string{aws.String("i-1")},
}, testAccountID)
require.NoError(t, err)
return aws.StringValue(di.ContainerInstances[0].Status)
}
// A leader that loses the lease must drop the bus and stop reaping, or two
// nodes would mark instances DRAINING and stop their tasks at once. Re-elected,
// it takes both back up.
func TestScheduler_LosingTheLeaseDropsTheBusAndTheReaperUntilReElection(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
require.NoError(t, err)
registerInstance(t, svc, "web", "i-1", 1024, 2048)
kv, err := svc.bucket(t.Context(), testAccountID)
require.NoError(t, err)
var rec InstanceRecord
_, err = taskdefinition.GetJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec)
require.NoError(t, err)
rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec))
store := kvstore.OpenSyncBucket(testutil.NewJetStream(t, nc))
sc := NewScheduler(nc, svc, kvsync.NewLease(store, KeyScheduler, "holder-a", 300*time.Millisecond))
ctx, cancel := context.WithCancel(t.Context())
done := make(chan struct{})
go func() { defer close(done); kvsync.RunElection(ctx, sc.lease, sc.onGained, sc.unsubscribeBus) }()
t.Cleanup(func() { cancel(); <-done })
held := func() bool { return sc.lease.Held() && sc.subCount() == 4 }
require.Eventually(t, held, 5*time.Second, 10*time.Millisecond, "the leader subscribes to the four bus subjects")
syncKV, err := store.KV(t.Context())
require.NoError(t, err)
_, err = syncKV.Put(t.Context(), KeyScheduler, []byte(`{"holder":"holder-b","generation":0}`))
require.NoError(t, err)
require.Eventually(t, func() bool { return !sc.lease.Held() && sc.subCount() == 0 }, 5*time.Second, 10*time.Millisecond,
"a lost lease must drop every bus subscription")
revisit, err := sc.timerPass(t.Context())
require.NoError(t, err)
assert.Equal(t, leaseRefresh, revisit)
assert.Equal(t, "ACTIVE", containerInstanceStatus(t, svc), "a node that lost the lease must not reap")
require.NoError(t, syncKV.Delete(t.Context(), KeyScheduler))
require.Eventually(t, held, 5*time.Second, 10*time.Millisecond, "re-elected, the bus comes back")
_, err = sc.timerPass(t.Context())
require.NoError(t, err)
assert.Equal(t, "DRAINING", containerInstanceStatus(t, svc), "the re-elected leader reaps again")
}
+30 -25
View File
@@ -14,6 +14,7 @@ import (
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
"github.com/mulgadc/spinifex/internal/testkit"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -571,7 +572,7 @@ func TestScheduler_ReapBucket_StopsStaleInstanceTasks(t *testing.T) {
rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec))
sc := NewScheduler(nc, svc, "test-holder")
sc := testScheduler(t, nc, svc, "test-holder")
sc.reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
di, err := svc.DescribeContainerInstances(context.Background(), &ecs.DescribeContainerInstancesInput{
@@ -586,32 +587,38 @@ func TestScheduler_ReapBucket_StopsStaleInstanceTasks(t *testing.T) {
assert.Equal(t, stoppedReasonReaped, aws.StringValue(dt.Tasks[0].StoppedReason))
}
// Leader election: first holder wins Create; a second holder is rejected.
// Leader election: the first holder wins; a second node, and a second attempt
// in the same process, are both refused while it leads.
func TestScheduler_AcquireLease_SingleLeader(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
a := NewScheduler(nc, svc, "holder-a")
b := NewScheduler(nc, svc, "holder-b")
require.True(t, a.lease.TryAcquire(t.Context()))
assert.False(t, b.lease.TryAcquire(t.Context()))
assert.True(t, a.lease.TryAcquire(t.Context()), "a holder re-attempting keeps leadership")
a := testScheduler(t, nc, svc, "holder-a")
b := testScheduler(t, nc, svc, "holder-b")
tok, err := a.lease.TryLock(t.Context())
require.NoError(t, err)
t.Cleanup(func() { a.lease.Unlock(context.Background(), tok) })
_, err = b.lease.TryLock(t.Context())
assert.ErrorAs(t, err, new(*kvsync.HeldError))
_, err = a.lease.TryLock(t.Context())
assert.ErrorAs(t, err, new(*kvsync.HeldError), "a second attempt in the leading process is refused")
assert.True(t, a.lease.Held(), "a refused second attempt keeps leadership")
}
// Leadership owns the Layer-2 bus subscriptions. This pins the wiring — gaining
// the lease subscribes, losing it unsubscribes — which is the regression this
// conversion could introduce and the one thing kvlease cannot check itself.
// Leadership owns the Layer-2 bus subscriptions. This pins the wiring: gaining
// the lease subscribes, losing it unsubscribes.
func TestScheduler_LeadershipWiresBusSubscriptions(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
sc := NewScheduler(nc, svc, "holder-a")
require.NoError(t, sc.leaseErr)
sc := testScheduler(t, nc, svc, "holder-a")
require.Zero(t, sc.subCount(), "subscriptions must not exist before election")
require.True(t, sc.lease.TryAcquire(t.Context()))
stop, err := electOnce(t, sc)
require.NoError(t, err)
assert.Equal(t, 4, sc.subCount(), "leader must own the register, heartbeat, task-state and reconcile-wake subscriptions")
sc.lease.Release(t.Context())
stop()
assert.Zero(t, sc.subCount(), "a released leader must drop its subscriptions")
}
@@ -620,20 +627,21 @@ func TestScheduler_LeadershipWiresBusSubscriptions(t *testing.T) {
func TestScheduler_LoserHoldsNoSubscriptions(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
a := NewScheduler(nc, svc, "holder-a")
b := NewScheduler(nc, svc, "holder-b")
a := testScheduler(t, nc, svc, "holder-a")
b := testScheduler(t, nc, svc, "holder-b")
require.True(t, a.lease.TryAcquire(t.Context()))
require.False(t, b.lease.TryAcquire(t.Context()))
_, err := electOnce(t, a)
require.NoError(t, err)
_, err = b.lease.TryLock(t.Context())
require.ErrorAs(t, err, new(*kvsync.HeldError))
assert.Zero(t, b.subCount())
assert.Equal(t, 4, a.subCount())
}
func TestScheduler_RunReleasesLeaseOnShutdown(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
_, nc, _ := testutil.StartTestJetStream(t)
svc := NewService(nc, testRegion, "").WithDeps(Deps{IAM: newStubIAM()})
sc := NewScheduler(nc, svc, "holder-a")
require.NoError(t, sc.leaseErr)
sc := testScheduler(t, nc, svc, "holder-a")
ctx, cancel := context.WithCancel(t.Context())
done := make(chan struct{})
@@ -643,10 +651,7 @@ func TestScheduler_RunReleasesLeaseOnShutdown(t *testing.T) {
cancel()
<-done
kv, err := InitLeaderBucket(t.Context(), js)
require.NoError(t, err)
_, err = kv.Get(t.Context(), schedulerLeaderKey)
require.Error(t, err, "Run returned with the lease key still present")
requireLeaseKeyAbsent(t, nc, "Run returned with the lease key still present")
}
// --- GPU placement dimension (Epic C2) ---
+7 -26
View File
@@ -4,10 +4,8 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -19,18 +17,11 @@ import (
// cluster, task-definition, task, service, and container-instance state. It is
// created lazily on first cluster touch (no daemon-boot pre-creation) so accounts
// without any ECS clusters never grow a bucket (ecs-v1.md Q2).
//
// Shared leader bucket "spinifex-ecs-leader" holds 60s-TTL CAS locks keyed by
// "{accountID}/{clusterName}" for the per-cluster scheduler leader election
// (Q3). Created once at daemon boot.
const (
KVBucketECSAccountPrefix = "ecs-account-"
KVBucketECSAccountVersion = 1
KVBucketECSAccountHistory = 1
KVBucketECSLeader = "spinifex-ecs-leader"
KVBucketECSLeaderVersion = 1
KVBucketECSLeaderTTL = 60 * time.Second
)
// Key-path helpers for the per-account bucket. The layout matches ecs-v1.md Q2.
@@ -119,15 +110,15 @@ func CapacityProviderKey(name string) string {
return CapacityProvidersPrefix() + name
}
// Store is the per-daemon ECS KV handle. Per-account and leader buckets are
// accessed via the package-level factories below.
// Store is the per-daemon ECS KV handle. Per-account buckets are accessed via
// the package-level factories below.
type Store struct {
nc *nats.Conn
}
// NewStore constructs a Store bound to the supplied NATS connection. It does not
// touch JetStream — per-account buckets are created lazily by
// GetOrCreateAccountBucket and the leader bucket by InitLeaderBucket.
// GetOrCreateAccountBucket.
func NewStore(nc *nats.Conn) (*Store, error) {
if nc == nil {
return nil, errors.New("ecs store: nats connection is nil")
@@ -158,7 +149,7 @@ func accountBuckets(ctx context.Context, nc *nats.Conn) ([]accountBucket, error)
if err != nil {
return nil, fmt.Errorf("jetstream: %w", err)
}
names, err := kvutil.BucketNames(ctx, js)
names, err := kvstore.BucketNames(ctx, js)
if err != nil {
return nil, err
}
@@ -176,7 +167,7 @@ func accountBuckets(ctx context.Context, nc *nats.Conn) ([]accountBucket, error)
// return the existing handle.
func GetOrCreateAccountBucket(ctx context.Context, js jetstream.JetStream, accountID string) (jetstream.KeyValue, error) {
bucket := AccountBucketName(accountID)
kv, err := kvutil.GetOrCreateBucket(ctx, js, bucket, KVBucketECSAccountHistory)
kv, err := kvstore.GetOrCreateBucket(ctx, js, bucket, KVBucketECSAccountHistory)
if err != nil {
return nil, fmt.Errorf("failed to create ECS per-account KV bucket %s: %w", bucket, err)
}
@@ -185,13 +176,3 @@ func GetOrCreateAccountBucket(ctx context.Context, js jetstream.JetStream, accou
}
return kv, nil
}
// InitLeaderBucket creates (or attaches to) the shared spinifex-ecs-leader bucket
// used for per-cluster scheduler leader-lease CAS locks.
func InitLeaderBucket(ctx context.Context, js jetstream.JetStream) (jetstream.KeyValue, error) {
return kvlease.OpenBucket(ctx, js, kvlease.BucketConfig{
Name: KVBucketECSLeader,
TTL: KVBucketECSLeaderTTL,
Version: KVBucketECSLeaderVersion,
})
}
-69
View File
@@ -1,13 +1,9 @@
package ecs
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -46,71 +42,6 @@ func TestGetOrCreateAccountBucket_Idempotent(t *testing.T) {
assert.Equal(t, kv1.Bucket(), kv2.Bucket())
}
func TestInitLeaderBucket_Idempotent(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
js := testutil.NewJetStream(t, nc)
kv1, err := InitLeaderBucket(t.Context(), js)
require.NoError(t, err)
require.NotNil(t, kv1)
assert.Equal(t, KVBucketECSLeader, kv1.Bucket())
kv2, err := InitLeaderBucket(t.Context(), js)
require.NoError(t, err)
require.NotNil(t, kv2)
assert.Equal(t, KVBucketECSLeader, kv2.Bucket())
}
// The scheduler calls InitLeaderBucket on every tick. Creating first and falling
// back on already-exists reaches the same handle, so idempotency alone cannot
// tell the two orders apart — only the API traffic can.
//
// On a multi-node cluster the stored bucket is replicated while this call asks
// for the default, and JetStream answers the mismatch with an error rather than
// a no-op: 10058, once per tick, forever. That is invisible from the caller and
// showed up only as a 16% JetStream API error rate on the meta leader.
func TestInitLeaderBucket_AttachesWithoutCreating(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
// Watch from before the first call. The server queues the API advisory after
// the reply the caller waits on, so subscribing once the bucket exists can
// still catch the create that first call legitimately made.
creates := make(chan string, 8)
sub, err := nc.Subscribe("$JS.EVENT.ADVISORY.API", func(m *nats.Msg) {
var adv struct {
Subject string `json:"subject"`
}
if json.Unmarshal(m.Data, &adv) == nil &&
strings.HasPrefix(adv.Subject, "$JS.API.STREAM.CREATE.") {
creates <- adv.Subject
}
})
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
require.NoError(t, nc.Flush())
_, err = InitLeaderBucket(t.Context(), js)
require.NoError(t, err)
// Take the first, legitimate create off the channel before the call under
// test, so anything left there afterwards can only have come from it.
select {
case <-creates:
case <-time.After(5 * time.Second):
t.Fatal("the first InitLeaderBucket must create the bucket")
}
_, err = InitLeaderBucket(t.Context(), js)
require.NoError(t, err)
require.NoError(t, nc.Flush())
select {
case subject := <-creates:
t.Fatalf("InitLeaderBucket issued %s on an existing bucket; attach before create", subject)
case <-time.After(250 * time.Millisecond):
}
}
// Key-path helpers must produce the ecs-v1.md Q2 layout exactly: prefixes are
// what the List* enumerations watch, so a drift here silently breaks listing.
func TestKeyPaths(t *testing.T) {
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"slices"
"strings"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
"github.com/nats-io/nats.go/jetstream"
)
@@ -128,7 +128,7 @@ func deleteRecord(ctx context.Context, kv jetstream.KeyValue, cluster, principal
// casUpdate does a revision-checked read-modify-write. mutate returns true
// when a field changed. Returns ErrNotFound if absent.
func casUpdate(ctx context.Context, kv jetstream.KeyValue, cluster, principalARN string, mutate func(*Record) bool) (*Record, error) {
return kvutil.Update(ctx, kv, Key(cluster, principalARN), kvutil.CASConfig{NotFound: ErrNotFound}, func(r *Record) (bool, error) {
return kvstore.Update(ctx, kv, Key(cluster, principalARN), kvstore.CASConfig{NotFound: ErrNotFound}, func(r *Record) (bool, error) {
return mutate(r), nil
})
}

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