diff --git a/spinifex/daemon/daemon_handlers_eks_test.go b/spinifex/daemon/daemon_handlers_eks_test.go index 166ae00d8..ab453e289 100644 --- a/spinifex/daemon/daemon_handlers_eks_test.go +++ b/spinifex/daemon/daemon_handlers_eks_test.go @@ -88,12 +88,15 @@ func TestDaemonHandleEKS_AllHandlersDispatchToService(t *testing.T) { {"eks.DisassociateAccessPolicy", d.handleEKSDisassociateAccessPolicy, invalid}, {"eks.ListAssociatedAccessPolicies", d.handleEKSListAssociatedAccessPolicies, invalid}, {"eks.ListAccessPolicies", d.handleEKSListAccessPolicies, ""}, - {"eks.ListAddons", d.handleEKSListAddons, notImpl}, - {"eks.DescribeAddonVersions", d.handleEKSDescribeAddonVersions, notImpl}, - {"eks.CreateAddon", d.handleEKSCreateAddon, notImpl}, - {"eks.DeleteAddon", d.handleEKSDeleteAddon, notImpl}, - {"eks.DescribeAddon", d.handleEKSDescribeAddon, notImpl}, - {"eks.UpdateAddon", d.handleEKSUpdateAddon, notImpl}, + // Addon handlers are implemented (Sprint 6 P1): an empty body fails + // cluster validation with InvalidParameterValue, except + // DescribeAddonVersions which returns the static catalogue (success). + {"eks.ListAddons", d.handleEKSListAddons, invalid}, + {"eks.DescribeAddonVersions", d.handleEKSDescribeAddonVersions, ""}, + {"eks.CreateAddon", d.handleEKSCreateAddon, invalid}, + {"eks.DeleteAddon", d.handleEKSDeleteAddon, invalid}, + {"eks.DescribeAddon", d.handleEKSDescribeAddon, invalid}, + {"eks.UpdateAddon", d.handleEKSUpdateAddon, invalid}, {"eks.AssociateIdentityProviderConfig", d.handleEKSAssociateIdentityProviderConfig, notImpl}, {"eks.DescribeIdentityProviderConfig", d.handleEKSDescribeIdentityProviderConfig, notImpl}, {"eks.ListIdentityProviderConfigs", d.handleEKSListIdentityProviderConfigs, notImpl}, diff --git a/spinifex/gateway/eks.go b/spinifex/gateway/eks.go index 95c1a0c17..9db5e0e97 100644 --- a/spinifex/gateway/eks.go +++ b/spinifex/gateway/eks.go @@ -146,9 +146,9 @@ var eksRoutes = []eksRoute{ func(gw *GatewayConfig, acct, callerARN string, p []string, b []byte) (any, error) { return gateway_eks.DescribeAddonVersions(gw.NATSConn, acct) }}, - {"GET", regexp.MustCompile(`^/addons$`), "ListAddons", + {"GET", regexp.MustCompile(`^/clusters/([^/]+)/addons$`), "ListAddons", func(gw *GatewayConfig, acct, callerARN string, p []string, b []byte) (any, error) { - return gateway_eks.ListAddons(gw.NATSConn, acct) + return gateway_eks.ListAddons(gw.NATSConn, acct, p[0]) }}, {"POST", regexp.MustCompile(`^/clusters/([^/]+)/addons$`), "CreateAddon", func(gw *GatewayConfig, acct, callerARN string, p []string, b []byte) (any, error) { diff --git a/spinifex/gateway/eks/addons.go b/spinifex/gateway/eks/addons.go index 04033d2bb..43848676a 100644 --- a/spinifex/gateway/eks/addons.go +++ b/spinifex/gateway/eks/addons.go @@ -7,9 +7,11 @@ import ( "github.com/nats-io/nats.go" ) -// ListAddons — GET /addons -func ListAddons(natsConn *nats.Conn, accountID string) (*eks.ListAddonsOutput, error) { - return handlers_eks.NewNATSEKSService(natsConn).ListAddons(&eks.ListAddonsInput{}, accountID) +// ListAddons — GET /clusters/{name}/addons +func ListAddons(natsConn *nats.Conn, accountID, cluster string) (*eks.ListAddonsOutput, error) { + return handlers_eks.NewNATSEKSService(natsConn).ListAddons(&eks.ListAddonsInput{ + ClusterName: aws.String(cluster), + }, accountID) } // DescribeAddonVersions — GET /addons/supported-versions diff --git a/spinifex/gateway/eks/wrappers_test.go b/spinifex/gateway/eks/wrappers_test.go index a012fd0b7..00cf68ded 100644 --- a/spinifex/gateway/eks/wrappers_test.go +++ b/spinifex/gateway/eks/wrappers_test.go @@ -207,7 +207,7 @@ func TestGatewayWrappers_Addons(t *testing.T) { _, nc := testutil.StartTestNATS(t) stubEKSResponder(t, nc) - out1, err := ListAddons(nc, acct) + out1, err := ListAddons(nc, acct, "alpha") require.NoError(t, err) assert.NotNil(t, out1) diff --git a/spinifex/gateway/eks_test.go b/spinifex/gateway/eks_test.go index 787e074ad..b4562fdf1 100644 --- a/spinifex/gateway/eks_test.go +++ b/spinifex/gateway/eks_test.go @@ -41,7 +41,7 @@ func TestLookupEKSAction_ResolvesKnownRoutes(t *testing.T) { {"DELETE", "/clusters/alpha/access-entries/arn-user/access-policies", "DisassociateAccessPolicy", []string{"alpha", "arn-user"}}, {"GET", "/clusters/alpha/access-entries/arn-user/access-policies", "ListAssociatedAccessPolicies", []string{"alpha", "arn-user"}}, {"GET", "/access-policies", "ListAccessPolicies", nil}, - {"GET", "/addons", "ListAddons", nil}, + {"GET", "/clusters/alpha/addons", "ListAddons", []string{"alpha"}}, {"GET", "/addons/supported-versions", "DescribeAddonVersions", nil}, {"POST", "/clusters/alpha/addons", "CreateAddon", []string{"alpha"}}, {"GET", "/clusters/alpha/addons/vpc-cni", "DescribeAddon", []string{"alpha", "vpc-cni"}}, diff --git a/spinifex/handlers/eks/addon_catalog.go b/spinifex/handlers/eks/addon_catalog.go new file mode 100644 index 000000000..e9c30d989 --- /dev/null +++ b/spinifex/handlers/eks/addon_catalog.go @@ -0,0 +1,89 @@ +package handlers_eks + +import "slices" + +// AddonSpec describes one Spinifex-supported managed add-on. The catalog is a +// static in-binary registry (air-gapped; there is no internet add-on catalog), +// so DescribeAddonVersions and CreateAddon validate against these entries. +type AddonSpec struct { + // Name is the AWS add-on name (e.g. "aws-load-balancer-controller"). + Name string + // Versions lists the supported versions newest-first; [0] is the default. + Versions []string + // DefaultVersion is the version CreateAddon applies when none is requested. + // Always equal to Versions[0]. + DefaultVersion string + // RequiresIRSA reports whether the add-on needs an IAM-role-for-service- + // account binding to operate (surfaced as requiresIamPermissions). + RequiresIRSA bool + // Description is a short human-readable summary. + Description string +} + +// addonCatalog is the bundled set of supported add-ons. Keep newest version +// first in each Versions slice; newAddonSpec enforces DefaultVersion = [0]. +var addonCatalog = buildAddonCatalog( + newAddonSpec("aws-load-balancer-controller", true, + "Provisions ELBv2 load balancers for Kubernetes Service/Ingress resources.", + "2.8.1"), + newAddonSpec("coredns", false, + "Cluster DNS server.", + "1.11.1"), +) + +// newAddonSpec builds a spec, pinning DefaultVersion to the first (newest) +// version. Panics on an empty version list — the catalog is build-time data. +func newAddonSpec(name string, requiresIRSA bool, description string, versions ...string) AddonSpec { + if len(versions) == 0 { + panic("eks: addon spec " + name + " has no versions") + } + return AddonSpec{ + Name: name, + Versions: versions, + DefaultVersion: versions[0], + RequiresIRSA: requiresIRSA, + Description: description, + } +} + +// buildAddonCatalog indexes the specs by name. +func buildAddonCatalog(specs ...AddonSpec) map[string]AddonSpec { + out := make(map[string]AddonSpec, len(specs)) + for _, s := range specs { + out[s.Name] = s + } + return out +} + +// lookupAddon returns the spec for name and whether it is in the catalog. +func lookupAddon(name string) (AddonSpec, bool) { + spec, ok := addonCatalog[name] + return spec, ok +} + +// supportsVersion reports whether the spec lists the given version. +func (s AddonSpec) supportsVersion(version string) bool { + return slices.Contains(s.Versions, version) +} + +// catalogSpecs returns the catalog entries sorted by name for stable output. +func catalogSpecs() []AddonSpec { + names := make([]string, 0, len(addonCatalog)) + for n := range addonCatalog { + names = append(names, n) + } + insertionSortStrings(names) + out := make([]AddonSpec, 0, len(names)) + for _, n := range names { + out = append(out, addonCatalog[n]) + } + return out +} + +func insertionSortStrings(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j-1] > s[j]; j-- { + s[j-1], s[j] = s[j], s[j-1] + } + } +} diff --git a/spinifex/handlers/eks/addon_catalog_test.go b/spinifex/handlers/eks/addon_catalog_test.go new file mode 100644 index 000000000..12fb1a7ee --- /dev/null +++ b/spinifex/handlers/eks/addon_catalog_test.go @@ -0,0 +1,47 @@ +package handlers_eks + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLookupAddon_KnownAndUnknown(t *testing.T) { + spec, ok := lookupAddon("aws-load-balancer-controller") + require.True(t, ok) + assert.Equal(t, "aws-load-balancer-controller", spec.Name) + assert.True(t, spec.RequiresIRSA) + + _, ok = lookupAddon("does-not-exist") + assert.False(t, ok) +} + +func TestAddonSpec_DefaultVersionIsNewest(t *testing.T) { + for name, spec := range addonCatalog { + require.NotEmpty(t, spec.Versions, "addon %s must list versions", name) + assert.Equal(t, spec.Versions[0], spec.DefaultVersion, + "addon %s default version must be the newest (first) version", name) + } +} + +func TestAddonSpec_SupportsVersion(t *testing.T) { + spec, ok := lookupAddon("aws-load-balancer-controller") + require.True(t, ok) + assert.True(t, spec.supportsVersion(spec.DefaultVersion)) + assert.False(t, spec.supportsVersion("0.0.0-nope")) +} + +func TestCatalogSpecs_SortedByName(t *testing.T) { + specs := catalogSpecs() + require.Len(t, specs, len(addonCatalog)) + for i := 1; i < len(specs); i++ { + assert.LessOrEqual(t, specs[i-1].Name, specs[i].Name, "catalog must be name-sorted") + } +} + +func TestNewAddonSpec_PanicsWithoutVersions(t *testing.T) { + assert.Panics(t, func() { + newAddonSpec("broken", false, "no versions") + }) +} diff --git a/spinifex/handlers/eks/addon_installer.go b/spinifex/handlers/eks/addon_installer.go new file mode 100644 index 000000000..e181bf6ac --- /dev/null +++ b/spinifex/handlers/eks/addon_installer.go @@ -0,0 +1,102 @@ +package handlers_eks + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/nats-io/nats.go" +) + +// AddonInstaller delivers (and removes) a managed add-on's Kubernetes manifests +// to a cluster. It is deliberately transport-agnostic: the add-on state machine +// depends only on this narrow contract so the VM-side delivery transport (which +// drops manifests into the K3s server auto-deploy dir) can drop in without +// touching the op layer. +// +// The reachability constraint (cluster_reconciler.go:73-79) means delivery must +// be VM-side, not a daemon apiserver apply — a private cluster's apiserver is +// not host-reachable. +type AddonInstaller interface { + // Install renders and delivers the add-on described by rec. It does not flip + // the record to ACTIVE — that transition is gated on the cluster's state + // report confirming the manifest applied and pods are ready. + Install(accountID, cluster string, rec *AddonRecord) error + // Uninstall removes the add-on's delivered manifests from the cluster. + Uninstall(accountID, cluster, addon string) error +} + +// stagedManifest is the artifact the VM-side delivery transport consumes. It +// names the bundled add-on + version and carries the operator-supplied config +// so the VM can render the final Kubernetes objects from the baked manifests. +type stagedManifest struct { + AddonName string `json:"addonName"` + AddonVersion string `json:"addonVersion"` + ServiceAccountRoleArn string `json:"serviceAccountRoleArn,omitempty"` + ConfigurationValues string `json:"configurationValues,omitempty"` +} + +// stagingInstaller renders the bundled manifest descriptor for an add-on and +// stages it in the per-account KV at AddonManifestKey. The VM-side delivery +// slice (separate bead) pulls staged manifests and applies them via the K3s +// auto-deploy dir; until then an installed add-on sits CREATING — honest, not a +// false ACTIVE. +type stagingInstaller struct { + nc *nats.Conn +} + +var _ AddonInstaller = (*stagingInstaller)(nil) + +// newStagingInstaller builds the default installer bound to the daemon's NATS +// connection. +func newStagingInstaller(nc *nats.Conn) *stagingInstaller { + return &stagingInstaller{nc: nc} +} + +func (i *stagingInstaller) acctKV(accountID string) (nats.KeyValue, error) { + if i.nc == nil { + return nil, errors.New("eks: stagingInstaller nil NATS connection") + } + js, err := i.nc.JetStream() + if err != nil { + return nil, fmt.Errorf("jetstream: %w", err) + } + return GetOrCreateAccountBucket(js, accountID) +} + +func (i *stagingInstaller) Install(accountID, cluster string, rec *AddonRecord) error { + if rec == nil { + return errors.New("eks: stagingInstaller Install nil record") + } + kv, err := i.acctKV(accountID) + if err != nil { + return err + } + manifest := stagedManifest{ + AddonName: rec.AddonName, + AddonVersion: rec.AddonVersion, + ServiceAccountRoleArn: rec.ServiceAccountRoleArn, + ConfigurationValues: rec.ConfigurationValues, + } + data, err := json.Marshal(&manifest) + if err != nil { + return fmt.Errorf("marshal staged manifest %s: %w", rec.AddonName, err) + } + key := AddonManifestKey(cluster, rec.AddonName) + if _, err := kv.Put(key, data); err != nil { + return fmt.Errorf("kv put %s: %w", key, err) + } + return nil +} + +func (i *stagingInstaller) Uninstall(accountID, cluster, addon string) error { + kv, err := i.acctKV(accountID) + if err != nil { + return err + } + key := AddonManifestKey(cluster, addon) + if err := kv.Delete(key); err != nil && !errors.Is(err, nats.ErrKeyNotFound) { + return fmt.Errorf("kv delete %s: %w", key, err) + } + return nil +} diff --git a/spinifex/handlers/eks/addon_state.go b/spinifex/handlers/eks/addon_state.go new file mode 100644 index 000000000..e9467dc14 --- /dev/null +++ b/spinifex/handlers/eks/addon_state.go @@ -0,0 +1,196 @@ +package handlers_eks + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/nats-io/nats.go" +) + +// AddonStatus mirrors the AWS EKS addon.status enum verbatim so clients match +// against the values without translation. +type AddonStatus string + +const ( + AddonStatusCreating AddonStatus = "CREATING" + AddonStatusActive AddonStatus = "ACTIVE" + AddonStatusUpdating AddonStatus = "UPDATING" + AddonStatusDeleting AddonStatus = "DELETING" + AddonStatusDegraded AddonStatus = "DEGRADED" + AddonStatusCreateFailed AddonStatus = "CREATE_FAILED" +) + +// AddonRecord is the persisted-state envelope for a managed add-on bound to a +// cluster. It tracks the AWS-visible lifecycle status plus the IRSA role and +// opaque configuration the installer needs. +type AddonRecord struct { + AddonName string `json:"addonName"` + AddonVersion string `json:"addonVersion"` + Status AddonStatus `json:"status"` + ServiceAccountRoleArn string `json:"serviceAccountRoleArn,omitempty"` + ConfigurationValues string `json:"configurationValues,omitempty"` + Health string `json:"health,omitempty"` + Arn string `json:"arn"` + Tags map[string]string `json:"tags,omitempty"` + CreatedAt time.Time `json:"createdAt"` + ModifiedAt time.Time `json:"modifiedAt"` +} + +// ErrAddonNotFound is returned by GetAddonRecord / the CAS helper when no +// record exists for the add-on. Callers translate to the AWS shape +// (ResourceNotFoundException) at the service boundary. +var ErrAddonNotFound = errors.New("eks: addon not found") + +// AddonARN composes the deterministic ARN for a managed add-on (one per +// add-on name per cluster). +func AddonARN(region, accountID, cluster, addon string) string { + return fmt.Sprintf("arn:aws:eks:%s:%s:addon/%s/%s", region, accountID, cluster, addon) +} + +// PutAddonRecord writes the record unconditionally. +func PutAddonRecord(kv nats.KeyValue, cluster string, rec *AddonRecord) error { + if rec == nil { + return errors.New("eks: PutAddonRecord nil record") + } + if cluster == "" || rec.AddonName == "" { + return errors.New("eks: PutAddonRecord missing cluster or addon name") + } + data, err := json.Marshal(rec) + if err != nil { + return fmt.Errorf("marshal addon %s: %w", rec.AddonName, err) + } + key := AddonKey(cluster, rec.AddonName) + if _, err := kv.Put(key, data); err != nil { + return fmt.Errorf("kv put %s: %w", key, err) + } + return nil +} + +// GetAddonRecord reads one record. Returns ErrAddonNotFound if absent. +func GetAddonRecord(kv nats.KeyValue, cluster, addon string) (*AddonRecord, error) { + if cluster == "" || addon == "" { + return nil, errors.New("eks: GetAddonRecord empty cluster or addon name") + } + entry, err := kv.Get(AddonKey(cluster, addon)) + if err != nil { + if errors.Is(err, nats.ErrKeyNotFound) { + return nil, ErrAddonNotFound + } + return nil, fmt.Errorf("kv get addon: %w", err) + } + var rec AddonRecord + if err := json.Unmarshal(entry.Value(), &rec); err != nil { + return nil, fmt.Errorf("unmarshal addon %s: %w", addon, err) + } + return &rec, nil +} + +// ListAddonRecords returns every add-on record under a cluster, sorted by add-on +// name for stable output. The staged-manifest sub-keys +// (clusters/{c}/addons/{addon}/manifest) are skipped — only the record keys +// (one path segment under the prefix) are returned. +func ListAddonRecords(kv nats.KeyValue, cluster string) ([]*AddonRecord, error) { + if cluster == "" { + return nil, errors.New("eks: ListAddonRecords empty cluster") + } + keys, err := kv.Keys() + if err != nil { + if errors.Is(err, nats.ErrNoKeysFound) { + return nil, nil + } + return nil, fmt.Errorf("kv keys: %w", err) + } + prefix := AddonsPrefix(cluster) + out := make([]*AddonRecord, 0) + for _, k := range keys { + if !strings.HasPrefix(k, prefix) { + continue + } + // Skip sub-keys (e.g. the staged manifest); a record key is exactly one + // segment under the prefix. + if strings.Contains(strings.TrimPrefix(k, prefix), "/") { + continue + } + entry, err := kv.Get(k) + if err != nil { + if errors.Is(err, nats.ErrKeyNotFound) { + continue + } + return nil, fmt.Errorf("kv get %s: %w", k, err) + } + var rec AddonRecord + if err := json.Unmarshal(entry.Value(), &rec); err != nil { + return nil, fmt.Errorf("unmarshal addon %s: %w", k, err) + } + out = append(out, &rec) + } + sortAddonRecords(out) + return out, nil +} + +// DeleteAddonRecord removes one record (and its staged manifest, if any). +// Returns ErrAddonNotFound if the record did not exist. +func DeleteAddonRecord(kv nats.KeyValue, cluster, addon string) error { + key := AddonKey(cluster, addon) + if _, err := kv.Get(key); err != nil { + if errors.Is(err, nats.ErrKeyNotFound) { + return ErrAddonNotFound + } + return fmt.Errorf("kv get %s: %w", key, err) + } + if err := kv.Delete(key); err != nil { + return fmt.Errorf("kv delete %s: %w", key, err) + } + // Best-effort: drop the staged manifest so a re-create starts clean. + manifestKey := AddonManifestKey(cluster, addon) + if err := kv.Delete(manifestKey); err != nil && !errors.Is(err, nats.ErrKeyNotFound) { + return fmt.Errorf("kv delete %s: %w", manifestKey, err) + } + return nil +} + +// casUpdateAddon does a revision-checked read-modify-write. mutate returns true +// if it changed a field. Returns ErrAddonNotFound if absent. +func casUpdateAddon(kv nats.KeyValue, cluster, addon string, mutate func(*AddonRecord) bool) (*AddonRecord, error) { + key := AddonKey(cluster, addon) + for range maxClusterStateCASRetries { + entry, err := kv.Get(key) + if err != nil { + if errors.Is(err, nats.ErrKeyNotFound) { + return nil, ErrAddonNotFound + } + return nil, fmt.Errorf("kv get %s: %w", key, err) + } + var rec AddonRecord + if err := json.Unmarshal(entry.Value(), &rec); err != nil { + return nil, fmt.Errorf("unmarshal addon %s: %w", addon, err) + } + if !mutate(&rec) { + return &rec, nil + } + data, err := json.Marshal(&rec) + if err != nil { + return nil, fmt.Errorf("marshal addon %s: %w", addon, err) + } + _, err = kv.Update(key, data, entry.Revision()) + if err == nil { + return &rec, nil + } + if errors.Is(err, nats.ErrKeyExists) { + continue + } + return nil, fmt.Errorf("kv update %s: %w", key, err) + } + return nil, fmt.Errorf("eks: casUpdateAddon %s exhausted CAS retries", addon) +} + +func sortAddonRecords(recs []*AddonRecord) { + for i := 1; i < len(recs); i++ { + for j := i; j > 0 && recs[j-1].AddonName > recs[j].AddonName; j-- { + recs[j-1], recs[j] = recs[j], recs[j-1] + } + } +} diff --git a/spinifex/handlers/eks/addon_state_test.go b/spinifex/handlers/eks/addon_state_test.go new file mode 100644 index 000000000..709e7a10a --- /dev/null +++ b/spinifex/handlers/eks/addon_state_test.go @@ -0,0 +1,108 @@ +package handlers_eks + +import ( + "testing" + "time" + + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleAddonRecord(name string) *AddonRecord { + now := time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC) + return &AddonRecord{ + AddonName: name, + AddonVersion: "1.0.0", + Status: AddonStatusCreating, + Arn: "arn:aws:eks:us-east-1:111122223333:addon/c1/" + name, + CreatedAt: now, + ModifiedAt: now, + } +} + +func TestAddonRecordGuards(t *testing.T) { + require.Error(t, PutAddonRecord(nil, "c1", nil)) + require.Error(t, PutAddonRecord(nil, "", sampleAddonRecord("x"))) + require.Error(t, PutAddonRecord(nil, "c1", &AddonRecord{})) + + _, err := GetAddonRecord(nil, "", "x") + require.Error(t, err) + + _, err = ListAddonRecords(nil, "") + require.Error(t, err) +} + +func TestAddonRecord_RoundTrip(t *testing.T) { + kv := newClusterStateTestKV(t) + + in := sampleAddonRecord("aws-load-balancer-controller") + in.Tags = map[string]string{"team": "platform"} + require.NoError(t, PutAddonRecord(kv, "c1", in)) + + got, err := GetAddonRecord(kv, "c1", in.AddonName) + require.NoError(t, err) + assert.Equal(t, in.AddonName, got.AddonName) + assert.Equal(t, in.AddonVersion, got.AddonVersion) + assert.Equal(t, in.Status, got.Status) + assert.Equal(t, "platform", got.Tags["team"]) +} + +func TestGetAddonRecord_NotFound(t *testing.T) { + kv := newClusterStateTestKV(t) + _, err := GetAddonRecord(kv, "c1", "ghost") + assert.ErrorIs(t, err, ErrAddonNotFound) +} + +func TestListAddonRecords_SkipsManifestSubKeys(t *testing.T) { + kv := newClusterStateTestKV(t) + + require.NoError(t, PutAddonRecord(kv, "c1", sampleAddonRecord("coredns"))) + require.NoError(t, PutAddonRecord(kv, "c1", sampleAddonRecord("aws-load-balancer-controller"))) + // A staged manifest sub-key must not be mistaken for a record. + _, err := kv.Put(AddonManifestKey("c1", "coredns"), []byte(`{"addonName":"coredns"}`)) + require.NoError(t, err) + + recs, err := ListAddonRecords(kv, "c1") + require.NoError(t, err) + require.Len(t, recs, 2) + // Sorted by name. + assert.Equal(t, "aws-load-balancer-controller", recs[0].AddonName) + assert.Equal(t, "coredns", recs[1].AddonName) +} + +func TestDeleteAddonRecord_RemovesRecordAndManifest(t *testing.T) { + kv := newClusterStateTestKV(t) + require.NoError(t, PutAddonRecord(kv, "c1", sampleAddonRecord("coredns"))) + _, err := kv.Put(AddonManifestKey("c1", "coredns"), []byte(`{}`)) + require.NoError(t, err) + + require.NoError(t, DeleteAddonRecord(kv, "c1", "coredns")) + + _, err = GetAddonRecord(kv, "c1", "coredns") + assert.ErrorIs(t, err, ErrAddonNotFound) + _, err = kv.Get(AddonManifestKey("c1", "coredns")) + assert.ErrorIs(t, err, nats.ErrKeyNotFound) + + // Deleting again is a not-found. + assert.ErrorIs(t, DeleteAddonRecord(kv, "c1", "coredns"), ErrAddonNotFound) +} + +func TestCasUpdateAddon(t *testing.T) { + kv := newClusterStateTestKV(t) + require.NoError(t, PutAddonRecord(kv, "c1", sampleAddonRecord("coredns"))) + + updated, err := casUpdateAddon(kv, "c1", "coredns", func(r *AddonRecord) bool { + r.Status = AddonStatusActive + return true + }) + require.NoError(t, err) + assert.Equal(t, AddonStatusActive, updated.Status) + + got, err := GetAddonRecord(kv, "c1", "coredns") + require.NoError(t, err) + assert.Equal(t, AddonStatusActive, got.Status) + + _, err = casUpdateAddon(kv, "c1", "ghost", func(*AddonRecord) bool { return true }) + assert.ErrorIs(t, err, ErrAddonNotFound) +} diff --git a/spinifex/handlers/eks/addons.go b/spinifex/handlers/eks/addons.go new file mode 100644 index 000000000..8fcb1e0db --- /dev/null +++ b/spinifex/handlers/eks/addons.go @@ -0,0 +1,279 @@ +package handlers_eks + +import ( + "errors" + "log/slog" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/eks" + "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/nats-io/nats.go" +) + +// ListAddons returns the names of every managed add-on installed on a cluster. +func (s *EKSServiceImpl) ListAddons(input *eks.ListAddonsInput, accountID string) (*eks.ListAddonsOutput, error) { + if input == nil { + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + cluster := aws.StringValue(input.ClusterName) + acctKV, err := s.acctKVForCluster(accountID, cluster) + if err != nil { + return nil, err + } + recs, err := ListAddonRecords(acctKV, cluster) + if err != nil { + return nil, err + } + names := make([]string, 0, len(recs)) + for _, rec := range recs { + names = append(names, rec.AddonName) + } + return &eks.ListAddonsOutput{Addons: aws.StringSlice(names)}, nil +} + +// DescribeAddonVersions returns the bundled add-on catalog, optionally filtered +// to a single add-on by name. It is cluster-independent (the catalog is static) +// so it does not touch the KV store. +func (s *EKSServiceImpl) DescribeAddonVersions(input *eks.DescribeAddonVersionsInput, _ string) (*eks.DescribeAddonVersionsOutput, error) { + filter := "" + if input != nil { + filter = aws.StringValue(input.AddonName) + } + specs := catalogSpecs() + out := make([]*eks.AddonInfo, 0, len(specs)) + for _, spec := range specs { + if filter != "" && spec.Name != filter { + continue + } + out = append(out, addonSpecToAWS(spec)) + } + return &eks.DescribeAddonVersionsOutput{Addons: out}, nil +} + +// CreateAddon validates the add-on against the bundled catalog, persists a +// CREATING record, and stages it for delivery. The record reaches ACTIVE only +// once the cluster's state report confirms delivery (separate slice). +func (s *EKSServiceImpl) CreateAddon(input *eks.CreateAddonInput, accountID string) (*eks.CreateAddonOutput, error) { + if input == nil { + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + cluster := aws.StringValue(input.ClusterName) + addonName := aws.StringValue(input.AddonName) + if addonName == "" { + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + spec, ok := lookupAddon(addonName) + if !ok { + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + version := aws.StringValue(input.AddonVersion) + if version == "" { + version = spec.DefaultVersion + } else if !spec.supportsVersion(version) { + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + acctKV, err := s.acctKVForCluster(accountID, cluster) + if err != nil { + return nil, err + } + if _, err := GetAddonRecord(acctKV, cluster, addonName); err == nil { + return nil, errors.New(awserrors.ErrorEKSResourceInUse) + } else if !errors.Is(err, ErrAddonNotFound) { + return nil, err + } + now := time.Now().UTC() + rec := &AddonRecord{ + AddonName: addonName, + AddonVersion: version, + Status: AddonStatusCreating, + ServiceAccountRoleArn: aws.StringValue(input.ServiceAccountRoleArn), + ConfigurationValues: aws.StringValue(input.ConfigurationValues), + Arn: AddonARN(s.deps.Region, accountID, cluster, addonName), + Tags: aws.StringValueMap(input.Tags), + CreatedAt: now, + ModifiedAt: now, + } + if err := PutAddonRecord(acctKV, cluster, rec); err != nil { + return nil, err + } + if err := s.addonInstaller().Install(accountID, cluster, rec); err != nil { + s.markAddonFailed(acctKV, cluster, addonName, err) + return nil, err + } + return &eks.CreateAddonOutput{Addon: addonRecordToAWS(cluster, rec)}, nil +} + +// DescribeAddon returns one installed add-on's record. +func (s *EKSServiceImpl) DescribeAddon(input *eks.DescribeAddonInput, accountID string) (*eks.DescribeAddonOutput, error) { + if input == nil { + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + cluster := aws.StringValue(input.ClusterName) + addonName := aws.StringValue(input.AddonName) + acctKV, err := s.acctKVForCluster(accountID, cluster) + if err != nil { + return nil, err + } + rec, err := GetAddonRecord(acctKV, cluster, addonName) + if err != nil { + if errors.Is(err, ErrAddonNotFound) { + return nil, errors.New(awserrors.ErrorEKSResourceNotFound) + } + return nil, err + } + return &eks.DescribeAddonOutput{Addon: addonRecordToAWS(cluster, rec)}, nil +} + +// UpdateAddon CASes the version/config/role onto the record, marks it UPDATING, +// and re-stages it for delivery. +func (s *EKSServiceImpl) UpdateAddon(input *eks.UpdateAddonInput, accountID string) (*eks.UpdateAddonOutput, error) { + if input == nil { + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + cluster := aws.StringValue(input.ClusterName) + addonName := aws.StringValue(input.AddonName) + acctKV, err := s.acctKVForCluster(accountID, cluster) + if err != nil { + return nil, err + } + // Validate a requested version against the catalog before the CAS. + if v := aws.StringValue(input.AddonVersion); v != "" { + spec, ok := lookupAddon(addonName) + if !ok || !spec.supportsVersion(v) { + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + } + now := time.Now().UTC() + rec, err := casUpdateAddon(acctKV, cluster, addonName, func(r *AddonRecord) bool { + if v := aws.StringValue(input.AddonVersion); v != "" { + r.AddonVersion = v + } + if input.ConfigurationValues != nil { + r.ConfigurationValues = aws.StringValue(input.ConfigurationValues) + } + if input.ServiceAccountRoleArn != nil { + r.ServiceAccountRoleArn = aws.StringValue(input.ServiceAccountRoleArn) + } + r.Status = AddonStatusUpdating + r.ModifiedAt = now + return true + }) + if err != nil { + if errors.Is(err, ErrAddonNotFound) { + return nil, errors.New(awserrors.ErrorEKSResourceNotFound) + } + return nil, err + } + if err := s.addonInstaller().Install(accountID, cluster, rec); err != nil { + s.markAddonFailed(acctKV, cluster, addonName, err) + return nil, err + } + return &eks.UpdateAddonOutput{Update: &eks.Update{ + Id: aws.String(rec.Arn), + Status: aws.String(eks.UpdateStatusSuccessful), + Type: aws.String(eks.UpdateTypeAddonUpdate), + CreatedAt: aws.Time(now), + }}, nil +} + +// DeleteAddon marks the record DELETING, removes the staged manifest, and +// deletes the record. +func (s *EKSServiceImpl) DeleteAddon(input *eks.DeleteAddonInput, accountID string) (*eks.DeleteAddonOutput, error) { + if input == nil { + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + cluster := aws.StringValue(input.ClusterName) + addonName := aws.StringValue(input.AddonName) + acctKV, err := s.acctKVForCluster(accountID, cluster) + if err != nil { + return nil, err + } + rec, err := GetAddonRecord(acctKV, cluster, addonName) + if err != nil { + if errors.Is(err, ErrAddonNotFound) { + return nil, errors.New(awserrors.ErrorEKSResourceNotFound) + } + return nil, err + } + rec.Status = AddonStatusDeleting + rec.ModifiedAt = time.Now().UTC() + out := &eks.DeleteAddonOutput{Addon: addonRecordToAWS(cluster, rec)} + if err := s.addonInstaller().Uninstall(accountID, cluster, addonName); err != nil { + return nil, err + } + if err := DeleteAddonRecord(acctKV, cluster, addonName); err != nil { + if errors.Is(err, ErrAddonNotFound) { + return nil, errors.New(awserrors.ErrorEKSResourceNotFound) + } + return nil, err + } + return out, nil +} + +// addonRecordToAWS converts a persisted record to the SDK Addon shape. +func addonRecordToAWS(cluster string, rec *AddonRecord) *eks.Addon { + out := &eks.Addon{ + AddonArn: aws.String(rec.Arn), + AddonName: aws.String(rec.AddonName), + AddonVersion: aws.String(rec.AddonVersion), + ClusterName: aws.String(cluster), + Status: aws.String(string(rec.Status)), + CreatedAt: aws.Time(rec.CreatedAt), + ModifiedAt: aws.Time(rec.ModifiedAt), + } + if rec.ServiceAccountRoleArn != "" { + out.ServiceAccountRoleArn = aws.String(rec.ServiceAccountRoleArn) + } + if rec.ConfigurationValues != "" { + out.ConfigurationValues = aws.String(rec.ConfigurationValues) + } + if rec.Health != "" { + out.Health = &eks.AddonHealth{Issues: []*eks.AddonIssue{{ + Message: aws.String(rec.Health), + }}} + } + if len(rec.Tags) > 0 { + out.Tags = aws.StringMap(rec.Tags) + } + return out +} + +// addonSpecToAWS converts a catalog spec to the SDK AddonInfo shape, expanding +// each supported version into an AddonVersionInfo. +func addonSpecToAWS(spec AddonSpec) *eks.AddonInfo { + versions := make([]*eks.AddonVersionInfo, 0, len(spec.Versions)) + for _, v := range spec.Versions { + versions = append(versions, &eks.AddonVersionInfo{ + AddonVersion: aws.String(v), + RequiresIamPermissions: aws.Bool(spec.RequiresIRSA), + }) + } + return &eks.AddonInfo{ + AddonName: aws.String(spec.Name), + AddonVersions: versions, + } +} + +// addonInstaller returns the wired installer, defaulting to the staging +// installer bound to the daemon NATS connection when none was injected. +func (s *EKSServiceImpl) addonInstaller() AddonInstaller { + if s.deps.AddonInstaller != nil { + return s.deps.AddonInstaller + } + return newStagingInstaller(s.deps.NATSConn) +} + +// markAddonFailed best-effort flips a record to CREATE_FAILED with the reason +// after an installer error so a failed install is not left looking healthy. +func (s *EKSServiceImpl) markAddonFailed(acctKV nats.KeyValue, cluster, addon string, cause error) { + now := time.Now().UTC() + if _, err := casUpdateAddon(acctKV, cluster, addon, func(r *AddonRecord) bool { + r.Status = AddonStatusCreateFailed + r.Health = cause.Error() + r.ModifiedAt = now + return true + }); err != nil { + slog.Warn("markAddonFailed: CAS failed", "cluster", cluster, "addon", addon, "err", err) + } +} diff --git a/spinifex/handlers/eks/addons_test.go b/spinifex/handlers/eks/addons_test.go new file mode 100644 index 000000000..2a7d9c17d --- /dev/null +++ b/spinifex/handlers/eks/addons_test.go @@ -0,0 +1,241 @@ +package handlers_eks + +import ( + "errors" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/eks" + "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeAddonInstaller records calls so tests can assert the state machine drives +// the installer with the right record. +type fakeAddonInstaller struct { + installs []*AddonRecord + uninstalls []string + installErr error +} + +var _ AddonInstaller = (*fakeAddonInstaller)(nil) + +func (f *fakeAddonInstaller) Install(_, _ string, rec *AddonRecord) error { + f.installs = append(f.installs, rec) + return f.installErr +} + +func (f *fakeAddonInstaller) Uninstall(_, _, addon string) error { + f.uninstalls = append(f.uninstalls, addon) + return nil +} + +func setupAddonService(t *testing.T) (*EKSServiceImpl, *fakeAddonInstaller) { + t.Helper() + svc := setupTestService(t) + seedTestCluster(t, svc, "c1") + fake := &fakeAddonInstaller{} + svc.deps.AddonInstaller = fake + return svc, fake +} + +const albController = "aws-load-balancer-controller" + +func TestDescribeAddonVersions_ReturnsCatalog(t *testing.T) { + svc := setupTestService(t) + + out, err := svc.DescribeAddonVersions(&eks.DescribeAddonVersionsInput{}, testAccountID) + require.NoError(t, err) + assert.Len(t, out.Addons, len(addonCatalog)) + + // Filtered to one addon. + out, err = svc.DescribeAddonVersions(&eks.DescribeAddonVersionsInput{ + AddonName: aws.String(albController), + }, testAccountID) + require.NoError(t, err) + require.Len(t, out.Addons, 1) + assert.Equal(t, albController, aws.StringValue(out.Addons[0].AddonName)) + require.NotEmpty(t, out.Addons[0].AddonVersions) + assert.True(t, aws.BoolValue(out.Addons[0].AddonVersions[0].RequiresIamPermissions)) +} + +func TestCreateAddon_UnknownClusterIsNotFound(t *testing.T) { + svc := setupTestService(t) + _, err := svc.CreateAddon(&eks.CreateAddonInput{ + ClusterName: aws.String("missing"), AddonName: aws.String(albController), + }, testAccountID) + require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound) +} + +func TestCreateAddon_UnknownAddonRejected(t *testing.T) { + svc, _ := setupAddonService(t) + _, err := svc.CreateAddon(&eks.CreateAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String("not-a-real-addon"), + }, testAccountID) + require.EqualError(t, err, awserrors.ErrorInvalidParameterValue) +} + +func TestCreateAddon_UnknownVersionRejected(t *testing.T) { + svc, _ := setupAddonService(t) + _, err := svc.CreateAddon(&eks.CreateAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String(albController), + AddonVersion: aws.String("9.9.9-nope"), + }, testAccountID) + require.EqualError(t, err, awserrors.ErrorInvalidParameterValue) +} + +func TestCreateAddon_CreatesStagingAndDefaultsVersion(t *testing.T) { + svc, fake := setupAddonService(t) + spec, _ := lookupAddon(albController) + + out, err := svc.CreateAddon(&eks.CreateAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String(albController), + }, testAccountID) + require.NoError(t, err) + require.NotNil(t, out.Addon) + // Version defaults to catalog default; status starts CREATING (honest until + // VM-side delivery confirms ACTIVE). + assert.Equal(t, spec.DefaultVersion, aws.StringValue(out.Addon.AddonVersion)) + assert.Equal(t, string(AddonStatusCreating), aws.StringValue(out.Addon.Status)) + assert.Contains(t, aws.StringValue(out.Addon.AddonArn), ":addon/c1/"+albController) + + // Installer was driven with the persisted record. + require.Len(t, fake.installs, 1) + assert.Equal(t, albController, fake.installs[0].AddonName) + + // Duplicate create → ResourceInUseException. + _, err = svc.CreateAddon(&eks.CreateAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String(albController), + }, testAccountID) + require.EqualError(t, err, awserrors.ErrorEKSResourceInUse) +} + +func TestCreateAddon_InstallerFailureMarksCreateFailed(t *testing.T) { + svc, fake := setupAddonService(t) + fake.installErr = errors.New("delivery bus unreachable") + + _, err := svc.CreateAddon(&eks.CreateAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String(albController), + }, testAccountID) + require.Error(t, err) + + desc, err := svc.DescribeAddon(&eks.DescribeAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String(albController), + }, testAccountID) + require.NoError(t, err) + assert.Equal(t, string(AddonStatusCreateFailed), aws.StringValue(desc.Addon.Status)) +} + +func TestAddon_DescribeListDelete(t *testing.T) { + svc, fake := setupAddonService(t) + + _, err := svc.CreateAddon(&eks.CreateAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String(albController), + }, testAccountID) + require.NoError(t, err) + + desc, err := svc.DescribeAddon(&eks.DescribeAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String(albController), + }, testAccountID) + require.NoError(t, err) + assert.Equal(t, albController, aws.StringValue(desc.Addon.AddonName)) + + list, err := svc.ListAddons(&eks.ListAddonsInput{ClusterName: aws.String("c1")}, testAccountID) + require.NoError(t, err) + assert.Equal(t, []string{albController}, aws.StringValueSlice(list.Addons)) + + _, err = svc.DeleteAddon(&eks.DeleteAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String(albController), + }, testAccountID) + require.NoError(t, err) + assert.Equal(t, []string{albController}, fake.uninstalls) + + _, err = svc.DescribeAddon(&eks.DescribeAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String(albController), + }, testAccountID) + require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound) +} + +func TestDescribeDeleteAddon_MissingIsNotFound(t *testing.T) { + svc, _ := setupAddonService(t) + _, err := svc.DescribeAddon(&eks.DescribeAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String(albController), + }, testAccountID) + require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound) + _, err = svc.DeleteAddon(&eks.DeleteAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String(albController), + }, testAccountID) + require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound) +} + +func TestUpdateAddon_ChangesVersionAndReinstalls(t *testing.T) { + svc, fake := setupAddonService(t) + spec, _ := lookupAddon("coredns") + + _, err := svc.CreateAddon(&eks.CreateAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String("coredns"), + }, testAccountID) + require.NoError(t, err) + require.Len(t, fake.installs, 1) + + out, err := svc.UpdateAddon(&eks.UpdateAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String("coredns"), + ConfigurationValues: aws.String(`{"replicaCount":2}`), + }, testAccountID) + require.NoError(t, err) + require.NotNil(t, out.Update) + assert.Equal(t, eks.UpdateTypeAddonUpdate, aws.StringValue(out.Update.Type)) + require.Len(t, fake.installs, 2, "update must re-drive the installer") + + desc, err := svc.DescribeAddon(&eks.DescribeAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String("coredns"), + }, testAccountID) + require.NoError(t, err) + assert.Equal(t, `{"replicaCount":2}`, aws.StringValue(desc.Addon.ConfigurationValues)) + assert.Equal(t, spec.DefaultVersion, aws.StringValue(desc.Addon.AddonVersion)) + assert.Equal(t, string(AddonStatusUpdating), aws.StringValue(desc.Addon.Status)) +} + +func TestUpdateAddon_UnknownVersionRejected(t *testing.T) { + svc, _ := setupAddonService(t) + _, err := svc.CreateAddon(&eks.CreateAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String("coredns"), + }, testAccountID) + require.NoError(t, err) + + _, err = svc.UpdateAddon(&eks.UpdateAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String("coredns"), + AddonVersion: aws.String("9.9.9-nope"), + }, testAccountID) + require.EqualError(t, err, awserrors.ErrorInvalidParameterValue) +} + +func TestUpdateAddon_MissingIsNotFound(t *testing.T) { + svc, _ := setupAddonService(t) + _, err := svc.UpdateAddon(&eks.UpdateAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String("coredns"), + }, testAccountID) + require.EqualError(t, err, awserrors.ErrorEKSResourceNotFound) +} + +// The default (uninjected) installer stages a manifest in KV that the VM-side +// delivery slice consumes. +func TestStagingInstaller_StagesManifest(t *testing.T) { + svc := setupTestService(t) + seedTestCluster(t, svc, "c1") + + _, err := svc.CreateAddon(&eks.CreateAddonInput{ + ClusterName: aws.String("c1"), AddonName: aws.String(albController), + ServiceAccountRoleArn: aws.String("arn:aws:iam::111122223333:role/alb"), + }, testAccountID) + require.NoError(t, err) + + js, err := svc.deps.NATSConn.JetStream() + require.NoError(t, err) + kv, err := GetOrCreateAccountBucket(js, testAccountID) + require.NoError(t, err) + entry, err := kv.Get(AddonManifestKey("c1", albController)) + require.NoError(t, err, "installer must stage a manifest for VM-side delivery") + assert.Contains(t, string(entry.Value()), albController) +} diff --git a/spinifex/handlers/eks/service_impl.go b/spinifex/handlers/eks/service_impl.go index 375de4e6e..899463b94 100644 --- a/spinifex/handlers/eks/service_impl.go +++ b/spinifex/handlers/eks/service_impl.go @@ -63,6 +63,11 @@ type EKSServiceDeps struct { Image k3sAMIResolver EIP eipProvisioner Worker WorkerLauncher + + // AddonInstaller delivers managed-addon manifests to clusters. Optional: + // when nil the service defaults to the KV staging installer + // (newStagingInstaller) bound to NATSConn. + AddonInstaller AddonInstaller } // WorkerLauncher is the narrow EC2 surface CreateNodegroup needs to launch and @@ -1107,31 +1112,7 @@ func accessPolicyName(arn string) string { return arn } -// --- Addons --- - -func (s *EKSServiceImpl) ListAddons(_ *eks.ListAddonsInput, _ string) (*eks.ListAddonsOutput, error) { - return nil, notImpl() -} - -func (s *EKSServiceImpl) DescribeAddonVersions(_ *eks.DescribeAddonVersionsInput, _ string) (*eks.DescribeAddonVersionsOutput, error) { - return nil, notImpl() -} - -func (s *EKSServiceImpl) CreateAddon(_ *eks.CreateAddonInput, _ string) (*eks.CreateAddonOutput, error) { - return nil, notImpl() -} - -func (s *EKSServiceImpl) DeleteAddon(_ *eks.DeleteAddonInput, _ string) (*eks.DeleteAddonOutput, error) { - return nil, notImpl() -} - -func (s *EKSServiceImpl) DescribeAddon(_ *eks.DescribeAddonInput, _ string) (*eks.DescribeAddonOutput, error) { - return nil, notImpl() -} - -func (s *EKSServiceImpl) UpdateAddon(_ *eks.UpdateAddonInput, _ string) (*eks.UpdateAddonOutput, error) { - return nil, notImpl() -} +// --- Addons (see addons.go) --- // --- OIDC identity-provider configs --- diff --git a/spinifex/handlers/eks/service_impl_test.go b/spinifex/handlers/eks/service_impl_test.go index f89a1214b..963884c51 100644 --- a/spinifex/handlers/eks/service_impl_test.go +++ b/spinifex/handlers/eks/service_impl_test.go @@ -323,28 +323,6 @@ func TestListAccessPolicies_ReturnsSupportedCatalogue(t *testing.T) { } } -func TestEKSServiceImpl_AddonsMethodsReturnNotImplemented(t *testing.T) { - svc := setupTestService(t) - - _, err := svc.ListAddons(&eks.ListAddonsInput{ClusterName: aws.String("c1")}, testAccountID) - require.EqualError(t, err, awserrors.ErrorNotImplemented) - - _, err = svc.DescribeAddonVersions(&eks.DescribeAddonVersionsInput{}, testAccountID) - require.EqualError(t, err, awserrors.ErrorNotImplemented) - - _, err = svc.CreateAddon(&eks.CreateAddonInput{ClusterName: aws.String("c1"), AddonName: aws.String("vpc-cni")}, testAccountID) - require.EqualError(t, err, awserrors.ErrorNotImplemented) - - _, err = svc.DeleteAddon(&eks.DeleteAddonInput{ClusterName: aws.String("c1"), AddonName: aws.String("vpc-cni")}, testAccountID) - require.EqualError(t, err, awserrors.ErrorNotImplemented) - - _, err = svc.DescribeAddon(&eks.DescribeAddonInput{ClusterName: aws.String("c1"), AddonName: aws.String("vpc-cni")}, testAccountID) - require.EqualError(t, err, awserrors.ErrorNotImplemented) - - _, err = svc.UpdateAddon(&eks.UpdateAddonInput{ClusterName: aws.String("c1"), AddonName: aws.String("vpc-cni")}, testAccountID) - require.EqualError(t, err, awserrors.ErrorNotImplemented) -} - func TestEKSServiceImpl_OIDCMethodsReturnNotImplemented(t *testing.T) { svc := setupTestService(t) diff --git a/spinifex/handlers/eks/store.go b/spinifex/handlers/eks/store.go index 5a46d4ca4..937b259bc 100644 --- a/spinifex/handlers/eks/store.go +++ b/spinifex/handlers/eks/store.go @@ -129,6 +129,23 @@ func EventKey(cluster, ts string) string { return fmt.Sprintf("clusters/%s/events/%s", cluster, ts) } +// AddonsPrefix returns the KV key prefix under which all of a cluster's managed +// add-on records live. Used by ListAddons to enumerate. +func AddonsPrefix(cluster string) string { + return fmt.Sprintf("clusters/%s/addons/", cluster) +} + +// AddonKey returns the KV key for a managed add-on record under a cluster. +func AddonKey(cluster, addon string) string { + return AddonsPrefix(cluster) + addon +} + +// AddonManifestKey returns the KV key under which the rendered manifest for a +// managed add-on is staged for the VM-side delivery transport to consume. +func AddonManifestKey(cluster, addon string) string { + return AddonsPrefix(cluster) + addon + "/manifest" +} + // Store is the per-daemon EKS KV handle. Per-account and leader buckets are // accessed via the package-level factories below. type Store struct {