mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 10:52:04 +00:00
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:
@@ -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},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user