mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-06 02:51:30 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4be471c196 | |||
| ce29eb12bf | |||
| 8ef35a9c1d | |||
| 79e8c16ea5 | |||
| 17ef43327e | |||
| acc56dca66 | |||
| 668f6341e3 |
@@ -90,6 +90,7 @@ 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:
|
||||
@@ -104,8 +105,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"; \
|
||||
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/ && \
|
||||
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/ && \
|
||||
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)/
|
||||
|
||||
@@ -17,7 +17,6 @@ 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"},
|
||||
|
||||
@@ -16,8 +16,8 @@ load_mod() {
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# lambda-agent reads its identity from fw_cfg and unloads qemu_fw_cfg before
|
||||
# anything else runs. virtio_net needs net_failover and failover, and the
|
||||
# lambda-agent reads its identity from fw_cfg, or the configuration disk, and
|
||||
# seals it before anything else runs. virtio_net needs net_failover and failover, and the
|
||||
# insmod fallback resolves no dependencies, so load the chain bottom-up.
|
||||
load_mod qemu_fw_cfg || echo "[init] qemu_fw_cfg did not load" >/dev/console
|
||||
load_mod failover || true
|
||||
@@ -26,8 +26,30 @@ load_mod virtio_net || echo "[init] virtio_net did not load" >/dev/console
|
||||
|
||||
ip link set lo up
|
||||
|
||||
# Without fw_cfg data the executor attaches the same entries as a tar archive
|
||||
# on the second disk; lambda-agent reads them here and erases the disk before
|
||||
# the function starts. qemu_fw_cfg loads without a device, so test the entry.
|
||||
CFG_ROOT=/spinifex-cfg
|
||||
FWCFG_ROOT=/sys/firmware/qemu_fw_cfg
|
||||
if [ ! -f "$FWCFG_ROOT/by_name/opt/spinifex/netcfg/raw" ]; then
|
||||
load_mod virtio_blk || echo "[init] virtio_blk did not load" >/dev/console
|
||||
i=0
|
||||
while [ "$i" -lt 25 ] && [ ! -b /dev/vdb ]; do
|
||||
sleep 0.04
|
||||
i=$((i + 1))
|
||||
done
|
||||
if [ -b /dev/vdb ]; then
|
||||
mkdir -m 0700 "$CFG_ROOT"
|
||||
if ! mount -t tmpfs -o mode=0700,size=1m tmpfs "$CFG_ROOT" ||
|
||||
! tar -xf /dev/vdb -C "$CFG_ROOT"; then
|
||||
echo "[init] configuration disk not readable" >/dev/console
|
||||
fi
|
||||
FWCFG_ROOT=$CFG_ROOT
|
||||
fi
|
||||
fi
|
||||
|
||||
# The guest's addresses come from the same netcfg entry as other system VMs.
|
||||
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
|
||||
NETCFG=$FWCFG_ROOT/by_name/opt/spinifex/netcfg/raw
|
||||
i=0
|
||||
while [ "$i" -lt 25 ] && [ ! -f "$NETCFG" ]; do
|
||||
sleep 0.04
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
@@ -25,9 +26,22 @@ type startup struct {
|
||||
}
|
||||
|
||||
func defaultStartup() startup {
|
||||
if _, err := os.Stat(hostlink.ConfigDiskRoot); err == nil {
|
||||
return configDiskStartup(hostlink.ConfigDiskRoot, hostlink.ConfigDiskDevice)
|
||||
}
|
||||
return startup{fwCfgDir: hostlink.FwCfgDir, fwCfgRoot: hostlink.FwCfgRoot, unload: hostlink.UnloadFwCfg}
|
||||
}
|
||||
|
||||
// configDiskStartup reads the entries init extracted from the configuration
|
||||
// disk, and seals by erasing the disk and removing the extracted tree.
|
||||
func configDiskStartup(root, device string) startup {
|
||||
return startup{
|
||||
fwCfgDir: root + "/by_name/" + lambdav1.FwCfgPrefix,
|
||||
fwCfgRoot: root,
|
||||
unload: func() error { return hostlink.SealConfigDisk(device, root) },
|
||||
}
|
||||
}
|
||||
|
||||
// connect loads the identity, seals fw_cfg whether or not the load succeeded,
|
||||
// then dials the host. Under TLS 1.3 a host refusal is seen only on first read.
|
||||
func (s startup) connect(ctx context.Context) (*tls.Conn, lambdav1.Scope, int, error) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//test:in-package — package main cannot be imported by an external test package.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// An identity that does not load still seals the configuration disk: the
|
||||
// guest key on it is erased before the agent refuses to start.
|
||||
func TestConfigDiskStartup_RefusedLoadErasesDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := filepath.Join(dir, "cfg")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "by_name"), 0o700))
|
||||
device := filepath.Join(dir, "vdb")
|
||||
require.NoError(t, os.WriteFile(device, bytes.Repeat([]byte("guest-key"), 512), 0o600))
|
||||
|
||||
_, _, code, err := configDiskStartup(root, device).connect(t.Context())
|
||||
require.Error(t, err)
|
||||
require.Equal(t, exitRefused, code)
|
||||
got, rerr := os.ReadFile(device)
|
||||
require.NoError(t, rerr)
|
||||
require.Len(t, got, 9*512)
|
||||
require.Equal(t, make([]byte, len(got)), got, "configuration disk erased")
|
||||
}
|
||||
|
||||
// A disk that cannot be erased fails closed: the agent refuses, and the
|
||||
// extracted tree is kept so the seal check sees it.
|
||||
func TestConfigDiskStartup_UnerasableDiskRefuses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := filepath.Join(dir, "cfg")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "by_name"), 0o700))
|
||||
|
||||
_, _, code, err := configDiskStartup(root, filepath.Join(dir, "missing")).connect(t.Context())
|
||||
require.ErrorContains(t, err, "erase configuration disk")
|
||||
require.Equal(t, exitRefused, code)
|
||||
require.DirExists(t, root)
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package clusterv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
import "time"
|
||||
|
||||
// ClusterStateBucket is the KV bucket holding each daemon's heartbeat alongside
|
||||
// other cluster-wide state.
|
||||
@@ -14,27 +11,6 @@ func HeartbeatKey(node string) string {
|
||||
return "heartbeat." + node
|
||||
}
|
||||
|
||||
// LivenessWindow is how recently a node must have heartbeated for a reader
|
||||
// deciding on a takeover to count it live. It is six heartbeats, compared
|
||||
// across node clocks, where NTP skew is small against it.
|
||||
const LivenessWindow = 60 * time.Second
|
||||
|
||||
// LiveAt reports whether a heartbeat written at written still vouches for its
|
||||
// node at now: it does while it is under LivenessWindow old.
|
||||
func LiveAt(written, now time.Time) bool {
|
||||
return now.Sub(written) < LivenessWindow
|
||||
}
|
||||
|
||||
// HeartbeatWritten dates a stored heartbeat value. One that cannot be decoded
|
||||
// or dated vouches for nothing, which is an answer rather than a fault.
|
||||
func HeartbeatWritten(data []byte) (time.Time, bool) {
|
||||
var beat HeartbeatTimestamp
|
||||
if json.Unmarshal(data, &beat) != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return beat.Time()
|
||||
}
|
||||
|
||||
// HeartbeatTimestamp is the read side of a heartbeat for node liveness. It
|
||||
// decodes only the timestamp, so a reader does not depend on the capacity
|
||||
// figures the daemon writes beside it.
|
||||
|
||||
@@ -33,23 +33,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ const (
|
||||
// this contract family. It is the NATS permission/subscription pattern for
|
||||
// the per-instance command route, rather than the broader descendant match.
|
||||
InstanceCommandSubjectWildcard = InstanceCommandSubjectPrefix + "*"
|
||||
|
||||
// HandledHeader is set to "1" on every reply an owner sends to a
|
||||
// modify-instance-metadata-options or set-disable-api-termination command.
|
||||
// An owner that predates those commands cannot set it.
|
||||
HandledHeader = "Spinifex-Ec2-Cmd-Handled"
|
||||
)
|
||||
|
||||
// InstanceCommandSubject returns the targeted command subject for instanceID.
|
||||
@@ -23,7 +28,9 @@ func InstanceCommandSubject(instanceID string) string {
|
||||
}
|
||||
|
||||
// EC2InstanceCommand is the NATS wire format for EC2 instance commands
|
||||
// (stop, terminate, start, attach/detach-volume, attach/detach-eni).
|
||||
// (stop, terminate, start, reboot, attach/detach-volume, attach/detach-eni,
|
||||
// IAM profile, spot lineage, tags, monitoring, metadata options and
|
||||
// termination protection).
|
||||
type EC2InstanceCommand struct {
|
||||
ID string `json:"id"`
|
||||
Attributes EC2CommandAttributes `json:"attributes"`
|
||||
@@ -35,6 +42,9 @@ type EC2InstanceCommand struct {
|
||||
SpotLineageData *SpotLineageData `json:"spot_lineage_data,omitempty"`
|
||||
InstanceTagsData *InstanceTagsData `json:"instance_tags_data,omitempty"`
|
||||
InstanceMonitoringData *InstanceMonitoringData `json:"instance_monitoring_data,omitempty"`
|
||||
|
||||
InstanceMetadataOptionsData *InstanceMetadataOptionsData `json:"instance_metadata_options_data,omitempty"`
|
||||
DisableApiTerminationData *DisableApiTerminationData `json:"disable_api_termination_data,omitempty"`
|
||||
}
|
||||
|
||||
// EC2CommandAttributes indicates which action the daemon should perform.
|
||||
@@ -52,6 +62,9 @@ type EC2CommandAttributes struct {
|
||||
SetInstanceTags bool `json:"set_instance_tags,omitempty"`
|
||||
RemoveInstanceTags bool `json:"remove_instance_tags,omitempty"`
|
||||
SetInstanceMonitoring bool `json:"set_instance_monitoring,omitempty"`
|
||||
|
||||
ModifyInstanceMetadataOptions bool `json:"modify_instance_metadata_options,omitempty"`
|
||||
SetDisableApiTermination bool `json:"set_disable_api_termination,omitempty"`
|
||||
}
|
||||
|
||||
// AttachVolumeData carries parameters for an attach-volume command.
|
||||
@@ -103,6 +116,23 @@ type InstanceMonitoringData struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// InstanceMetadataOptionsData carries the requested metadata options for a
|
||||
// modify-instance-metadata-options command. Unset fields are left unchanged.
|
||||
type InstanceMetadataOptionsData struct {
|
||||
HttpTokens string `json:"http_tokens,omitempty"`
|
||||
HttpEndpoint string `json:"http_endpoint,omitempty"`
|
||||
HttpProtocolIpv6 string `json:"http_protocol_ipv6,omitempty"`
|
||||
InstanceMetadataTags string `json:"instance_metadata_tags,omitempty"`
|
||||
HttpPutResponseHopLimit *int64 `json:"http_put_response_hop_limit,omitempty"`
|
||||
}
|
||||
|
||||
// DisableApiTerminationData carries the desired termination-protection value
|
||||
// for a set-disable-api-termination command. Carried as data so "false" is an
|
||||
// explicit value, not the absence of one.
|
||||
type DisableApiTerminationData struct {
|
||||
Value bool `json:"value"`
|
||||
}
|
||||
|
||||
// SpotLineageData carries the SIR id stamped onto a spot-launched instance.
|
||||
// Lifecycle is always "spot" for this command, so only the SIR id travels.
|
||||
type SpotLineageData struct {
|
||||
|
||||
@@ -24,6 +24,9 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
SetInstanceTags: true,
|
||||
RemoveInstanceTags: true,
|
||||
SetInstanceMonitoring: true,
|
||||
|
||||
ModifyInstanceMetadataOptions: true,
|
||||
SetDisableApiTermination: true,
|
||||
},
|
||||
AttachVolumeData: &AttachVolumeData{VolumeID: "vol-1", Device: "/dev/sdf"},
|
||||
DetachVolumeData: &DetachVolumeData{VolumeID: "vol-2", Device: "/dev/sdg", Force: true},
|
||||
@@ -33,6 +36,15 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
SpotLineageData: &SpotLineageData{SpotInstanceRequestId: "sir-1"},
|
||||
InstanceTagsData: &InstanceTagsData{Tags: map[string]string{"Name": "example"}, TagKeys: []string{"old"}},
|
||||
InstanceMonitoringData: &InstanceMonitoringData{Enabled: true},
|
||||
|
||||
InstanceMetadataOptionsData: &InstanceMetadataOptionsData{
|
||||
HttpTokens: "required",
|
||||
HttpEndpoint: "enabled",
|
||||
HttpProtocolIpv6: "disabled",
|
||||
InstanceMetadataTags: "enabled",
|
||||
HttpPutResponseHopLimit: int64Ptr(3),
|
||||
},
|
||||
DisableApiTerminationData: &DisableApiTerminationData{Value: false},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(command)
|
||||
@@ -52,7 +64,9 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
"set_spot_lineage": true,
|
||||
"set_instance_tags": true,
|
||||
"remove_instance_tags": true,
|
||||
"set_instance_monitoring": true
|
||||
"set_instance_monitoring": true,
|
||||
"modify_instance_metadata_options": true,
|
||||
"set_disable_api_termination": true
|
||||
},
|
||||
"attach_volume_data": {"volume_id": "vol-1", "device": "/dev/sdf"},
|
||||
"detach_volume_data": {"volume_id": "vol-2", "device": "/dev/sdg", "force": true},
|
||||
@@ -61,10 +75,64 @@ func TestEC2InstanceCommandJSONContract(t *testing.T) {
|
||||
"iam_profile_association_data": {"instance_profile_arn": "arn:aws:iam::123456789012:instance-profile/example"},
|
||||
"spot_lineage_data": {"spot_instance_request_id": "sir-1"},
|
||||
"instance_tags_data": {"tags": {"Name": "example"}, "tag_keys": ["old"]},
|
||||
"instance_monitoring_data": {"enabled": true}
|
||||
"instance_monitoring_data": {"enabled": true},
|
||||
"instance_metadata_options_data": {
|
||||
"http_tokens": "required",
|
||||
"http_endpoint": "enabled",
|
||||
"http_protocol_ipv6": "disabled",
|
||||
"instance_metadata_tags": "enabled",
|
||||
"http_put_response_hop_limit": 3
|
||||
},
|
||||
"disable_api_termination_data": {"value": false}
|
||||
}`, string(data))
|
||||
}
|
||||
|
||||
func int64Ptr(v int64) *int64 { return &v }
|
||||
|
||||
// An owner that predates the metadata and termination-protection commands
|
||||
// sends and receives payloads without the new fields.
|
||||
func TestEC2InstanceCommandNewFieldsAreOptionalOnTheWire(t *testing.T) {
|
||||
var n1 EC2InstanceCommand
|
||||
require.NoError(t, json.Unmarshal([]byte(`{
|
||||
"id": "i-0123456789abcdef0",
|
||||
"attributes": {"stop_instance": true},
|
||||
"instance_monitoring_data": {"enabled": true}
|
||||
}`), &n1))
|
||||
require.Equal(t, "i-0123456789abcdef0", n1.ID)
|
||||
require.True(t, n1.Attributes.StopInstance)
|
||||
require.False(t, n1.Attributes.ModifyInstanceMetadataOptions)
|
||||
require.False(t, n1.Attributes.SetDisableApiTermination)
|
||||
require.Nil(t, n1.InstanceMetadataOptionsData)
|
||||
require.Nil(t, n1.DisableApiTerminationData)
|
||||
|
||||
unset, err := json.Marshal(EC2InstanceCommand{ID: "i-1", Attributes: EC2CommandAttributes{StopInstance: true}})
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(unset), "modify_instance_metadata_options")
|
||||
require.NotContains(t, string(unset), "set_disable_api_termination")
|
||||
require.NotContains(t, string(unset), "instance_metadata_options_data")
|
||||
require.NotContains(t, string(unset), "disable_api_termination_data")
|
||||
}
|
||||
|
||||
func TestInstanceMetadataOptionsDataOmitsUnsetFields(t *testing.T) {
|
||||
data, err := json.Marshal(InstanceMetadataOptionsData{HttpPutResponseHopLimit: int64Ptr(1)})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"http_put_response_hop_limit": 1}`, string(data))
|
||||
|
||||
data, err = json.Marshal(InstanceMetadataOptionsData{})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{}`, string(data))
|
||||
}
|
||||
|
||||
func TestDisableApiTerminationDataKeepsExplicitFalse(t *testing.T) {
|
||||
data, err := json.Marshal(DisableApiTerminationData{Value: false})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"value": false}`, string(data))
|
||||
}
|
||||
|
||||
func TestHandledHeaderName(t *testing.T) {
|
||||
require.Equal(t, "Spinifex-Ec2-Cmd-Handled", HandledHeader)
|
||||
}
|
||||
|
||||
func TestInstanceCommandSubjects(t *testing.T) {
|
||||
require.Equal(t, "ec2.cmd.i-0123456789abcdef0", InstanceCommandSubject("i-0123456789abcdef0"))
|
||||
require.Equal(t, "ec2.cmd.*", InstanceCommandSubjectWildcard)
|
||||
|
||||
@@ -22,7 +22,15 @@ 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.
|
||||
|
||||
@@ -36,6 +44,8 @@ Under TLS 1.3 the guest's handshake can complete before the host refuses its cer
|
||||
|
||||
The guest's own interface settings come from `opt/spinifex/netcfg`, the entry other system VMs use.
|
||||
The agent unloads `qemu_fw_cfg` even when an entry is missing, and refuses to start if the fw_cfg tree is still readable afterwards.
|
||||
On a VMM without fw_cfg (the test-link Firecracker executor), the executor attaches the same entries as a tar archive on the second, writable virtio-blk disk, laid out as `by_name/<entry>/raw`.
|
||||
The runtime image's init extracts it to a root-only tmpfs at `/spinifex-cfg`, and the agent reads the entries there, then overwrites the whole disk with zeros and removes the tree; it refuses to start if the tree is still present.
|
||||
Until the message format exists, the agent authenticates and holds the connection but accepts no host message and starts no user code.
|
||||
|
||||
**Authority:** one environment's attempt only.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// On a VMM without fw_cfg the executor attaches the same entries as a tar
|
||||
// archive on a second disk, which the runtime image's init extracts here in
|
||||
// the fw_cfg sysfs layout, so the fw_cfg readers work unchanged.
|
||||
const (
|
||||
ConfigDiskRoot = "/spinifex-cfg"
|
||||
ConfigDiskDir = ConfigDiskRoot + "/by_name/" + lambdav1.FwCfgPrefix
|
||||
ConfigDiskDevice = "/dev/vdb"
|
||||
)
|
||||
|
||||
// zeroChunk bounds each write while erasing the configuration disk.
|
||||
const zeroChunk = 64 << 10
|
||||
|
||||
// unmount is a var so tests without privilege can stand in for the tmpfs.
|
||||
var unmount = unix.Unmount
|
||||
|
||||
// SealConfigDisk overwrites the whole configuration disk with zeros, then
|
||||
// removes the extracted tree. The tree stays if the disk cannot be erased, so
|
||||
// SealFwCfg's check on root fails closed.
|
||||
func SealConfigDisk(device, root string) error {
|
||||
if err := zeroDevice(device); err != nil {
|
||||
return fmt.Errorf("hostlink: erase configuration disk: %w", err)
|
||||
}
|
||||
if err := unmount(root, 0); err != nil && !errors.Is(err, unix.EINVAL) {
|
||||
return fmt.Errorf("hostlink: unmount %s: %w", root, err)
|
||||
}
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
return fmt.Errorf("hostlink: remove %s: %w", root, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func zeroDevice(device string) error {
|
||||
f, err := os.OpenFile(device, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size, err := f.Seek(0, io.SeekEnd)
|
||||
if err == nil {
|
||||
_, err = f.Seek(0, io.SeekStart)
|
||||
}
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
zeros := make([]byte, zeroChunk)
|
||||
for left := size; left > 0; {
|
||||
n := min(left, int64(len(zeros)))
|
||||
if _, err := f.Write(zeros[:n]); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
left -= n
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//test:in-package reaches the unexported configDisk and fwCfgTree readers to seal and read a real archive
|
||||
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// configDisk writes a fake disk holding secret and an extracted tree; the
|
||||
// stand-in unmount reports the tree is not a mount, as on a plain directory.
|
||||
func configDisk(t *testing.T, secret []byte) (device, root string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
device = filepath.Join(dir, "vdb")
|
||||
require.NoError(t, os.WriteFile(device, append(bytes.Repeat([]byte{0xa5}, 3*zeroChunk+7), secret...), 0o600))
|
||||
root = fwCfgTree(t)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "by_name", "key"), secret, 0o600))
|
||||
old := unmount
|
||||
unmount = func(string, int) error { return unix.EINVAL }
|
||||
t.Cleanup(func() { unmount = old })
|
||||
return device, root
|
||||
}
|
||||
|
||||
func TestSealConfigDisk(t *testing.T) {
|
||||
secret := []byte("guest-key-material")
|
||||
|
||||
t.Run("erases the disk and removes the tree", func(t *testing.T) {
|
||||
device, root := configDisk(t, secret)
|
||||
before, err := os.Stat(device)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, SealFwCfg(root, func() error { return SealConfigDisk(device, root) }))
|
||||
|
||||
disk, err := os.ReadFile(device)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, disk, int(before.Size()))
|
||||
assert.Equal(t, make([]byte, len(disk)), disk, "configuration disk not fully erased")
|
||||
_, err = os.Stat(root)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
})
|
||||
t.Run("an unwritable disk keeps the tree and fails closed", func(t *testing.T) {
|
||||
device, root := configDisk(t, secret)
|
||||
require.NoError(t, os.Chmod(device, 0o400))
|
||||
err := SealFwCfg(root, func() error { return SealConfigDisk(device, root) })
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "still readable")
|
||||
_, err = os.Stat(root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
t.Run("a failed unmount keeps the tree", func(t *testing.T) {
|
||||
device, root := configDisk(t, secret)
|
||||
unmount = func(string, int) error { return unix.EBUSY }
|
||||
require.Error(t, SealFwCfg(root, func() error { return SealConfigDisk(device, root) }))
|
||||
_, err := os.Stat(root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -321,6 +321,19 @@ 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})
|
||||
|
||||
@@ -14,6 +14,7 @@ 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"
|
||||
)
|
||||
@@ -183,7 +184,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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketACM, KVBucketACMVersion)
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketACM, KVBucketACMVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketBedrockRPMUsage, 1)
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucketBedrockRPMUsage, 1)
|
||||
if err != nil {
|
||||
slog.Warn("bedrock rpm sync: open usage bucket failed, sync disabled", "err", err)
|
||||
return
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.Update(ctx, s.overrides, accountID, kvstore.CASConfig{
|
||||
_, err := kvutil.Update(ctx, s.overrides, accountID, kvutil.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 kvstore.IsRevisionConflict(err):
|
||||
case kvutil.IsRevisionConflict(err):
|
||||
// A concurrent write landed between the read and the guarded
|
||||
// delete; retry against whatever revision now holds.
|
||||
continue
|
||||
|
||||
@@ -22,10 +22,10 @@ const ReconcileInterval = 30 * time.Second
|
||||
// dimension; the live-counted dimensions need no stored state.
|
||||
const KVBucketAccountUsage = "spinifex-account-usage"
|
||||
|
||||
// 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"
|
||||
// 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"
|
||||
|
||||
// KVBucketAccountQuota holds the per-account limit overrides, keyed by
|
||||
// accountID. It is separate from the accounts bucket so a quota write never
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.GetOrCreateBucket(ctx, s.js, KVBucketAccountQuota, 1)
|
||||
kv, err := kvutil.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 kvstore.IsRevisionConflict(writeErr):
|
||||
case kvutil.IsRevisionConflict(writeErr):
|
||||
continue
|
||||
default:
|
||||
slog.ErrorContext(ctx, "Failed to write sealed quota override record", "accountID", accountID, "err", writeErr)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
nsconfig "github.com/mulgadc/northstar/pkg/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
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, lease *kvsync.Lease, names []string, remove bool) (*ChangeResult, error) {
|
||||
func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, 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, lease *kvsync.Lease, names []stri
|
||||
if len(batch.Changes) == 0 {
|
||||
return &ChangeResult{}, nil
|
||||
}
|
||||
release, err := w.awaitWriterElection(ctx, lease)
|
||||
release, err := w.awaitWriterElection(ctx, nc, holder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -85,19 +85,19 @@ func (w *Writer) Delegate(ctx context.Context, lease *kvsync.Lease, names []stri
|
||||
return w.ApplyBatch(batch)
|
||||
}
|
||||
|
||||
func (w *Writer) awaitWriterElection(ctx context.Context, lease *kvsync.Lease) (func(), error) {
|
||||
if lease == nil {
|
||||
func (w *Writer) awaitWriterElection(ctx context.Context, nc *nats.Conn, holder string) (func(), error) {
|
||||
if nc == nil {
|
||||
return func() {}, nil
|
||||
}
|
||||
tick := time.NewTicker(delegateLockPoll)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
if release, ok := reconcilelock.AcquireLeader(ctx, lease); ok {
|
||||
if release, ok := reconcilelock.AcquireLeader(ctx, nc, KVBucketDNSReconcile, holder); ok {
|
||||
return release, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("another writer has the zone: %w", ctx.Err())
|
||||
return nil, fmt.Errorf("another node is writing the zone: %w", ctx.Err())
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
|
||||
w, _ := newMultiNodeTestWriter(t)
|
||||
_, err := w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net", "B.r53e2e.spx3.net."}, false)
|
||||
_, err := w.Delegate(t.Context(), nil, "node1", []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, []string{"a.r53e2e.spx3.net"}, true)
|
||||
_, err = w.Delegate(t.Context(), nil, "node1", []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, []string{"a.r53e2e.spx3.net"}, false)
|
||||
_, err := w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
w.nameservers = w.nameservers[:1]
|
||||
_, err = w.Delegate(t.Context(), nil, []string{"a.r53e2e.spx3.net"}, true)
|
||||
_, err = w.Delegate(t.Context(), nil, "node1", []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, []string{"ok.spx3.net", name}, false)
|
||||
_, err := w.Delegate(t.Context(), nil, "node1", []string{"ok.spx3.net", name}, false)
|
||||
require.Error(t, err, name)
|
||||
}
|
||||
servers, _, _, err := w.PlatformDelegation("ok.spx3.net")
|
||||
@@ -76,45 +76,22 @@ func TestDelegateRefusesPlatformAndForeignNames(t *testing.T) {
|
||||
|
||||
func TestDelegateWaitsForTheZoneWriterElection(t *testing.T) {
|
||||
w, _ := newMultiNodeTestWriter(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
prev := delegateLockPoll
|
||||
delegateLockPoll = 10 * time.Millisecond
|
||||
t.Cleanup(func() { delegateLockPoll = prev })
|
||||
|
||||
release, ok := reconcilelock.AcquireLeader(t.Context(), dnsLease(js, "node2"))
|
||||
release, ok := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "node2")
|
||||
require.True(t, ok)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
node1 := dnsLease(js, "node1")
|
||||
_, err := w.Delegate(ctx, node1, []string{"a.spx3.net"}, false)
|
||||
require.ErrorContains(t, err, "another writer has the zone")
|
||||
_, err := w.Delegate(ctx, nc, "node1", []string{"a.spx3.net"}, false)
|
||||
require.ErrorContains(t, err, "another node is writing the zone")
|
||||
|
||||
release()
|
||||
_, err = w.Delegate(t.Context(), node1, []string{"a.spx3.net"}, false)
|
||||
_, err = w.Delegate(t.Context(), nc, "node1", []string{"a.spx3.net"}, false)
|
||||
require.NoError(t, err)
|
||||
servers, _, _, err := w.PlatformDelegation("a.spx3.net")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, servers, 2)
|
||||
}
|
||||
|
||||
// The reconcile pass and a delegation on one node share the DNS lease, so a
|
||||
// delegation waits for this node's own pass as it would for another node's.
|
||||
func TestDelegateWaitsForThisNodesOwnReconcilePass(t *testing.T) {
|
||||
w, _ := newMultiNodeTestWriter(t)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
prev := delegateLockPoll
|
||||
delegateLockPoll = 10 * time.Millisecond
|
||||
t.Cleanup(func() { delegateLockPoll = prev })
|
||||
|
||||
shared := dnsLease(js, "node1")
|
||||
release, ok := reconcilelock.AcquireLeader(t.Context(), shared)
|
||||
require.True(t, ok, "the reconcile pass holds the lease")
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := w.Delegate(ctx, shared, []string{"a.spx3.net"}, false)
|
||||
require.ErrorContains(t, err, "another writer has the zone")
|
||||
|
||||
release()
|
||||
_, err = w.Delegate(t.Context(), shared, []string{"a.spx3.net"}, false)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DefaultReconcileInterval is how often the drift backstop rebuilds managed
|
||||
@@ -33,12 +33,6 @@ 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.
|
||||
@@ -108,8 +102,7 @@ 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
|
||||
// lease elects the one node that writes the zones; nil skips the election.
|
||||
lease *kvsync.Lease
|
||||
nc *nats.Conn
|
||||
// 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
|
||||
@@ -118,6 +111,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -127,15 +121,18 @@ 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, lease *kvsync.Lease, writer *Writer, desired DesiredFunc, sources ...reconciler.Source) *Reconciler {
|
||||
func NewReconciler(cfg *config.Config, cluster *config.ClusterConfig, nc *nats.Conn, writer *Writer, desired DesiredFunc, sources ...reconciler.Source) *Reconciler {
|
||||
r := &Reconciler{
|
||||
lease: lease,
|
||||
nc: nc,
|
||||
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
|
||||
@@ -169,24 +166,25 @@ 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) {
|
||||
return r.reconcileOnce(ctx), nil
|
||||
r.reconcileOnce(ctx)
|
||||
return 0, nil
|
||||
},
|
||||
Resync: r.interval,
|
||||
})
|
||||
}
|
||||
|
||||
// reconcileOnce computes the converging batch and applies it, on whichever node
|
||||
// wins this cycle's election, and returns the election's revisit when it lost. The read that finds the stale records and the
|
||||
// wins this cycle's election. 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) time.Duration {
|
||||
func (r *Reconciler) reconcileOnce(ctx context.Context) {
|
||||
if !r.enabled {
|
||||
return 0
|
||||
return
|
||||
}
|
||||
if r.lease != nil {
|
||||
release, retry, elected := reconcilelock.ElectPass(ctx, r.lease)
|
||||
if r.nc != nil {
|
||||
release, elected := reconcilelock.AcquireLeader(ctx, r.nc, KVBucketDNSReconcile, r.holder)
|
||||
if !elected {
|
||||
return retry
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
@@ -195,19 +193,18 @@ func (r *Reconciler) reconcileOnce(ctx context.Context) time.Duration {
|
||||
// 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 0
|
||||
return
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return 0
|
||||
return
|
||||
}
|
||||
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 0
|
||||
return
|
||||
}
|
||||
slog.Debug("dns reconcile: converged", "changes", res.Applied, "zones", res.Zones)
|
||||
return 0
|
||||
}
|
||||
|
||||
// computeBatchBounded runs computeBatch with a deadline, so a pass that never
|
||||
|
||||
@@ -8,50 +8,41 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
r := reconcilerFor(w, desired)
|
||||
r.lease = lease
|
||||
r.nc = nc
|
||||
r.holder = holder
|
||||
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) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
_, nc, _ := 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(), dnsLease(js, "peer"))
|
||||
release, elected := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "peer")
|
||||
require.True(t, elected, "the peer has to hold the lease or this test proves nothing")
|
||||
|
||||
r := electingReconcilerFor(w, dnsLease(js, "node1"), func() DesiredSet {
|
||||
r := electingReconcilerFor(w, nc, "node1", func() DesiredSet {
|
||||
return DesiredSet{Changes: []Change{upsert("lb-1.elb."+testBase, "10.200.1.9")}}
|
||||
})
|
||||
revisit := r.reconcileOnce(t.Context())
|
||||
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()
|
||||
}
|
||||
@@ -59,10 +50,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) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
w, objects := newTestWriter(t)
|
||||
|
||||
r := electingReconcilerFor(w, dnsLease(js, "node1"), func() DesiredSet {
|
||||
r := electingReconcilerFor(w, nc, "node1", func() DesiredSet {
|
||||
return DesiredSet{Changes: []Change{upsert("lb-1.elb."+testBase, "10.200.1.9")}}
|
||||
})
|
||||
r.reconcileOnce(t.Context())
|
||||
|
||||
@@ -224,7 +224,7 @@ func TestReconciler_RunReturnsWhenDisabled(t *testing.T) {
|
||||
// path of Run: the startup pass must fire before any watch or resync activity,
|
||||
// and cancelling ctx must still let the loop return.
|
||||
func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
endpoint, _ := fakeS3(t, "northstar")
|
||||
|
||||
var passes atomic.Int64
|
||||
@@ -235,7 +235,8 @@ func TestReconciler_RunPerformsStartupPassThenStopsOnCancel(t *testing.T) {
|
||||
Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1",
|
||||
AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET",
|
||||
},
|
||||
lease: dnsLease(js, "test-node"),
|
||||
nc: nc,
|
||||
holder: "test-node",
|
||||
desired: func() DesiredSet {
|
||||
passes.Add(1)
|
||||
return DesiredSet{}
|
||||
@@ -267,7 +268,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) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
endpoint, _ := fakeS3(t, "northstar")
|
||||
|
||||
blocked := make(chan struct{})
|
||||
@@ -280,7 +281,8 @@ func TestReconcileOnce_AStuckPassSurrendersLeadership(t *testing.T) {
|
||||
Endpoint: endpoint, Bucket: "northstar", Region: "us-east-1",
|
||||
AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET",
|
||||
},
|
||||
lease: dnsLease(js, "stuck-node"),
|
||||
nc: nc,
|
||||
holder: "stuck-node",
|
||||
computeTimeout: 100 * time.Millisecond,
|
||||
desired: func() DesiredSet {
|
||||
<-blocked
|
||||
@@ -300,7 +302,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(), dnsLease(js, "next-node"))
|
||||
release, elected := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "next-node")
|
||||
require.True(t, elected, "another node must be able to take the next cycle")
|
||||
release()
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ const (
|
||||
// PUT so every northstar instance reloads just that zone immediately, instead
|
||||
// of waiting for the next S3 sync poll. No queue group: all servers consume.
|
||||
SubjectZoneReload = "dns.zone.reload"
|
||||
// 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"
|
||||
// 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"
|
||||
// PrivateZone is the fixed AWS-parity private DNS zone (IMDS synthHostname).
|
||||
PrivateZone = "compute.internal"
|
||||
// DefaultTTL is applied when a change omits a TTL.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 kvstore.IsRevisionConflict(writeErr):
|
||||
case kvutil.IsRevisionConflict(writeErr):
|
||||
continue
|
||||
default:
|
||||
slog.ErrorContext(ctx, "Failed to write sealed account settings record", "accountID", accountID, "err", writeErr)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
ebsencryption "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/encryption"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketAccountSettings, 10)
|
||||
settingsKV, err := kvutil.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, kvstore.ErrRead):
|
||||
case errors.Is(err, kvutil.ErrRead):
|
||||
slog.ErrorContext(ctx, "Failed to read account settings from KV", "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvstore.ErrDecode):
|
||||
case errors.Is(err, kvutil.ErrDecode):
|
||||
slog.ErrorContext(ctx, "Corrupt account settings record in KV", "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvstore.ErrEncode):
|
||||
case errors.Is(err, kvutil.ErrEncode):
|
||||
slog.ErrorContext(ctx, "Failed to marshal account settings record", "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvstore.ErrWrite):
|
||||
case errors.Is(err, kvutil.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 := kvstore.Update(ctx, s.settingsKV, settingsKey(accountID), kvstore.CASConfig{
|
||||
_, err := kvutil.Update(ctx, s.settingsKV, settingsKey(accountID), kvutil.CASConfig{
|
||||
CreateIfAbsent: true,
|
||||
Exhausted: func(string, int) error { return errSettingsContended },
|
||||
}, func(rec *AccountSettingsRecord) (bool, error) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
@@ -65,6 +66,20 @@ func ModifyInstanceAttribute(ctx context.Context, input *ec2.ModifyInstanceAttri
|
||||
|
||||
slog.InfoContext(ctx, "ModifyInstanceAttribute: Processing request", "instance_id", *input.InstanceId)
|
||||
|
||||
// Termination protection on a running instance lives on its owner, so it
|
||||
// goes there first. A nil value is not a value and keeps the queue path.
|
||||
if input.DisableApiTermination != nil && input.DisableApiTermination.Value != nil {
|
||||
cmd := ec2v1.EC2InstanceCommand{
|
||||
ID: *input.InstanceId,
|
||||
Attributes: ec2v1.EC2CommandAttributes{SetDisableApiTermination: true},
|
||||
DisableApiTerminationData: &ec2v1.DisableApiTerminationData{Value: *input.DisableApiTermination.Value},
|
||||
}
|
||||
if _, err := routeInstanceMutation[ec2.ModifyInstanceAttributeOutput](ctx, natsConn, *input.InstanceId, accountID, cmd, "ec2.ModifyInstanceAttribute", input); err != nil {
|
||||
return ec2.ModifyInstanceAttributeOutput{}, err
|
||||
}
|
||||
return ec2.ModifyInstanceAttributeOutput{}, nil
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "ModifyInstanceAttribute: Failed to marshal request", "instance_id", *input.InstanceId, "err", err)
|
||||
|
||||
@@ -3,10 +3,13 @@ package instance
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -226,3 +229,53 @@ func TestModifyInstanceAttribute_ValidationFailure(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
}
|
||||
|
||||
func TestModifyInstanceAttribute_DisableApiTerminationRouting(t *testing.T) {
|
||||
const id = "i-0123456789abcdef0"
|
||||
_, nc := startTestNATSServer(t)
|
||||
|
||||
var gotValues []bool
|
||||
var mu sync.Mutex
|
||||
_, err := nc.Subscribe(ec2v1.InstanceCommandSubject(id), func(msg *nats.Msg) {
|
||||
var cmd ec2v1.EC2InstanceCommand
|
||||
if json.Unmarshal(msg.Data, &cmd) == nil && cmd.Attributes.SetDisableApiTermination && cmd.DisableApiTerminationData != nil {
|
||||
mu.Lock()
|
||||
gotValues = append(gotValues, cmd.DisableApiTerminationData.Value)
|
||||
mu.Unlock()
|
||||
}
|
||||
reply := nats.NewMsg(msg.Reply)
|
||||
reply.Data = []byte(`{}`)
|
||||
reply.Header.Set(ec2v1.HandledHeader, "1")
|
||||
_ = msg.RespondMsg(reply)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
var queue atomic.Int32
|
||||
_, err = nc.Subscribe("ec2.ModifyInstanceAttribute", func(msg *nats.Msg) {
|
||||
queue.Add(1)
|
||||
_ = msg.Respond([]byte(`{}`))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, v := range []bool{true, false} {
|
||||
_, err := ModifyInstanceAttribute(context.Background(), &ec2.ModifyInstanceAttributeInput{
|
||||
InstanceId: aws.String(id),
|
||||
DisableApiTermination: &ec2.AttributeBooleanValue{Value: aws.Bool(v)},
|
||||
}, nc, "acct")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
mu.Lock()
|
||||
assert.Equal(t, []bool{true, false}, gotValues, "explicit true and false both reach the owner")
|
||||
mu.Unlock()
|
||||
assert.Zero(t, queue.Load())
|
||||
|
||||
// A nil value is not a value: queue path, no owner command.
|
||||
_, err = ModifyInstanceAttribute(context.Background(), &ec2.ModifyInstanceAttributeInput{
|
||||
InstanceId: aws.String(id),
|
||||
DisableApiTermination: &ec2.AttributeBooleanValue{},
|
||||
}, nc, "acct")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(1), queue.Load())
|
||||
mu.Lock()
|
||||
assert.Len(t, gotValues, 2, "no owner command for a nil value")
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ package instance
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
@@ -22,5 +22,16 @@ func ModifyInstanceMetadataOptions(ctx context.Context, input *ec2.ModifyInstanc
|
||||
if !strings.HasPrefix(*input.InstanceId, "i-") {
|
||||
return nil, errors.New(awserrors.ErrorInvalidInstanceIDMalformed)
|
||||
}
|
||||
return natsmsg.NATSRequest[ec2.ModifyInstanceMetadataOptionsOutput](ctx, natsConn, "ec2.ModifyInstanceMetadataOptions", input, 30*time.Second, accountID)
|
||||
cmd := ec2v1.EC2InstanceCommand{
|
||||
ID: *input.InstanceId,
|
||||
Attributes: ec2v1.EC2CommandAttributes{ModifyInstanceMetadataOptions: true},
|
||||
InstanceMetadataOptionsData: &ec2v1.InstanceMetadataOptionsData{
|
||||
HttpTokens: aws.StringValue(input.HttpTokens),
|
||||
HttpEndpoint: aws.StringValue(input.HttpEndpoint),
|
||||
HttpProtocolIpv6: aws.StringValue(input.HttpProtocolIpv6),
|
||||
InstanceMetadataTags: aws.StringValue(input.InstanceMetadataTags),
|
||||
HttpPutResponseHopLimit: input.HttpPutResponseHopLimit,
|
||||
},
|
||||
}
|
||||
return routeInstanceMutation[ec2.ModifyInstanceMetadataOptionsOutput](ctx, natsConn, *input.InstanceId, accountID, cmd, "ec2.ModifyInstanceMetadataOptions", input)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,16 @@ package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -30,3 +35,179 @@ func TestModifyInstanceMetadataOptions_RejectsMalformedID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const metaTestID = "i-0123456789abcdef0"
|
||||
|
||||
func metaInput(hop int64) *ec2.ModifyInstanceMetadataOptionsInput {
|
||||
return &ec2.ModifyInstanceMetadataOptionsInput{
|
||||
InstanceId: aws.String(metaTestID),
|
||||
HttpPutResponseHopLimit: aws.Int64(hop),
|
||||
}
|
||||
}
|
||||
|
||||
// ownerReply answers one ec2.cmd request, optionally carrying the handled marker.
|
||||
func ownerReply(t *testing.T, nc *nats.Conn, handled bool, body []byte, calls *atomic.Int32) {
|
||||
t.Helper()
|
||||
_, err := nc.Subscribe(ec2v1.InstanceCommandSubject(metaTestID), func(msg *nats.Msg) {
|
||||
calls.Add(1)
|
||||
reply := nats.NewMsg(msg.Reply)
|
||||
reply.Data = body
|
||||
if handled {
|
||||
reply.Header.Set(ec2v1.HandledHeader, "1")
|
||||
}
|
||||
_ = msg.RespondMsg(reply)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func queueReply(t *testing.T, nc *nats.Conn, body []byte, calls *atomic.Int32) {
|
||||
t.Helper()
|
||||
_, err := nc.QueueSubscribe("ec2.ModifyInstanceMetadataOptions", "spinifex-workers", func(msg *nats.Msg) {
|
||||
calls.Add(1)
|
||||
_ = msg.Respond(body)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func metaOutputJSON(t *testing.T, hop int64) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(&ec2.ModifyInstanceMetadataOptionsOutput{
|
||||
InstanceId: aws.String(metaTestID),
|
||||
InstanceMetadataOptions: &ec2.InstanceMetadataOptionsResponse{HttpPutResponseHopLimit: aws.Int64(hop)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestModifyInstanceMetadataOptions_OwnerAnswerReturnedAsIs(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
var owner, queue atomic.Int32
|
||||
ownerReply(t, nc, true, metaOutputJSON(t, 3), &owner)
|
||||
queueReply(t, nc, []byte(`{}`), &queue)
|
||||
|
||||
out, err := ModifyInstanceMetadataOptions(context.Background(), metaInput(3), nc, "acct")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), aws.Int64Value(out.InstanceMetadataOptions.HttpPutResponseHopLimit))
|
||||
assert.Equal(t, int32(1), owner.Load())
|
||||
assert.Zero(t, queue.Load())
|
||||
}
|
||||
|
||||
func TestModifyInstanceMetadataOptions_OwnerErrorWithHeaderIsNotRetried(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
var owner, queue atomic.Int32
|
||||
ownerReply(t, nc, true, awserrors.GenerateErrorPayload(awserrors.ErrorServerInternal), &owner)
|
||||
queueReply(t, nc, metaOutputJSON(t, 3), &queue)
|
||||
|
||||
_, err := ModifyInstanceMetadataOptions(context.Background(), metaInput(3), nc, "acct")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
assert.Zero(t, queue.Load())
|
||||
}
|
||||
|
||||
func TestModifyInstanceMetadataOptions_OwnerValidationErrorRelayed(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
var owner, queue atomic.Int32
|
||||
ownerReply(t, nc, true, awserrors.GenerateErrorPayload(awserrors.ErrorUnsupportedOperation), &owner)
|
||||
queueReply(t, nc, metaOutputJSON(t, 3), &queue)
|
||||
|
||||
_, err := ModifyInstanceMetadataOptions(context.Background(), metaInput(3), nc, "acct")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorUnsupportedOperation, err.Error())
|
||||
assert.Zero(t, queue.Load())
|
||||
}
|
||||
|
||||
func TestModifyInstanceMetadataOptions_HeaderlessOtherErrorReturnedAsIs(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
var owner, queue atomic.Int32
|
||||
ownerReply(t, nc, false, awserrors.GenerateErrorPayload(awserrors.ErrorInvalidInstanceIDNotFound), &owner)
|
||||
queueReply(t, nc, metaOutputJSON(t, 3), &queue)
|
||||
|
||||
_, err := ModifyInstanceMetadataOptions(context.Background(), metaInput(3), nc, "acct")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, err.Error())
|
||||
assert.Zero(t, queue.Load())
|
||||
}
|
||||
|
||||
func TestS9_N1OwnerRejectionFallsBackOnceToQueueGroup(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
var owner, queue atomic.Int32
|
||||
ownerReply(t, nc, false, awserrors.GenerateErrorPayload(awserrors.ErrorServerInternal), &owner)
|
||||
queueReply(t, nc, metaOutputJSON(t, 3), &queue)
|
||||
|
||||
out, err := ModifyInstanceMetadataOptions(context.Background(), metaInput(3), nc, "acct")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), aws.Int64Value(out.InstanceMetadataOptions.HttpPutResponseHopLimit))
|
||||
assert.Equal(t, int32(1), owner.Load(), "exactly one owner request")
|
||||
assert.Equal(t, int32(1), queue.Load(), "exactly one queue-group request")
|
||||
}
|
||||
|
||||
func TestModifyInstanceMetadataOptions_NoOwnerUsesQueueGroupAfterThreeAttempts(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
var sleeps atomic.Int32
|
||||
prev := cmdRetrySleep
|
||||
cmdRetrySleep = func(time.Duration) { sleeps.Add(1) }
|
||||
t.Cleanup(func() { cmdRetrySleep = prev })
|
||||
|
||||
var queue atomic.Int32
|
||||
queueReply(t, nc, metaOutputJSON(t, 2), &queue)
|
||||
|
||||
out, err := ModifyInstanceMetadataOptions(context.Background(), metaInput(2), nc, "acct")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), aws.Int64Value(out.InstanceMetadataOptions.HttpPutResponseHopLimit))
|
||||
assert.Equal(t, int32(2), sleeps.Load(), "three attempts means two backoffs")
|
||||
assert.Equal(t, int32(1), queue.Load())
|
||||
}
|
||||
|
||||
func TestModifyInstanceMetadataOptions_AbsentIsNotFound(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
var queue atomic.Int32
|
||||
queueReply(t, nc, awserrors.GenerateErrorPayload(awserrors.ErrorInvalidInstanceIDNotFound), &queue)
|
||||
|
||||
_, err := ModifyInstanceMetadataOptions(context.Background(), metaInput(2), nc, "acct")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, err.Error())
|
||||
}
|
||||
|
||||
func TestModifyInstanceMetadataOptions_OwnerTimeoutIsServerInternalWithoutFallback(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
prev := instanceCmdTimeout
|
||||
instanceCmdTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { instanceCmdTimeout = prev })
|
||||
|
||||
// The owner applies the change, then withholds its reply.
|
||||
var applied, queue atomic.Int32
|
||||
_, err := nc.Subscribe(ec2v1.InstanceCommandSubject(metaTestID), func(*nats.Msg) { applied.Add(1) })
|
||||
require.NoError(t, err)
|
||||
queueReply(t, nc, metaOutputJSON(t, 3), &queue)
|
||||
|
||||
_, err = ModifyInstanceMetadataOptions(context.Background(), metaInput(3), nc, "acct")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
assert.Equal(t, int32(1), applied.Load(), "the owner saw the command, so the outcome is unknown")
|
||||
assert.Zero(t, queue.Load(), "a timeout never falls back to the queue group")
|
||||
}
|
||||
|
||||
func TestModifyInstanceMetadataOptions_TimeoutAfterApplyIsUnknownNotRolledBack(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
prev := instanceCmdTimeout
|
||||
instanceCmdTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { instanceCmdTimeout = prev })
|
||||
|
||||
// The owner persists the change, then the reply is lost.
|
||||
var persisted atomic.Int64
|
||||
_, err := nc.Subscribe(ec2v1.InstanceCommandSubject(metaTestID), func(msg *nats.Msg) {
|
||||
var cmd ec2v1.EC2InstanceCommand
|
||||
if json.Unmarshal(msg.Data, &cmd) == nil && cmd.InstanceMetadataOptionsData != nil {
|
||||
persisted.Store(aws.Int64Value(cmd.InstanceMetadataOptionsData.HttpPutResponseHopLimit))
|
||||
}
|
||||
})
|
||||
require.NoError(t, err)
|
||||
var queue atomic.Int32
|
||||
queueReply(t, nc, metaOutputJSON(t, 1), &queue)
|
||||
|
||||
_, err = ModifyInstanceMetadataOptions(context.Background(), metaInput(3), nc, "acct")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
assert.Zero(t, queue.Load(), "no fallback that could race a live owner")
|
||||
assert.Equal(t, int64(3), persisted.Load(), "the gateway error does not undo what the owner stored")
|
||||
}
|
||||
|
||||
@@ -99,3 +99,46 @@ func classifyUnownedInstance(ctx context.Context, natsConn *nats.Conn, instanceI
|
||||
}
|
||||
return bucketStateAbsent, allOK
|
||||
}
|
||||
|
||||
// routeInstanceMutation sends cmd to the instance's owner first and falls back
|
||||
// to the queue-group subject only when that is safe: no daemon runs the
|
||||
// instance (ErrNoResponders after every retry), or the owner is N-1 and
|
||||
// rejected the command without writing (ServerInternal with no HandledHeader).
|
||||
// A timeout or other transport error is an unknown outcome and never falls back.
|
||||
func routeInstanceMutation[Out any](ctx context.Context, natsConn *nats.Conn, instanceID, accountID string, cmd ec2v1.EC2InstanceCommand, queueSubject string, queueInput any) (*Out, error) {
|
||||
queue := func() (*Out, error) {
|
||||
return natsmsg.NATSRequest[Out](ctx, natsConn, queueSubject, queueInput, 30*time.Second, accountID)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
msg, err := requestInstanceCmd(ctx, natsConn, instanceID, accountID, data)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrNoResponders) {
|
||||
return queue()
|
||||
}
|
||||
slog.ErrorContext(ctx, "Owner request failed, outcome unknown", "instance_id", instanceID, "subject", queueSubject, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
responseError, parseErr := awserrors.ValidateErrorPayload(msg.Data)
|
||||
if parseErr != nil {
|
||||
handled := msg.Header.Get(ec2v1.HandledHeader) == "1"
|
||||
if !handled && *responseError.Code == awserrors.ErrorServerInternal {
|
||||
slog.WarnContext(ctx, "Owner did not recognise the command, using the queue group", "instance_id", instanceID, "subject", queueSubject)
|
||||
return queue()
|
||||
}
|
||||
if responseError.Message != nil && *responseError.Message != "" {
|
||||
return nil, awserrors.Errorf(*responseError.Code, "%s", *responseError.Message)
|
||||
}
|
||||
return nil, errors.New(*responseError.Code)
|
||||
}
|
||||
|
||||
var out Out
|
||||
if err := json.Unmarshal(msg.Data, &out); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
@@ -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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketEgressOnlyIGW, 10)
|
||||
eigwKV, err := kvutil.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 := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
|
||||
vpcKV, err := kvutil.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, kvstore.AccountKey(accountID, *input.VpcId)); err != nil {
|
||||
if _, err := s.vpcKV.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, eigwID), data); err != nil {
|
||||
if _, err := s.eigwKV.Put(ctx, kvutil.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 := kvstore.AccountKey(accountID, eigwID)
|
||||
key := kvutil.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 == kvstore.VersionKey {
|
||||
if key == kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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[kvstore.AccountKey(testAccountID, vpcID)] = []byte(`{"vpc_id":"` + vpcID + `","state":"available"}`)
|
||||
vpcEntries[kvutil.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{
|
||||
kvstore.AccountKey(testAccountID, vpcID): []byte(`{"vpc_id":"vpc-alpha456","state":"available"}`),
|
||||
kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.AccountKey(accountID, idempotencyKeyHash(key))
|
||||
kvKey := kvutil.AccountKey(accountID, idempotencyKeyHash(key))
|
||||
|
||||
if out := s.cachedAllocation(ctx, kvKey); out != nil {
|
||||
slog.WarnContext(ctx, "AllocateAddress: returning first result for a retried request",
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/projection"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/topology"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 != kvstore.VersionKey && strings.HasSuffix(k, suffix) {
|
||||
if k != kvutil.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 == kvstore.VersionKey || !strings.HasSuffix(k, suffix) {
|
||||
if k == kvutil.VersionKey || !strings.HasSuffix(k, suffix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.eipKV.Get(ctx, k)
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketEIPs, 10)
|
||||
eipKV, err := kvutil.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 := kvstore.GetOrCreateBucketWithTTL(ctx, js, KVBucketEIPIdempotency, 1, eipIdempotencyTTL)
|
||||
idemKV, err := kvutil.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, kvstore.AccountKey(accountID, allocID), data); err != nil {
|
||||
if _, err := s.eipKV.Put(ctx, kvutil.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 := kvstore.AccountKey(accountID, allocID)
|
||||
key := kvutil.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 := kvstore.AccountKey(accountID, allocID)
|
||||
key := kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvutil.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 == kvstore.VersionKey {
|
||||
if k == kvutil.VersionKey {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
@@ -699,7 +699,7 @@ func (s *EIPServiceImpl) DescribeAddressesAttribute(ctx context.Context, input *
|
||||
if id == nil {
|
||||
continue
|
||||
}
|
||||
key := kvstore.AccountKey(accountID, *id)
|
||||
key := kvutil.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 == kvstore.VersionKey {
|
||||
if k == kvutil.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 == kvstore.VersionKey {
|
||||
if k == kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvutil.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.eipKV.Get(ctx, k)
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketIGW, 10)
|
||||
igwKV, err := kvutil.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 := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
|
||||
vpcKV, err := kvutil.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 := kvstore.GetOrCreateBucket(ctx, js, ec2eip.KVBucketEIPs, 10)
|
||||
eipKV, err := kvutil.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, kvstore.AccountKey(accountID, igwID), data); err != nil {
|
||||
if _, err := s.igwKV.Put(ctx, kvutil.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 := kvstore.AccountKey(accountID, igwID)
|
||||
key := kvutil.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 == kvstore.VersionKey {
|
||||
if key == kvutil.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 := kvstore.AccountKey(accountID, igwID)
|
||||
key := kvutil.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, kvstore.AccountKey(accountID, vpcID)); err != nil {
|
||||
if _, err := s.vpcKV.Get(ctx, kvutil.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 := kvstore.AccountKey(accountID, igwID)
|
||||
key := kvutil.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, kvstore.AccountKey(accountID, "*"))
|
||||
lister, err := s.eipKV.ListKeysFiltered(ctx, kvutil.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, kvstore.AccountKey(accountID, vpcID)); err != nil {
|
||||
if _, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID)); err != nil {
|
||||
return false, awserrors.IDNotFound(awserrors.ErrorInvalidVpcIDNotFound, "vpc", vpcID)
|
||||
}
|
||||
|
||||
key := kvstore.AccountKey(accountID, igwID)
|
||||
key := kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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 := kvstore.Update(ctx, kv, recordKey, kvstore.CASConfig{}, func(r *IGWRecord) (bool, error) {
|
||||
record, err := kvutil.Update(ctx, kv, recordKey, kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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[kvstore.AccountKey(testAccountID, vpcID)] = []byte(`{"vpc_id":"` + vpcID + `","state":"available"}`)
|
||||
vpcEntries[kvutil.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, kvstore.AccountKey(testAccountID, igwID), vpcID))
|
||||
require.NoError(t, MarkAttached(context.Background(), svc.igwKV, kvutil.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 := kvstore.AccountKey(testAccountID, igwID)
|
||||
key := kvutil.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 := kvstore.AccountKey(testAccountID, igwID)
|
||||
key := kvutil.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(), kvstore.AccountKey(testAccountID, rec.AllocationId), data)
|
||||
_, err = svc.eipKV.Put(context.Background(), kvutil.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(), kvstore.AccountKey("999999999999", "eipalloc-foreign"), foreign)
|
||||
_, err = svc.eipKV.Put(context.Background(), kvutil.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(), kvstore.AccountKey(testAccountID, vpcID), []byte(`{"vpc_id":"vpc-alpha123","state":"available"}`))
|
||||
_, err = vpcKV.Put(t.Context(), kvutil.AccountKey(testAccountID, vpcID), []byte(`{"vpc_id":"vpc-alpha123","state":"available"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Refresh service to pick up the VPC KV bucket
|
||||
|
||||
@@ -1265,10 +1265,6 @@ 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)
|
||||
}
|
||||
@@ -2764,7 +2760,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 := s.vmMgr.CheckUEFIVarStore(ctx, instance); err != nil {
|
||||
if err := vm.CheckUEFIVarStore(instance); err != nil {
|
||||
slog.ErrorContext(ctx, "launchClaimedInstance: refusing to launch a UEFI instance without exactly one variable store",
|
||||
"instanceId", instance.ID, "err", err)
|
||||
undo()
|
||||
@@ -2897,10 +2893,6 @@ 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,12 +1,33 @@
|
||||
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
|
||||
InstanceID string `json:"instance_id"`
|
||||
// State is the record's lifecycle state, such as running or terminated.
|
||||
State string
|
||||
State string `json:"state"`
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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"
|
||||
)
|
||||
@@ -25,9 +26,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. 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, 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.
|
||||
func (s *Store) MarkDeleting(ctx context.Context, instanceID string) (bool, error) {
|
||||
for range markAttempts {
|
||||
rec, rev, err := s.Get(ctx, instanceID)
|
||||
@@ -41,6 +42,7 @@ 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
|
||||
@@ -70,7 +72,7 @@ func (s *Store) InstanceIDs(ctx context.Context) ([]string, error) {
|
||||
|
||||
var ids []string
|
||||
for key := range lister.Keys() {
|
||||
if key != kvstore.VersionKey {
|
||||
if key != kvutil.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 late pointer move, must not bring the store back to live.
|
||||
// as a write-back retry, 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,7 +47,8 @@ func TestCompareAndSet_RefusesDeletingToLive(t *testing.T) {
|
||||
rev, err = s.CompareAndSet(t.Context(), deleting, rev)
|
||||
require.NoError(t, err)
|
||||
|
||||
revived := pointedRecord()
|
||||
revived := liveRecord()
|
||||
revived.WriteBack = writeBack()
|
||||
_, err = s.CompareAndSet(t.Context(), revived, rev)
|
||||
require.ErrorIs(t, err, uefistore.ErrDeleting)
|
||||
|
||||
@@ -57,9 +58,24 @@ func TestCompareAndSet_RefusesDeletingToLive(t *testing.T) {
|
||||
assert.Equal(t, uefistore.StateDeleting, got.State)
|
||||
}
|
||||
|
||||
func TestMarkDeleting_MovesLiveToDeletingAndKeepsWhatItNames(t *testing.T) {
|
||||
// A write-back owed on a deleting store would put an object behind the delete.
|
||||
func TestCompareAndSet_RefusesDeletingWithAWriteBack(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
|
||||
has, err := s.MarkDeleting(t.Context(), instanceID)
|
||||
@@ -69,8 +85,8 @@ func TestMarkDeleting_MovesLiveToDeletingAndKeepsWhatItNames(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)
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
package uefistore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
)
|
||||
|
||||
// ClaimPrefix is the kvsync prefix of every store's claim. An instance's claim
|
||||
// is ClaimPrefix+instanceID, and names the node that may hold variable changes
|
||||
// no object has yet.
|
||||
const ClaimPrefix = "uefi."
|
||||
|
||||
// NewClaims returns node's group of store claims over store. The daemon builds
|
||||
// it once per process, as kvsync requires of every prefix.
|
||||
func NewClaims(store kvsync.KVStore, node string) *kvsync.ClaimGroup {
|
||||
return kvsync.NewClaimGroup(store, ClaimPrefix, node)
|
||||
}
|
||||
|
||||
// ErrFenced is a stamp or pointer move refused because the record carries a
|
||||
// claim generation other than the caller's: a later acquisition has stamped
|
||||
// it, so the caller no longer owns the store and must change nothing.
|
||||
var ErrFenced = errors.New("uefistore: store record is fenced to another claim generation")
|
||||
|
||||
// fenceAttempts bounds the read-then-write loops of Stamp and MovePointer. A
|
||||
// lost race is re-read, so a concurrent stamp is seen and refused, not retried.
|
||||
const fenceAttempts = 5
|
||||
|
||||
// Stamp raises instanceID's record to generation, the claim token the caller
|
||||
// has just acquired or adopted, and returns the record it stamped, whose
|
||||
// pointer the caller boots from. One above generation is ErrFenced, and one
|
||||
// deleting is ErrDeleting. A record already at generation is still written by
|
||||
// CAS, because a follower can answer the read with an older pointer.
|
||||
func (s *Store) Stamp(ctx context.Context, instanceID string, generation uint64) (*UEFIStoreRecord, error) {
|
||||
if generation == 0 {
|
||||
return nil, fmt.Errorf("uefistore: stamp %s: generation 0 is no claim", instanceID)
|
||||
}
|
||||
for range fenceAttempts {
|
||||
rec, rev, err := s.Get(ctx, instanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case rec.State == StateDeleting:
|
||||
return nil, fmt.Errorf("%w: %s", ErrDeleting, instanceID)
|
||||
case rec.Generation > generation:
|
||||
return nil, fmt.Errorf("%w: %s is at generation %d, above the claim's %d", ErrFenced, instanceID, rec.Generation, generation)
|
||||
}
|
||||
rec.Generation = generation
|
||||
_, err = s.CompareAndSet(ctx, rec, rev)
|
||||
if err == nil {
|
||||
return rec, nil
|
||||
}
|
||||
if !errors.Is(err, kvstore.ErrConflict) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("uefistore: stamp %s: %w after %d attempts", instanceID, kvstore.ErrConflict, fenceAttempts)
|
||||
}
|
||||
|
||||
// MovePointer points instanceID's record at ref only while the record is at
|
||||
// generation. A record at any other generation is ErrFenced and is left
|
||||
// unchanged. Every move is a CAS at the revision read, even to the current
|
||||
// key, so a read a follower answered stale cannot report success.
|
||||
func (s *Store) MovePointer(ctx context.Context, instanceID string, generation uint64, ref UEFIVarStoreRef) error {
|
||||
if err := ref.validate(instanceID); err != nil {
|
||||
return fmt.Errorf("%w: move %s: %w", ErrInvalidRecord, instanceID, err)
|
||||
}
|
||||
for range fenceAttempts {
|
||||
rec, rev, err := s.Get(ctx, instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case rec.State == StateDeleting:
|
||||
return fmt.Errorf("%w: %s", ErrDeleting, instanceID)
|
||||
case rec.Generation != generation:
|
||||
return fmt.Errorf("%w: %s is at generation %d, and the writer holds %d", ErrFenced, instanceID, rec.Generation, generation)
|
||||
}
|
||||
rec.Pointer = &ref
|
||||
_, err = s.CompareAndSet(ctx, rec, rev)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, kvstore.ErrConflict) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("uefistore: move %s: %w after %d attempts", instanceID, kvstore.ErrConflict, fenceAttempts)
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package uefistore_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/instance/uefistore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func revisionOf(t *testing.T, s *uefistore.Store) uint64 {
|
||||
t.Helper()
|
||||
_, rev, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
return rev
|
||||
}
|
||||
|
||||
func TestStamp_RaisesTheGenerationAndReturnsThePointerToBootFrom(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, err := s.Stamp(t.Context(), instanceID, 9)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint64(9), rec.Generation)
|
||||
assert.Equal(t, pointer("b"), rec.Pointer)
|
||||
|
||||
got, _, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rec, got, "the returned record is the one stored")
|
||||
}
|
||||
|
||||
// A daemon restart adopts its claim at the same generation. The stamp still
|
||||
// writes by CAS, so a pointer read from a lagging follower fails the write and
|
||||
// is read again rather than booted from.
|
||||
func TestStamp_AtTheRecordsGenerationConfirmsTheReadByCAS(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
before := revisionOf(t, s)
|
||||
|
||||
rec, err := s.Stamp(t.Context(), instanceID, 7)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pointer("b"), rec.Pointer)
|
||||
assert.Equal(t, uint64(7), rec.Generation)
|
||||
assert.Greater(t, revisionOf(t, s), before, "the read must be confirmed by a revision-checked write")
|
||||
}
|
||||
|
||||
func TestStamp_RefusesAndChangesNothing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deleting bool
|
||||
generation uint64
|
||||
want error
|
||||
}{
|
||||
{name: "a later acquisition stamped it", generation: 6, want: uefistore.ErrFenced},
|
||||
{name: "the store is being deleted", deleting: true, generation: 8, want: uefistore.ErrDeleting},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
rev, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
if tt.deleting {
|
||||
_, err = s.MarkDeleting(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
rev = revisionOf(t, s)
|
||||
}
|
||||
|
||||
_, err = s.Stamp(t.Context(), instanceID, tt.generation)
|
||||
require.ErrorIs(t, err, tt.want)
|
||||
assert.Equal(t, rev, revisionOf(t, s))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStamp_MissingRecordIsNotFound(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Stamp(t.Context(), instanceID, 1)
|
||||
require.ErrorIs(t, err, kvstore.ErrNotFound)
|
||||
}
|
||||
|
||||
// The record keeps only the current version, so each move replaces the
|
||||
// pointer and names nothing it replaced.
|
||||
func TestMovePointer_ReplacesThePointer(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, name := range []string{"c", "d"} {
|
||||
require.NoError(t, s.MovePointer(t.Context(), instanceID, 7, *pointer(name)))
|
||||
}
|
||||
|
||||
got, _, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pointer("d"), got.Pointer)
|
||||
assert.Equal(t, uint64(7), got.Generation)
|
||||
}
|
||||
|
||||
// A move to the key the record already names is still a CAS at the revision
|
||||
// read, so a fenced writer that read a lagging follower cannot see success.
|
||||
func TestMovePointer_ToTheCurrentKeyConfirmsTheReadByCAS(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
before := revisionOf(t, s)
|
||||
|
||||
require.NoError(t, s.MovePointer(t.Context(), instanceID, 7, *pointer("b")))
|
||||
assert.Greater(t, revisionOf(t, s), before)
|
||||
}
|
||||
|
||||
func TestMovePointer_RefusesAndChangesNothing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deleting bool
|
||||
generation uint64
|
||||
ref *uefistore.UEFIVarStoreRef
|
||||
want error
|
||||
}{
|
||||
{name: "an earlier generation", generation: 6, ref: pointer("c"), want: uefistore.ErrFenced},
|
||||
{name: "a later generation than the stamp", generation: 8, ref: pointer("c"), want: uefistore.ErrFenced},
|
||||
{name: "a deleting store", deleting: true, generation: 7, ref: pointer("c"), want: uefistore.ErrDeleting},
|
||||
{name: "a key under another instance", generation: 7,
|
||||
ref: &uefistore.UEFIVarStoreRef{Key: uefistore.KeyPrefix("i-0fedcba9876543210") + "c", Firmware: pointer("c").Firmware, WriterNode: "n"},
|
||||
want: uefistore.ErrInvalidRecord},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
require.NoError(t, err)
|
||||
if tt.deleting {
|
||||
_, err = s.MarkDeleting(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
rev := revisionOf(t, s)
|
||||
|
||||
err = s.MovePointer(t.Context(), instanceID, tt.generation, *tt.ref)
|
||||
require.ErrorIs(t, err, tt.want)
|
||||
got, gotRev, err := s.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rev, gotRev)
|
||||
assert.Equal(t, pointer("b"), got.Pointer)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// claimedStore is a store record and a claim group per node over one
|
||||
// JetStream, as two daemons see them.
|
||||
type claimedStore struct {
|
||||
records *uefistore.Store
|
||||
a, b *kvsync.Claim
|
||||
}
|
||||
|
||||
func newClaimedStore(t *testing.T) *claimedStore {
|
||||
t.Helper()
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
records := uefistore.NewStore(js)
|
||||
require.NoError(t, records.Open(t.Context()))
|
||||
_, err := records.Create(t.Context(), liveRecord())
|
||||
require.NoError(t, err)
|
||||
return &claimedStore{
|
||||
records: records,
|
||||
a: uefistore.NewClaims(kvstore.OpenSyncBucket(js), "node-a").Get(instanceID),
|
||||
b: uefistore.NewClaims(kvstore.OpenSyncBucket(js), "node-b").Get(instanceID),
|
||||
}
|
||||
}
|
||||
|
||||
// A node partitioned long enough to be taken over is fenced by the new
|
||||
// owner's stamp: its pointer move fails and the record keeps what it had.
|
||||
func TestFence_TheOldHoldersMoveFailsAfterTheNewOwnersStamp(t *testing.T) {
|
||||
c := newClaimedStore(t)
|
||||
first, err := c.a.TryLock(t.Context())
|
||||
require.NoError(t, err)
|
||||
_, err = c.records.Stamp(t.Context(), instanceID, first.Generation)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.records.MovePointer(t.Context(), instanceID, first.Generation, *pointer("b")))
|
||||
|
||||
second, err := c.b.TakeOver(t.Context(), first)
|
||||
require.NoError(t, err)
|
||||
_, err = c.records.Stamp(t.Context(), instanceID, second.Generation)
|
||||
require.NoError(t, err)
|
||||
rev := revisionOf(t, c.records)
|
||||
|
||||
err = c.records.MovePointer(t.Context(), instanceID, first.Generation, *pointer("late"))
|
||||
require.ErrorIs(t, err, uefistore.ErrFenced)
|
||||
got, gotRev, err := c.records.Get(t.Context(), instanceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rev, gotRev)
|
||||
assert.Equal(t, pointer("b"), got.Pointer)
|
||||
assert.Equal(t, second.Generation, got.Generation)
|
||||
}
|
||||
|
||||
// The new owner reads the pointer after it stamps, so a move the old holder
|
||||
// landed between the takeover and the stamp is the one it boots from.
|
||||
func TestFence_AMoveBetweenTakeoverAndStampIsThePointerTheNewOwnerBoots(t *testing.T) {
|
||||
c := newClaimedStore(t)
|
||||
first, err := c.a.TryLock(t.Context())
|
||||
require.NoError(t, err)
|
||||
_, err = c.records.Stamp(t.Context(), instanceID, first.Generation)
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := c.b.TakeOver(t.Context(), first)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.records.MovePointer(t.Context(), instanceID, first.Generation, *pointer("landed")))
|
||||
|
||||
rec, err := c.records.Stamp(t.Context(), instanceID, second.Generation)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, keyOf("landed"), rec.Pointer.Key)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package uefistore holds the types that describe an instance's UEFI variable
|
||||
// store, 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.
|
||||
// store and the bucket of store records that carry its deletion and write-back
|
||||
// obligations past the life of the instance record.
|
||||
package uefistore
|
||||
|
||||
import (
|
||||
@@ -53,27 +53,22 @@ 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"`
|
||||
Generation uint64 `json:"generation"`
|
||||
Pointer *UEFIVarStoreRef `json:"pointer,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// KeyPrefix is the object-store prefix every store object of ownerID (an
|
||||
@@ -88,8 +83,8 @@ func ownsKey(ownerID, key string) bool {
|
||||
return ok && name != "" && !strings.Contains(name, "/")
|
||||
}
|
||||
|
||||
// validate checks rec against key. The pointer must name one object directly
|
||||
// under the instance's own prefix, because a deletion acts on it.
|
||||
// validate checks rec against key. A write-back key outside the instance's own
|
||||
// prefix is refused because a superseded obligation deletes that key.
|
||||
func (rec *UEFIStoreRecord) validate(key string) error {
|
||||
switch {
|
||||
case rec.SchemaVersion > RecordSchemaVersion:
|
||||
@@ -103,32 +98,29 @@ 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 p := rec.Pointer; p != nil {
|
||||
if err := p.validate(rec.InstanceID); err != nil {
|
||||
return fmt.Errorf("%w: %s pointer: %w", ErrInvalidRecord, key, err)
|
||||
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))
|
||||
}
|
||||
}
|
||||
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 (pointers and deletion obligations)",
|
||||
Description: "Spinifex UEFI variable store records (deletion and write-back obligations)",
|
||||
History: 1,
|
||||
// Left false: a lost bucket has lost obligations, and an empty
|
||||
// recreated one would report every store as having none.
|
||||
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
@@ -30,25 +31,15 @@ func liveRecord() *uefistore.UEFIStoreRecord {
|
||||
return &uefistore.UEFIStoreRecord{InstanceID: instanceID, AccountID: "111122223333", State: uefistore.StateLive}
|
||||
}
|
||||
|
||||
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 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 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)
|
||||
@@ -69,14 +60,15 @@ 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 := kvstore.ReadVersion(t.Context(), kv)
|
||||
v, err := kvutil.ReadVersion(t.Context(), kv)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uefistore.BucketVersion, v)
|
||||
}
|
||||
|
||||
func TestCreate_RoundTripsAndStampsSchemaVersion(t *testing.T) {
|
||||
s, _ := openStore(t)
|
||||
rec := pointedRecord()
|
||||
rec := liveRecord()
|
||||
rec.WriteBack = writeBack()
|
||||
|
||||
rev, err := s.Create(t.Context(), rec)
|
||||
require.NoError(t, err)
|
||||
@@ -91,7 +83,9 @@ 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)
|
||||
_, err := s.Create(t.Context(), pointedRecord())
|
||||
rec := liveRecord()
|
||||
rec.WriteBack = writeBack()
|
||||
_, err := s.Create(t.Context(), rec)
|
||||
require.NoError(t, err)
|
||||
|
||||
kv, err := js.KeyValue(t.Context(), uefistore.BucketName)
|
||||
@@ -103,12 +97,11 @@ func TestRecord_WireShape(t *testing.T) {
|
||||
"instance_id": "i-0123456789abcdef0",
|
||||
"account_id": "111122223333",
|
||||
"state": "live",
|
||||
"generation": 7,
|
||||
"pointer": {
|
||||
"write_back": {
|
||||
"node": "node-2",
|
||||
"key": "uefi/i-0123456789abcdef0/b",
|
||||
"firmware": {"arch": "x86_64", "vars_size": 540672, "code_sha256": "c0de"},
|
||||
"writer_node": "node-2",
|
||||
"written_at": "2026-11-03T01:02:03Z"
|
||||
"seeded_from": "uefi/i-0123456789abcdef0/a",
|
||||
"since": "2026-11-03T01:02:03Z"
|
||||
}
|
||||
}`, string(entry.Value()))
|
||||
}
|
||||
@@ -152,7 +145,8 @@ func TestCompareAndSet_StaleRevisionConflictsAndWritesNothing(t *testing.T) {
|
||||
first, err := s.Create(t.Context(), liveRecord())
|
||||
require.NoError(t, err)
|
||||
|
||||
moved := pointedRecord()
|
||||
moved := liveRecord()
|
||||
moved.WriteBack = writeBack()
|
||||
second, err := s.CompareAndSet(t.Context(), moved, first)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, second, first)
|
||||
@@ -166,7 +160,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, pointer("b"), got.Pointer)
|
||||
assert.Equal(t, writeBack(), got.WriteBack)
|
||||
}
|
||||
|
||||
// Two actors that read the same revision race to change it; exactly one may
|
||||
@@ -186,11 +180,12 @@ func TestCompareAndSet_ConcurrentWritersFromOneReadHaveOneWinner(t *testing.T) {
|
||||
)
|
||||
for i := range writers {
|
||||
wg.Go(func() {
|
||||
rec := pointedRecord()
|
||||
rec.Pointer.WriterNode = fmt.Sprintf("node-%d", i)
|
||||
rec := liveRecord()
|
||||
rec.WriteBack = writeBack()
|
||||
rec.WriteBack.Node = fmt.Sprintf("node-%d", i)
|
||||
if _, err := s.CompareAndSet(context.Background(), rec, rev); err == nil {
|
||||
mu.Lock()
|
||||
wins = append(wins, rec.Pointer.WriterNode)
|
||||
wins = append(wins, rec.WriteBack.Node)
|
||||
mu.Unlock()
|
||||
} else {
|
||||
assert.ErrorIs(t, err, kvstore.ErrConflict)
|
||||
@@ -202,7 +197,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.Pointer.WriterNode)
|
||||
assert.Equal(t, wins[0], got.WriteBack.Node)
|
||||
}
|
||||
|
||||
func TestCompareAndDelete_StaleRevisionKeepsRecord(t *testing.T) {
|
||||
@@ -223,20 +218,29 @@ func TestCompareAndDelete_StaleRevisionKeepsRecord(t *testing.T) {
|
||||
require.ErrorIs(t, err, kvstore.ErrNotFound)
|
||||
}
|
||||
|
||||
// A deletion acts on the key the record points at, so a key outside the
|
||||
// A superseded obligation deletes its recorded key, 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" },
|
||||
"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 = "" },
|
||||
"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"
|
||||
},
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
@@ -244,7 +248,8 @@ func TestCompareAndSet_RefusesInvalidRecords(t *testing.T) {
|
||||
rev, err := s.Create(t.Context(), liveRecord())
|
||||
require.NoError(t, err)
|
||||
|
||||
rec := pointedRecord()
|
||||
rec := liveRecord()
|
||||
rec.WriteBack = writeBack()
|
||||
mutate(rec)
|
||||
_, err = s.CompareAndSet(t.Context(), rec, rev)
|
||||
require.ErrorIs(t, err, uefistore.ErrInvalidRecord)
|
||||
@@ -252,7 +257,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.Pointer)
|
||||
assert.Nil(t, got.WriteBack)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -261,11 +266,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 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"}}`,
|
||||
"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"}}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
|
||||
@@ -102,38 +102,3 @@ 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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.Keys(ctx, s.kv)
|
||||
keys, err := kvutil.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 := kvstore.Keys(ctx, s.kv)
|
||||
keys, err := kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.Keys(ctx, s.kv)
|
||||
keys, err := kvutil.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/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"
|
||||
@@ -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(), kvstore.AccountKey(testAccountID, deleted)))
|
||||
require.NoError(t, inner.Delete(t.Context(), kvutil.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, 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, 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, nameKey(testAccountID, "unreadable"), "lt-0unreadable000000")
|
||||
putRaw(t, inner, kvstore.AccountKey(testAccountID, "lt-0bad.vx"), "{}")
|
||||
putRaw(t, inner, kvutil.AccountKey(testAccountID, "lt-0bad.vx"), "{}")
|
||||
|
||||
recorder := &readRecordingKV{KeyValue: readonlystore.NewKV(t, inner)}
|
||||
svc.kv = &faultyKV{KeyValue: recorder, failGet: func(key string) bool {
|
||||
return key == kvstore.AccountKey(testAccountID, "lt-0unreadable000000")
|
||||
return key == kvutil.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/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"
|
||||
@@ -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, kvstore.VersionKey), storedKeys(t, svc.kv))
|
||||
assert.Equal(t, sortedKeys(header, header+".v1", header+".v2", name, kvutil.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), kvstore.VersionKey)...), storedKeys(t, inner))
|
||||
assert.Equal(t, sortedKeys(append(tc.left(header, nameIdx), kvutil.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{kvstore.VersionKey}, storedKeys(t, inner))
|
||||
assert.Equal(t, []string{kvutil.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{kvstore.VersionKey}, storedKeys(t, inner))
|
||||
assert.Equal(t, []string{kvutil.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{kvstore.VersionKey}, storedKeys(t, inner))
|
||||
assert.Equal(t, []string{kvutil.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 := kvstore.AccountKey(testAccountID, brokenID)
|
||||
brokenHeader := kvutil.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 := kvstore.AccountKey(testAccountID, "lt-0broken000000000")
|
||||
brokenHeader := kvutil.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 := kvstore.AccountKey(testAccountID, id)
|
||||
header := kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketLaunchTemplates, 10)
|
||||
kv, err := kvutil.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 kvstore.AccountKey(accountID, "name."+hex.EncodeToString([]byte(name)))
|
||||
return kvutil.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 kvstore.AccountKey(accountID, ltID+".v"+strconv.FormatInt(n, 10))
|
||||
return kvutil.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 kvstore.AccountKey(accountID, ltID+".v")
|
||||
return kvutil.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, kvstore.AccountKey(accountID, ltID))
|
||||
entry, err := s.kv.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, string(entry.Value())))
|
||||
_, herr := s.kv.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, h.LaunchTemplateId), data); err != nil {
|
||||
if _, err := s.kv.Put(ctx, kvutil.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 := kvstore.AccountKey(accountID, ltID)
|
||||
key := kvutil.AccountKey(accountID, ltID)
|
||||
for range maxVersionRetries {
|
||||
_, err := s.kv.Update(ctx, key, entry.Value(), entry.Revision())
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !kvstore.IsRevisionConflict(err) {
|
||||
if !kvutil.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 := kvstore.AccountKey(accountID, header.LaunchTemplateId)
|
||||
key := kvutil.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 !kvstore.IsRevisionConflict(err) {
|
||||
if !kvutil.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 := kvstore.AccountKey(accountID, ltID)
|
||||
headerKey := kvutil.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, kvstore.AccountKey(accountID, ltID)); errors.Is(gerr, jetstream.ErrKeyNotFound) {
|
||||
if _, gerr := s.kv.Get(ctx, kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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(), kvstore.AccountKey(testAccountID, aws.StringValue(lt.LaunchTemplateId))))
|
||||
require.NoError(t, svc.kv.Delete(t.Context(), kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.AccountKey(testAccountID, id)
|
||||
header := kvutil.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 := kvstore.AccountKey(testAccountID, id)
|
||||
header := kvutil.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 := kvstore.AccountKey(testAccountID, id)
|
||||
header := kvutil.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 := kvstore.AccountKey(testAccountID, id)
|
||||
header := kvutil.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 == kvstore.AccountKey(testAccountID, id) }}
|
||||
svc.kv = &faultyKV{KeyValue: svc.kv, failGet: func(key string) bool { return key == kvutil.AccountKey(testAccountID, id) }}
|
||||
},
|
||||
"header undecodable": func(t *testing.T, svc *LaunchTemplateServiceImpl, _ string) {
|
||||
_, err := svc.kv.Put(t.Context(), kvstore.AccountKey(testAccountID, "lt-0broken000000000"), []byte("{not json"))
|
||||
_, err := svc.kv.Put(t.Context(), kvutil.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 := kvstore.AccountKey(testAccountID, id)
|
||||
header := kvutil.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 := kvstore.AccountKey(testAccountID, id)
|
||||
header := kvutil.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 := kvstore.AccountKey(testAccountID, id)
|
||||
header := kvutil.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 := kvstore.AccountKey(testAccountID, id)
|
||||
header := kvutil.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 := kvstore.AccountKey(testAccountID, id)
|
||||
header := kvutil.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 := kvstore.AccountKey(testAccountID, id)
|
||||
header := kvutil.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 := kvstore.AccountKey(testAccountID, id)
|
||||
header := kvutil.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 := kvstore.AccountKey(testAccountID, id)
|
||||
header := kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.Keys(ctx, s.kv)
|
||||
keys, err := kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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{
|
||||
kvstore.AccountKey(testAccountID, "rtb-routed"): fmt.Appendf(nil,
|
||||
kvutil.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(), kvstore.AccountKey(testAccountID, allocID))
|
||||
entry, err := svc.eipKV.Get(t.Context(), kvutil.AccountKey(testAccountID, allocID))
|
||||
require.NoError(t, err)
|
||||
var eip ec2eip.EIPRecord
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &eip))
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketNatGateways, 10)
|
||||
natgwKV, err := kvutil.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 := kvstore.GetOrCreateBucket(ctx, js, ec2eip.KVBucketEIPs, 10)
|
||||
eipKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2eip.KVBucketEIPs, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get EIP KV bucket: %w", err)
|
||||
}
|
||||
|
||||
subnetKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketSubnets, 10)
|
||||
subnetKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketSubnets, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get subnet KV bucket: %w", err)
|
||||
}
|
||||
|
||||
vpcKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
|
||||
vpcKV, err := kvutil.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 kvstore.GetOrCreateBucketWithOptions(ctx, js, kvstore.BucketOptions{
|
||||
return kvutil.GetOrCreateBucketWithOptions(ctx, js, kvutil.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, kvstore.AccountKey(accountID, subnetID))
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, allocID))
|
||||
eipEntry, err := s.eipKV.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, natgwID), data); err != nil {
|
||||
if _, err := s.natgwKV.Put(ctx, kvutil.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, kvstore.AccountKey(accountID, allocID), eipData, eipEntry.Revision()); err != nil {
|
||||
if _, err := s.eipKV.Update(ctx, kvutil.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 := kvstore.AccountKey(accountID, natgwID)
|
||||
key := kvutil.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 := kvstore.GetOrCreateBucket(ctx, js, kvBucketRouteTables, 10)
|
||||
rtbKV, err := kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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, kvstore.AccountKey(accountID, assoc.SubnetId))
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.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 := kvstore.AccountKey(accountID, record.AllocationId)
|
||||
key := kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@ func (s *NatGatewayServiceImpl) DescribeNatGateways(ctx context.Context, input *
|
||||
if foundIDs[id] {
|
||||
continue
|
||||
}
|
||||
key := kvstore.AccountKey(accountID, id)
|
||||
key := kvutil.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/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,18 +31,18 @@ func setupTestServiceJS(t *testing.T) (*NatGatewayServiceImpl, jetstream.JetStre
|
||||
|
||||
// Seed VPC KV
|
||||
testutil.SeedKV(t, js, ec2vpc.KVBucketVPCs, map[string][]byte{
|
||||
kvstore.AccountKey(testAccountID, "vpc-test1"): []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`),
|
||||
kvutil.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{
|
||||
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}`),
|
||||
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}`),
|
||||
})
|
||||
|
||||
// Seed EIP KV
|
||||
testutil.SeedKV(t, js, ec2eip.KVBucketEIPs, map[string][]byte{
|
||||
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"}`),
|
||||
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"}`),
|
||||
})
|
||||
|
||||
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(), kvstore.AccountKey(testAccountID, natgwID))
|
||||
_, err = svc.natgwKV.Get(t.Context(), kvutil.AccountKey(testAccountID, natgwID))
|
||||
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.Keys(ctx, s.kv)
|
||||
keys, err := kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.Keys(ctx, s.kv)
|
||||
keys, err := kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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/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"
|
||||
@@ -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(), kvstore.AccountKey(testAccountID, "undecodable"), []byte("{not json"))
|
||||
_, err = svc.kv.Put(t.Context(), kvutil.AccountKey(testAccountID, "undecodable"), []byte("{not json"))
|
||||
require.NoError(t, err)
|
||||
_, err = svc.kv.Put(t.Context(), kvstore.AccountKey(testAccountID, "anonymous"), []byte(`{"group_name":"anonymous"}`))
|
||||
_, err = svc.kv.Put(t.Context(), kvutil.AccountKey(testAccountID, "anonymous"), []byte(`{"group_name":"anonymous"}`))
|
||||
require.NoError(t, err)
|
||||
createTestGroup(t, svc, "unreadable", ec2.PlacementStrategySpread)
|
||||
require.Contains(t, bucketKeys(t, svc.kv), kvstore.VersionKey)
|
||||
require.Contains(t, bucketKeys(t, svc.kv), kvutil.VersionKey)
|
||||
|
||||
svc.kv = &hookedKV{KeyValue: readonlystore.NewKV(t, svc.kv), failGet: kvstore.AccountKey(testAccountID, "unreadable")}
|
||||
svc.kv = &hookedKV{KeyValue: readonlystore.NewKV(t, svc.kv), failGet: kvutil.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/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"
|
||||
@@ -84,7 +84,7 @@ func TestCurrentBehaviour_AccountTermination_PlacementGroupStorageLayout(t *test
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []string{testAccountID + ".spread-a", kvstore.VersionKey}, bucketKeys(t, svc.kv))
|
||||
assert.Equal(t, []string{testAccountID + ".spread-a", kvutil.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 := kvstore.AccountKey(testAccountID, "broken")
|
||||
brokenKey := kvutil.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 := kvstore.AccountKey(testAccountID, "cluster-a")
|
||||
key := kvutil.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(), kvstore.AccountKey(testAccountID, "broken"), []byte("{not json"))
|
||||
_, err := svc.kv.Put(t.Context(), kvutil.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(), kvstore.AccountKey(testAccountID, "broken"))
|
||||
_, err = svc.kv.Get(t.Context(), kvutil.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 := kvstore.AccountKey(testAccountID, "cluster-a")
|
||||
key := kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketPlacementGroups, 10)
|
||||
kv, err := kvutil.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 := kvstore.AccountKey(accountID, groupName)
|
||||
key := kvutil.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, kvstore.AccountKey(accountID, groupName)); err != nil {
|
||||
if _, err := s.deleteGroup(ctx, accountID, kvutil.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 := kvstore.DeleteIf(ctx, s.kv, key, casConfig(), func(record *PlacementGroupRecord) error {
|
||||
record, err := kvutil.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 == kvstore.VersionKey {
|
||||
if k == kvutil.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 kvstore outcomes that map to
|
||||
// errPGAbsent and errPGContended mark the two kvutil 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 kvstore failure into the AWS error the caller returns.
|
||||
// A non-kvstore error came from mutate and is already an AWS code.
|
||||
// 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.
|
||||
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, kvstore.ErrRead):
|
||||
case errors.Is(err, kvutil.ErrRead):
|
||||
slog.ErrorContext(ctx, "Failed to read placement group from KV",
|
||||
"op", op, "groupName", groupName, "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvstore.ErrDecode):
|
||||
case errors.Is(err, kvutil.ErrDecode):
|
||||
slog.ErrorContext(ctx, "Corrupt placement group record in KV",
|
||||
"op", op, "groupName", groupName, "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvstore.ErrEncode):
|
||||
case errors.Is(err, kvutil.ErrEncode):
|
||||
slog.ErrorContext(ctx, "Failed to marshal placement group record",
|
||||
"op", op, "groupName", groupName, "accountID", accountID, "err", err)
|
||||
case errors.Is(err, kvstore.ErrWrite):
|
||||
case errors.Is(err, kvutil.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() kvstore.CASConfig {
|
||||
return kvstore.CASConfig{
|
||||
func casConfig() kvutil.CASConfig {
|
||||
return kvutil.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 := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
_, err := kvutil.Update(ctx, s.kv, kvutil.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 := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
_, err := kvutil.Update(ctx, s.kv, kvutil.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 := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
_, err := kvutil.Update(ctx, s.kv, kvutil.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 := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
_, err := kvutil.Update(ctx, s.kv, kvutil.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 := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
_, err := kvutil.Update(ctx, s.kv, kvutil.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 := kvstore.Update(ctx, s.kv, kvstore.AccountKey(accountID, input.GroupName), casConfig(),
|
||||
_, err := kvutil.Update(ctx, s.kv, kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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(), kvstore.AccountKey(testAccountID, groupName))
|
||||
entry, err := svc.kv.Get(t.Context(), kvutil.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 := kvstore.Update(t.Context(), svc.kv, kvstore.AccountKey(testAccountID, groupName), kvstore.CASConfig{},
|
||||
_, err := kvutil.Update(t.Context(), svc.kv, kvutil.AccountKey(testAccountID, groupName), kvutil.CASConfig{},
|
||||
func(record *PlacementGroupRecord) (bool, error) {
|
||||
mutate(record)
|
||||
return true, nil
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketRouteTables, 10)
|
||||
rtbKV, err := kvutil.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 := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
|
||||
vpcKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketVPCs, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get VPC KV bucket: %w", err)
|
||||
}
|
||||
|
||||
igwKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2igw.KVBucketIGW, 10)
|
||||
igwKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2igw.KVBucketIGW, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get IGW KV bucket: %w", err)
|
||||
}
|
||||
|
||||
subnetKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketSubnets, 10)
|
||||
subnetKV, err := kvutil.GetOrCreateBucket(ctx, js, ec2vpc.KVBucketSubnets, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get subnet KV bucket: %w", err)
|
||||
}
|
||||
|
||||
natgwKV, err := kvstore.GetOrCreateBucket(ctx, js, ec2natgw.KVBucketNatGateways, 10)
|
||||
natgwKV, err := kvutil.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, kvstore.AccountKey(accountID, rtbID))
|
||||
entry, err := s.rtbKV.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, record.RouteTableId), data); err != nil {
|
||||
if _, err := s.rtbKV.Put(ctx, kvutil.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 kvstore outcomes that map to
|
||||
// errRTBAbsent and errRTBContended mark the two kvutil 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 := kvstore.Update(ctx, s.rtbKV, kvstore.AccountKey(accountID, rtbID), kvstore.CASConfig{
|
||||
_, err := kvutil.Update(ctx, s.rtbKV, kvutil.AccountKey(accountID, rtbID), kvutil.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, kvstore.ErrRead):
|
||||
case errors.Is(err, kvutil.ErrRead):
|
||||
slog.ErrorContext(ctx, "Failed to read route table from KV", "routeTableId", rtbID, "err", err)
|
||||
case errors.Is(err, kvstore.ErrDecode):
|
||||
case errors.Is(err, kvutil.ErrDecode):
|
||||
slog.ErrorContext(ctx, "Corrupt route table record in KV", "routeTableId", rtbID, "err", err)
|
||||
case errors.Is(err, kvstore.ErrEncode):
|
||||
case errors.Is(err, kvutil.ErrEncode):
|
||||
slog.ErrorContext(ctx, "Failed to marshal route table record", "routeTableId", rtbID, "err", err)
|
||||
case errors.Is(err, kvstore.ErrWrite):
|
||||
case errors.Is(err, kvutil.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, kvstore.AccountKey(accountID, vpcID))
|
||||
entry, err := s.vpcKV.Get(ctx, kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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, kvstore.AccountKey(accountID, rtbID)); err != nil {
|
||||
if err := s.rtbKV.Delete(ctx, kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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, kvstore.AccountKey(accountID, igwID))
|
||||
igwEntry, err := s.igwKV.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, natgwID))
|
||||
natgwEntry, err := s.natgwKV.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, igwID))
|
||||
igwEntry, err := s.igwKV.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, subnetID))
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1168,7 +1168,7 @@ func (s *RouteTableServiceImpl) ReplaceRouteTableAssociation(ctx context.Context
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
if key == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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, kvstore.AccountKey(accountID, natgwID))
|
||||
natgwEntry, err := s.natgwKV.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, r.NatGatewayId))
|
||||
natgwEntry, err := s.natgwKV.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, subnetID))
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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{
|
||||
kvstore.AccountKey(testAccountID, "vpc-test1"): []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`),
|
||||
kvutil.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{
|
||||
kvstore.AccountKey(testAccountID, "igw-test1"): []byte(`{"internet_gateway_id":"igw-test1","vpc_id":"vpc-test1","state":"available"}`),
|
||||
kvutil.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{
|
||||
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"}`),
|
||||
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"}`),
|
||||
})
|
||||
|
||||
// Seed NAT Gateway KV
|
||||
testutil.SeedKV(t, js, ec2natgw.KVBucketNatGateways, map[string][]byte{
|
||||
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"}`),
|
||||
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"}`),
|
||||
})
|
||||
|
||||
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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
)
|
||||
|
||||
// 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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
)
|
||||
|
||||
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(), kvstore.AccountKey(testAccountID, "sir-bad"), []byte("{not json"))
|
||||
_, err := svc.activeKV.Put(t.Context(), kvutil.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(), kvstore.AccountKey(testAccountID, "sir-empty"), []byte(`{"account_id":"x"}`))
|
||||
_, err := svc.terminalKV.Put(t.Context(), kvutil.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: kvstore.AccountKey(testAccountID, "sir-bbb")}
|
||||
svc.activeKV = failingGetKV{KeyValue: svc.activeKV, key: kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.Keys(ctx, s.activeKV)
|
||||
keys, err := kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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(), kvstore.AccountKey(testAccountID, "sir-broken01"), []byte("{not json"))
|
||||
_, err := svc.activeKV.Put(t.Context(), kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketSpotRequests, 10)
|
||||
activeKV, err := kvutil.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 kvstore.GetOrCreateBucketWithOptions(ctx, js, kvstore.BucketOptions{
|
||||
return kvutil.GetOrCreateBucketWithOptions(ctx, js, kvutil.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, kvstore.AccountKey(accountID, sirID), data); err != nil {
|
||||
if _, err := s.activeKV.Put(ctx, kvutil.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 := kvstore.AccountKey(accountID, sirID)
|
||||
key := kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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/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"
|
||||
@@ -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 := kvstore.AccountKey(acct, id)
|
||||
key := kvutil.AccountKey(acct, id)
|
||||
seed, err := json.Marshal(tagMirrorRecord{Name: "original", Tags: map[string]string{}})
|
||||
require.NoError(t, err)
|
||||
kv := seedKV(t, "tagmirror-cb", map[string][]byte{key: seed})
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.AccountKey(accountID, resourceID)
|
||||
_, err := kvstore.Update(ctx, kv, key, kvstore.CASConfig{NotFound: errRecordAbsent}, func(rec *R) (bool, error) {
|
||||
key := kvutil.AccountKey(accountID, resourceID)
|
||||
_, err := kvutil.Update(ctx, kv, key, kvutil.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/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"
|
||||
@@ -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{
|
||||
kvstore.AccountKey("acct", "res-1"): rec,
|
||||
kvstore.AccountKey("acct", "res-bad"): []byte("not-json"),
|
||||
kvutil.AccountKey("acct", "res-1"): rec,
|
||||
kvutil.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(), kvstore.AccountKey("acct", "res-1"))
|
||||
entry, err := kv.Get(t.Context(), kvutil.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{
|
||||
kvstore.AccountKey("acct", "vol-1"): withTags,
|
||||
kvstore.AccountKey("acct", "vol-2"): nilTags,
|
||||
kvutil.AccountKey("acct", "vol-1"): withTags,
|
||||
kvutil.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(), kvstore.AccountKey("acct", id))
|
||||
entry, err := kv.Get(t.Context(), kvutil.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(), kvstore.AccountKey("acct", "snap-other"), otherRec)
|
||||
_, err = kv.Put(t.Context(), kvutil.AccountKey("acct", "snap-other"), otherRec)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, MirrorKVRecordTags(t.Context(), kv, "acct", "vol-", resources, tagsOf,
|
||||
MergeTagsMut(&ec2.CreateTagsInput{Tags: []*ec2.Tag{
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsfilters "github.com/mulgadc/spinifex/spinifex/foundation/aws/filters"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.Keys(ctx, s.kv)
|
||||
keys, err := kvutil.Keys(ctx, s.kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, fmt.Errorf("list tag keys: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 kvstore.GetOrCreateBucket(ctx, js, KVBucketTags, kvTagsHistory)
|
||||
return kvutil.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 := kvstore.Update(ctx, s.kv, key, kvstore.CASConfig{
|
||||
_, err := kvutil.Update(ctx, s.kv, key, kvutil.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 kvstore.Put(ctx, s.kv, tagsKVKey(accountID, resourceID), kvstore.CASConfig{
|
||||
return kvutil.Put(ctx, s.kv, tagsKVKey(accountID, resourceID), kvutil.CASConfig{
|
||||
Attempts: tagCASAttempts,
|
||||
CreateIfAbsent: true,
|
||||
}, data)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
networkv1 "github.com/mulgadc/spinifex/contracts/network/v1"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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, kvstore.AccountKey(accountID, claim.VpcId)); err == nil {
|
||||
if _, err := s.vpcKV.Get(ctx, kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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, kvstore.AccountKey(accountID, claim.VpcId), VPCRecord{
|
||||
created, err := createRecord(ctx, s.vpcKV, kvutil.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, kvstore.AccountKey(accountID, claim.SubnetId), SubnetRecord{
|
||||
created, err = createRecord(ctx, s.subnetKV, kvutil.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 := kvstore.Update(ctx, s.defaultsKV, accountID, kvstore.CASConfig{}, func(c *defaultVPCClaim) (bool, error) {
|
||||
if _, err := kvutil.Update(ctx, s.defaultsKV, accountID, kvutil.CASConfig{}, func(c *defaultVPCClaim) (bool, error) {
|
||||
if c.Complete || c.VpcId != claim.VpcId {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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, kvstore.AccountKey(accountID, id), ec2vpc.VPCRecord{
|
||||
putRecord(t, js, ec2vpc.KVBucketVPCs, kvutil.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, kvstore.AccountKey(accountID, "subnet-00000000000older"), ec2vpc.SubnetRecord{
|
||||
putRecord(t, js, ec2vpc.KVBucketSubnets, kvutil.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, kvstore.AccountKey(accountID, "vpc-00000000000000half"), ec2vpc.VPCRecord{
|
||||
putRecord(t, js, ec2vpc.KVBucketVPCs, kvutil.AccountKey(accountID, "vpc-00000000000000half"), ec2vpc.VPCRecord{
|
||||
VpcId: "vpc-00000000000000half", CidrBlock: ec2vpc.DefaultVPCCidr, IsDefault: true, VNI: 4242,
|
||||
})
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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, kvstore.AccountKey(accountID, subnetId))
|
||||
subnetEntry, err := s.subnetKV.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, eniId), data); err != nil {
|
||||
if _, err := s.eniKV.Put(ctx, kvutil.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 := kvstore.AccountKey(accountID, eniId)
|
||||
key := kvutil.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 := kvstore.AccountKey(accountID, eniID)
|
||||
key := kvutil.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 := kvstore.AccountKey(accountID, eniId)
|
||||
key := kvutil.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 == kvstore.VersionKey {
|
||||
if key == kvutil.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, kvstore.AccountKey(accountID, eniId), eniId, func(record *ENIRecord) error {
|
||||
err := s.casENI(ctx, kvutil.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 := kvstore.AccountKey(accountID, eniId)
|
||||
key := kvutil.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 := kvstore.AccountKey(accountID, eniId)
|
||||
key := kvutil.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, kvstore.AccountKey(accountID, eniId), eniId, func(r *ENIRecord) error {
|
||||
return s.casENI(ctx, kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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 == kvstore.VersionKey {
|
||||
if key == kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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 := kvstore.AccountKey(accountID, eniId)
|
||||
key := kvutil.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, kvstore.AccountKey(accountID, id))
|
||||
entry, err := s.sgKV.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, vpcID))
|
||||
entry, err := s.vpcKV.Get(ctx, kvutil.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, kvstore.AccountKey(accountID, sgId))
|
||||
sgEntry, err := s.sgKV.Get(ctx, kvutil.AccountKey(accountID, sgId))
|
||||
if err != nil {
|
||||
return sgNotFoundError(sgId)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/projection"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/topology"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 == kvstore.VersionKey || !strings.HasSuffix(k, suffix) {
|
||||
if k == kvutil.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/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"
|
||||
@@ -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(), kvstore.AccountKey(testAccountID, eniID))
|
||||
entry, err := inner.Get(context.Background(), kvutil.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(), kvstore.AccountKey(testAccountID, eniID), eniID, func(*ENIRecord) error {
|
||||
err := svc.casENI(t.Context(), kvutil.AccountKey(testAccountID, eniID), eniID, func(*ENIRecord) error {
|
||||
conflicts++
|
||||
// Move the record under the next compare-and-swap, as a competing writer does.
|
||||
return svc.UpdateENI(testAccountID, eniID, func(r *ENIRecord) { r.Description = "other writer" })
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 != kvstore.VersionKey && strings.HasSuffix(k, suffix) {
|
||||
if k != kvutil.VersionKey && strings.HasSuffix(k, suffix) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 == kvstore.VersionKey {
|
||||
if k == kvutil.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, kvstore.AccountKey(accountID, groupId), data); err != nil {
|
||||
if _, err := s.sgKV.Put(ctx, kvutil.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 := kvstore.AccountKey(accountID, groupId)
|
||||
key := kvutil.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, kvstore.AccountKey(accountID, r.SourceSG))
|
||||
entry, err := s.sgKV.Get(ctx, kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvutil.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 == kvstore.VersionKey {
|
||||
if key == kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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 := kvstore.AccountKey(accountID, groupId)
|
||||
key := kvutil.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 := kvstore.AccountKey(accountID, groupId)
|
||||
key := kvutil.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 := kvstore.AccountKey(accountID, groupId)
|
||||
key := kvutil.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 := kvstore.AccountKey(accountID, groupId)
|
||||
key := kvutil.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 := kvstore.AccountKey(accountID, req.groupId)
|
||||
key := kvutil.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 := kvstore.AccountKey(accountID, groupId)
|
||||
key := kvutil.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, kvstore.AccountKey(accountID, groupId), data); err != nil {
|
||||
if _, err := s.sgKV.Create(ctx, kvutil.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 := kvstore.AccountKey(accountID, groupId)
|
||||
key := kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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(), kvstore.AccountKey(testAccountID, sgID))
|
||||
entry, err := svc.sgKV.Get(t.Context(), kvutil.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 := kvstore.AccountKey(testAccountID, sgID)
|
||||
key := kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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(), kvstore.AccountKey(testAccountID, sgID), data)
|
||||
_, err = svc.sgKV.Put(t.Context(), kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 == kvstore.VersionKey {
|
||||
if k == kvutil.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(), kvstore.AccountKey(testAccountID, vpcID), data)
|
||||
_, err = svc.vpcKV.Put(t.Context(), kvutil.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(), kvstore.AccountKey(testAccountID, sgID))
|
||||
entry, err := svc.sgKV.Get(t.Context(), kvutil.AccountKey(testAccountID, sgID))
|
||||
require.NoError(t, err)
|
||||
var record SecurityGroupRecord
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &record))
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/paging"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/zoneid"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketVPCs, 10)
|
||||
vpcKV, err := kvutil.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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketSubnets, 10)
|
||||
subnetKV, err := kvutil.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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketVNICounter, 10)
|
||||
vniKV, err := kvutil.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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketENIs, 10)
|
||||
eniKV, err := kvutil.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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketSecurityGroups, 10)
|
||||
sgKV, err := kvutil.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 := kvstore.GetOrCreateBucket(ctx, js, KVBucketDefaultVPCs, 10)
|
||||
defaultsKV, err := kvutil.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 := kvstore.GetOrCreateBucket(ctx, js, "spinifex-vpc-route-tables", 10)
|
||||
rtbKV, err := kvutil.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 := kvstore.GetOrCreateBucket(ctx, js, "spinifex-igw", 10)
|
||||
igwKV, err := kvutil.GetOrCreateBucket(ctx, js, "spinifex-igw", 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket spinifex-igw: %w", err)
|
||||
}
|
||||
eigwKV, err := kvstore.GetOrCreateBucket(ctx, js, "spinifex-eigw", 10)
|
||||
eigwKV, err := kvutil.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 := kvstore.Update(ctx, s.vniKV, vniCounterKey, kvstore.CASConfig{CreateIfAbsent: true, Attempts: vniCASAttempts},
|
||||
_, err := kvutil.Update(ctx, s.vniKV, vniCounterKey, kvutil.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, kvstore.AccountKey(accountID, vpcID), data); err != nil {
|
||||
if _, err := s.vpcKV.Put(ctx, kvutil.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, kvstore.AccountKey(accountID, vpcId)); err != nil {
|
||||
if _, err := s.vpcKV.Get(ctx, kvutil.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 := kvstore.AccountKey(accountID, vpcID)
|
||||
key := kvutil.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 == kvstore.VersionKey {
|
||||
if k == kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvutil.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, kvstore.AccountKey(accountID, rtbID)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
if err := s.rtbKV.Delete(ctx, kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(k, prefix) {
|
||||
if k == kvutil.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 == kvstore.VersionKey {
|
||||
if key == kvutil.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, kvstore.AccountKey(accountID, vpcID))
|
||||
vpcEntry, err := s.vpcKV.Get(ctx, kvutil.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 == kvstore.VersionKey {
|
||||
if k == kvutil.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, kvstore.AccountKey(accountID, subnetID), data); err != nil {
|
||||
if _, err := s.subnetKV.Put(ctx, kvutil.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 := kvstore.AccountKey(accountID, subnetID)
|
||||
key := kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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 == kvstore.VersionKey {
|
||||
if key == kvutil.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 := kvstore.AccountKey(accountID, subnetID)
|
||||
key := kvutil.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 := kvstore.AccountKey(accountID, vpcID)
|
||||
key := kvutil.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 := kvstore.AccountKey(accountID, vpcID)
|
||||
key := kvutil.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, kvstore.AccountKey(accountID, rtbID), data); err != nil {
|
||||
if _, err := s.rtbKV.Create(ctx, kvutil.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 == kvstore.VersionKey || !strings.HasPrefix(key, prefix) {
|
||||
if key == kvutil.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 == kvstore.VersionKey {
|
||||
if key == kvutil.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 := kvstore.AccountKey(accountID, subnetId)
|
||||
key := kvutil.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 == kvstore.VersionKey {
|
||||
if k == kvutil.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/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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, kvstore.VersionKey, k, "no subnet record may exist")
|
||||
assert.Equal(t, kvutil.VersionKey, k, "no subnet record may exist")
|
||||
}
|
||||
assert.Zero(t, events.Load(), "no topology event may be published")
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.Keys(t.Context(), kv)
|
||||
entries, err := kvutil.Keys(t.Context(), kv)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 1, "a failed load must not mint a key alongside the unreadable one")
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 kvstore.GetOrCreateBucket(ctx, js, SigningBucket, signingKeyHistory)
|
||||
return kvutil.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 := kvstore.Keys(ctx, kv)
|
||||
names, err := kvutil.Keys(ctx, kv)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil, nil, 0, fmt.Errorf("list signing keys: %w", err)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"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 := kvstore.GetOrCreateBucket(ctx, s.js, name, KVBucketAccountHistory)
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, s.js, name, KVBucketAccountHistory)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ecr: open account bucket %s: %w", name, err)
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ func TestReaper_Awsvpc_ReleasesENI(t *testing.T) {
|
||||
rec.LastSeen = time.Now().UTC().Add(-2 * heartbeatTimeout)
|
||||
require.NoError(t, taskdefinition.PutJSON(t.Context(), kv, InstanceKey("web", "i-1"), &rec))
|
||||
|
||||
sc := testScheduler(t, nc, svc, "test-holder")
|
||||
sc := NewScheduler(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 := testScheduler(t, nc, svc, "test-holder")
|
||||
sc := NewScheduler(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))
|
||||
|
||||
testScheduler(t, nc, svc, "test-holder").reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
|
||||
NewScheduler(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))
|
||||
|
||||
testScheduler(t, nc, svc, "test-holder").reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
|
||||
NewScheduler(nc, svc, "test-holder").reapBucket(context.Background(), kv, testAccountID, time.Now().UTC())
|
||||
|
||||
reaped := instanceStatus(t, svc, "web", "i-1")
|
||||
require.Equal(t, InstanceStatusDraining, reaped.Status)
|
||||
|
||||
@@ -50,7 +50,7 @@ func schedulerRig(t *testing.T) (*Scheduler, *Service, jetstream.KeyValue) {
|
||||
registerInstance(t, svc, "web", "i-1", 4096, 8192)
|
||||
kv, err := svc.bucket(t.Context(), testAccountID)
|
||||
require.NoError(t, err)
|
||||
return testScheduler(t, nc, svc, "test-holder"), svc, kv
|
||||
return NewScheduler(nc, svc, "test-holder"), svc, kv
|
||||
}
|
||||
|
||||
// runOneTask places a task and returns its ID, read back from the record rather
|
||||
|
||||
@@ -13,19 +13,17 @@ import (
|
||||
|
||||
ecsv1 "github.com/mulgadc/spinifex/contracts/ecs/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/reconciler"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvsync"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const (
|
||||
schedulerLeaseTTL = 60 * time.Second
|
||||
leaseRefresh = schedulerLeaseTTL / 3
|
||||
schedulerLeaderKey = "scheduler"
|
||||
leaseRefresh = 20 * time.Second
|
||||
reaperInterval = 30 * time.Second
|
||||
heartbeatTimeout = 90 * time.Second
|
||||
stoppedReasonReaped = "ContainerInstance disconnected"
|
||||
@@ -61,14 +59,17 @@ const (
|
||||
reconcileResync = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Scheduler is the per-daemon ECS control loop. A single leader (elected on its
|
||||
// lease) owns the Layer-2 bus subscriptions and the heartbeat
|
||||
// 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
|
||||
// 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
|
||||
lease *kvsync.Lease
|
||||
nc *nats.Conn
|
||||
svc *Service
|
||||
holder string
|
||||
|
||||
lease *kvlease.Lease
|
||||
leaseErr error
|
||||
|
||||
// wake carries a bus message that changed something the service reconcile
|
||||
// reads. Fed by the task-state subscription and not by the heartbeat one:
|
||||
@@ -88,28 +89,47 @@ type Scheduler struct {
|
||||
subs []*nats.Subscription
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// NewScheduler constructs a Scheduler that leads while it holds lease.
|
||||
func NewScheduler(nc *nats.Conn, svc *Service, lease *kvsync.Lease) *Scheduler {
|
||||
// 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.
|
||||
return &Scheduler{nc: nc, svc: svc, lease: lease, wake: make(chan struct{}, 1)}
|
||||
sc := &Scheduler{nc: nc, svc: svc, holder: holder, wake: make(chan struct{}, 1)}
|
||||
sc.lease, sc.leaseErr = kvlease.New(kvlease.Config{
|
||||
Name: "ecs/scheduler",
|
||||
Bucket: sc.leaderBucket,
|
||||
Key: schedulerLeaderKey,
|
||||
Holder: holder,
|
||||
TTL: KVBucketECSLeaderTTL,
|
||||
Renew: leaseRefresh,
|
||||
Retry: leaseRefresh,
|
||||
// The leader owns the Layer-2 bus subscriptions and the instance-role
|
||||
// converge pass. A node that can do neither stands down rather than hold
|
||||
// the lease and process nothing.
|
||||
OnGained: sc.onGained,
|
||||
OnLost: sc.unsubscribeBus,
|
||||
})
|
||||
return sc
|
||||
}
|
||||
|
||||
func (sc *Scheduler) leaderBucket(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
js, err := jetstream.New(sc.nc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return InitLeaderBucket(ctx, js)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
sc.lease.Run(ctx)
|
||||
}()
|
||||
|
||||
// Two loops rather than four tickers, split by what wakes them. Services are
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user