feat: EKS managed Addon API (state machine + staging installer) (#317)

* feat: un-stub EKS Addon API with managed-addon state machine

Wire the 6 EKS Addon ops (List/DescribeVersions/Create/Describe/Update/
Delete) to a per-cluster managed-addon state machine in the per-account KV.

- addon_catalog.go: static air-gapped catalog (aws-load-balancer-controller,
  coredns) with version validation.
- addon_state.go: AddonRecord + AddonStatus, KV CRUD + CAS helpers; list skips
  staged-manifest sub-keys; delete sweeps record + manifest.
- addon_installer.go: transport-agnostic AddonInstaller; stagingInstaller
  stages a manifest descriptor in KV for the (future) VM-side delivery slice.
- addons.go: op impls + AWS-shape converters; installer failure flips
  CREATE_FAILED. Status stays CREATING until delivery confirms ACTIVE (private
  apiserver is host-unreachable, so delivery must be VM-side).
- service_impl.go: drop notImpl stubs; add AddonInstaller dep (nil-defaults to
  staging installer).

DeleteClusterPrefix already sweeps clusters/{name}/ so addon keys clean up on
cluster delete.

* fix: route ListAddons at GET /clusters/{name}/addons

ListAddons was wired at GET /addons with no cluster param, so the AWS CLI's
GET /clusters/{name}/addons hit no route (InvalidActionException) and the
wrapper couldn't pass a cluster to the service. Match the real EKS path and
thread the cluster name through.
This commit is contained in:
Josh Siviour
2026-06-08 13:56:41 +10:00
committed by GitHub
parent 3d95664c80
commit 1a05386936
15 changed files with 1103 additions and 60 deletions
+9 -6
View File
@@ -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},
+2 -2
View File
@@ -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) {
+5 -3
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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"}},
+89
View File
@@ -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]
}
}
}
@@ -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")
})
}
+102
View File
@@ -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
}
+196
View File
@@ -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]
}
}
}
+108
View File
@@ -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)
}
+279
View File
@@ -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)
}
}
+241
View File
@@ -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)
}
+6 -25
View File
@@ -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 ---
@@ -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)
+17
View File
@@ -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 {