feat(ecs): Phase 4 gateway scaffolding + KV constants (Sprint 4a) (#413)

* fix: drop removed predastore s3.Config.Compaction from admin_test

The predastore submodule bump removed the Compaction field from s3.Config.
Parse the rendered [compaction] block into a local struct so the template
round-trip test no longer references the deleted field.

* feat(ecs): Phase 4 gateway scaffolding + KV constants (Sprint 4a)

ECS v1 control-plane skeleton. Every action is a 501 NotImplemented stub
until its handler lands in a later Phase 4 sprint; this sprint locks the
API contract + JetStream KV schema so sprints 4c-4h can build against it.

- gateway/ecs: X-Amz-Target JSON 1.1 dispatch (AmazonEC2ContainerServiceV20141113),
  full v1 action namespace mapped to NotImplemented (mirrors gateway/ecrapi)
- gateway: register ecs in supportedServices + Request dispatch switch
- handlers/ecs/store.go: per-account bucket ecs-account-{accountID} + shared
  leader bucket spinifex-ecs-leader + Q2 key-path helpers (mirrors handlers/eks)
- NATS_HANDLER_NAMING.md: document the two-layer ecs.* subject convention (Q14)

Ref: docs/development/feature/ecs-v1.md, ecs-4a-gateway-scaffolding.md

* test(ecs): cover store factories + gateway handler/error branches

Lift Sprint 4a diff coverage above the 60% gate (54.5% -> 78%):
- handlers/ecs: JetStream-backed GetOrCreateAccountBucket / InitLeaderBucket
  idempotency + NewStore success (testutil.StartTestJetStream)
- gateway/ecs: in-package NotImplemented + WriteJSONResponse coverage
- gateway: ECS_Request missing-account-ID branch
This commit is contained in:
Josh Siviour
2026-06-24 15:15:51 +10:00
committed by GitHub
parent 1a4046fd69
commit 78739cc93b
8 changed files with 621 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
package gateway
import (
"errors"
"io"
"log/slog"
"net/http"
"strings"
"github.com/mulgadc/spinifex/spinifex/awserrors"
gateway_ecs "github.com/mulgadc/spinifex/spinifex/gateway/ecs"
)
// ecsActionFromTarget extracts the action suffix from an X-Amz-Target header.
// Any "<Prefix>.<Action>" or bare "<Action>" form is accepted.
func ecsActionFromTarget(target string) string {
if i := strings.LastIndex(target, "."); i >= 0 {
return target[i+1:]
}
return target
}
// ECS_Request dispatches AWS JSON 1.1 ECS control-plane requests. The action
// comes from X-Amz-Target; errors are returned as awserrors codes and rendered
// by the shared ErrorHandler. Every action is a NotImplemented (501) stub until
// its real handler lands in a later Phase 4 sprint.
func (gw *GatewayConfig) ECS_Request(w http.ResponseWriter, r *http.Request) error {
action := ecsActionFromTarget(r.Header.Get("X-Amz-Target"))
if action == "" {
return errors.New(awserrors.ErrorMissingAction)
}
handler, ok := gateway_ecs.Actions[action]
if !ok {
slog.Debug("ECS: unknown action", "action", action)
return errors.New(awserrors.ErrorInvalidAction)
}
if err := gw.checkPolicy(r, "ecs", action); err != nil {
return err
}
accountID, _ := r.Context().Value(ctxAccountID).(string)
if accountID == "" {
slog.Error("ECS_Request: no account ID in auth context")
return errors.New(awserrors.ErrorServerInternal)
}
body, err := io.ReadAll(r.Body)
if err != nil {
slog.Error("ECS_Request: failed to read body", "err", err)
return errors.New(awserrors.ErrorInvalidParameterValue)
}
output, err := handler(gw.NATSConn, accountID, body)
if err != nil {
return err
}
gateway_ecs.WriteJSONResponse(w, output)
return nil
}
+104
View File
@@ -0,0 +1,104 @@
// Package gateway_ecs is the HTTP-side glue for the ECS control plane: the AWS
// JSON 1.1 surface dispatched by X-Amz-Target
// (AmazonEC2ContainerServiceV20141113.<Action>), matching aws-sdk-go's
// service/ecs request shape.
//
// The full v1 action namespace (ecs-v1.md §1) is registered as the API contract;
// every action resolves to the shared NotImplemented stub until its real handler
// lands in a later Phase 4 sprint.
package gateway_ecs
import (
"errors"
"log/slog"
"net/http"
"github.com/aws/aws-sdk-go/private/protocol/json/jsonutil"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/nats-io/nats.go"
)
// TargetPrefix is the X-Amz-Target service prefix for ECS control-plane calls.
const TargetPrefix = "AmazonEC2ContainerServiceV20141113"
// JSONContentType is the AWS JSON 1.1 content type ECS clients expect.
const JSONContentType = "application/x-amz-json-1.1"
// Handler is the signature every ECS control-plane action implements. nc is the
// gateway NATS connection (handlers relay onto ecs.* subjects); accountID is the
// resolved caller account; body is the raw JSON 1.1 request payload.
type Handler func(nc *nats.Conn, accountID string, body []byte) (any, error)
// NotImplemented is the placeholder for every unimplemented ECS control-plane
// action. It returns the AWS NotImplemented error, which the shared gateway
// ErrorHandler renders as a 501 JSON 1.1 envelope.
func NotImplemented(_ *nats.Conn, _ string, _ []byte) (any, error) {
return nil, errors.New(awserrors.ErrorNotImplemented)
}
// Actions is the authoritative ECS control-plane action namespace (ecs-v1.md §1).
// Real implementations replace the NotImplemented value as each action lands; the
// key set is the v1 API contract.
var Actions = map[string]Handler{
// Cluster. PutClusterCapacityProviders is a no-op stub in v1 (Q15).
"CreateCluster": NotImplemented,
"DescribeClusters": NotImplemented,
"ListClusters": NotImplemented,
"DeleteCluster": NotImplemented,
"UpdateCluster": NotImplemented,
"PutClusterCapacityProviders": NotImplemented,
// Task definition.
"RegisterTaskDefinition": NotImplemented,
"DeregisterTaskDefinition": NotImplemented,
"DescribeTaskDefinition": NotImplemented,
"ListTaskDefinitions": NotImplemented,
"ListTaskDefinitionFamilies": NotImplemented,
// Task.
"RunTask": NotImplemented,
"StartTask": NotImplemented,
"StopTask": NotImplemented,
"DescribeTasks": NotImplemented,
"ListTasks": NotImplemented,
// Service. ListServicesByNamespace is a no-op stub in v1.
"CreateService": NotImplemented,
"UpdateService": NotImplemented,
"DeleteService": NotImplemented,
"DescribeServices": NotImplemented,
"ListServices": NotImplemented,
"ListServicesByNamespace": NotImplemented,
// Container instance.
"RegisterContainerInstance": NotImplemented,
"DeregisterContainerInstance": NotImplemented,
"DescribeContainerInstances": NotImplemented,
"ListContainerInstances": NotImplemented,
"UpdateContainerInstancesState": NotImplemented,
// Account settings (passthrough; no enforcement v1).
"PutAccountSetting": NotImplemented,
"ListAccountSettings": NotImplemented,
// Tags.
"TagResource": NotImplemented,
"UntagResource": NotImplemented,
"ListTagsForResource": NotImplemented,
}
// WriteJSONResponse serialises obj as a 200 AWS JSON 1.1 response using the
// SDK's jsonutil marshaler (epoch-second time encoding), matching ECR/ACM/EKS.
func WriteJSONResponse(w http.ResponseWriter, obj any) {
body, err := jsonutil.BuildJSON(obj)
if err != nil {
slog.Error("ECS: failed to marshal response JSON", "err", err)
http.Error(w, awserrors.ErrorInternalError, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", JSONContentType)
w.WriteHeader(http.StatusOK)
if _, err := w.Write(body); err != nil {
slog.Error("ECS: failed to write response", "err", err)
}
}
+40
View File
@@ -0,0 +1,40 @@
package gateway_ecs
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNotImplemented(t *testing.T) {
out, err := NotImplemented(nil, "123456789012", []byte("{}"))
assert.Nil(t, out)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorNotImplemented, err.Error())
}
// Every registered action is the NotImplemented stub in this sprint.
func TestActions_AllNotImplemented(t *testing.T) {
require.NotEmpty(t, Actions)
for action, h := range Actions {
_, err := h(nil, "123456789012", []byte("{}"))
require.Error(t, err, "action %q", action)
assert.Equal(t, awserrors.ErrorNotImplemented, err.Error(), "action %q", action)
}
}
func TestWriteJSONResponse(t *testing.T) {
w := httptest.NewRecorder()
WriteJSONResponse(w, map[string]string{"clusterArn": "arn:aws:ecs:::cluster/x"})
assert.Equal(t, 200, w.Code)
assert.Equal(t, JSONContentType, w.Header().Get("Content-Type"))
var got map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
assert.Equal(t, "arn:aws:ecs:::cluster/x", got["clusterArn"])
}
+100
View File
@@ -0,0 +1,100 @@
package gateway
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/awserrors"
gateway_ecs "github.com/mulgadc/spinifex/spinifex/gateway/ecs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupECSRequest(target, body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
if target != "" {
req.Header.Set("X-Amz-Target", target)
}
ctx := context.WithValue(req.Context(), ctxService, "ecs")
ctx = context.WithValue(ctx, ctxAccountID, "123456789012")
return req.WithContext(ctx)
}
func TestECSActionFromTarget(t *testing.T) {
assert.Equal(t, "RunTask",
ecsActionFromTarget("AmazonEC2ContainerServiceV20141113.RunTask"))
assert.Equal(t, "CreateCluster", ecsActionFromTarget("CreateCluster"))
assert.Equal(t, "", ecsActionFromTarget(""))
}
// The Actions map is the v1 API contract (ecs-v1.md §1): every action across the
// Cluster / TaskDefinition / Task / Service / ContainerInstance / Account / Tags
// surfaces must be registered, even though each is a 501 stub in this sprint.
func TestECSActionsMap_NamespaceRegistered(t *testing.T) {
namespace := []string{
// Cluster
"CreateCluster", "DescribeClusters", "ListClusters", "DeleteCluster",
"UpdateCluster", "PutClusterCapacityProviders",
// Task definition
"RegisterTaskDefinition", "DeregisterTaskDefinition", "DescribeTaskDefinition",
"ListTaskDefinitions", "ListTaskDefinitionFamilies",
// Task
"RunTask", "StartTask", "StopTask", "DescribeTasks", "ListTasks",
// Service
"CreateService", "UpdateService", "DeleteService", "DescribeServices",
"ListServices", "ListServicesByNamespace",
// Container instance
"RegisterContainerInstance", "DeregisterContainerInstance",
"DescribeContainerInstances", "ListContainerInstances",
"UpdateContainerInstancesState",
// Account
"PutAccountSetting", "ListAccountSettings",
// Tags
"TagResource", "UntagResource", "ListTagsForResource",
}
for _, action := range namespace {
_, ok := gateway_ecs.Actions[action]
assert.True(t, ok, "action %q should be registered", action)
}
}
func TestECSRequest_MissingTarget(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true}
err := gw.ECS_Request(httptest.NewRecorder(), setupECSRequest("", ""))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
}
func TestECSRequest_UnknownAction(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true}
err := gw.ECS_Request(httptest.NewRecorder(),
setupECSRequest("AmazonEC2ContainerServiceV20141113.MadeUpAction", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
}
// Every registered action resolves to the 501 stub until its real handler lands
// in a later Phase 4 sprint.
func TestECSRequest_KnownActionNotImplemented(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true}
err := gw.ECS_Request(httptest.NewRecorder(),
setupECSRequest("AmazonEC2ContainerServiceV20141113.RunTask", "{}"))
require.Error(t, err)
assert.Equal(t, awserrors.ErrorNotImplemented, err.Error())
}
// A request that clears auth but carries no account ID in context is an internal
// fault, not a client error.
func TestECSRequest_MissingAccountID(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}"))
req.Header.Set("X-Amz-Target", "AmazonEC2ContainerServiceV20141113.ListClusters")
req = req.WithContext(context.WithValue(req.Context(), ctxService, "ecs"))
gw := &GatewayConfig{DisableLogging: true}
err := gw.ECS_Request(httptest.NewRecorder(), req)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
}
+3
View File
@@ -110,6 +110,7 @@ var supportedServices = map[string]bool{
"account": true,
"elasticloadbalancing": true,
"eks": true,
"ecs": true,
"ecr": true,
"acm": true,
"tagging": true,
@@ -335,6 +336,8 @@ func (gw *GatewayConfig) Request(w http.ResponseWriter, r *http.Request) {
err = gw.ELBv2_Request(w, r)
case "eks":
err = gw.EKS_Request(w, r)
case "ecs":
err = gw.ECS_Request(w, r)
case "ecr":
err = gw.ECR_Request(w, r)
case "acm":
+53
View File
@@ -222,3 +222,56 @@ JetStream KV compare-and-swap, so a concurrent PATCH that loses the revision
race is rejected rather than silently merged. Absent records and CAS conflicts
travel back as response flags (`found`, `conflict`), not transport errors, so
they round-trip a reply envelope that otherwise only carries AWS error codes.
## ECS (Elastic Container Service)
ECS uses two layers of NATS subjects (ecs-v1.md Q14). The `.bus.` segment
distinguishes internal scheduler↔agent traffic from the AWS API surface.
### Layer 1 — AWS API surface
The gateway translates ECS JSON 1.1 requests (X-Amz-Target
`AmazonEC2ContainerServiceV20141113.<Action>`) into `ecs.<AWSAction>` NATS
requests, using the existing `spinifex-workers` queue group. Handler names
follow the `handleECS<AWSAction>` convention. The full v1 namespace:
```
ecs.CreateCluster, ecs.DescribeClusters, ecs.ListClusters, ecs.DeleteCluster,
ecs.UpdateCluster, ecs.PutClusterCapacityProviders
ecs.RegisterTaskDefinition, ecs.DeregisterTaskDefinition,
ecs.DescribeTaskDefinition, ecs.ListTaskDefinitions, ecs.ListTaskDefinitionFamilies
ecs.RunTask, ecs.StartTask, ecs.StopTask, ecs.DescribeTasks, ecs.ListTasks
ecs.CreateService, ecs.UpdateService, ecs.DeleteService, ecs.DescribeServices,
ecs.ListServices, ecs.ListServicesByNamespace
ecs.RegisterContainerInstance, ecs.DeregisterContainerInstance,
ecs.DescribeContainerInstances, ecs.ListContainerInstances,
ecs.UpdateContainerInstancesState
ecs.PutAccountSetting, ecs.ListAccountSettings
ecs.TagResource, ecs.UntagResource, ecs.ListTagsForResource
```
Today every action is a 501 stub; the subjects land with their handler bodies.
### Layer 2 — internal scheduler↔agent bus
The per-cluster scheduler and the on-VM ecs-agent communicate on
`ecs.bus.<accountID>.<clusterName>.*`. Cluster identity is
`<accountID>.<clusterName>` (matches the KV layout), not a UUID.
```
ecs.bus.<accountID>.<clusterName>.assign.<instanceID> # scheduler → agent
ecs.bus.<accountID>.<clusterName>.task-state.<taskID> # agent → scheduler
ecs.bus.<accountID>.<clusterName>.instance-heartbeat.<instanceID> # agent → scheduler
ecs.bus.<accountID>.<clusterName>.instance-register.<instanceID> # agent → scheduler
ecs.bus.<accountID>.<clusterName>.service-reconcile # scheduler-internal tick
```
This layer lands with the scheduler + agent bodies. The Phase 5 per-AZ NATS
cutover inserts `{azID}` after `ecs.` → `ecs.<azID>.bus.<accountID>.<clusterName>.*`.
### Cross-service calls
The scheduler also publishes on existing namespaces (no `ecs.` prefix):
`ec2.CreateNetworkInterface`, `ec2.AttachNetworkInterface`, `sts.AssumeRole`,
`elbv2.RegisterTargets`, `elbv2.DeregisterTargets`,
`route53.ChangeResourceRecordSets`.
+172
View File
@@ -0,0 +1,172 @@
package handlers_ecs
import (
"errors"
"fmt"
"log/slog"
"time"
"github.com/mulgadc/spinifex/spinifex/migrate"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
// JetStream KV bucket and key-path constants for the ECS control plane.
//
// Per-account bucket "ecs-account-{accountID}" holds all customer-visible
// cluster, task-definition, task, service, and container-instance state. It is
// created lazily on first cluster touch (no daemon-boot pre-creation) so accounts
// without any ECS clusters never grow a bucket (ecs-v1.md Q2).
//
// Shared leader bucket "spinifex-ecs-leader" holds 60s-TTL CAS locks keyed by
// "{accountID}/{clusterName}" for the per-cluster scheduler leader election
// (Q3). Created once at daemon boot.
const (
KVBucketECSAccountPrefix = "ecs-account-"
KVBucketECSAccountVersion = 1
KVBucketECSAccountHistory = 1
KVBucketECSLeader = "spinifex-ecs-leader"
KVBucketECSLeaderVersion = 1
KVBucketECSLeaderTTL = 60 * time.Second
)
// Key-path helpers for the per-account bucket. The layout matches ecs-v1.md Q2.
// Task definitions are account-scoped (not cluster-scoped) per the AWS ARN shape:
//
// clusters/{name}/meta
// clusters/{name}/instances/{instanceID}
// clusters/{name}/tasks/{taskID}
// clusters/{name}/services/{serviceName}
// taskdef-families/{family}/latest-rev
// taskdef-families/{family}/revs/{rev}
// ClusterMetaKey returns the KV key for a cluster's meta record.
func ClusterMetaKey(name string) string {
return fmt.Sprintf("clusters/%s/meta", name)
}
// InstancesPrefix returns the KV key prefix under which all of a cluster's
// container-instance records live. Used by ListContainerInstances to enumerate.
func InstancesPrefix(cluster string) string {
return fmt.Sprintf("clusters/%s/instances/", cluster)
}
// InstanceKey returns the KV key for a container-instance record under a cluster.
func InstanceKey(cluster, instanceID string) string {
return InstancesPrefix(cluster) + instanceID
}
// TasksPrefix returns the KV key prefix under which all of a cluster's task
// records live. Used by ListTasks and the agent reboot reconciler to enumerate.
func TasksPrefix(cluster string) string {
return fmt.Sprintf("clusters/%s/tasks/", cluster)
}
// TaskKey returns the KV key for a task record under a cluster.
func TaskKey(cluster, taskID string) string {
return TasksPrefix(cluster) + taskID
}
// ServicesPrefix returns the KV key prefix under which all of a cluster's service
// records live. Used by ListServices to enumerate.
func ServicesPrefix(cluster string) string {
return fmt.Sprintf("clusters/%s/services/", cluster)
}
// ServiceKey returns the KV key for a service record under a cluster.
func ServiceKey(cluster, serviceName string) string {
return ServicesPrefix(cluster) + serviceName
}
// TaskDefFamiliesPrefix returns the KV key prefix under which all task-definition
// families live. Task definitions are account-scoped, not cluster-scoped (Q2).
func TaskDefFamiliesPrefix() string {
return "taskdef-families/"
}
// TaskDefLatestRevKey returns the KV key holding a family's latest revision
// number. Read-modify-written on RegisterTaskDefinition.
func TaskDefLatestRevKey(family string) string {
return fmt.Sprintf("taskdef-families/%s/latest-rev", family)
}
// TaskDefRevsPrefix returns the KV key prefix under which all revisions of a
// task-definition family live. Used by ListTaskDefinitions to enumerate.
func TaskDefRevsPrefix(family string) string {
return fmt.Sprintf("taskdef-families/%s/revs/", family)
}
// TaskDefRevKey returns the KV key for a specific revision of a task-definition
// family.
func TaskDefRevKey(family string, rev int) string {
return fmt.Sprintf("%s%d", TaskDefRevsPrefix(family), rev)
}
// LeaderLeaseKey returns the per-cluster scheduler leader-lease key in the shared
// leader bucket (Q3).
func LeaderLeaseKey(accountID, clusterName string) string {
return fmt.Sprintf("%s/%s", accountID, clusterName)
}
// Store is the per-daemon ECS KV handle. Per-account and leader buckets are
// accessed via the package-level factories below.
type Store struct {
nc *nats.Conn
}
// NewStore constructs a Store bound to the supplied NATS connection. It does not
// touch JetStream — per-account buckets are created lazily by
// GetOrCreateAccountBucket and the leader bucket by InitLeaderBucket.
func NewStore(nc *nats.Conn) (*Store, error) {
if nc == nil {
return nil, errors.New("ecs store: nats connection is nil")
}
return &Store{nc: nc}, nil
}
// AccountBucketName returns the per-account JetStream KV bucket name for the
// given AWS account ID.
func AccountBucketName(accountID string) string {
return KVBucketECSAccountPrefix + accountID
}
// GetOrCreateAccountBucket returns the per-account KV bucket for accountID,
// creating it on first use. Idempotent: subsequent calls with the same accountID
// return the existing handle.
func GetOrCreateAccountBucket(js nats.JetStreamContext, accountID string) (nats.KeyValue, error) {
bucket := AccountBucketName(accountID)
kv, err := utils.GetOrCreateKVBucket(js, bucket, KVBucketECSAccountHistory)
if err != nil {
return nil, fmt.Errorf("failed to create ECS per-account KV bucket %s: %w", bucket, err)
}
if err := migrate.DefaultRegistry.RunKV(bucket, kv, KVBucketECSAccountVersion); err != nil {
return nil, fmt.Errorf("migrate %s: %w", bucket, err)
}
return kv, nil
}
// InitLeaderBucket creates (or attaches to) the shared spinifex-ecs-leader bucket
// used for per-cluster scheduler leader-lease CAS locks. The bucket is configured
// with History=1 and a 60s TTL so stale leases expire on their own when a leader
// dies mid-cycle. utils.GetOrCreateKVBucket doesn't expose a TTL knob, so this
// takes the direct js.CreateKeyValue path and falls back to js.KeyValue on
// already-exists.
func InitLeaderBucket(js nats.JetStreamContext) (nats.KeyValue, error) {
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{
Bucket: KVBucketECSLeader,
History: 1,
TTL: KVBucketECSLeaderTTL,
})
if err != nil {
kv, err = js.KeyValue(KVBucketECSLeader)
if err != nil {
return nil, fmt.Errorf("failed to create or open ECS leader bucket %s: %w", KVBucketECSLeader, err)
}
}
if err := migrate.DefaultRegistry.RunKV(KVBucketECSLeader, kv, KVBucketECSLeaderVersion); err != nil {
return nil, fmt.Errorf("migrate %s: %w", KVBucketECSLeader, err)
}
slog.Info("ECS leader bucket initialized", "bucket", KVBucketECSLeader, "ttl", KVBucketECSLeaderTTL)
return kv, nil
}
+87
View File
@@ -0,0 +1,87 @@
package handlers_ecs
import (
"testing"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testAccountID = "123456789012"
func TestAccountBucketName(t *testing.T) {
assert.Equal(t, "ecs-account-123456789012", AccountBucketName(testAccountID))
}
func TestNewStore_NilConn(t *testing.T) {
_, err := NewStore(nil)
require.Error(t, err)
}
func TestNewStore_Valid(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
s, err := NewStore(nc)
require.NoError(t, err)
require.NotNil(t, s)
}
func TestGetOrCreateAccountBucket_Idempotent(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
kv1, err := GetOrCreateAccountBucket(js, testAccountID)
require.NoError(t, err)
require.NotNil(t, kv1)
kv2, err := GetOrCreateAccountBucket(js, testAccountID)
require.NoError(t, err)
require.NotNil(t, kv2)
assert.Equal(t, AccountBucketName(testAccountID), kv1.Bucket())
assert.Equal(t, kv1.Bucket(), kv2.Bucket())
}
func TestInitLeaderBucket_Idempotent(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
kv1, err := InitLeaderBucket(js)
require.NoError(t, err)
require.NotNil(t, kv1)
assert.Equal(t, KVBucketECSLeader, kv1.Bucket())
kv2, err := InitLeaderBucket(js)
require.NoError(t, err)
require.NotNil(t, kv2)
assert.Equal(t, KVBucketECSLeader, kv2.Bucket())
}
// Key-path helpers must produce the ecs-v1.md Q2 layout exactly: prefixes are
// what the List* enumerations watch, so a drift here silently breaks listing.
func TestKeyPaths(t *testing.T) {
assert.Equal(t, "clusters/web/meta", ClusterMetaKey("web"))
assert.Equal(t, "clusters/web/instances/", InstancesPrefix("web"))
assert.Equal(t, "clusters/web/instances/i-abc", InstanceKey("web", "i-abc"))
assert.Equal(t, "clusters/web/tasks/", TasksPrefix("web"))
assert.Equal(t, "clusters/web/tasks/t-abc", TaskKey("web", "t-abc"))
assert.Equal(t, "clusters/web/services/", ServicesPrefix("web"))
assert.Equal(t, "clusters/web/services/api", ServiceKey("web", "api"))
assert.Equal(t, "taskdef-families/", TaskDefFamiliesPrefix())
assert.Equal(t, "taskdef-families/nginx/latest-rev", TaskDefLatestRevKey("nginx"))
assert.Equal(t, "taskdef-families/nginx/revs/", TaskDefRevsPrefix("nginx"))
assert.Equal(t, "taskdef-families/nginx/revs/3", TaskDefRevKey("nginx", 3))
assert.Equal(t, "123456789012/web", LeaderLeaseKey("123456789012", "web"))
}
// Prefix helpers must be a true prefix of their per-record key so a KV
// prefix-watch over the prefix sees the record key.
func TestPrefixContainment(t *testing.T) {
assert.Contains(t, InstanceKey("c", "x"), InstancesPrefix("c"))
assert.Contains(t, TaskKey("c", "x"), TasksPrefix("c"))
assert.Contains(t, ServiceKey("c", "x"), ServicesPrefix("c"))
assert.Contains(t, TaskDefRevKey("f", 1), TaskDefRevsPrefix("f"))
}