diff --git a/contracts/eks/v1/v1_compat_test.go b/contracts/eks/v1/v1_compat_test.go new file mode 100644 index 000000000..a87532d21 --- /dev/null +++ b/contracts/eks/v1/v1_compat_test.go @@ -0,0 +1,34 @@ +package eksv1 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// A v1 decoder ignores fields a later sender adds, so a report carrying them +// decodes to the same value as one without. +func TestAddonStatusReport_IgnoresUnknownFields(t *testing.T) { + body := `{"addon":"argocd","version":"3.0.23","phase":"ready","message":"","ts":1736935200,` + + `"incarnationId":"inc-1","generation":4,"executorEpoch":7}` + var got AddonStatusReport + require.NoError(t, json.Unmarshal([]byte(body), &got)) + require.Equal(t, AddonStatusReport{Addon: "argocd", Version: "3.0.23", Phase: AddonPhaseReady, TS: 1736935200}, got) +} + +// Both deployed body forms tolerate added top-level and per-add-on fields. +func TestInternalAddonsResponse_IgnoresUnknownFields(t *testing.T) { + for name, body := range map[string]string{ + "rest-json": `{"Addons":[{"AddonName":"spinifex-noop","AddonVersion":"0.1.0","ServiceAccountRoleArn":"",` + + `"ConfigurationValues":"","IncarnationId":"inc-1","Generation":4}],"ExecutorEpoch":7}`, + "encoding-json": `{"addons":[{"addonName":"spinifex-noop","addonVersion":"0.1.0",` + + `"incarnationId":"inc-1","generation":4}],"executorEpoch":7}`, + } { + t.Run(name, func(t *testing.T) { + var got InternalAddonsResponse + require.NoError(t, json.Unmarshal([]byte(body), &got)) + require.Equal(t, InternalAddonsResponse{Addons: []StagedAddonManifest{{AddonName: "spinifex-noop", AddonVersion: "0.1.0"}}}, got) + }) + } +} diff --git a/spinifex/agents/eks/gatewayfetch/v1_compat_test.go b/spinifex/agents/eks/gatewayfetch/v1_compat_test.go new file mode 100644 index 000000000..a5524c803 --- /dev/null +++ b/spinifex/agents/eks/gatewayfetch/v1_compat_test.go @@ -0,0 +1,23 @@ +package gatewayfetch + +import ( + "bytes" + "testing" +) + +// Fields a later gateway adds never reach the four TSV columns the on-VM +// addon-sync agent reads. +func TestEmitAddonsTSV_IgnoresUnknownFields(t *testing.T) { + body := []byte(`{"Addons":[{"AddonName":"aws-load-balancer-controller","AddonVersion":"2.11.0",` + + `"ServiceAccountRoleArn":"arn:aws:iam::123456789012:role/alb","ConfigurationValues":"a\tb",` + + `"IncarnationId":"inc-1","Generation":4}],"ExecutorEpoch":7}`) + + var buf bytes.Buffer + if err := emitAddonsTSV(&buf, body); err != nil { + t.Fatalf("emitAddonsTSV: %v", err) + } + want := "aws-load-balancer-controller\t2.11.0\tarn:aws:iam::123456789012:role/alb\tYQli\n" + if buf.String() != want { + t.Fatalf("TSV mismatch:\n got: %q\nwant: %q", buf.String(), want) + } +} diff --git a/spinifex/domains/eks/addon/v1_compat_test.go b/spinifex/domains/eks/addon/v1_compat_test.go new file mode 100644 index 000000000..2f03105da --- /dev/null +++ b/spinifex/domains/eks/addon/v1_compat_test.go @@ -0,0 +1,159 @@ +package addon + +import ( + "context" + "encoding/json" + "slices" + "testing" + + "github.com/nats-io/nats.go/jetstream" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newerRecordJSON is a record as a later binary might store it: every v1 field +// plus fields this binary's Record does not declare. +const newerRecordJSON = `{"addonName":"argocd","addonVersion":"3.0.23","status":"ACTIVE",` + + `"arn":"arn:aws:eks:us-east-1:111122223333:addon/c1/argocd",` + + `"createdAt":"2026-06-08T00:00:00Z","modifiedAt":"2026-06-08T00:00:00Z",` + + `"incarnationId":"inc-1","generation":4,"observedGeneration":3,"origin":"user"}` + +const newerManifestJSON = `{"addonName":"argocd","addonVersion":"3.0.23",` + + `"incarnationId":"inc-1","generation":4}` + +func seedNewerRecord(t *testing.T, kv jetstream.KeyValue) { + t.Helper() + _, err := kv.Put(t.Context(), "clusters/c1/addons/argocd", []byte(newerRecordJSON)) + require.NoError(t, err) + _, err = kv.Put(t.Context(), "clusters/c1/addons/argocd/manifest", []byte(newerManifestJSON)) + require.NoError(t, err) +} + +func storedKeys(t *testing.T, kv jetstream.KeyValue, key string) []string { + t.Helper() + entry, err := kv.Get(t.Context(), key) + require.NoError(t, err) + var top map[string]json.RawMessage + require.NoError(t, json.Unmarshal(entry.Value(), &top)) + keys := make([]string, 0, len(top)) + for k := range top { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} + +// Every owner write is decode-into-Record then re-encode, so fields this +// binary does not declare are erased from the record and the re-staged manifest. +func TestOwnerWrites_DropUnknownRecordAndManifestFields(t *testing.T) { + t.Parallel() + failed := "image pull" + for _, tc := range []struct { + name string + write func(t *testing.T, o *Owner, kv jetstream.KeyValue) + wantRecord []string + wantManifest []string + }{ + { + name: "Update", + write: func(t *testing.T, o *Owner, kv jetstream.KeyValue) { + _, err := o.Update(t.Context(), kv, testAccountID, "c1", "argocd", Change{}) + require.NoError(t, err) + }, + wantRecord: []string{"addonName", "addonVersion", "arn", "createdAt", "modifiedAt", "status"}, + wantManifest: []string{"addonName", "addonVersion"}, + }, + { + name: "ApplyReport failed", + write: func(t *testing.T, o *Owner, kv jetstream.KeyValue) { + require.NoError(t, o.ApplyReport(t.Context(), kv, "c1", Report{Addon: "argocd", Phase: PhaseFailed, Message: failed})) + }, + wantRecord: []string{"addonName", "addonVersion", "arn", "createdAt", "health", "modifiedAt", "status"}, + wantManifest: []string{"addonName", "addonVersion", "generation", "incarnationId"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + kv := newTestKV(t) + seedNewerRecord(t, kv) + o := New("us-east-1", NewStagingInstaller(func(_ context.Context, _ string) (jetstream.KeyValue, error) { return kv, nil })) + tc.write(t, o, kv) + assert.Equal(t, tc.wantRecord, storedKeys(t, kv, "clusters/c1/addons/argocd")) + assert.Equal(t, tc.wantManifest, storedKeys(t, kv, "clusters/c1/addons/argocd/manifest")) + }) + } +} + +// ApplyReport skips the write when nothing changes, so a no-op report leaves +// unknown fields in place. +func TestOwnerApplyReport_NoOpKeepsUnknownFields(t *testing.T) { + t.Parallel() + kv := newTestKV(t) + seedNewerRecord(t, kv) + o := New("us-east-1", &fakeInstaller{}) + + require.NoError(t, o.ApplyReport(t.Context(), kv, "c1", Report{Addon: "argocd", Phase: PhaseReady})) + + entry, err := kv.Get(t.Context(), "clusters/c1/addons/argocd") + require.NoError(t, err) + assert.JSONEq(t, newerRecordJSON, string(entry.Value())) +} + +func TestOwnerDelete_ErasesRecordAndManifestWithUnknownFields(t *testing.T) { + t.Parallel() + kv := newTestKV(t) + seedNewerRecord(t, kv) + inst := &fakeInstaller{} + o := New("us-east-1", inst) + + rec, err := o.Delete(t.Context(), kv, testAccountID, "c1", "argocd") + require.NoError(t, err) + assert.Equal(t, StatusDeleting, rec.Status) + assert.Equal(t, []string{"argocd"}, inst.uninstalls) + + _, err = kv.Get(t.Context(), "clusters/c1/addons/argocd") + require.ErrorIs(t, err, jetstream.ErrKeyNotFound) + _, err = kv.Get(t.Context(), "clusters/c1/addons/argocd/manifest") + require.ErrorIs(t, err, jetstream.ErrKeyNotFound) +} + +// Reads ignore unknown fields and never write back. +func TestReads_IgnoreUnknownFieldsWithoutRewriting(t *testing.T) { + t.Parallel() + kv := newTestKV(t) + seedNewerRecord(t, kv) + + rec, err := Get(t.Context(), kv, "c1", "argocd") + require.NoError(t, err) + assert.Equal(t, StatusActive, rec.Status) + + recs, err := List(t.Context(), kv, "c1") + require.NoError(t, err) + require.Len(t, recs, 1) + assert.Equal(t, "3.0.23", recs[0].AddonVersion) + + ms, err := ListManifests(t.Context(), kv, "c1") + require.NoError(t, err) + assert.Equal(t, []Manifest{{AddonName: "argocd", AddonVersion: "3.0.23"}}, ms) + + entry, err := kv.Get(t.Context(), "clusters/c1/addons/argocd") + require.NoError(t, err) + assert.Equal(t, uint64(1), entry.Revision()) +} + +// A field whose JSON type changes is not ignored: one undecodable record fails +// the whole cluster listing, and one undecodable manifest fails guest delivery. +func TestList_OneUndecodableEntryFailsTheListing(t *testing.T) { + t.Parallel() + kv := newTestKV(t) + require.NoError(t, put(t.Context(), kv, "c1", sampleRecord("argocd"))) + _, err := kv.Put(t.Context(), "clusters/c1/addons/coredns", []byte(`{"addonName":"coredns","status":1}`)) + require.NoError(t, err) + _, err = kv.Put(t.Context(), "clusters/c1/addons/coredns/manifest", []byte(`{"addonName":"coredns","addonVersion":2}`)) + require.NoError(t, err) + + _, err = List(t.Context(), kv, "c1") + require.ErrorContains(t, err, "unmarshal addon clusters/c1/addons/coredns") + _, err = ListManifests(t.Context(), kv, "c1") + require.ErrorContains(t, err, "unmarshal staged manifest clusters/c1/addons/coredns/manifest") +} diff --git a/spinifex/handlers/eks/addon_v1_compat_test.go b/spinifex/handlers/eks/addon_v1_compat_test.go new file mode 100644 index 000000000..5af6c363e --- /dev/null +++ b/spinifex/handlers/eks/addon_v1_compat_test.go @@ -0,0 +1,48 @@ +package handlers_eks + +import ( + "context" + "testing" + + eksv1 "github.com/mulgadc/spinifex/contracts/eks/v1" + "github.com/mulgadc/spinifex/spinifex/domains/eks/addon" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The reconciler's subscriber decodes then applies; a report carrying fields +// this binary does not know is applied exactly as one without them. +func TestAddonStatusReport_UnknownFieldsAreAppliedAsToday(t *testing.T) { + t.Parallel() + _, kv := setupStagingAddonService(t) + _, err := kv.Put(t.Context(), "clusters/c1/addons/argocd", []byte(`{"addonName":"argocd","addonVersion":"3.0.23",`+ + `"status":"ACTIVE","arn":"arn","createdAt":"2026-06-08T00:00:00Z","modifiedAt":"2026-06-08T00:00:00Z"}`)) + require.NoError(t, err) + r := &ClusterReconciler{acctKV: kv, clusterName: "c1", addonReports: addon.New("", nil)} + + report, err := unmarshalAddonStatusReport([]byte(`{"addon":"argocd","version":"3.0.23","phase":"failed",` + + `"message":"rollout stalled","ts":1,"incarnationId":"stale-inc","generation":1}`)) + require.NoError(t, err) + r.applyAddonStatusReport(t.Context(), report) + + rec, err := addon.Get(t.Context(), kv, "c1", "argocd") + require.NoError(t, err) + assert.Equal(t, addon.StatusDegraded, rec.Status) + assert.Equal(t, "rollout stalled", rec.Health) +} + +// The daemon copies the four v1 manifest fields onto the NATS reply, so a +// staged manifest's unknown fields never reach the gateway or the guest. +func TestListStagedAddonManifests_ProjectsOnlyV1Fields(t *testing.T) { + t.Parallel() + svc, kv := setupStagingAddonService(t) + _, err := kv.Put(t.Context(), albManifestKey, []byte(`{"addonName":"aws-load-balancer-controller",`+ + `"addonVersion":"2.11.0","serviceAccountRoleArn":"`+addonLifecycleRoleARN+`","incarnationId":"inc-1","generation":4}`)) + require.NoError(t, err) + + out, err := svc.ListStagedAddonManifests(context.Background(), &ListStagedAddonManifestsInput{ClusterName: "c1"}, testAccountID) + require.NoError(t, err) + assert.Equal(t, []eksv1.StagedAddonManifest{{ + AddonName: "aws-load-balancer-controller", AddonVersion: "2.11.0", ServiceAccountRoleArn: addonLifecycleRoleARN, + }}, out.Manifests) +}