From fa66ddc4ddcfddcb62d1a972f8432670116178ae Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Tue, 29 Sep 2026 00:20:39 +0200 Subject: [PATCH] add drift from previous to live - read live objects with get and list only - keep drift separate from helm actions and resource changes - show drift in the human and json plan --- ...c-plan-describes-intent-not-feasibility.md | 5 + ...tic-plan-uses-previous-live-and-desired.md | 43 + ...06-semantic-plan-follows-helm-lifecycle.md | 6 +- ...its-contract-not-kubernetes-feasibility.md | 2 +- ...plan-requires-release-and-cluster-state.md | 14 +- ...rmalizes-only-deterministic-equivalents.md | 38 +- internal/e2e/e2e_semantic_plan_test.go | 91 +- internal/plan/doc.go | 3 +- internal/plan/live_reader.go | 100 ++ internal/plan/live_reader_test.go | 121 +++ internal/plan/resource_changes.go | 9 +- internal/plan/semantic/drift.go | 228 +++++ internal/plan/semantic/drift_test.go | 280 +++++ internal/plan/semantic/plan.go | 24 +- internal/plan/semantic_build.go | 81 +- internal/plan/semantic_build_crd_test.go | 4 +- internal/plan/semantic_build_test.go | 39 +- internal/plan/semantic_drift.go | 537 ++++++++++ internal/plan/semantic_drift_cases_test.go | 963 ++++++++++++++++++ internal/plan/semantic_drift_internal_test.go | 228 +++++ internal/plan/semantic_drift_test.go | 134 +++ internal/plan/view/human.go | 150 ++- internal/plan/view/human_semantic_test.go | 71 +- internal/plan/view/human_test.go | 109 ++ internal/plan/view/json.go | 28 + internal/plan/view/json_test.go | 122 +++ internal/plan/view/prepare.go | 12 + internal/plan/view/project.go | 121 ++- internal/plan/view/project_internal_test.go | 49 + internal/plan/view/redact.go | 28 + internal/plan/view/redact_test.go | 37 + .../plan/view/schema/semantic_plan.v1.json | 141 +++ internal/plan/view/schema_test.go | 86 ++ .../view/testdata/golden/human_drift.golden | 35 + .../view/testdata/golden/json_update.golden | 1 + 35 files changed, 3786 insertions(+), 154 deletions(-) create mode 100644 internal/plan/live_reader.go create mode 100644 internal/plan/live_reader_test.go create mode 100644 internal/plan/semantic/drift.go create mode 100644 internal/plan/semantic/drift_test.go create mode 100644 internal/plan/semantic_drift.go create mode 100644 internal/plan/semantic_drift_cases_test.go create mode 100644 internal/plan/semantic_drift_internal_test.go create mode 100644 internal/plan/semantic_drift_test.go create mode 100644 internal/plan/view/testdata/golden/human_drift.golden diff --git a/docs/adr/0004-semantic-plan-describes-intent-not-feasibility.md b/docs/adr/0004-semantic-plan-describes-intent-not-feasibility.md index d02331c..4913864 100644 --- a/docs/adr/0004-semantic-plan-describes-intent-not-feasibility.md +++ b/docs/adr/0004-semantic-plan-describes-intent-not-feasibility.md @@ -29,6 +29,11 @@ scope. It does not GET Live objects, and it does not call Create, Update, Patch, or Delete, including server-side or mutating dry-run forms of those writes. +Drift performs only GET and LIST against Live Kubernetes resources. +It performs no server-side or mutating dry-run and no write +operation. The existing Helm client-side dry-run rendering used to +construct Desired remains unchanged. + These concerns are outside the semantic plan: - server-side apply and server dry-run prediction diff --git a/docs/adr/0005-semantic-plan-uses-previous-live-and-desired.md b/docs/adr/0005-semantic-plan-uses-previous-live-and-desired.md index 986d839..4476002 100644 --- a/docs/adr/0005-semantic-plan-uses-previous-live-and-desired.md +++ b/docs/adr/0005-semantic-plan-uses-previous-live-and-desired.md @@ -44,6 +44,49 @@ are drift regardless of who changed them. A field intentionally absent from Desired that existed in Previous is a declarative removal: a Resource Change, not Drift by itself. +Drift actions are Modified, Missing, and Unexpected. They are not +create, update, or delete. + +- Modified: Previous and Live both exist and the declared surface + differs. Both snapshots are present and Fields is non-empty. +- Missing: Previous exists and Live does not. Previous is present, + Live is absent, and Fields is empty. +- Unexpected: a release-owned Live object has no Previous logical + identity. Previous is absent, Live is present, and Fields is empty. + +Modified and Missing name the Previous declaration: its apiVersion, +kind, effective namespace, and name. Unexpected names the observed +Live object. An apiVersion transition alone is not Drift, and it is +not Missing plus Unexpected. A modified or missing entry keeps the +Previous apiVersion even when Live was read through another version. + +A fresh install has no Previous. It reads no Live, and its Drift is +empty. A Live object that already uses a Desired name is not Drift +on a fresh install. + +On an existing release, each named Previous declaration is read with +GET through that declaration's own REST mapping, effective namespace, +and name. Identity is not resolved through Desired. + +Unexpected objects are listed only in buckets taken from Previous: +group, kind, and effective namespace. Each unambiguous bucket is +listed once, through the highest Previous version in that bucket, +with the label selector `deployah.dev/instance` equal to the release +name. An object is Unexpected only when all of these hold: the +instance label matches the release, `deployah.dev/source` is `spec` +or `manifests`, there is no `helm.sh/hook` annotation, +`meta.helm.sh/release-name` and `meta.helm.sh/release-namespace` +match the release, the logical identity is not in Previous, and the +object is not the target Namespace. Chart CRDs and Helm hook runtime +resources are not Previous declarations, so they are outside this +scope. A bucket that contains a generateName-only Previous +declaration is not listed. Named resources in that bucket are still +read with GET. + +Drift does not change HelmAction, Resource Changes, tasks, chart +CRDs, Summary, or HasEffects. A plan can be a no-op and still list +Drift. + Do not report API-server bookkeeping as Resource Changes or drift: status, uid, resourceVersion, generation, managedFields, creation timestamps, and similar server-maintained metadata. This is not a diff --git a/docs/adr/0006-semantic-plan-follows-helm-lifecycle.md b/docs/adr/0006-semantic-plan-follows-helm-lifecycle.md index 52b8227..6a49b00 100644 --- a/docs/adr/0006-semantic-plan-follows-helm-lifecycle.md +++ b/docs/adr/0006-semantic-plan-follows-helm-lifecycle.md @@ -32,9 +32,9 @@ hooks still run (ADR-0014). CRD lifecycle is ADR-0008. The target namespace is an execution prerequisite created by Helm -install, outside the release. It is never a Resource Change. Planning -does not read Live to decide whether that namespace exists. A missing -namespace is not a planning failure. +install, outside the release. It is never a Resource Change and never +a Drift entry. Planning does not read Live to decide whether that +namespace exists. A missing namespace is not a planning failure. Neither the Desired render nor the Previous release baseline may declare that target namespace. If either does, planning fails with a diff --git a/docs/adr/0007-deployah-validates-its-contract-not-kubernetes-feasibility.md b/docs/adr/0007-deployah-validates-its-contract-not-kubernetes-feasibility.md index 096f89d..4911798 100644 --- a/docs/adr/0007-deployah-validates-its-contract-not-kubernetes-feasibility.md +++ b/docs/adr/0007-deployah-validates-its-contract-not-kubernetes-feasibility.md @@ -60,7 +60,7 @@ It does not inspect CRD spec semantics, infer scope, or treat those files as Kubernetes objects it owns. Raw bytes are preserved for Helm. Kubernetes acceptance belongs to Helm and Kubernetes. -If the planner needs discovery, REST mapping, or a Live GET to +If the planner needs discovery, REST mapping, or a Live GET or LIST to determine current state and the read fails, planning fails. That is missing information, not prediction. Do not use managedFields as the semantic source of truth for Deployah ownership. diff --git a/docs/adr/0010-semantic-plan-requires-release-and-cluster-state.md b/docs/adr/0010-semantic-plan-requires-release-and-cluster-state.md index b2d1598..4b2e57b 100644 --- a/docs/adr/0010-semantic-plan-requires-release-and-cluster-state.md +++ b/docs/adr/0010-semantic-plan-requires-release-and-cluster-state.md @@ -14,8 +14,12 @@ HelmAction and Resource Changes. ## Decision Resource Changes require enough release state to construct Previous, -and discovery to resolve scope (ADR-0013). Drift requires Live as -well. Deployah does not support offline semantic planning. +and discovery to resolve scope (ADR-0013). Drift on an existing +release requires GET and LIST of Live. A fresh install needs no Live +access and reports empty Drift. A failed Live read fails planning. +There is no partial or incomplete Drift. Drift never writes to Live. +Deployah does not support offline semantic planning of an existing +release. When that information is unavailable, the planner must not fabricate Previous, Drift, HelmAction, or Resource Changes. @@ -37,5 +41,7 @@ separate capabilities. They are not semantic planning. - Operators without release access and discovery cannot obtain a semantic plan. -- Drift also needs Live, so it cannot be reported from the release - baseline alone. +- Drift on an existing release needs Live, so it cannot be reported + from the release baseline alone. +- A failed Live read fails the whole plan. There is no incomplete + Drift result. diff --git a/docs/adr/0015-semantic-comparison-normalizes-only-deterministic-equivalents.md b/docs/adr/0015-semantic-comparison-normalizes-only-deterministic-equivalents.md index 63ca7d9..1a492d8 100644 --- a/docs/adr/0015-semantic-comparison-normalizes-only-deterministic-equivalents.md +++ b/docs/adr/0015-semantic-comparison-normalizes-only-deterministic-equivalents.md @@ -16,8 +16,9 @@ The declared surface is ADR-0005. Semantic comparison may treat two representations as equal only when Deployah has an explicit, deterministic, semantics-preserving -equivalence rule. Example: Kubernetes resource quantities whose -canonical forms are equal (`1000m` and `1`). +equivalence rule. Kubernetes resource quantities such as `1000m` and +`1` are not one of those rules. DiffFields does not treat them as +equal, so a non-canonical quantity is a difference. Normalization is comparison-only. It must not rewrite user manifests, rewrite Previous Helm manifests, mutate Live objects, become generic @@ -29,6 +30,30 @@ normalized between equivalent effective representations so raw rendered YAML and Helm's applied representation do not produce artificial release changes. +Drift compares the Previous-declared surface with Live. Maps keep +only keys declared in Previous. An empty Previous map declares no +keys, so Live keys under it are not drift. Lists are compared by +position, and extra Live elements are kept whole. Dropping those +elements would hide drift without schema-aware list semantics. +Previous `[a]` against Live `[a, b]` is Modified. A reordered list is +Modified. There is no strategic-merge, list-map-key, or OpenAPI list +semantics. `/apiVersion` is ignored for Drift. + +The only absence equivalences are explicit and path-aware. An empty +`resources.limits` map on a container of an apps Deployment, an apps +StatefulSet, or a batch CronJob equals a Live object that omits that +key. A core v1 Secret folds `stringData` into `data` on the +comparison copy only: each string value is the base64 of its UTF-8 +bytes, and `stringData` overrides the same `data` key. Non-string +`stringData` values stay in `stringData`. There is no generic rule +that `null`, `{}`, or `[]` equals absent for other paths, other +kinds, or CRDs. + +Snapshots stay separate from comparison copies. The stored Previous +snapshot is never rewritten. Field paths and values may come from the +normalized comparison copies. Presentation renders those paths from +the field values and does not rewrite the stored Previous snapshot. + The plan needs meaningful structural comparison: changing one field inside an object or list should not force the whole resource to look opaque. Live-only undeclared state must not become synthetic removals @@ -45,10 +70,15 @@ not this decision. ### Positive -- Equivalent quantity encodings and Helm ownership metadata do not - appear as fake release changes. +- Helm ownership metadata does not appear as a fake release change. +- Drift shows the declared surface. Live-only keys under a declared + map do not become removals. ### Negative - Only listed, deterministic rules count as equal. Unknown encodings still show as differences. +- An empty `resources.limits` map does not declare keys, so a Live + value under that map is not drift. +- A cluster-scoped object whose declared `metadata.namespace` the API + server drops is Modified, because that field stays in Previous. diff --git a/internal/e2e/e2e_semantic_plan_test.go b/internal/e2e/e2e_semantic_plan_test.go index 7a23415..459217e 100644 --- a/internal/e2e/e2e_semantic_plan_test.go +++ b/internal/e2e/e2e_semantic_plan_test.go @@ -51,7 +51,7 @@ func (c *semanticPlanClient) RenderManifestsWithPrep( return c.result, c.prep, func() {}, nil } -func (s *E2ESuite) TestSemanticPlanDoesNotMutateOrReadLive() { +func (s *E2ESuite) TestSemanticPlanDoesNotMutateLive() { t := s.T() resolved := &spec.ResolvedSpec{ Spec: &spec.Spec{Project: "shop"}, @@ -70,7 +70,7 @@ func (s *E2ESuite) TestSemanticPlanDoesNotMutateOrReadLive() { }, prep: helm.ReleasePrep{Operation: helm.OperationInstall, NextRevision: 1}, } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, s.mapper, plan.SemanticBuildInput{ + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, s.mapper, nil, plan.SemanticBuildInput{ ClusterContext: kindContext, Resolved: resolved, }) @@ -91,20 +91,20 @@ func (s *E2ESuite) TestSemanticPlanDoesNotMutateOrReadLive() { require.True(t, apierrors.IsNotFound(err), "namespace %s should still be absent: %v", ns, err) _, err = cs.CoreV1().ConfigMaps(ns).Get(t.Context(), "app", metav1.GetOptions{}) require.True(t, apierrors.IsNotFound(err), "configmap app should still be absent: %v", err) + assert.Empty(t, p.Drift) }) - t.Run("upgrade ignores live drift", func(t *testing.T) { + t.Run("upgrade reads live and does not mutate", func(t *testing.T) { ns := fixtureNamespace("semantic-plan-upgrade") manifest := configMapManifest(ns, "app", "same") + "---\n" + configMapManifest(ns, "other", "same") - cs, _ := s.kubeClients(t) + cs, dyn := s.kubeClients(t) _, err := cs.CoreV1().Namespaces().Create(t.Context(), &corev1.Namespace{Name: ns}, metav1.CreateOptions{}) require.NoError(t, err) t.Cleanup(func() { s.deleteNamespace(t, ns) }) for _, name := range []string{"app", "other"} { _, err = cs.CoreV1().ConfigMaps(ns).Create(t.Context(), &corev1.ConfigMap{ - Name: name, - Namespace: ns, - Data: map[string]string{"key": "same"}, + Name: name, Namespace: ns, + Data: map[string]string{"key": "same"}, }, metav1.CreateOptions{}) require.NoError(t, err) } @@ -114,6 +114,47 @@ func (s *E2ESuite) TestSemanticPlanDoesNotMutateOrReadLive() { _, err = cs.CoreV1().ConfigMaps(ns).Update(t.Context(), live, metav1.UpdateOptions{}) require.NoError(t, err) require.NoError(t, cs.CoreV1().ConfigMaps(ns).Delete(t.Context(), "other", metav1.DeleteOptions{})) + for _, cm := range []*corev1.ConfigMap{ + { + Name: "extra", + Namespace: ns, + Labels: map[string]string{spec.LabelInstance: "web"}, + Annotations: map[string]string{ + spec.AnnotationSource: spec.SourceSpec, + "meta.helm.sh/release-name": "web", + "meta.helm.sh/release-namespace": ns, + }, + Data: map[string]string{"key": "extra"}, + }, + { + Name: "label-only", + Namespace: ns, + Labels: map[string]string{spec.LabelInstance: "web"}, + Data: map[string]string{"key": "extra"}, + }, + { + Name: "no-helm", + Namespace: ns, + Labels: map[string]string{spec.LabelInstance: "web"}, + Annotations: map[string]string{ + spec.AnnotationSource: spec.SourceSpec, + }, + Data: map[string]string{"key": "extra"}, + }, + } { + _, err = cs.CoreV1().ConfigMaps(ns).Create(t.Context(), cm, metav1.CreateOptions{}) + require.NoError(t, err) + } + + versions := map[string]string{} + nsObj, err := cs.CoreV1().Namespaces().Get(t.Context(), ns, metav1.GetOptions{}) + require.NoError(t, err) + versions["namespace"] = nsObj.ResourceVersion + for _, name := range []string{"app", "extra", "label-only", "no-helm"} { + got, getErr := cs.CoreV1().ConfigMaps(ns).Get(t.Context(), name, metav1.GetOptions{}) + require.NoError(t, getErr) + versions[name] = got.ResourceVersion + } release := &v1.Release{ Name: "web", @@ -136,7 +177,7 @@ func (s *E2ESuite) TestSemanticPlanDoesNotMutateOrReadLive() { NextRevision: 2, }, } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, s.mapper, plan.SemanticBuildInput{ + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, s.mapper, plan.NewDynamicLiveReader(dyn), plan.SemanticBuildInput{ ClusterContext: kindContext, Resolved: resolved, }) @@ -145,10 +186,36 @@ func (s *E2ESuite) TestSemanticPlanDoesNotMutateOrReadLive() { require.NoError(t, err) assert.Equal(t, semantic.HelmNone, p.HelmAction) assert.Empty(t, p.Changes) - - got, err := cs.CoreV1().ConfigMaps(ns).Get(t.Context(), "app", metav1.GetOptions{}) - require.NoError(t, err) - assert.Equal(t, "drifted", got.Data["key"]) + require.Len(t, p.Drift, 3) + byName := map[string]semantic.DriftChange{} + for _, d := range p.Drift { + byName[d.Resource.Name] = d + } + modified := byName["app"] + assert.Equal(t, semantic.DriftModified, modified.Action) + require.Len(t, modified.Fields, 1) + assert.Equal(t, "/data/key", modified.Fields[0].Path) + assert.Equal(t, semantic.DriftMissing, byName["other"].Action) + assert.Equal(t, semantic.DriftUnexpected, byName["extra"].Action) + _, hasLabelOnly := byName["label-only"] + assert.False(t, hasLabelOnly) + _, hasNoHelm := byName["no-helm"] + assert.False(t, hasNoHelm) + + for name, rv := range versions { + if name == "namespace" { + got, getErr := cs.CoreV1().Namespaces().Get(t.Context(), ns, metav1.GetOptions{}) + require.NoError(t, getErr) + assert.Equal(t, rv, got.ResourceVersion) + continue + } + got, getErr := cs.CoreV1().ConfigMaps(ns).Get(t.Context(), name, metav1.GetOptions{}) + require.NoError(t, getErr) + assert.Equal(t, rv, got.ResourceVersion) + if name == "app" { + assert.Equal(t, "drifted", got.Data["key"]) + } + } _, err = cs.CoreV1().ConfigMaps(ns).Get(t.Context(), "other", metav1.GetOptions{}) require.True(t, apierrors.IsNotFound(err), "deleted configmap should stay absent: %v", err) }) diff --git a/internal/plan/doc.go b/internal/plan/doc.go index 09804f0..7c6a1f3 100644 --- a/internal/plan/doc.go +++ b/internal/plan/doc.go @@ -21,7 +21,8 @@ // upgrade, or none from the Helm operation and from comparing the // previous release with the render and its hooks. Resource changes // compare the previous manifest with the rendered Desired manifest. -// Discovery supplies scope only. Chart CRDs pass through to Helm. +// Discovery supplies scope only. An existing release also reads Live +// with GET and LIST to compute Drift. Chart CRDs pass through to Helm. // The result is a [deployah.dev/deployah/internal/plan/semantic.Plan]. // Those types live in plan/semantic. Their rendering lives in plan/view. // diff --git a/internal/plan/live_reader.go b/internal/plan/live_reader.go new file mode 100644 index 0000000..80b4292 --- /dev/null +++ b/internal/plan/live_reader.go @@ -0,0 +1,100 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/dynamic" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// LiveReader reads Live objects for Drift. It only gets and lists. +// It does not write or run a server-side dry-run. Helm still renders +// Desired with its own client-side dry-run on [SemanticBuildClient]. +type LiveReader interface { + // Get returns the named object. A missing object is a NotFound + // error. namespace applies only when mapping is namespaced. + Get(ctx context.Context, mapping *meta.RESTMapping, namespace, name string) (*unstructured.Unstructured, error) + // List returns objects that match selector. namespace applies only + // when mapping is namespaced. selector must be non-nil. + List(ctx context.Context, mapping *meta.RESTMapping, namespace string, selector labels.Selector) ([]unstructured.Unstructured, error) +} + +// DynamicLiveReader is a [LiveReader] backed by a dynamic client. +// It sends only get and list requests. +type DynamicLiveReader struct { + client dynamic.Interface +} + +// NewDynamicLiveReader returns a reader for client. client must be non-nil +// before Get or List is called. +func NewDynamicLiveReader(client dynamic.Interface) *DynamicLiveReader { + return &DynamicLiveReader{client: client} +} + +var _ LiveReader = (*DynamicLiveReader)(nil) + +// Get implements [LiveReader]. +func (r *DynamicLiveReader) Get(ctx context.Context, mapping *meta.RESTMapping, namespace, name string) (*unstructured.Unstructured, error) { + ri, err := r.resource(mapping, namespace) + if err != nil { + return nil, err + } + obj, err := ri.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + return obj.DeepCopy(), nil +} + +// List implements [LiveReader]. +func (r *DynamicLiveReader) List(ctx context.Context, mapping *meta.RESTMapping, namespace string, selector labels.Selector) ([]unstructured.Unstructured, error) { + if selector == nil { + return nil, fmt.Errorf("live list requires a label selector") + } + ri, err := r.resource(mapping, namespace) + if err != nil { + return nil, err + } + list, err := ri.List(ctx, metav1.ListOptions{LabelSelector: selector.String()}) + if err != nil { + return nil, err + } + out := make([]unstructured.Unstructured, 0, len(list.Items)) + for i := range list.Items { + out = append(out, *list.Items[i].DeepCopy()) + } + return out, nil +} + +func (r *DynamicLiveReader) resource(mapping *meta.RESTMapping, namespace string) (dynamic.ResourceInterface, error) { + if r == nil || r.client == nil { + return nil, fmt.Errorf("live read requires a dynamic client") + } + if mapping == nil || mapping.Scope == nil { + return nil, fmt.Errorf("live read requires a REST mapping with scope") + } + ri := r.client.Resource(mapping.Resource) + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + return ri.Namespace(namespace), nil + } + return ri, nil +} diff --git a/internal/plan/live_reader_test.go b/internal/plan/live_reader_test.go new file mode 100644 index 0000000..df8bba0 --- /dev/null +++ b/internal/plan/live_reader_test.go @@ -0,0 +1,121 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "deployah.dev/deployah/internal/plan" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + fakedynamic "k8s.io/client-go/dynamic/fake" +) + +func TestDynamicLiveReader_GetAndList(t *testing.T) { + t.Parallel() + app := liveObject("v1", "ConfigMap", "prod", "app", map[string]string{"deployah.dev/instance": "web"}, nil) + other := liveObject("v1", "ConfigMap", "prod", "other", map[string]string{"deployah.dev/instance": "else"}, nil) + ns := liveObject("v1", "Namespace", "", "prod", map[string]string{"deployah.dev/instance": "web"}, nil) + dyn := fakedynamic.NewSimpleDynamicClient(runtime.NewScheme(), app, other, ns) + reader := plan.NewDynamicLiveReader(dyn) + + got, err := reader.Get(t.Context(), configMapMapping(), "prod", "app") + require.NoError(t, err) + assert.Equal(t, "app", got.GetName()) + + _, err = reader.Get(t.Context(), configMapMapping(), "prod", "missing") + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err)) + + cluster, err := reader.Get(t.Context(), namespaceMapping(), "ignored", "prod") + require.NoError(t, err) + assert.Equal(t, "prod", cluster.GetName()) + assert.Empty(t, cluster.GetNamespace()) + + selector, err := labels.Parse("deployah.dev/instance=web") + require.NoError(t, err) + listed, err := reader.List(t.Context(), configMapMapping(), "prod", selector) + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, "app", listed[0].GetName()) + + for _, action := range dyn.Actions() { + assert.Contains(t, []string{"get", "list"}, action.GetVerb()) + } +} + +func TestDynamicLiveReader_RequiresClientAndSelector(t *testing.T) { + t.Parallel() + reader := plan.NewDynamicLiveReader(nil) + _, err := reader.Get(t.Context(), configMapMapping(), "prod", "app") + require.Error(t, err) + assert.ErrorContains(t, err, "dynamic client") + + dyn := fakedynamic.NewSimpleDynamicClient(runtime.NewScheme()) + _, err = plan.NewDynamicLiveReader(dyn).List(t.Context(), configMapMapping(), "prod", nil) + require.Error(t, err) + assert.ErrorContains(t, err, "label selector") + assert.Empty(t, dyn.Actions()) +} + +func configMapMapping() *meta.RESTMapping { + return &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, + GroupVersionKind: schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, + Scope: meta.RESTScopeNamespace, + } +} + +func namespaceMapping() *meta.RESTMapping { + return &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}, + GroupVersionKind: schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}, + Scope: meta.RESTScopeRoot, + } +} + +func liveObject(apiVersion, kind, namespace, name string, lbl, ann map[string]string) *unstructured.Unstructured { + meta := map[string]any{"name": name} + if namespace != "" { + meta["namespace"] = namespace + } + if len(lbl) > 0 { + labels := make(map[string]any, len(lbl)) + for k, v := range lbl { + labels[k] = v + } + meta["labels"] = labels + } + if len(ann) > 0 { + annotations := make(map[string]any, len(ann)) + for k, v := range ann { + annotations[k] = v + } + meta["annotations"] = annotations + } + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": meta, + }} +} diff --git a/internal/plan/resource_changes.go b/internal/plan/resource_changes.go index ee6a7d3..21f4a9d 100644 --- a/internal/plan/resource_changes.go +++ b/internal/plan/resource_changes.go @@ -60,11 +60,13 @@ func (k declarationKey) String() string { return k.Group + "/" + k.Kind + " " + k.Namespace + "/generateName=" + k.GenerateName } -// declaration is one flattened Previous or Desired object. Pairing uses -// effectiveNamespace and does not write it back. Named resources set -// identity. generateName resources set pairKey. +// declaration is one flattened Previous or Desired object. Pairing +// uses effectiveNamespace and does not write it back. A name sets +// identity. generateName sets pairKey. mapping is this object's REST +// mapping, so Drift GETs Previous on its own. type declaration struct { obj *unstructured.Unstructured + mapping *meta.RESTMapping effectiveNamespace string namespaced bool identity *logicalIdentity @@ -173,6 +175,7 @@ func declareOne(mapper RESTMapper, obj *unstructured.Unstructured, releaseNamesp } d := declaration{ obj: obj, + mapping: mapping, effectiveNamespace: effective, namespaced: namespaced, } diff --git a/internal/plan/semantic/drift.go b/internal/plan/semantic/drift.go new file mode 100644 index 0000000..9bc76d7 --- /dev/null +++ b/internal/plan/semantic/drift.go @@ -0,0 +1,228 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package semantic + +import ( + "cmp" + "fmt" + "slices" + + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// DriftAction is the observed Previous-to-Live state of one resource. +// The zero value is invalid. It is not a release action: there is no +// create, update, or delete vocabulary here. +type DriftAction int + +const ( + // DriftModified means Previous and Live both exist and the declared + // semantic surface differs. + DriftModified DriftAction = iota + 1 + // DriftMissing means Previous exists and Live does not. + DriftMissing + // DriftUnexpected means a release-owned Live object has no Previous + // logical identity. + DriftUnexpected +) + +func (a DriftAction) String() string { + switch a { + case DriftModified: + return "modified" + case DriftMissing: + return "missing" + case DriftUnexpected: + return "unexpected" + default: + return fmt.Sprintf("DriftAction(%d)", int(a)) + } +} + +func (a DriftAction) valid() bool { + switch a { + case DriftModified, DriftMissing, DriftUnexpected: + return true + default: + return false + } +} + +func driftActionRank(a DriftAction) int { + switch a { + case DriftModified: + return 0 + case DriftMissing: + return 1 + case DriftUnexpected: + return 2 + default: + return 3 + } +} + +// DriftChange is one difference between Previous and Live. +// [AttachDrift] checks and stores it. It is cluster state, separate +// from a release change. +// +// Name is required, and GenerateName must be empty. Modified and +// Missing use the Previous declaration, including its apiVersion. +// Unexpected uses the Live object. An apiVersion change alone is not +// drift. +// +// Previous is unset for Unexpected and is never rewritten. Live is +// unset for Missing. Modified Live keeps only the declared surface. +// Unexpected Live has server bookkeeping removed. Only Modified has +// Fields. [AttachDrift] keeps them and does not recompute them. +type DriftChange struct { + Resource ResourceRef + Action DriftAction + Previous *ResourceSnapshot + Live *ResourceSnapshot + Fields []FieldChange +} + +// HasDrift reports whether the plan lists any drift. Drift is observed +// cluster state and does not change [Plan.HasEffects] or [Plan.IsNoOp]. +func (p Plan) HasDrift() bool { + return len(p.Drift) > 0 +} + +// AttachDrift returns a copy of p with drift checked and sorted. A +// fresh install ([Header.FreshInstall]) accepts only an empty +// collection. Other plan fields stay as they are, including whether +// the plan has effects or is a no-op. +// +// Entries sort by group, kind, namespace, and name, then by apiVersion +// and action. Group comes from apiVersion. The same group, kind, +// namespace, and name twice is an error. +func AttachDrift(p Plan, drift []DriftChange) (Plan, error) { + copied := copyDrift(drift) + if p.Header.FreshInstall && len(copied) > 0 { + return Plan{}, fmt.Errorf("fresh install must not include drift") + } + seen := make(map[string]struct{}, len(copied)) + for i := range copied { + if err := validateDrift(copied[i]); err != nil { + return Plan{}, fmt.Errorf("drift %d: %w", i, err) + } + key := driftIdentityKey(copied[i].Resource) + if _, dup := seen[key]; dup { + return Plan{}, fmt.Errorf("drift %s: duplicate logical identity", copied[i].Resource) + } + seen[key] = struct{}{} + } + sortDrift(copied) + p.Drift = copied + return p, nil +} + +func validateDrift(d DriftChange) error { + if !d.Action.valid() { + return fmt.Errorf("invalid action %s", d.Action) + } + if d.Resource.Name == "" { + return fmt.Errorf("name is required") + } + if d.Resource.GenerateName != "" { + return fmt.Errorf("generateName must be empty") + } + switch d.Action { + case DriftModified: + if d.Previous == nil { + return fmt.Errorf("modified requires a previous snapshot") + } + if d.Live == nil { + return fmt.Errorf("modified requires a live snapshot") + } + if len(d.Fields) == 0 { + return fmt.Errorf("modified requires a field change") + } + case DriftMissing: + if d.Previous == nil { + return fmt.Errorf("missing requires a previous snapshot") + } + if d.Live != nil { + return fmt.Errorf("missing must not have a live snapshot") + } + if len(d.Fields) != 0 { + return fmt.Errorf("missing must not have field changes") + } + case DriftUnexpected: + if d.Previous != nil { + return fmt.Errorf("unexpected must not have a previous snapshot") + } + if d.Live == nil { + return fmt.Errorf("unexpected requires a live snapshot") + } + if len(d.Fields) != 0 { + return fmt.Errorf("unexpected must not have field changes") + } + } + return nil +} + +func sortDrift(drift []DriftChange) { + slices.SortFunc(drift, compareDrift) + for i := range drift { + slices.SortFunc(drift[i].Fields, func(a, b FieldChange) int { + return cmp.Compare(a.Path, b.Path) + }) + } +} + +func compareDrift(a, b DriftChange) int { + return cmp.Or( + cmp.Compare(driftGroup(a.Resource.APIVersion), driftGroup(b.Resource.APIVersion)), + cmp.Compare(a.Resource.Kind, b.Resource.Kind), + cmp.Compare(a.Resource.Namespace, b.Resource.Namespace), + cmp.Compare(a.Resource.Name, b.Resource.Name), + cmp.Compare(a.Resource.APIVersion, b.Resource.APIVersion), + cmp.Compare(driftActionRank(a.Action), driftActionRank(b.Action)), + ) +} + +// driftIdentityKey is logical identity: group, kind, namespace, and +// name. apiVersion is not included. +func driftIdentityKey(r ResourceRef) string { + return driftGroup(r.APIVersion) + "\x00" + r.Kind + "\x00" + r.Namespace + "\x00" + r.Name +} + +func driftGroup(apiVersion string) string { + gv, err := schema.ParseGroupVersion(apiVersion) + if err != nil { + return "" + } + if gv.Group == "core" { + return "" + } + return gv.Group +} + +func copyDrift(in []DriftChange) []DriftChange { + if in == nil { + return []DriftChange{} + } + out := slices.Clone(in) + for i := range out { + out[i].Previous = copySnapshot(out[i].Previous) + out[i].Live = copySnapshot(out[i].Live) + out[i].Fields = copyFields(out[i].Fields) + if out[i].Fields == nil { + out[i].Fields = []FieldChange{} + } + } + return out +} diff --git a/internal/plan/semantic/drift_test.go b/internal/plan/semantic/drift_test.go new file mode 100644 index 0000000..1bc5075 --- /dev/null +++ b/internal/plan/semantic/drift_test.go @@ -0,0 +1,280 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package semantic_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "deployah.dev/deployah/internal/plan/semantic" +) + +func TestNew_DriftStartsEmpty(t *testing.T) { + t.Parallel() + p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, nil) + require.NoError(t, err) + require.NotNil(t, p.Drift) + assert.Empty(t, p.Drift) + assert.False(t, p.HasDrift()) +} + +func TestAttachDrift_FreshInstallRejectsDrift(t *testing.T) { + t.Parallel() + base := mustPlan(t, semantic.Header{FreshInstall: true}, semantic.HelmInstall) + tests := []struct { + name string + drift []semantic.DriftChange + wantErr string + }{ + {name: "missing", drift: []semantic.DriftChange{missingDrift("app")}, wantErr: "fresh install must not include drift"}, + {name: "unexpected", drift: []semantic.DriftChange{unexpectedDrift("extra")}, wantErr: "fresh install must not include drift"}, + {name: "nil"}, + {name: "empty", drift: []semantic.DriftChange{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := semantic.AttachDrift(base, tt.drift) + if tt.wantErr != "" { + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, got.Drift) + assert.Empty(t, got.Drift) + assert.False(t, got.HasDrift()) + }) + } +} + +func TestAttachDrift_Validation(t *testing.T) { + t.Parallel() + base := mustPlan(t, semantic.Header{}, semantic.HelmUpgrade) + live := &semantic.ResourceSnapshot{Object: cm("app", "live")} + prev := &semantic.ResourceSnapshot{Object: cm("app", "prev")} + fields := []semantic.FieldChange{{Path: "/data/key", Op: semantic.FieldReplace, Before: "prev", After: "live"}} + tests := []struct { + name string + drift semantic.DriftChange + wantErr string + }{ + { + name: "invalid action", + drift: semantic.DriftChange{Resource: ref("ConfigMap", "app"), Action: 0}, + wantErr: "invalid action", + }, + { + name: "name required", + drift: semantic.DriftChange{ + Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "ConfigMap", GenerateName: "app-"}, + Action: semantic.DriftMissing, + Previous: prev, + }, + wantErr: "name is required", + }, + { + name: "generateName empty", + drift: semantic.DriftChange{ + Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "ConfigMap", Name: "app", GenerateName: "app-"}, + Action: semantic.DriftMissing, + Previous: prev, + }, + wantErr: "generateName must be empty", + }, + { + name: "modified needs previous", + drift: semantic.DriftChange{Resource: ref("ConfigMap", "app"), Action: semantic.DriftModified, Live: live, Fields: fields}, + wantErr: "modified requires a previous snapshot", + }, + { + name: "modified needs live", + drift: semantic.DriftChange{Resource: ref("ConfigMap", "app"), Action: semantic.DriftModified, Previous: prev, Fields: fields}, + wantErr: "modified requires a live snapshot", + }, + { + name: "modified needs fields", + drift: semantic.DriftChange{Resource: ref("ConfigMap", "app"), Action: semantic.DriftModified, Previous: prev, Live: live}, + wantErr: "modified requires a field change", + }, + { + name: "missing needs previous", + drift: semantic.DriftChange{Resource: ref("ConfigMap", "app"), Action: semantic.DriftMissing}, + wantErr: "missing requires a previous snapshot", + }, + { + name: "missing rejects live", + drift: semantic.DriftChange{Resource: ref("ConfigMap", "app"), Action: semantic.DriftMissing, Previous: prev, Live: live}, + wantErr: "missing must not have a live snapshot", + }, + { + name: "missing rejects fields", + drift: semantic.DriftChange{Resource: ref("ConfigMap", "app"), Action: semantic.DriftMissing, Previous: prev, Fields: fields}, + wantErr: "missing must not have field changes", + }, + { + name: "unexpected rejects previous", + drift: semantic.DriftChange{Resource: ref("ConfigMap", "app"), Action: semantic.DriftUnexpected, Previous: prev, Live: live}, + wantErr: "unexpected must not have a previous snapshot", + }, + { + name: "unexpected needs live", + drift: semantic.DriftChange{Resource: ref("ConfigMap", "app"), Action: semantic.DriftUnexpected}, + wantErr: "unexpected requires a live snapshot", + }, + { + name: "unexpected rejects fields", + drift: semantic.DriftChange{Resource: ref("ConfigMap", "app"), Action: semantic.DriftUnexpected, Live: live, Fields: fields}, + wantErr: "unexpected must not have field changes", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := semantic.AttachDrift(base, []semantic.DriftChange{tt.drift}) + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestAttachDrift_DuplicateLogicalIdentity(t *testing.T) { + t.Parallel() + base := mustPlan(t, semantic.Header{}, semantic.HelmUpgrade) + first := missingDrift("app") + second := missingDrift("app") + second.Resource.APIVersion = "core/v1" + _, err := semantic.AttachDrift(base, []semantic.DriftChange{first, second}) + require.Error(t, err) + assert.ErrorContains(t, err, "duplicate logical identity") +} + +func TestAttachDrift_SortsAndCopies(t *testing.T) { + t.Parallel() + change := updateChange("app", "v1", "v2") + base, err := semantic.New(semantic.Header{Release: "web"}, semantic.HelmUpgrade, []semantic.ResourceChange{change}, nil) + require.NoError(t, err) + summary := base.Summary + + zeta := missingDrift("zeta") + alpha := unexpectedDrift("alpha") + alpha.Resource.APIVersion = "apps/v1" + alpha.Resource.Kind = "Deployment" + mod := modifiedDrift("mid") + mod.Fields = []semantic.FieldChange{ + {Path: "/b", Op: semantic.FieldReplace, Before: "1", After: "2"}, + {Path: "/a", Op: semantic.FieldReplace, Before: "1", After: "2"}, + } + input := []semantic.DriftChange{zeta, alpha, mod} + got, err := semantic.AttachDrift(base, input) + require.NoError(t, err) + require.Len(t, got.Drift, 3) + // Group sorts before kind, so core ConfigMaps precede apps/Deployment. + assert.Equal(t, "mid", got.Drift[0].Resource.Name) + assert.Equal(t, "zeta", got.Drift[1].Resource.Name) + assert.Equal(t, "alpha", got.Drift[2].Resource.Name) + assert.Equal(t, "/a", got.Drift[0].Fields[0].Path) + assert.Equal(t, "/b", got.Drift[0].Fields[1].Path) + + gotData, ok := got.Drift[0].Previous.Object["data"].(map[string]any) + require.True(t, ok) + gotData["key"] = "mutated" + got.Drift[0].Fields[0].Before = "mutated" + inputData, ok := input[2].Previous.Object["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "prev", inputData["key"]) + assert.Equal(t, "1", input[2].Fields[0].Before) + + assert.Equal(t, summary, got.Summary) + assert.Equal(t, semantic.HelmUpgrade, got.HelmAction) + require.Len(t, got.Changes, 1) + assert.Equal(t, semantic.Update, got.Changes[0].Action) + assert.True(t, got.HasEffects()) + assert.False(t, got.IsNoOp()) + assert.True(t, got.HasDrift()) +} + +func TestAttachDrift_DoesNotChangeReleaseIntent(t *testing.T) { + t.Parallel() + base, err := semantic.New(semantic.Header{Release: "web"}, semantic.HelmNone, nil, nil) + require.NoError(t, err) + got, err := semantic.AttachDrift(base, []semantic.DriftChange{missingDrift("app")}) + require.NoError(t, err) + assert.Equal(t, semantic.HelmNone, got.HelmAction) + assert.Empty(t, got.Changes) + assert.Equal(t, semantic.Summary{}, got.Summary) + assert.False(t, got.HasEffects()) + assert.True(t, got.IsNoOp()) + assert.True(t, got.HasDrift()) +} + +func TestDriftAction_String(t *testing.T) { + t.Parallel() + tests := []struct { + action semantic.DriftAction + want string + }{ + {action: semantic.DriftModified, want: "modified"}, + {action: semantic.DriftMissing, want: "missing"}, + {action: semantic.DriftUnexpected, want: "unexpected"}, + {want: "DriftAction(0)"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, tt.action.String()) + }) + } +} + +func mustPlan(t *testing.T, header semantic.Header, action semantic.HelmAction) semantic.Plan { + t.Helper() + p, err := semantic.New(header, action, nil, nil) + require.NoError(t, err) + return p +} + +func missingDrift(name string) semantic.DriftChange { + return semantic.DriftChange{ + Resource: ref("ConfigMap", name), + Action: semantic.DriftMissing, + Previous: &semantic.ResourceSnapshot{Object: cm(name, "prev")}, + } +} + +func unexpectedDrift(name string) semantic.DriftChange { + return semantic.DriftChange{ + Resource: ref("ConfigMap", name), + Action: semantic.DriftUnexpected, + Live: &semantic.ResourceSnapshot{Object: cm(name, "live")}, + } +} + +func modifiedDrift(name string) semantic.DriftChange { + return semantic.DriftChange{ + Resource: ref("ConfigMap", name), + Action: semantic.DriftModified, + Previous: &semantic.ResourceSnapshot{Object: cm(name, "prev")}, + Live: &semantic.ResourceSnapshot{Object: cm(name, "live")}, + Fields: []semantic.FieldChange{{ + Path: "/data/key", + Op: semantic.FieldReplace, + Before: "prev", + After: "live", + }}, + } +} diff --git a/internal/plan/semantic/plan.go b/internal/plan/semantic/plan.go index e70867e..d2b2f44 100644 --- a/internal/plan/semantic/plan.go +++ b/internal/plan/semantic/plan.go @@ -19,23 +19,26 @@ import ( "slices" ) -// Plan is one run's Helm release intent and its Previous-to-Desired -// resource changes. Build it with [New]. Add chart CRDs with -// [AttachChartCRDs]. Snapshots still contain secrets, and this type -// is not the plan JSON document. +// Plan is one run's Helm release intent, its Previous-to-Desired +// resource changes, and its Previous-to-Live drift. Build it with +// [New]. Add chart CRDs with [AttachChartCRDs] and drift with +// [AttachDrift]. Snapshots still contain secrets, and this type is +// not the plan JSON document. type Plan struct { Header Header HelmAction HelmAction Changes []ResourceChange + Drift []DriftChange Tasks []TaskPlan ChartCRDs []ChartCRD Summary Summary } // New checks and sorts its inputs, fills [Summary], and returns -// non-nil slices. Chart CRDs stay empty. It returns an error if a -// task reference is missing, ambiguous, or shared. helmAction and -// field changes are stored as given. +// non-nil slices. Chart CRDs and drift stay empty. It returns an +// error if a task reference is missing, ambiguous, or shared. +// helmAction and field changes are stored as given. Drift is +// observed cluster state and is not an input here. func New(header Header, helmAction HelmAction, changes []ResourceChange, tasks []TaskPlan) (Plan, error) { if !helmAction.valid() { return Plan{}, fmt.Errorf("invalid helm action %s", helmAction) @@ -82,6 +85,7 @@ func New(header Header, helmAction HelmAction, changes []ResourceChange, tasks [ HelmAction: helmAction, Changes: copiedChanges, Tasks: copiedTasks, + Drift: []DriftChange{}, ChartCRDs: []ChartCRD{}, Summary: Summarize(copiedChanges), }, nil @@ -122,7 +126,8 @@ func validateChartCRD(c ChartCRD) error { } // HasEffects reports whether the plan lists a resource change or a task -// that would change or run. [HelmAction] is not an effect. +// that would change or run. [HelmAction] is not an effect. [Plan.Drift] +// is observed cluster state and is not an effect. func (p Plan) HasEffects() bool { if len(p.Changes) > 0 { return true @@ -136,7 +141,8 @@ func (p Plan) HasEffects() bool { } // IsNoOp reports whether the plan is a non-install [HelmNone] with no -// known effects. +// known release effects. Drift does not count, so a plan can be a +// no-op while [Plan.HasDrift] is true. func (p Plan) IsNoOp() bool { return !p.Header.FreshInstall && p.HelmAction == HelmNone && diff --git a/internal/plan/semantic_build.go b/internal/plan/semantic_build.go index 4f2becc..588dd8f 100644 --- a/internal/plan/semantic_build.go +++ b/internal/plan/semantic_build.go @@ -32,18 +32,17 @@ import ( // RESTMapper maps a GroupKind and version to a REST mapping. // [BuildSemanticPlan] uses it only to learn scope and whether a // resource can be built. It does not read live objects or send writes. -// [meta.RESTMapper] satisfies it. +// Live reads for Drift go through [LiveReader]. [meta.RESTMapper] +// satisfies it. type RESTMapper interface { RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) } var _ RESTMapper = meta.RESTMapper(nil) -// SemanticBuildClient is the one render [BuildSemanticPlan] calls. -// It returns the render and its prep. A real Helm client looks up -// release history in that call and returns Previous through the prep. -// The interface stays small so this package does not import -// [deployah.dev/deployah/internal/session], and tests can pass a fake. +// SemanticBuildClient is the render [BuildSemanticPlan] calls. It +// returns the render and its prep. A Helm client reads release history +// there and returns Previous on the prep. type SemanticBuildClient interface { RenderManifestsWithPrep( ctx context.Context, @@ -75,27 +74,29 @@ type SemanticBuildInput struct { SkipCRDs bool } -// BuildSemanticPlan renders the spec and returns the Helm release -// intent and the resource changes from Previous to Desired. +// BuildSemanticPlan renders the spec and returns Helm release intent, +// changes from Previous to Desired, and Drift from Previous to Live. // -// It calls [SemanticBuildClient.RenderManifestsWithPrep] unchanged. A -// real Helm client reads release history, renders Desired with a -// client-side dry-run, and may use Helm discovery. After that, mapper -// is used only to learn scope and whether each resource can be built. -// That step does not read live objects or send create, update, patch, -// or delete requests. +// Rendering calls [SemanticBuildClient.RenderManifestsWithPrep] as +// given. A Helm client may read release history and dry-run Desired +// on the client. mapper then only checks scope and whether a resource +// can be built. It does not read Live or write. // -// Previous is the current release manifest, empty on install. Desired -// is the render. Only Desired is [semantic.Create]. Only Previous is -// [semantic.Delete]: it leaves Desired release state. A resource in -// both that differs is [semantic.Update]. Equal ones are omitted. +// Drift is always set. A fresh install leaves it empty, and live may +// be nil. An existing release requires live. Drift only gets and +// lists. A failed read fails the plan, except when the object is +// missing. Drift leaves the Helm action, changes, tasks, chart CRDs, +// and summary as they are. // -// The plan fails if the render declares the target namespace, or if -// the previous release does on upgrade. Other namespaces stay ordinary +// Previous is the stored release, empty on install. Desired is the +// render. Only Desired is [semantic.Create], only Previous is +// [semantic.Delete], and a difference is [semantic.Update]. Equal +// objects are left out. The target namespace in the render, or in +// Previous on upgrade, fails the plan. Other namespaces stay ordinary // changes. // -// Chart CRD files go to Helm and are recorded as lifecycle, not as -// resource changes. Identity comes from [SemanticBuildInput.CRDDocs]. +// Chart CRD files go to Helm. Their lifecycle comes from +// [SemanticBuildInput.CRDDocs], not from changes or drift. // // Call the returned cleanup once. It is never nil. On error the plan // is empty and the render result is nil. @@ -103,6 +104,7 @@ func BuildSemanticPlan( ctx context.Context, client SemanticBuildClient, mapper RESTMapper, + live LiveReader, input SemanticBuildInput, ) (semantic.Plan, *render.RenderResult, func(), error) { cleanup := func() {} @@ -127,13 +129,16 @@ func BuildSemanticPlan( if err != nil { return semantic.Plan{}, nil, cleanup, fmt.Errorf("render preparation: %w", err) } + if prep.Operation == helm.OperationUpgrade && live == nil { + return semantic.Plan{}, nil, cleanup, fmt.Errorf("semantic plan for an existing release requires a live reader") + } helmAction, err := deriveHelmAction(prep, result.Manifest, result.Hooks) if err != nil { return semantic.Plan{}, nil, cleanup, fmt.Errorf("determine helm release intent: %w", err) } - changes, err := declaredChanges(mapper, prep, result) + changes, previous, err := declaredChanges(mapper, prep, result) if err != nil { return semantic.Plan{}, nil, cleanup, err } @@ -160,49 +165,57 @@ func BuildSemanticPlan( if err != nil { return semantic.Plan{}, nil, cleanup, fmt.Errorf("assemble semantic plan: %w", err) } + drift, err := observeDrift(ctx, live, previous, header) + if err != nil { + return semantic.Plan{}, nil, cleanup, fmt.Errorf("observe drift: %w", err) + } + p, err = semantic.AttachDrift(p, drift) + if err != nil { + return semantic.Plan{}, nil, cleanup, fmt.Errorf("assemble semantic plan: %w", err) + } return p, result, cleanup, nil } -func declaredChanges(mapper RESTMapper, prep helm.ReleasePrep, result *render.RenderResult) ([]semantic.ResourceChange, error) { +func declaredChanges(mapper RESTMapper, prep helm.ReleasePrep, result *render.RenderResult) ([]semantic.ResourceChange, []declaration, error) { desiredObjs, err := flattenManifest(result.Manifest) if err != nil { - return nil, fmt.Errorf("rendered manifest: %w", err) + return nil, nil, fmt.Errorf("rendered manifest: %w", err) } if ownErr := checkTargetNamespaceOwnership("rendered manifest", desiredObjs, result.Namespace); ownErr != nil { - return nil, ownErr + return nil, nil, ownErr } desired, err := declareAll(mapper, desiredObjs, result.Namespace, "rendered manifest") if err != nil { - return nil, err + return nil, nil, err } var previous []declaration if prep.Operation == helm.OperationUpgrade { if prep.Current == nil { - return nil, fmt.Errorf("upgrade requires the previous release") + return nil, nil, fmt.Errorf("upgrade requires the previous release") } side := fmt.Sprintf("previous release revision %d", prep.Current.Version) previousObjs, flatErr := flattenManifest(prep.Current.Manifest) if flatErr != nil { - return nil, fmt.Errorf("%s: %w", side, flatErr) + return nil, nil, fmt.Errorf("%s: %w", side, flatErr) } if ownErr := checkTargetNamespaceOwnership(side, previousObjs, result.Namespace); ownErr != nil { - return nil, ownErr + return nil, nil, ownErr } previous, err = declareAll(mapper, previousObjs, result.Namespace, side) if err != nil { - return nil, err + return nil, nil, err } } changes, err := diffDeclared(previous, desired) if err != nil { - return nil, err + return nil, nil, err } if err = stampHelmOrder(changes); err != nil { - return nil, fmt.Errorf("assemble semantic plan: %w", err) + return nil, nil, fmt.Errorf("assemble semantic plan: %w", err) } - return changes, nil + return changes, previous, nil } func chartCRDsFromDocs(docs []extras.CRDDoc, op helm.Operation, skipCRDs bool) []semantic.ChartCRD { diff --git a/internal/plan/semantic_build_crd_test.go b/internal/plan/semantic_build_crd_test.go index a78d0f7..148a278 100644 --- a/internal/plan/semantic_build_crd_test.go +++ b/internal/plan/semantic_build_crd_test.go @@ -38,7 +38,7 @@ func TestBuildSemanticPlan_CRDFilesDoNotProduceResourceChanges(t *testing.T) { Kind: "CustomResourceDefinition", Name: "widgets.example.com", }} - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), in) + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), liveFor(client), in) t.Cleanup(cleanup) require.NoError(t, err) assert.Equal(t, rawCopy, in.CRDs[0].Raw) @@ -89,7 +89,7 @@ func TestBuildSemanticPlan_ChartCRDLifecycle(t *testing.T) { in := buildInput("ctx", resolvedSpec(), nil) in.CRDDocs = docs in.SkipCRDs = tc.skip - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), in) + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), liveFor(client), in) t.Cleanup(cleanup) require.NoError(t, err) assert.Equal(t, tc.wantAction, p.HelmAction) diff --git a/internal/plan/semantic_build_test.go b/internal/plan/semantic_build_test.go index 2cd98a0..f283160 100644 --- a/internal/plan/semantic_build_test.go +++ b/internal/plan/semantic_build_test.go @@ -188,9 +188,16 @@ func buildInput(clusterContext string, resolved *spec.ResolvedSpec, post postren } } +func liveFor(client *fakeBuildClient) plan.LiveReader { + if client != nil && client.prep.Operation == helm.OperationUpgrade { + return &fakeLive{} + } + return nil +} + func mustBuild(t *testing.T, client *fakeBuildClient, mapper plan.RESTMapper) semantic.Plan { t.Helper() - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, mapper, buildInput("ctx", resolvedSpec(), nil)) + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, mapper, liveFor(client), buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.NoError(t, err) return p @@ -209,7 +216,7 @@ func changeByName(t *testing.T, changes []semantic.ResourceChange, name string) func TestBuildSemanticPlan_RequiresClient(t *testing.T) { t.Parallel() - p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), nil, newMapper(), buildInput("ctx", resolvedSpec(), nil)) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), nil, newMapper(), nil, buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, "semantic plan requires a helm client") @@ -247,7 +254,7 @@ func TestBuildSemanticPlan_RequiresInput(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() client := &fakeBuildClient{} - p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, tt.mapper, tt.in) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, tt.mapper, nil, tt.in) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, tt.wantErr) @@ -267,7 +274,7 @@ func TestBuildSemanticPlan_FreshInstallCreatesDesired(t *testing.T) { client.cleanup = func() { cleanups++ } ctx := context.WithValue(t.Context(), ctxKey{}, "pipeline") - p, got, cleanup, err := plan.BuildSemanticPlan(ctx, client, newMapper(), buildInput("kind-dev", resolvedSpec(), post)) + p, got, cleanup, err := plan.BuildSemanticPlan(ctx, client, newMapper(), liveFor(client), buildInput("kind-dev", resolvedSpec(), post)) require.NotNil(t, cleanup) t.Cleanup(func() { if cleanups == 0 { @@ -567,7 +574,7 @@ func TestBuildSemanticPlan_Tasks(t *testing.T) { "cleanup": {Task: spec.Task{On: spec.TaskOnSchedule}}, "manual": {Task: spec.Task{On: spec.TaskOnManual}}, } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolved, nil)) + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), liveFor(client), buildInput("ctx", resolved, nil)) t.Cleanup(cleanup) require.NoError(t, err) assert.Equal(t, semantic.HelmNone, p.HelmAction) @@ -600,7 +607,7 @@ func TestBuildSemanticPlan_Tasks(t *testing.T) { }, }, } - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolved, nil)) + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), liveFor(client), buildInput("ctx", resolved, nil)) t.Cleanup(cleanup) require.NoError(t, err) require.Len(t, p.Changes, 1) @@ -688,7 +695,7 @@ func TestBuildSemanticPlan_TargetNamespaceOwnership(t *testing.T) { } else { client = upgradeClient(tt.previous, tt.desired, 4) } - _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolvedSpec(), nil)) + _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), liveFor(client), buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, tt.wantErr) @@ -759,7 +766,7 @@ data: } else { client = upgradeClient(tt.previous, tt.desired, 4) } - _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolvedSpec(), nil)) + _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), liveFor(client), buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, tt.wantErr) @@ -817,7 +824,7 @@ spec: gk := schema.GroupKind{Group: "example.com", Kind: "Widget"} mapper.err[gk] = &meta.NoKindMatchError{GroupKind: gk} client := upgradeClient(widget, configMapYAML("app", "prod", "v1"), 4) - _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, mapper, buildInput("ctx", resolvedSpec(), nil)) + _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, mapper, liveFor(client), buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, "previous release revision 3: resolve resource mapping") @@ -840,7 +847,7 @@ spec: client := installClient(widget) in := buildInput("ctx", resolvedSpec(), nil) in.CRDs = []extras.RawFile{{Path: "widgets.yaml", Raw: []byte("apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\n")}} - _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, mapper, in) + _, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, mapper, liveFor(client), in) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, "rendered manifest: resolve resource mapping") @@ -922,7 +929,7 @@ func TestBuildSemanticPlan_PrepRenderMismatch(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() client := &fakeBuildClient{result: tt.result, prep: tt.prep, cleanup: func() {}} - p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolvedSpec(), nil)) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), liveFor(client), buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, "render preparation:") @@ -943,7 +950,7 @@ func TestBuildSemanticPlan_InvalidPrep(t *testing.T) { }, cleanup: func() {}, } - p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolvedSpec(), nil)) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), liveFor(client), buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, "determine helm release intent:") @@ -959,7 +966,7 @@ func TestBuildSemanticPlan_MappingFailureLeavesCleanup(t *testing.T) { client.cleanup = func() { cleanups++ } mapper := newMapper() mapper.err[schema.GroupKind{Kind: "ConfigMap"}] = errors.New("discovery failed") - p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, mapper, buildInput("ctx", resolvedSpec(), nil)) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, mapper, liveFor(client), buildInput("ctx", resolvedSpec(), nil)) require.NotNil(t, cleanup) t.Cleanup(func() { if cleanups == 0 { @@ -979,7 +986,7 @@ func TestBuildSemanticPlan_NilCleanup(t *testing.T) { t.Parallel() client := installClient(configMapYAML("app", "prod", "v1")) client.cleanup = nil - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolvedSpec(), nil)) + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), liveFor(client), buildInput("ctx", resolvedSpec(), nil)) require.NotNil(t, cleanup) t.Cleanup(cleanup) require.NoError(t, err) @@ -989,7 +996,7 @@ func TestBuildSemanticPlan_NilCleanup(t *testing.T) { func TestBuildSemanticPlan_RenderError(t *testing.T) { t.Parallel() client := &fakeBuildClient{err: errors.New("chart missing")} - p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), buildInput("ctx", resolvedSpec(), nil)) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), liveFor(client), buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.Error(t, err) assert.ErrorContains(t, err, "render manifests:") @@ -1073,7 +1080,7 @@ func TestBuildSemanticPlan_Hooks(t *testing.T) { t.Parallel() resolved := resolvedSpec() resolved.Tasks = tt.tasks - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), tt.client, newMapper(), buildInput("ctx", resolved, nil)) + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), tt.client, newMapper(), liveFor(tt.client), buildInput("ctx", resolved, nil)) t.Cleanup(cleanup) require.NoError(t, err) assert.Equal(t, tt.wantHelm, p.HelmAction) diff --git a/internal/plan/semantic_drift.go b/internal/plan/semantic_drift.go new file mode 100644 index 0000000..0787b4a --- /dev/null +++ b/internal/plan/semantic_drift.go @@ -0,0 +1,537 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan + +import ( + "context" + "encoding/base64" + "fmt" + "maps" + "slices" + "strconv" + "strings" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/version" + + "deployah.dev/deployah/internal/plan/semantic" + "deployah.dev/deployah/internal/spec" + + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +const ( + helmHookAnnotation = "helm.sh/hook" + helmReleaseNameAnnotation = "meta.helm.sh/release-name" + helmReleaseNamespaceAnnotation = "meta.helm.sh/release-namespace" + kubectlLastAppliedAnnotation = "kubectl.kubernetes.io/last-applied-configuration" +) + +// absenceEquiv matches an empty Previous map at pattern with a Live +// object that omits that key. "*" is one list index. Other empty +// values still differ. +type absenceEquiv struct { + group string + kind string + pattern []string +} + +// emptyLimitsEquivalences match the empty resources.limits maps +// Kubernetes drops on Deployah Deployments, StatefulSets, and CronJobs. +var emptyLimitsEquivalences = []absenceEquiv{ + {group: "apps", kind: "Deployment", pattern: []string{"spec", "template", "spec", "containers", "*", "resources", "limits"}}, + {group: "apps", kind: "StatefulSet", pattern: []string{"spec", "template", "spec", "containers", "*", "resources", "limits"}}, + {group: "batch", kind: "CronJob", pattern: []string{"spec", "jobTemplate", "spec", "template", "spec", "containers", "*", "resources", "limits"}}, +} + +type driftBucketKey struct { + group string + kind string + namespace string +} + +type driftBucket struct { + key driftBucketKey + namespaced bool + ambiguous bool + mappings []*meta.RESTMapping +} + +// observeDrift compares Previous with Live. A fresh install returns +// nothing and does not read live. Previous snapshots stay as stored. +// Modified Live is the projected comparison. Unexpected Live drops +// server bookkeeping and the last-applied annotation. A field path +// may exist only on the comparison copy. +func observeDrift(ctx context.Context, live LiveReader, previous []declaration, header semantic.Header) ([]semantic.DriftChange, error) { + if header.FreshInstall { + return []semantic.DriftChange{}, nil + } + if header.Release == "" { + return nil, fmt.Errorf("existing release requires a release name") + } + if live == nil { + return nil, fmt.Errorf("semantic plan for an existing release requires a live reader") + } + + known := make(map[logicalIdentity]struct{}) + buckets := map[driftBucketKey]*driftBucket{} + var drift []semantic.DriftChange + for _, d := range previous { + key, namespaced := bucketOf(d) + b := buckets[key] + if b == nil { + b = &driftBucket{key: key, namespaced: namespaced} + buckets[key] = b + } + if d.mapping != nil { + b.mappings = append(b.mappings, d.mapping) + } + if d.pairKey != nil { + b.ambiguous = true + continue + } + known[*d.identity] = struct{}{} + change, found, err := driftForPrevious(ctx, live, d) + if err != nil { + return nil, err + } + if found { + drift = append(drift, change) + } + } + + selector := labels.SelectorFromSet(labels.Set{spec.LabelInstance: header.Release}) + keys := make([]driftBucketKey, 0, len(buckets)) + for key := range buckets { + keys = append(keys, key) + } + slices.SortFunc(keys, func(a, b driftBucketKey) int { + return strings.Compare(a.group+"\x00"+a.kind+"\x00"+a.namespace, b.group+"\x00"+b.kind+"\x00"+b.namespace) + }) + for _, key := range keys { + b := buckets[key] + if b.ambiguous { + continue + } + mapping := representativeMapping(b.mappings) + if mapping == nil { + return nil, fmt.Errorf("list live %s %s: missing resource mapping", key.kind, key.namespace) + } + ns := "" + if b.namespaced { + ns = key.namespace + } + items, err := live.List(ctx, mapping, ns, selector) + if err != nil { + return nil, fmt.Errorf("list live %s %s/%s: %w", key.kind, key.namespace, key.group, err) + } + for i := range items { + change, keep, unexpectedErr := unexpectedChange(&items[i], b.namespaced, header, known) + if unexpectedErr != nil { + return nil, unexpectedErr + } + if keep { + drift = append(drift, change) + } + } + } + if drift == nil { + return []semantic.DriftChange{}, nil + } + return drift, nil +} + +func bucketOf(d declaration) (driftBucketKey, bool) { + if d.identity != nil { + return driftBucketKey{group: d.identity.Group, kind: d.identity.Kind, namespace: d.identity.Namespace}, d.namespaced + } + return driftBucketKey{group: d.pairKey.Group, kind: d.pairKey.Kind, namespace: d.pairKey.Namespace}, d.namespaced +} + +func driftForPrevious(ctx context.Context, live LiveReader, d declaration) (semantic.DriftChange, bool, error) { + obj, err := live.Get(ctx, d.mapping, d.effectiveNamespace, d.identity.Name) + if apierrors.IsNotFound(err) { + return semantic.DriftChange{ + Resource: resourceRef(d), + Action: semantic.DriftMissing, + Previous: snapshotOf(d.obj), + }, true, nil + } + if err != nil { + return semantic.DriftChange{}, false, fmt.Errorf("read live %s: %w", d.identity, err) + } + fields, liveCmp, diffErr := comparePreviousToLive(d, obj) + if diffErr != nil { + return semantic.DriftChange{}, false, fmt.Errorf("diff live %s: %w", d.identity, diffErr) + } + if len(fields) == 0 { + return semantic.DriftChange{}, false, nil + } + return semantic.DriftChange{ + Resource: resourceRef(d), + Action: semantic.DriftModified, + Previous: snapshotOf(d.obj), + Live: &semantic.ResourceSnapshot{Object: liveCmp}, + Fields: fields, + }, true, nil +} + +func representativeMapping(mappings []*meta.RESTMapping) *meta.RESTMapping { + var best *meta.RESTMapping + for _, mapping := range mappings { + if mapping == nil { + continue + } + if best == nil || version.CompareKubeAwareVersionStrings(mapping.GroupVersionKind.Version, best.GroupVersionKind.Version) > 0 { + best = mapping + } + } + return best +} + +func unexpectedChange(obj *unstructured.Unstructured, namespaced bool, header semantic.Header, known map[logicalIdentity]struct{}) (semantic.DriftChange, bool, error) { + if !releaseOwned(obj, header.Release, header.Namespace) { + return semantic.DriftChange{}, false, nil + } + id := liveIdentity(obj, namespaced) + if _, ok := known[id]; ok { + return semantic.DriftChange{}, false, nil + } + if id.Group == "" && id.Kind == "Namespace" && id.Name == header.Namespace { + return semantic.DriftChange{}, false, nil + } + if id.Name == "" { + return semantic.DriftChange{}, false, fmt.Errorf("unexpected live %s has no name", id.Kind) + } + return semantic.DriftChange{ + Resource: liveResourceRef(obj, namespaced), + Action: semantic.DriftUnexpected, + Live: unexpectedSnapshot(obj), + }, true, nil +} + +func releaseOwned(obj *unstructured.Unstructured, release, releaseNamespace string) bool { + if obj.GetLabels()[spec.LabelInstance] != release { + return false + } + annotations := obj.GetAnnotations() + source := annotations[spec.AnnotationSource] + if source != spec.SourceSpec && source != spec.SourceManifests { + return false + } + if annotations[helmHookAnnotation] != "" { + return false + } + if annotations[helmReleaseNameAnnotation] != release { + return false + } + return annotations[helmReleaseNamespaceAnnotation] == releaseNamespace +} + +func liveIdentity(obj *unstructured.Unstructured, namespaced bool) logicalIdentity { + ns := "" + if namespaced { + ns = obj.GetNamespace() + } + return logicalIdentity{ + Group: obj.GroupVersionKind().Group, + Kind: obj.GetKind(), + Namespace: ns, + Name: obj.GetName(), + } +} + +func liveResourceRef(obj *unstructured.Unstructured, namespaced bool) semantic.ResourceRef { + ns := "" + if namespaced { + ns = obj.GetNamespace() + } + return semantic.ResourceRef{ + APIVersion: obj.GetAPIVersion(), + Kind: obj.GetKind(), + Namespace: ns, + Name: obj.GetName(), + } +} + +func unexpectedSnapshot(obj *unstructured.Unstructured) *semantic.ResourceSnapshot { + cp := obj.DeepCopy() + if cp.Object == nil { + return &semantic.ResourceSnapshot{} + } + stripBookkeeping(cp.Object) + annotations := cp.GetAnnotations() + if _, ok := annotations[kubectlLastAppliedAnnotation]; ok { + delete(annotations, kubectlLastAppliedAnnotation) + if len(annotations) == 0 { + cp.SetAnnotations(nil) + } else { + cp.SetAnnotations(annotations) + } + } + return snapshotOf(cp) +} + +func stripBookkeeping(obj map[string]any) { + for _, pointer := range semantic.BookkeepingPointers() { + deletePointer(obj, pointer) + } +} + +// comparePreviousToLive diffs prev's declared surface against Live. +// prev is left unchanged. The map is the projected Live snapshot and +// still contains apiVersion. The field list does not. +func comparePreviousToLive(prev declaration, live *unstructured.Unstructured) ([]semantic.FieldChange, map[string]any, error) { + prevCmp := comparisonCopy(prev) + foldSecretStringData(prevCmp, prev.obj.GetAPIVersion(), prev.obj.GetKind()) + liveMap := map[string]any{} + if live != nil && live.Object != nil { + liveMap = live.DeepCopy().Object + } + projected := projectValue(prevCmp, liveMap) + liveCmp, ok := projected.(map[string]any) + if !ok || liveCmp == nil { + liveCmp = map[string]any{} + } + applyAbsenceEquivalence(prevCmp, liveCmp, prev.obj.GroupVersionKind().GroupKind()) + fields, err := semantic.DiffFields(prevCmp, liveCmp) + if err != nil { + return nil, nil, err + } + return dropAPIVersion(fields), liveCmp, nil +} + +func dropAPIVersion(fields []semantic.FieldChange) []semantic.FieldChange { + out := make([]semantic.FieldChange, 0, len(fields)) + for _, field := range fields { + if field.Path == "/apiVersion" { + continue + } + out = append(out, field) + } + return out +} + +// foldSecretStringData copies a core v1 Secret's stringData into data +// on the comparison copy. stringData wins on the same key. Non-string +// values stay in stringData. +func foldSecretStringData(obj map[string]any, apiVersion, kind string) { + if kind != "Secret" || (apiVersion != "v1" && apiVersion != "core/v1") { + return + } + raw, ok := obj["stringData"].(map[string]any) + if !ok { + return + } + data, hadData := obj["data"].(map[string]any) + if hadData { + copied := make(map[string]any, len(data)) + maps.Copy(copied, data) + data = copied + } else { + data = map[string]any{} + } + left := map[string]any{} + folded := false + for k, v := range raw { + text, isString := v.(string) + if !isString { + left[k] = v + continue + } + data[k] = base64.StdEncoding.EncodeToString([]byte(text)) + folded = true + } + if hadData || folded { + obj["data"] = data + } + if len(left) == 0 { + delete(obj, "stringData") + return + } + obj["stringData"] = left +} + +// projectValue keeps only what Previous declared in live. An empty +// map declares no keys. Lists stay in order and keep extra Live +// items. A type mismatch keeps the Live value whole. +func projectValue(declared, live any) any { + switch typed := declared.(type) { + case map[string]any: + liveMap, ok := live.(map[string]any) + if !ok { + return copyDriftValue(live) + } + out := map[string]any{} + for key, declaredChild := range typed { + liveChild, exists := liveMap[key] + if !exists { + continue + } + out[key] = projectValue(declaredChild, liveChild) + } + return out + case []any: + liveList, ok := live.([]any) + if !ok { + return copyDriftValue(live) + } + out := make([]any, 0, len(liveList)) + for i := range liveList { + if i < len(typed) { + out = append(out, projectValue(typed[i], liveList[i])) + continue + } + out = append(out, copyDriftValue(liveList[i])) + } + return out + default: + return copyDriftValue(live) + } +} + +func applyAbsenceEquivalence(prev, live map[string]any, gk schema.GroupKind) { + for _, eq := range emptyLimitsEquivalences { + if eq.group != gk.Group || eq.kind != gk.Kind { + continue + } + for _, path := range expandPattern(prev, eq.pattern) { + value, ok := lookupPath(prev, path) + if !ok || !isEmptyMap(value) { + continue + } + if _, liveOK := lookupPath(live, path); liveOK { + continue + } + deletePath(prev, path) + } + } +} + +func isEmptyMap(v any) bool { + m, ok := v.(map[string]any) + return ok && len(m) == 0 +} + +func expandPattern(obj map[string]any, pattern []string) [][]string { + var out [][]string + var walk func(cur any, index int, acc []string) + walk = func(cur any, index int, acc []string) { + if index == len(pattern) { + out = append(out, slices.Clone(acc)) + return + } + token := pattern[index] + if token != "*" { + next, ok := mapChild(cur, token) + if !ok { + return + } + walk(next, index+1, append(acc, token)) + return + } + list, ok := cur.([]any) + if !ok { + return + } + for i := range list { + walk(list[i], index+1, append(acc, strconv.Itoa(i))) + } + } + walk(obj, 0, nil) + return out +} + +func mapChild(cur any, key string) (any, bool) { + m, ok := cur.(map[string]any) + if !ok { + return nil, false + } + next, ok := m[key] + return next, ok +} + +func lookupPath(obj map[string]any, path []string) (any, bool) { + var cur any = obj + for _, token := range path { + switch node := cur.(type) { + case map[string]any: + next, ok := node[token] + if !ok { + return nil, false + } + cur = next + case []any: + i, err := strconv.Atoi(token) + if err != nil || i < 0 || i >= len(node) { + return nil, false + } + cur = node[i] + default: + return nil, false + } + } + return cur, true +} + +func deletePath(obj map[string]any, path []string) { + if len(path) == 0 { + return + } + parentPath := path[:len(path)-1] + parent, ok := lookupPath(obj, parentPath) + if !ok { + return + } + token := path[len(path)-1] + if m, isMap := parent.(map[string]any); isMap { + delete(m, token) + } +} + +func deletePointer(obj map[string]any, pointer string) { + if pointer == "" || !strings.HasPrefix(pointer, "/") { + return + } + var tokens []string + for token := range strings.SplitSeq(pointer[1:], "/") { + tokens = append(tokens, strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")) + } + deletePath(obj, tokens) +} + +func copyDriftValue(v any) any { + switch val := v.(type) { + case nil: + return nil + case map[string]any: + out := make(map[string]any, len(val)) + for k, child := range val { + out[k] = copyDriftValue(child) + } + return out + case []any: + out := make([]any, 0, len(val)) + for i := range val { + out = append(out, copyDriftValue(val[i])) + } + return out + default: + return v + } +} diff --git a/internal/plan/semantic_drift_cases_test.go b/internal/plan/semantic_drift_cases_test.go new file mode 100644 index 0000000..78812bc --- /dev/null +++ b/internal/plan/semantic_drift_cases_test.go @@ -0,0 +1,963 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan_test + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/resource" + + "deployah.dev/deployah/internal/extras" + "deployah.dev/deployah/internal/plan" + "deployah.dev/deployah/internal/plan/semantic" + "deployah.dev/deployah/internal/spec" +) + +func TestBuildSemanticPlan_FreshInstallHasNoDrift(t *testing.T) { + t.Parallel() + manifest := configMapYAML("app", "prod", "v1") + p := mustBuild(t, installClient(manifest), newMapper()) + require.NotNil(t, p.Drift) + assert.Empty(t, p.Drift) + + live := &fakeLive{} + seedLive(t, live, manifest, "prod", func(obj *unstructured.Unstructured) { + stampRelease(obj, "web", "prod") + }) + got := buildDrift(t, installClient(manifest), newMapper(), live) + require.NotNil(t, got.Drift) + assert.Empty(t, got.Drift) + assert.Empty(t, live.Calls()) +} + +func TestBuildSemanticPlan_UpgradeRequiresLiveReader(t *testing.T) { + t.Parallel() + client := upgradeClient(configMapYAML("app", "prod", "v"), configMapYAML("app", "prod", "v"), 4) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), nil, buildInput("ctx", resolvedSpec(), nil)) + t.Cleanup(cleanup) + require.Error(t, err) + assert.ErrorContains(t, err, "requires a live reader") + assert.Zero(t, p) + assert.Nil(t, result) + assert.Equal(t, 1, client.calls) +} + +func TestBuildSemanticPlan_DriftAgainstLive(t *testing.T) { + t.Parallel() + unchanged := []struct { + name string + manifest string + live string + }{ + {name: "equal", manifest: configMapYAML("app", "prod", "same"), live: configMapYAML("app", "prod", "same")}, + { + name: "undeclared live field", + manifest: configMapYAML("app", "prod", "same"), + live: `apiVersion: v1 +kind: ConfigMap +metadata: + name: app + namespace: prod +data: + key: same + other: extra +spec: + clusterDomain: cluster.local +`, + }, + { + name: "bookkeeping only", + manifest: configMapYAML("app", "prod", "same"), + live: `apiVersion: v1 +kind: ConfigMap +metadata: + name: app + namespace: prod + uid: abc + resourceVersion: "9" + generation: 3 + creationTimestamp: "2020-01-01T00:00:00Z" + managedFields: + - manager: helm + annotations: + meta.helm.sh/release-name: web + meta.helm.sh/release-namespace: prod +data: + key: same +status: + observed: true +`, + }, + } + for _, tt := range unchanged { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + live := &fakeLive{} + seedLive(t, live, tt.live, "prod", nil) + p := buildDrift(t, upgradeClient(tt.manifest, tt.manifest, 4), newMapper(), live) + assert.Equal(t, semantic.HelmNone, p.HelmAction) + assert.Empty(t, p.Drift) + }) + } + + fields := []struct { + name string + manifest string + live string + path string + op semantic.FieldOp + before any + after any + }{ + { + name: "declared field differs", manifest: configMapYAML("app", "prod", "old"), + live: configMapYAML("app", "prod", "new"), path: "/data/key", op: semantic.FieldReplace, + before: "old", after: "new", + }, + { + name: "declared field missing", manifest: configMapYAML("app", "prod", "old"), + live: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n namespace: prod\n", + path: "/data", op: semantic.FieldRemove, before: map[string]any{"key": "old"}, + }, + } + for _, tt := range fields { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + live := &fakeLive{} + seedLive(t, live, tt.live, "prod", nil) + p := buildDrift(t, upgradeClient(tt.manifest, tt.manifest, 4), newMapper(), live) + assert.Equal(t, semantic.HelmNone, p.HelmAction) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftModified, d.Action) + require.Len(t, d.Fields, 1) + assert.Equal(t, tt.path, d.Fields[0].Path) + assert.Equal(t, tt.op, d.Fields[0].Op) + assert.Equal(t, tt.before, d.Fields[0].Before) + assert.Equal(t, tt.after, d.Fields[0].After) + for _, field := range d.Fields { + assert.NotEqual(t, "/apiVersion", field.Path) + } + }) + } + + t.Run("missing", func(t *testing.T) { + t.Parallel() + manifest := configMapYAML("app", "prod", "old") + p := buildDrift(t, upgradeClient(manifest, manifest, 4), newMapper(), &fakeLive{}) + assert.Equal(t, semantic.HelmNone, p.HelmAction) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftMissing, d.Action) + assert.Equal(t, semantic.ResourceRef{APIVersion: "v1", Kind: "ConfigMap", Namespace: "prod", Name: "app"}, d.Resource) + assert.Nil(t, d.Live) + assert.Empty(t, d.Fields) + data, ok := d.Previous.Object["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "old", data["key"]) + }) + t.Run("apiVersion alone", func(t *testing.T) { + t.Parallel() + manifest := hpaManifestNamed("v2beta2", "app", 2) + live := &fakeLive{} + seedLive(t, live, manifest, "prod", func(obj *unstructured.Unstructured) { + obj.SetAPIVersion("autoscaling/v2") + }) + p := buildDrift(t, upgradeClient(manifest, manifest, 4), newMapper(), live) + assert.Equal(t, semantic.HelmNone, p.HelmAction) + assert.Empty(t, p.Drift) + require.Len(t, gets(live), 1) + assert.Equal(t, "v2beta2", gets(live)[0].version) + assert.Equal(t, "prod", gets(live)[0].namespace) + }) + t.Run("apiVersion with a real field change keeps previous ref", func(t *testing.T) { + t.Parallel() + manifest := hpaManifestNamed("v2beta2", "app", 2) + live := &fakeLive{} + seedLive(t, live, manifest, "prod", func(obj *unstructured.Unstructured) { + obj.SetAPIVersion("autoscaling/v2") + require.NoError(t, unstructured.SetNestedField(obj.Object, int64(3), "spec", "maxReplicas")) + }) + p := buildDrift(t, upgradeClient(manifest, manifest, 4), newMapper(), live) + assert.Equal(t, semantic.HelmNone, p.HelmAction) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftModified, d.Action) + assert.Equal(t, "autoscaling/v2beta2", d.Resource.APIVersion) + require.Len(t, d.Fields, 1) + assert.Equal(t, "/spec/maxReplicas", d.Fields[0].Path) + assert.NotEqual(t, "/apiVersion", d.Fields[0].Path) + }) + t.Run("omitted namespace", func(t *testing.T) { + t.Parallel() + manifest := configMapYAML("app", "", "same") + live := &fakeLive{} + seedLive(t, live, configMapYAML("app", "prod", "same"), "prod", nil) + p := buildDrift(t, upgradeClient(manifest, manifest, 4), newMapper(), live) + assert.Equal(t, semantic.HelmNone, p.HelmAction) + assert.Empty(t, p.Drift) + require.Len(t, gets(live), 1) + assert.Equal(t, "v1", gets(live)[0].version) + assert.Equal(t, "prod", gets(live)[0].namespace) + }) + t.Run("cluster scoped declared namespace", func(t *testing.T) { + t.Parallel() + manifest := kindYAML("rbac.authorization.k8s.io/v1", "ClusterRole", "app", "prod", "rules: []") + live := &fakeLive{} + seedLive(t, live, kindYAML("rbac.authorization.k8s.io/v1", "ClusterRole", "app", "", "rules: []"), "", nil) + p := buildDrift(t, upgradeClient(manifest, manifest, 4), newMapper(), live) + assert.Equal(t, semantic.HelmNone, p.HelmAction) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftModified, d.Action) + require.Len(t, d.Fields, 1) + assert.Equal(t, "/metadata/namespace", d.Fields[0].Path) + assert.Equal(t, semantic.FieldRemove, d.Fields[0].Op) + require.Len(t, gets(live), 1) + assert.Equal(t, "v1", gets(live)[0].version) + assert.Empty(t, gets(live)[0].namespace) + }) +} + +func TestBuildSemanticPlan_DriftEquivalence(t *testing.T) { + t.Parallel() + emptyLimits := []struct { + name string + manifest string + }{ + {name: "deployment empty limits", manifest: workloadManifest("apps/v1", "Deployment")}, + {name: "statefulset empty limits", manifest: workloadManifest("apps/v1", "StatefulSet")}, + {name: "cronjob empty limits", manifest: workloadManifest("batch/v1", "CronJob")}, + } + for _, tt := range emptyLimits { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + live := &fakeLive{} + seedLive(t, live, tt.manifest, "prod", func(obj *unstructured.Unstructured) { + dropLimits(t, obj) + }) + p := buildDrift(t, upgradeClient(tt.manifest, tt.manifest, 4), newMapper(), live) + assert.Empty(t, p.Drift) + }) + } + t.Run("stringData matches data", func(t *testing.T) { + t.Parallel() + previous := secretManifest("old") + live := &fakeLive{} + seedLive(t, live, "apiVersion: v1\nkind: Secret\nmetadata:\n name: db\n namespace: prod\ndata:\n password: b2xk\n", "prod", nil) + p := buildDrift(t, upgradeClient(previous, previous, 4), newMapper(), live) + assert.Empty(t, p.Drift) + }) + t.Run("stringData differs", func(t *testing.T) { + t.Parallel() + previous := secretManifest("old") + live := &fakeLive{} + seedLive(t, live, "apiVersion: v1\nkind: Secret\nmetadata:\n name: db\n namespace: prod\ndata:\n password: bmV3\n", "prod", nil) + p := buildDrift(t, upgradeClient(previous, previous, 4), newMapper(), live) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftModified, d.Action) + require.Len(t, d.Fields, 1) + assert.Equal(t, "/data/password", d.Fields[0].Path) + assert.Equal(t, semantic.FieldReplace, d.Fields[0].Op) + _, hasData := d.Previous.Object["data"] + assert.False(t, hasData) + stringData, ok := d.Previous.Object["stringData"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "old", stringData["password"]) + }) + t.Run("crd empty limits is drift", func(t *testing.T) { + t.Parallel() + manifest := workloadManifest("example.com/v1", "Widget") + live := &fakeLive{} + seedLive(t, live, manifest, "prod", func(obj *unstructured.Unstructured) { + dropLimits(t, obj) + }) + p := buildDrift(t, upgradeClient(manifest, manifest, 4), newMapper(), live) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftModified, d.Action) + }) + t.Run("empty annotations are drift", func(t *testing.T) { + t.Parallel() + previous := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: app + namespace: prod + annotations: {} +spec: {} +` + live := &fakeLive{} + seedLive(t, live, previous, "prod", func(obj *unstructured.Unstructured) { + obj.SetAnnotations(nil) + }) + p := buildDrift(t, upgradeClient(previous, previous, 4), newMapper(), live) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftModified, d.Action) + }) + t.Run("null omitted is drift", func(t *testing.T) { + t.Parallel() + previous := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n namespace: prod\ndata:\n key: null\n" + live := &fakeLive{} + seedLive(t, live, configMapYAML("app", "prod", "ignored"), "prod", func(obj *unstructured.Unstructured) { + delete(obj.Object, "data") + }) + p := buildDrift(t, upgradeClient(previous, previous, 4), newMapper(), live) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftModified, d.Action) + }) + t.Run("empty list omitted is drift", func(t *testing.T) { + t.Parallel() + previous := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n namespace: prod\nspec:\n entries: []\n" + live := &fakeLive{} + seedLive(t, live, configMapYAML("app", "prod", "v"), "prod", func(obj *unstructured.Unstructured) { + delete(obj.Object, "data") + }) + p := buildDrift(t, upgradeClient(previous, previous, 4), newMapper(), live) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftModified, d.Action) + }) + t.Run("empty limits map declares no keys", func(t *testing.T) { + t.Parallel() + manifest := workloadManifest("apps/v1", "Deployment") + live := &fakeLive{} + seedLive(t, live, manifest, "prod", func(obj *unstructured.Unstructured) { + setLimits(t, obj, map[string]any{"cpu": "1"}) + }) + p := buildDrift(t, upgradeClient(manifest, manifest, 4), newMapper(), live) + assert.Empty(t, p.Drift) + }) +} + +func TestBuildSemanticPlan_DriftListSemantics(t *testing.T) { + t.Parallel() + tests := []struct { + name string + prev string + live string + path string + op semantic.FieldOp + }{ + {name: "extra live element", prev: listManifest([]string{"a"}), live: listManifest([]string{"a", "b"}), path: "/spec/entries/1", op: semantic.FieldAdd}, + {name: "missing live element", prev: listManifest([]string{"a", "b"}), live: listManifest([]string{"a"}), path: "/spec/entries/1", op: semantic.FieldRemove}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + live := &fakeLive{} + seedLive(t, live, tt.live, "prod", nil) + p := buildDrift(t, upgradeClient(tt.prev, tt.prev, 4), newMapper(), live) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftModified, d.Action) + require.NotEmpty(t, d.Fields) + assert.Equal(t, tt.path, d.Fields[0].Path) + assert.Equal(t, tt.op, d.Fields[0].Op) + }) + } + t.Run("reordered", func(t *testing.T) { + t.Parallel() + prev := listManifest([]string{"a", "b"}) + live := &fakeLive{} + seedLive(t, live, listManifest([]string{"b", "a"}), "prod", nil) + p := buildDrift(t, upgradeClient(prev, prev, 4), newMapper(), live) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftModified, d.Action) + }) + t.Run("undeclared key inside element", func(t *testing.T) { + t.Parallel() + prev := `apiVersion: example.com/v1 +kind: Widget +metadata: + name: app + namespace: prod +spec: + entries: + - name: a + value: "1" +` + liveYAML := `apiVersion: example.com/v1 +kind: Widget +metadata: + name: app + namespace: prod +spec: + entries: + - name: a + value: "1" + extra: x +` + live := &fakeLive{} + seedLive(t, live, liveYAML, "prod", nil) + p := buildDrift(t, upgradeClient(prev, prev, 4), newMapper(), live) + assert.Empty(t, p.Drift) + }) +} + +func TestBuildSemanticPlan_DriftIndependentOfReleaseIntent(t *testing.T) { + t.Parallel() + tests := []struct { + name string + previous string + desired string + live string + }{ + {name: "live differs from both", previous: "a", desired: "b", live: "c"}, + {name: "live already matches desired", previous: "a", desired: "b", live: "b"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + previous := configMapYAML("app", "prod", tt.previous) + desired := configMapYAML("app", "prod", tt.desired) + live := &fakeLive{} + seedLive(t, live, configMapYAML("app", "prod", tt.live), "prod", nil) + p := buildDrift(t, upgradeClient(previous, desired, 4), newMapper(), live) + require.Len(t, p.Changes, 1) + assert.Equal(t, semantic.Update, p.Changes[0].Action) + before, ok := p.Changes[0].Before.Object["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, tt.previous, before["key"]) + after, ok := p.Changes[0].After.Object["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, tt.desired, after["key"]) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftModified, d.Action) + liveData, ok := d.Live.Object["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, tt.live, liveData["key"]) + }) + } + t.Run("delete and missing", func(t *testing.T) { + t.Parallel() + previous := configMapYAML("app", "prod", "a") + p := buildDrift(t, upgradeClient(previous, "", 4), newMapper(), &fakeLive{}) + require.Len(t, p.Changes, 1) + assert.Equal(t, semantic.Delete, p.Changes[0].Action) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftMissing, d.Action) + }) + t.Run("drift does not change helm action", func(t *testing.T) { + t.Parallel() + manifest := configMapYAML("app", "prod", "same") + live := &fakeLive{} + seedLive(t, live, configMapYAML("app", "prod", "drifted"), "prod", nil) + p := buildDrift(t, upgradeClient(manifest, manifest, 4), newMapper(), live) + assert.Equal(t, semantic.HelmNone, p.HelmAction) + assert.Empty(t, p.Changes) + assert.NotEmpty(t, p.Drift) + + hook := planHook("migrate", "old") + changed := planHook("migrate", "new") + client := upgradePrepWithHook(manifest, manifest, 3, 4, hook, changed) + resolved := resolvedSpec() + resolved.Tasks = map[string]spec.ResolvedTask{ + "migrate": {Task: spec.Task{On: spec.TaskOnPreDeploy}, HookWeight: 1}, + } + hooked, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), live, buildInput("ctx", resolved, nil)) + t.Cleanup(cleanup) + require.NoError(t, err) + assert.Equal(t, semantic.HelmUpgrade, hooked.HelmAction) + require.Len(t, hooked.Tasks, 1) + assert.Equal(t, semantic.TaskUpdate, hooked.Tasks[0].Action) + assert.True(t, hooked.Tasks[0].WillRun) + }) + t.Run("noop with drift", func(t *testing.T) { + t.Parallel() + manifest := configMapYAML("app", "prod", "same") + live := &fakeLive{} + seedLive(t, live, configMapYAML("app", "prod", "drifted"), "prod", nil) + p := buildDrift(t, upgradeClient(manifest, manifest, 4), newMapper(), live) + assert.False(t, p.HasEffects()) + assert.True(t, p.IsNoOp()) + assert.True(t, p.HasDrift()) + assert.Equal(t, semantic.Summary{}, p.Summary) + }) + t.Run("schedule task stays unchanged", func(t *testing.T) { + t.Parallel() + previous := cronJobYAML("cleanup", "0 3 * * *") + live := &fakeLive{} + seedLive(t, live, previous, "prod", func(obj *unstructured.Unstructured) { + require.NoError(t, unstructured.SetNestedField(obj.Object, "0 4 * * *", "spec", "schedule")) + }) + client := upgradeClient(previous, previous, 4) + client.prep.Current.Config = map[string]any{ + "deployah": map[string]any{ + "resolved": map[string]any{ + "tasks": map[string]any{ + "cleanup": map[string]any{"on": string(spec.TaskOnSchedule)}, + }, + }, + }, + } + resolved := resolvedSpec() + resolved.Tasks = map[string]spec.ResolvedTask{ + "cleanup": {Task: spec.Task{On: spec.TaskOnSchedule}}, + } + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), live, buildInput("ctx", resolved, nil)) + t.Cleanup(cleanup) + require.NoError(t, err) + for _, task := range p.Tasks { + assert.Equal(t, semantic.TaskUnchanged, task.Action) + assert.False(t, task.WillRun) + } + d := onlyDrift(t, p) + assert.Equal(t, "cleanup", d.Resource.Name) + assert.Equal(t, semantic.DriftModified, d.Action) + }) +} + +func TestBuildSemanticPlan_UnexpectedDrift(t *testing.T) { + t.Parallel() + previous := configMapYAML("app", "prod", "same") + t.Run("release owned extra", func(t *testing.T) { + t.Parallel() + live := &fakeLive{} + seedLive(t, live, previous, "prod", nil) + live.add(gvr("v1", "ConfigMap"), "prod", "extra", releaseObject("v1", "ConfigMap", "prod", "extra")) + p := buildDrift(t, upgradeClient(previous, previous, 4), newMapper(), live) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftUnexpected, d.Action) + assert.Equal(t, semantic.ResourceRef{APIVersion: "v1", Kind: "ConfigMap", Namespace: "prod", Name: "extra"}, d.Resource) + assert.Nil(t, d.Previous) + meta, ok := d.Live.Object["metadata"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "extra", meta["name"]) + }) + t.Run("strips last applied annotation", func(t *testing.T) { + t.Parallel() + manifest := secretManifest("old") + live := &fakeLive{} + seedLive(t, live, "apiVersion: v1\nkind: Secret\nmetadata:\n name: db\n namespace: prod\ndata:\n password: b2xk\n", "prod", nil) + extra := releaseObject("v1", "Secret", "prod", "leaked") + anns := extra.GetAnnotations() + anns["kubectl.kubernetes.io/last-applied-configuration"] = `{"data":{"password":"plaintext-secret-payload"}}` + extra.SetAnnotations(anns) + require.NoError(t, unstructured.SetNestedMap(extra.Object, map[string]any{"password": "c2VjcmV0"}, "data")) + live.add(gvr("v1", "Secret"), "prod", "leaked", extra) + p := buildDrift(t, upgradeClient(manifest, manifest, 4), newMapper(), live) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftUnexpected, d.Action) + raw, err := json.Marshal(d.Live.Object) + require.NoError(t, err) + assert.NotContains(t, string(raw), "kubectl.kubernetes.io/last-applied-configuration") + assert.NotContains(t, string(raw), "plaintext-secret-payload") + assert.Contains(t, string(raw), "c2VjcmV0") + }) + unlabeled := releaseObject("v1", "ConfigMap", "prod", "extra") + unlabeled.SetLabels(map[string]string{}) + noSource := releaseObject("v1", "ConfigMap", "prod", "nosource") + noSourceAnns := noSource.GetAnnotations() + delete(noSourceAnns, spec.AnnotationSource) + noSource.SetAnnotations(noSourceAnns) + badSource := releaseObject("v1", "ConfigMap", "prod", "badsource") + badSourceAnns := badSource.GetAnnotations() + badSourceAnns[spec.AnnotationSource] = "user" + badSource.SetAnnotations(badSourceAnns) + manual := releaseObject("batch/v1", "Job", "prod", "run") + manualAnns := manual.GetAnnotations() + delete(manualAnns, "meta.helm.sh/release-name") + delete(manualAnns, "meta.helm.sh/release-namespace") + manual.SetAnnotations(manualAnns) + hooked := releaseObject("v1", "ConfigMap", "prod", "hooked") + hookedAnns := hooked.GetAnnotations() + hookedAnns["helm.sh/hook"] = "pre-install" + hooked.SetAnnotations(hookedAnns) + ignored := []struct { + name string + obj *unstructured.Unstructured + }{ + {name: "without instance label", obj: unlabeled}, + {name: "no source", obj: noSource}, + {name: "bad source", obj: badSource}, + {name: "missing helm metadata", obj: manual}, + {name: "helm hook", obj: hooked}, + } + for _, tt := range ignored { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + obj := tt.obj.DeepCopy() + live := &fakeLive{} + seedLive(t, live, previous, "prod", nil) + live.add(gvr(obj.GetAPIVersion(), obj.GetKind()), obj.GetNamespace(), obj.GetName(), obj) + p := buildDrift(t, upgradeClient(previous, previous, 4), newMapper(), live) + assert.Empty(t, p.Drift) + }) + } + t.Run("no global crawl", func(t *testing.T) { + t.Parallel() + live := &fakeLive{} + seedLive(t, live, previous, "prod", nil) + live.add(gvr("v1", "Secret"), "prod", "db", releaseObject("v1", "Secret", "prod", "db")) + p := buildDrift(t, upgradeClient(previous, previous, 4), newMapper(), live) + assert.Empty(t, p.Drift) + for _, call := range live.Calls() { + if call.verb == "list" { + assert.Contains(t, call.resource, "configmaps") + } + } + }) + t.Run("one list for two apiVersions", func(t *testing.T) { + t.Parallel() + manifest := joinManifests(hpaManifestNamed("v2beta2", "app", 2), hpaManifestNamed("v2", "other", 2)) + live := &fakeLive{} + seedLive(t, live, hpaManifestNamed("v2beta2", "app", 2), "prod", nil) + seedLive(t, live, hpaManifestNamed("v2", "other", 2), "prod", nil) + extra := releaseObject("autoscaling/v2", "HorizontalPodAutoscaler", "prod", "extra") + live.add(gvr("autoscaling/v2", "HorizontalPodAutoscaler"), "prod", "extra", extra) + p := buildDrift(t, upgradeClient(manifest, manifest, 4), newMapper(), live) + d := onlyDrift(t, p) + assert.Equal(t, semantic.DriftUnexpected, d.Action) + assert.Equal(t, "autoscaling/v2", d.Resource.APIVersion) + assert.Equal(t, "extra", d.Resource.Name) + var lists []liveCall + for _, call := range live.Calls() { + if call.verb == "list" { + lists = append(lists, call) + } + } + require.Len(t, lists, 1) + assert.Equal(t, "v2", lists[0].version) + }) + t.Run("target namespace is not drift", func(t *testing.T) { + t.Parallel() + manifest := kindYAML("v1", "Namespace", "other", "", "") + live := &fakeLive{} + seedLive(t, live, manifest, "", nil) + target := releaseObject("v1", "Namespace", "", "prod") + live.add(gvr("v1", "Namespace"), "", "prod", target) + p := buildDrift(t, upgradeClient(manifest, manifest, 4), newMapper(), live) + assert.Empty(t, p.Drift) + }) +} + +func TestBuildSemanticPlan_GenerateNameDrift(t *testing.T) { + t.Parallel() + named := configMapYAML("app", "prod", "v") + generated := `apiVersion: v1 +kind: ConfigMap +metadata: + generateName: app- + namespace: prod +data: + key: v +` + manifest := joinManifests(named, generated) + live := &fakeLive{} + seedLive(t, live, named, "prod", nil) + extra := releaseObject("v1", "ConfigMap", "prod", "app-xyz") + live.add(gvr("v1", "ConfigMap"), "prod", "app-xyz", extra) + p := buildDrift(t, upgradeClient(manifest, manifest, 4), newMapper(), live) + assert.Empty(t, p.Drift) + for _, call := range live.Calls() { + assert.NotEqual(t, "list", call.verb) + assert.NotContains(t, call.name, "app-") + } + require.Len(t, gets(live), 1) + assert.Equal(t, "app", gets(live)[0].name) +} + +func TestBuildSemanticPlan_DriftErrors(t *testing.T) { + t.Parallel() + previous := configMapYAML("app", "prod", "v") + t.Run("get", func(t *testing.T) { + t.Parallel() + live := &fakeLive{} + live.failGet("app", errors.New("boom")) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), upgradeClient(previous, previous, 4), newMapper(), live, buildInput("ctx", resolvedSpec(), nil)) + t.Cleanup(cleanup) + require.Error(t, err) + assert.ErrorContains(t, err, "observe drift") + assert.ErrorContains(t, err, "read live") + assert.Zero(t, p) + assert.Nil(t, result) + }) + t.Run("list", func(t *testing.T) { + t.Parallel() + live := &fakeLive{} + seedLive(t, live, previous, "prod", nil) + live.failList(gvr("v1", "ConfigMap"), "prod", errors.New("list failed")) + p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), upgradeClient(previous, previous, 4), newMapper(), live, buildInput("ctx", resolvedSpec(), nil)) + t.Cleanup(cleanup) + require.Error(t, err) + assert.ErrorContains(t, err, "observe drift") + assert.ErrorContains(t, err, "list live") + assert.Zero(t, p) + assert.Nil(t, result) + }) +} + +func TestBuildSemanticPlan_ChartCRDsDoNotReadLive(t *testing.T) { + t.Parallel() + live := &fakeLive{} + client := installClient(configMapYAML("app", "prod", "v1")) + in := buildInput("ctx", resolvedSpec(), nil) + in.CRDDocs = []extras.CRDDoc{{ + Path: "/abs/.deployah/crds/widgets.yaml", + Kind: "CustomResourceDefinition", + Name: "widgets.example.com", + }} + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newMapper(), live, in) + t.Cleanup(cleanup) + require.NoError(t, err) + require.NotNil(t, p.Drift) + assert.Empty(t, p.Drift) + assert.Empty(t, live.Calls()) + require.Len(t, p.ChartCRDs, 1) + for _, d := range p.Drift { + assert.NotEqual(t, "CustomResourceDefinition", d.Resource.Kind) + } +} + +func TestBuildSemanticPlan_DriftOrderIsStable(t *testing.T) { + t.Parallel() + alpha := configMapYAML("alpha", "prod", "v") + zeta := configMapYAML("zeta", "prod", "v") + forward := buildDrift(t, upgradeClient(joinManifests(zeta, alpha), joinManifests(zeta, alpha), 4), newMapper(), &fakeLive{}) + backward := buildDrift(t, upgradeClient(joinManifests(alpha, zeta), joinManifests(alpha, zeta), 4), newMapper(), &fakeLive{}) + require.Len(t, forward.Drift, 2) + assert.Equal(t, []string{"alpha", "zeta"}, []string{forward.Drift[0].Resource.Name, forward.Drift[1].Resource.Name}) + assert.Equal(t, forward.Drift[0].Resource, backward.Drift[0].Resource) + assert.Equal(t, forward.Drift[1].Resource, backward.Drift[1].Resource) +} + +func buildDrift(t *testing.T, client *fakeBuildClient, mapper plan.RESTMapper, live *fakeLive) semantic.Plan { + t.Helper() + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, mapper, live, buildInput("ctx", resolvedSpec(), nil)) + t.Cleanup(cleanup) + require.NoError(t, err) + return p +} + +func onlyDrift(t *testing.T, p semantic.Plan) semantic.DriftChange { + t.Helper() + require.Len(t, p.Drift, 1) + return p.Drift[0] +} + +func gets(live *fakeLive) []liveCall { + var out []liveCall + for _, call := range live.Calls() { + if call.verb == "get" { + out = append(out, call) + } + } + return out +} + +func gvr(apiVersion, kind string) schema.GroupVersionResource { + gv, err := schema.ParseGroupVersion(apiVersion) + if err != nil { + panic(err) + } + return schema.GroupVersionResource{Group: gv.Group, Version: gv.Version, Resource: strings.ToLower(kind) + "s"} +} + +func seedLive(t *testing.T, live *fakeLive, manifest, lookupNS string, mutate func(*unstructured.Unstructured)) { + t.Helper() + for _, obj := range parseManifest(t, manifest) { + stored := gvr(obj.GetAPIVersion(), obj.GetKind()) + name := obj.GetName() + if mutate != nil { + mutate(obj) + } + live.add(stored, lookupNS, name, obj) + } +} + +func parseManifest(t *testing.T, manifest string) []*unstructured.Unstructured { + t.Helper() + infos, err := resource.NewLocalBuilder(). + ContinueOnError(). + Flatten(). + Unstructured(). + Stream(bytes.NewBufferString(manifest), "manifest"). + Do().Infos() + require.NoError(t, err) + out := make([]*unstructured.Unstructured, 0, len(infos)) + for _, info := range infos { + obj, ok := info.Object.(*unstructured.Unstructured) + if !ok { + m, convErr := runtime.DefaultUnstructuredConverter.ToUnstructured(info.Object) + require.NoError(t, convErr) + obj = &unstructured.Unstructured{Object: m} + } + out = append(out, obj.DeepCopy()) + } + return out +} + +func stampRelease(obj *unstructured.Unstructured, release, namespace string) { + labels := obj.GetLabels() + if labels == nil { + labels = map[string]string{} + } + labels[spec.LabelInstance] = release + obj.SetLabels(labels) + anns := obj.GetAnnotations() + if anns == nil { + anns = map[string]string{} + } + anns[spec.AnnotationSource] = spec.SourceSpec + anns["meta.helm.sh/release-name"] = release + anns["meta.helm.sh/release-namespace"] = namespace + obj.SetAnnotations(anns) +} + +func releaseObject(apiVersion, kind, namespace, name string) *unstructured.Unstructured { + meta := map[string]any{"name": name} + if namespace != "" { + meta["namespace"] = namespace + } + obj := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": meta, + }} + stampRelease(obj, "web", "prod") + return obj +} + +func hpaManifestNamed(version, name string, replicas int) string { + return fmt.Sprintf(`apiVersion: autoscaling/%s +kind: HorizontalPodAutoscaler +metadata: + name: %s + namespace: prod +spec: + maxReplicas: %d + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: app +`, version, name, replicas) +} + +func secretManifest(password string) string { + return fmt.Sprintf(`apiVersion: v1 +kind: Secret +metadata: + name: db + namespace: prod +stringData: + password: %s +`, password) +} + +func workloadManifest(apiVersion, kind string) string { + body := `spec: + template: + spec: + containers: + - name: app + image: app:1 + resources: + limits: {} + requests: + cpu: 100m +` + if kind == "CronJob" { + body = `spec: + schedule: "0 * * * *" + jobTemplate: + spec: + template: + spec: + containers: + - name: app + image: app:1 + resources: + limits: {} + requests: + cpu: 100m +` + } + if kind == "Widget" { + body = `spec: + template: + spec: + containers: + - name: app + resources: + limits: {} +` + } + return fmt.Sprintf("apiVersion: %s\nkind: %s\nmetadata:\n name: app\n namespace: prod\n%s", apiVersion, kind, body) +} + +func listManifest(items []string) string { + var b strings.Builder + b.WriteString("apiVersion: example.com/v1\nkind: Widget\nmetadata:\n name: app\n namespace: prod\nspec:\n entries:\n") + for _, item := range items { + fmt.Fprintf(&b, " - value: %s\n", item) + } + return b.String() +} + +func dropLimits(t *testing.T, obj *unstructured.Unstructured) { + t.Helper() + containers := containersOf(t, obj) + for _, item := range containers { + container, ok := item.(map[string]any) + if !ok { + continue + } + resources, ok := container["resources"].(map[string]any) + if !ok { + continue + } + delete(resources, "limits") + } +} + +func setLimits(t *testing.T, obj *unstructured.Unstructured, limits map[string]any) { + t.Helper() + containers := containersOf(t, obj) + require.NotEmpty(t, containers) + item, ok := containers[0].(map[string]any) + require.True(t, ok) + resources, ok := item["resources"].(map[string]any) + if !ok || resources == nil { + resources = map[string]any{} + item["resources"] = resources + } + resources["limits"] = limits +} + +func containersOf(t *testing.T, obj *unstructured.Unstructured) []any { + t.Helper() + path := []string{"spec", "template", "spec", "containers"} + if obj.GetKind() == "CronJob" { + path = []string{"spec", "jobTemplate", "spec", "template", "spec", "containers"} + } + if obj.GetKind() == "Widget" { + path = []string{"spec", "template", "spec", "containers"} + } + cur := any(obj.Object) + for _, key := range path { + node, ok := cur.(map[string]any) + require.True(t, ok, "missing %s", key) + next, ok := node[key] + require.True(t, ok, "missing %s", key) + cur = next + } + list, ok := cur.([]any) + require.True(t, ok) + return list +} diff --git a/internal/plan/semantic_drift_internal_test.go b/internal/plan/semantic_drift_internal_test.go new file mode 100644 index 0000000..de4ed4d --- /dev/null +++ b/internal/plan/semantic_drift_internal_test.go @@ -0,0 +1,228 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestProjectValue_DeclaredSurface(t *testing.T) { + t.Parallel() + tests := []struct { + name string + declared map[string]any + live map[string]any + want map[string]any + }{ + { + name: "declared keys and extra list items", + declared: map[string]any{ + "data": map[string]any{"key": "v"}, + "items": []any{map[string]any{"name": "a"}}, + "count": 1, + }, + live: map[string]any{ + "data": map[string]any{"key": "v", "extra": "nope"}, + "items": []any{ + map[string]any{"name": "a", "extra": "nope"}, + map[string]any{"name": "b"}, + }, + "count": "nope", + "status": map[string]any{"ok": true}, + }, + want: map[string]any{ + "data": map[string]any{"key": "v"}, + "items": []any{ + map[string]any{"name": "a"}, + map[string]any{"name": "b"}, + }, + "count": "nope", + }, + }, + { + name: "empty map declares no keys", + declared: map[string]any{"limits": map[string]any{}}, + live: map[string]any{"limits": map[string]any{"cpu": "1"}}, + want: map[string]any{"limits": map[string]any{}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, ok := projectValue(tt.declared, tt.live).(map[string]any) + require.True(t, ok) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFoldSecretStringData(t *testing.T) { + t.Parallel() + obj := map[string]any{ + "data": map[string]any{"password": "cHJldg==", "kept": "eQ=="}, + "stringData": map[string]any{"password": "new", "token": "abc", "weird": 1}, + } + stored := map[string]any{"stringData": map[string]any{"password": "new"}} + foldSecretStringData(obj, "v1", "Secret") + data, ok := obj["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("new")), data["password"]) + assert.Equal(t, "eQ==", data["kept"]) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("abc")), data["token"]) + assert.Equal(t, map[string]any{"weird": 1}, obj["stringData"]) + assert.Equal(t, map[string]any{"password": "new"}, stored["stringData"]) + + other := map[string]any{"stringData": map[string]any{"password": "new"}} + foldSecretStringData(other, "v1", "ConfigMap") + _, hasData := other["data"] + assert.False(t, hasData) + assert.Contains(t, other, "stringData") + + core := map[string]any{"stringData": map[string]any{"password": "new"}} + foldSecretStringData(core, "core/v1", "Secret") + coreData, ok := core["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("new")), coreData["password"]) + _, still := core["stringData"] + assert.False(t, still) +} + +func TestApplyAbsenceEquivalence_PathAware(t *testing.T) { + t.Parallel() + prev := map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ + "containers": []any{ + map[string]any{ + "name": "app", + "resources": map[string]any{ + "limits": map[string]any{}, + "requests": map[string]any{"cpu": "100m"}, + }, + }, + map[string]any{ + "name": "side", + "resources": map[string]any{ + "limits": map[string]any{"cpu": "1"}, + }, + }, + }, + }, + }, + }, + } + live := map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ + "containers": []any{ + map[string]any{ + "name": "app", + "resources": map[string]any{ + "requests": map[string]any{"cpu": "100m"}, + }, + }, + map[string]any{ + "name": "side", + "resources": map[string]any{ + "limits": map[string]any{"cpu": "1"}, + }, + }, + }, + }, + }, + }, + } + applyAbsenceEquivalence(prev, live, schema.GroupKind{Group: "apps", Kind: "Deployment"}) + spec, ok := prev["spec"].(map[string]any) + require.True(t, ok) + template, ok := spec["template"].(map[string]any) + require.True(t, ok) + podSpec, ok := template["spec"].(map[string]any) + require.True(t, ok) + containers, ok := podSpec["containers"].([]any) + require.True(t, ok) + app, ok := containers[0].(map[string]any) + require.True(t, ok) + resources, ok := app["resources"].(map[string]any) + require.True(t, ok) + _, hasLimits := resources["limits"] + assert.False(t, hasLimits) + assert.Equal(t, map[string]any{"cpu": "100m"}, resources["requests"]) + sideContainer, ok := containers[1].(map[string]any) + require.True(t, ok) + side, ok := sideContainer["resources"].(map[string]any) + require.True(t, ok) + assert.Equal(t, map[string]any{"cpu": "1"}, side["limits"]) + + widget := map[string]any{"spec": map[string]any{"limits": map[string]any{}}} + applyAbsenceEquivalence(widget, map[string]any{}, schema.GroupKind{Group: "example.com", Kind: "Widget"}) + widgetSpec, ok := widget["spec"].(map[string]any) + require.True(t, ok) + assert.Equal(t, map[string]any{}, widgetSpec["limits"]) + + cron := map[string]any{ + "spec": map[string]any{ + "jobTemplate": map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ + "containers": []any{ + map[string]any{"resources": map[string]any{"limits": map[string]any{}}}, + }, + }, + }, + }, + }, + }, + } + applyAbsenceEquivalence(cron, map[string]any{}, schema.GroupKind{Group: "batch", Kind: "CronJob"}) + cronSpec, ok := cron["spec"].(map[string]any) + require.True(t, ok) + jobTemplate, ok := cronSpec["jobTemplate"].(map[string]any) + require.True(t, ok) + jobSpec, ok := jobTemplate["spec"].(map[string]any) + require.True(t, ok) + podTemplate, ok := jobSpec["template"].(map[string]any) + require.True(t, ok) + cronPod, ok := podTemplate["spec"].(map[string]any) + require.True(t, ok) + cronContainers, ok := cronPod["containers"].([]any) + require.True(t, ok) + cronContainer, ok := cronContainers[0].(map[string]any) + require.True(t, ok) + cronResources, ok := cronContainer["resources"].(map[string]any) + require.True(t, ok) + _, cronLimits := cronResources["limits"] + assert.False(t, cronLimits) + + paths := expandPattern(map[string]any{ + "spec": map[string]any{"containers": []any{ + map[string]any{"name": "a"}, + map[string]any{"name": "b"}, + }}, + }, []string{"spec", "containers", "*", "name"}) + assert.Equal(t, [][]string{ + {"spec", "containers", "0", "name"}, + {"spec", "containers", "1", "name"}, + }, paths) + assert.Empty(t, expandPattern(map[string]any{"spec": "nope"}, []string{"spec", "*"})) +} diff --git a/internal/plan/semantic_drift_test.go b/internal/plan/semantic_drift_test.go new file mode 100644 index 0000000..98bda8c --- /dev/null +++ b/internal/plan/semantic_drift_test.go @@ -0,0 +1,134 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plan_test + +import ( + "context" + "sync" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +type liveCall struct { + verb string + resource string + version string + namespace string + name string + selector string +} + +type storedLive struct { + gvr schema.GroupVersionResource + namespace string + name string + obj *unstructured.Unstructured +} + +// fakeLive is a read-only LiveReader. Get returns not-found unless an +// object was added for that mapping, namespace, and name. +type fakeLive struct { + mu sync.Mutex + items []storedLive + getErrors map[string]error + listErrors map[string]error + calls []liveCall +} + +func (f *fakeLive) add(gvr schema.GroupVersionResource, namespace, name string, obj *unstructured.Unstructured) { + f.mu.Lock() + defer f.mu.Unlock() + f.items = append(f.items, storedLive{gvr: gvr, namespace: namespace, name: name, obj: obj}) +} + +func (f *fakeLive) failGet(name string, err error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.getErrors == nil { + f.getErrors = map[string]error{} + } + f.getErrors[name] = err +} + +func (f *fakeLive) failList(gvr schema.GroupVersionResource, namespace string, err error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.listErrors == nil { + f.listErrors = map[string]error{} + } + f.listErrors[gvr.String()+"\x00"+namespace] = err +} + +func (f *fakeLive) Calls() []liveCall { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]liveCall, 0, len(f.calls)) + out = append(out, f.calls...) + return out +} + +func (f *fakeLive) Get(_ context.Context, mapping *meta.RESTMapping, namespace, name string) (*unstructured.Unstructured, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, liveCall{ + verb: "get", resource: mapping.Resource.String(), version: mapping.GroupVersionKind.Version, + namespace: namespace, name: name, + }) + if err := f.getErrors[name]; err != nil { + return nil, err + } + for _, item := range f.items { + if item.gvr == mapping.Resource && item.namespace == namespace && item.name == name { + return item.obj.DeepCopy(), nil + } + } + return nil, apierrors.NewNotFound(mapping.Resource.GroupResource(), name) +} + +func (f *fakeLive) List(_ context.Context, mapping *meta.RESTMapping, namespace string, selector labels.Selector) ([]unstructured.Unstructured, error) { + f.mu.Lock() + defer f.mu.Unlock() + sel := "" + if selector != nil { + sel = selector.String() + } + f.calls = append(f.calls, liveCall{ + verb: "list", resource: mapping.Resource.String(), version: mapping.GroupVersionKind.Version, + namespace: namespace, selector: sel, + }) + if err := f.listErrors[mapping.Resource.String()+"\x00"+namespace]; err != nil { + return nil, err + } + namespaced := mapping.Scope != nil && mapping.Scope.Name() == meta.RESTScopeNameNamespace + var out []unstructured.Unstructured + for _, item := range f.items { + if item.gvr != mapping.Resource { + continue + } + if namespaced && item.namespace != namespace { + continue + } + if selector != nil && !selector.Matches(labels.Set(item.obj.GetLabels())) { + continue + } + out = append(out, *item.obj.DeepCopy()) + } + return out, nil +} diff --git a/internal/plan/view/human.go b/internal/plan/view/human.go index 84062ca..0f043c9 100644 --- a/internal/plan/view/human.go +++ b/internal/plan/view/human.go @@ -35,8 +35,8 @@ const headerLabelWidth = 10 // WriteHuman writes a deterministic YAML-oriented rendering of p. It // does not mutate p. Resource headings use +, ~, and - markers. -// The footer is a Summary of resource counts and, when tasks exist, -// task counts. +// Drift, when present, is a separate section. The footer is a Summary +// of resource counts and, when tasks or drift exist, those counts. func WriteHuman(w io.Writer, p semantic.Plan, opts Options) error { prepared, err := prepareRender(p, opts) if err != nil { @@ -64,6 +64,17 @@ func WriteHuman(w io.Writer, p semantic.Plan, opts Options) error { } wroteBody = true } + if len(prepared.Drift) > 0 { + if wroteBody { + if berr := writeBlank(w); berr != nil { + return berr + } + } + if derr := writeHumanDrift(w, prepared.Drift, prepared.Header.Namespace, opts); derr != nil { + return derr + } + wroteBody = true + } if wroteBody { if berr := writeBlank(w); berr != nil { return berr @@ -257,6 +268,48 @@ func writeHumanChange(w io.Writer, c semantic.ResourceChange, planNS, indent str return writeActionBody(w, c.Action, c.Before, c.After, c.Fields, c.Resource, indent, opts) } +func writeHumanDrift(w io.Writer, drift []semantic.DriftChange, planNS string, opts Options) error { + if len(drift) == 0 { + return nil + } + if err := writeln(w, opts, theme.TextTitle, "Drift"); err != nil { + return err + } + if err := writeBlank(w); err != nil { + return err + } + for i := range drift { + if i > 0 { + if err := writeBlank(w); err != nil { + return err + } + } + if err := writeHumanDriftChange(w, drift[i], planNS, " ", opts); err != nil { + return err + } + } + return nil +} + +func writeHumanDriftChange(w io.Writer, d semantic.DriftChange, planNS, indent string, opts Options) error { + line := driftHeading(d.Action, d.Resource, planNS) + if err := writeln(w, opts, driftHeadingToken(d.Action), indent+line); err != nil { + return err + } + beforeObj, afterObj, renderFields := humanSides(d.Previous, d.Live, d.Fields, d.Resource, opts) + switch d.Action { + case semantic.DriftModified: + beforeProj, afterProj := projectFields(beforeObj, afterObj, renderFields) + return writeProjectedDiff(w, beforeProj, afterProj, indent, opts) + case semantic.DriftMissing: + return writeObjectYAML(w, beforeObj, indent+"- ", theme.StatusError, opts) + case semantic.DriftUnexpected: + return writeObjectYAML(w, afterObj, indent+"+ ", theme.StatusSuccess, opts) + default: + return nil + } +} + func writeActionBody( w io.Writer, action semantic.Action, @@ -266,26 +319,12 @@ func writeActionBody( indent string, opts Options, ) error { - beforeObj := humanObject(before) - afterObj := humanObject(after) - renderFields := fields - if !opts.ShowSecrets && isCoreSecret(ref) { - markSecretDiffs(beforeObj, afterObj, fields) - renderFields = secretSentinelFields(fields) - } + beforeObj, afterObj, renderFields := humanSides(before, after, fields, ref, opts) switch action { case semantic.Create: - text, err := marshalOrderedYAML(afterObj) - if err != nil { - return err - } - return writePrefixedYAML(w, text, indent+"+ ", theme.StatusSuccess, opts) + return writeObjectYAML(w, afterObj, indent+"+ ", theme.StatusSuccess, opts) case semantic.Delete: - text, err := marshalOrderedYAML(beforeObj) - if err != nil { - return err - } - return writePrefixedYAML(w, text, indent+"- ", theme.StatusError, opts) + return writeObjectYAML(w, beforeObj, indent+"- ", theme.StatusError, opts) case semantic.Update: beforeProj, afterProj := projectFields(beforeObj, afterObj, renderFields) return writeProjectedDiff(w, beforeProj, afterProj, indent, opts) @@ -294,6 +333,30 @@ func writeActionBody( } } +func humanSides( + before, after *semantic.ResourceSnapshot, + fields []semantic.FieldChange, + ref semantic.ResourceRef, + opts Options, +) (map[string]any, map[string]any, []semantic.FieldChange) { + beforeObj := humanObject(before) + afterObj := humanObject(after) + renderFields := fields + if !opts.ShowSecrets && isCoreSecret(ref) { + markSecretDiffs(beforeObj, afterObj, fields) + renderFields = secretSentinelFields(fields) + } + return beforeObj, afterObj, renderFields +} + +func writeObjectYAML(w io.Writer, obj map[string]any, prefix string, token theme.Token, opts Options) error { + text, err := marshalOrderedYAML(obj) + if err != nil { + return err + } + return writePrefixedYAML(w, text, prefix, token, opts) +} + func writeProjectedDiff(w io.Writer, before, after map[string]any, indent string, opts Options) error { beforeYAML, err := marshalOrderedYAML(before) if err != nil { @@ -357,6 +420,23 @@ func writeHumanFooter(w io.Writer, p semantic.Plan, opts Options) error { if err := writeln(w, opts, theme.TextPrimary, resourceLine); err != nil { return err } + if p.HasDrift() { + modified, missing, unexpected := 0, 0, 0 + for _, d := range p.Drift { + switch d.Action { + case semantic.DriftModified: + modified++ + case semantic.DriftMissing: + missing++ + case semantic.DriftUnexpected: + unexpected++ + } + } + driftLine := fmt.Sprintf(" Drift: %d modified, %d missing, %d unexpected", modified, missing, unexpected) + if err := writeln(w, opts, theme.TextPrimary, driftLine); err != nil { + return err + } + } if len(p.Tasks) == 0 { return nil } @@ -412,6 +492,38 @@ func formatGVK(ref semantic.ResourceRef) string { return gv.Group + "/" + gv.Version + "/" + ref.Kind } +func driftHeading(action semantic.DriftAction, ref semantic.ResourceRef, planNS string) string { + line := driftMarker(action) + " " + action.String() + " " + formatGVK(ref) + " " + quotedName(ref) + if ref.Namespace != "" && ref.Namespace != planNS { + line += fmt.Sprintf(" in namespace %q", ref.Namespace) + } + return line +} + +func driftMarker(action semantic.DriftAction) string { + switch action { + case semantic.DriftUnexpected: + return "+" + case semantic.DriftModified: + return "~" + case semantic.DriftMissing: + return "-" + default: + return "" + } +} + +func driftHeadingToken(action semantic.DriftAction) theme.Token { + switch action { + case semantic.DriftUnexpected: + return theme.StatusSuccess + case semantic.DriftMissing: + return theme.StatusError + default: + return theme.StatusWarning + } +} + func actionMarker(action semantic.Action) string { switch action { case semantic.Create: diff --git a/internal/plan/view/human_semantic_test.go b/internal/plan/view/human_semantic_test.go index 5f606a0..83bc4f9 100644 --- a/internal/plan/view/human_semantic_test.go +++ b/internal/plan/view/human_semantic_test.go @@ -15,6 +15,7 @@ package view_test import ( + "bytes" "context" "fmt" "os" @@ -29,7 +30,11 @@ import ( "helm.sh/helm/v4/pkg/chart/v2/loader" "helm.sh/helm/v4/pkg/postrenderer" "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/resource" "sigs.k8s.io/yaml" "deployah.dev/deployah/internal/extras" @@ -40,6 +45,7 @@ import ( "deployah.dev/deployah/internal/spec" v1 "helm.sh/helm/v4/pkg/release/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" ) const ( @@ -266,7 +272,7 @@ func semanticUpgradePlan(t *testing.T, previous, current *spec.Spec) semantic.Pl Newest: prevRelease, NextRevision: 2, }, - }, currResolved) + }, currResolved, liveMatchingManifest(t, prevRelease.Manifest, prevRelease.Namespace)) } func resolveProductSpec(t *testing.T, manifest *spec.Spec) *spec.ResolvedSpec { @@ -366,9 +372,9 @@ func nestedMap(root map[string]any, keys ...string) (map[string]any, bool) { return cur, true } -func buildSemanticPlan(t *testing.T, client plan.SemanticBuildClient, resolved *spec.ResolvedSpec) semantic.Plan { +func buildSemanticPlan(t *testing.T, client plan.SemanticBuildClient, resolved *spec.ResolvedSpec, live plan.LiveReader) semantic.Plan { t.Helper() - p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, productMapper{}, plan.SemanticBuildInput{ + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, productMapper{}, live, plan.SemanticBuildInput{ ClusterContext: productClusterContext, Resolved: resolved, }) @@ -418,3 +424,62 @@ func clusterScopedKind(gk schema.GroupKind) bool { return false } } + +// manifestLive returns Previous objects on Get and nothing on List, so an +// unchanged upgrade does not report drift. +type manifestLive struct { + namespace string + items []*unstructured.Unstructured +} + +func liveMatchingManifest(t *testing.T, manifest, namespace string) plan.LiveReader { + t.Helper() + if strings.TrimSpace(manifest) == "" { + return &manifestLive{namespace: namespace} + } + infos, err := resource.NewLocalBuilder(). + ContinueOnError(). + Flatten(). + Unstructured(). + Stream(bytes.NewBufferString(manifest), "manifest"). + Do().Infos() + require.NoError(t, err) + items := make([]*unstructured.Unstructured, 0, len(infos)) + for _, info := range infos { + obj, ok := info.Object.(*unstructured.Unstructured) + if !ok { + m, convErr := runtime.DefaultUnstructuredConverter.ToUnstructured(info.Object) + require.NoError(t, convErr) + obj = &unstructured.Unstructured{Object: m} + } + items = append(items, obj.DeepCopy()) + } + return &manifestLive{namespace: namespace, items: items} +} + +func (m *manifestLive) Get(_ context.Context, mapping *meta.RESTMapping, namespace, name string) (*unstructured.Unstructured, error) { + for _, obj := range m.items { + if obj.GetName() != name || obj.GetKind() != mapping.GroupVersionKind.Kind { + continue + } + if obj.GroupVersionKind().Group != mapping.GroupVersionKind.Group || obj.GroupVersionKind().Version != mapping.GroupVersionKind.Version { + continue + } + ns := "" + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + ns = obj.GetNamespace() + if ns == "" { + ns = m.namespace + } + } + if ns != namespace { + continue + } + return obj.DeepCopy(), nil + } + return nil, apierrors.NewNotFound(mapping.Resource.GroupResource(), name) +} + +func (m *manifestLive) List(context.Context, *meta.RESTMapping, string, labels.Selector) ([]unstructured.Unstructured, error) { + return nil, nil +} diff --git a/internal/plan/view/human_test.go b/internal/plan/view/human_test.go index 5eb24ed..7e6b8e8 100644 --- a/internal/plan/view/human_test.go +++ b/internal/plan/view/human_test.go @@ -967,6 +967,115 @@ func allActionsInputs() (semantic.Header, []semantic.ResourceChange, []semantic. return humanHeader(), changes, tasks } +func TestWriteHuman_DriftSection(t *testing.T) { + t.Parallel() + p, err := semantic.AttachDrift(mustPlanWithHeader(t, humanHeader(), semantic.HelmNone, nil, nil), []semantic.DriftChange{ + { + Resource: ref("ConfigMap", "app"), + Action: semantic.DriftModified, + Previous: snap(cm("app", "old")), + Live: snap(cm("app", "new")), + Fields: []semantic.FieldChange{{ + Path: "/data/key", + Op: semantic.FieldReplace, + Before: "old", + After: "new", + }}, + }, + { + Resource: ref("ConfigMap", "other"), + Action: semantic.DriftMissing, + Previous: snap(cm("other", "gone")), + }, + { + Resource: ref("ConfigMap", "extra"), + Action: semantic.DriftUnexpected, + Live: snap(cm("extra", "live")), + }, + }) + require.NoError(t, err) + var buf bytes.Buffer + require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) + text := buf.String() + assertGolden(t, "human_drift", text) + assert.Contains(t, text, "\nDrift\n") + assert.Contains(t, text, "~ modified v1/ConfigMap \"app\"") + assert.Contains(t, text, "- missing v1/ConfigMap \"other\"") + assert.Contains(t, text, "+ unexpected v1/ConfigMap \"extra\"") + assert.Contains(t, text, " Resources: 0 create, 0 update, 0 delete") + assert.Contains(t, text, " Drift: 1 modified, 1 missing, 1 unexpected") + assert.Less(t, strings.Index(text, "\nDrift\n"), strings.Index(text, "\nSummary\n")) + assert.NotContains(t, text, "Resources\n") +} + +func TestWriteHuman_EmptyDriftHasNoSection(t *testing.T) { + t.Parallel() + p := mustPlanWithHeader(t, humanHeader(), semantic.HelmNone, nil, nil) + var buf bytes.Buffer + require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) + assert.NotContains(t, buf.String(), "Drift") +} + +func TestWriteHuman_DriftSecretNormalizedPath(t *testing.T) { + t.Parallel() + p, err := semantic.AttachDrift(mustPlan(t, semantic.HelmNone, nil), []semantic.DriftChange{{ + Resource: ref("Secret", "db"), + Action: semantic.DriftModified, + Previous: snap(map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{"name": "db", "namespace": "prod"}, + "stringData": map[string]any{"password": "old"}, + }), + Live: snap(map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{"name": "db", "namespace": "prod"}, + "data": map[string]any{"password": "bmV3"}, + }), + Fields: []semantic.FieldChange{{ + Path: "/data/password", + Op: semantic.FieldReplace, + Before: "b2xk", + After: "bmV3", + }}, + }}) + require.NoError(t, err) + + tests := []struct { + name string + opts view.Options + has []string + omit []string + }{ + { + name: "redacted", + has: []string{"data:", "- ", "+ ", "password: (redacted)"}, + omit: []string{"old", "b2xk", "bmV3"}, + }, + { + name: "shown", + opts: view.Options{ShowSecrets: true}, + has: []string{"password: b2xk", "password: bmV3"}, + omit: []string{"old", "(redacted)"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + require.NoError(t, view.WriteHuman(&buf, p, tt.opts)) + text := buf.String() + for _, want := range tt.has { + assert.Contains(t, text, want) + } + for _, omit := range tt.omit { + assert.NotContains(t, text, omit) + } + }) + } +} + func assertHumanLayout(t *testing.T, text string) { t.Helper() assert.NotContains(t, text, "\n\n\n") diff --git a/internal/plan/view/json.go b/internal/plan/view/json.go index fa5b5d8..884ecb5 100644 --- a/internal/plan/view/json.go +++ b/internal/plan/view/json.go @@ -27,6 +27,7 @@ type document struct { Header headerDTO `json:"header"` HelmAction string `json:"helmAction"` Changes []changeDTO `json:"changes"` + Drift []driftDTO `json:"drift"` Tasks []taskDTO `json:"tasks"` ChartCRDs []chartCRDDTO `json:"chartCRDs"` Summary summaryDTO `json:"summary"` @@ -50,6 +51,14 @@ type changeDTO struct { Fields []fieldDTO `json:"fields"` } +type driftDTO struct { + Resource resourceDTO `json:"resource"` + Action string `json:"action"` + Previous any `json:"previous"` + Live any `json:"live"` + Fields []fieldDTO `json:"fields"` +} + type resourceDTO struct { APIVersion string `json:"apiVersion"` Kind string `json:"kind"` @@ -123,6 +132,10 @@ func newDocument(p semantic.Plan, opts Options) (document, error) { for _, c := range prepared.Changes { changes = append(changes, toChangeDTO(c)) } + drift := make([]driftDTO, 0, len(prepared.Drift)) + for _, d := range prepared.Drift { + drift = append(drift, toDriftDTO(d)) + } tasks := make([]taskDTO, 0, len(prepared.Tasks)) for _, t := range prepared.Tasks { tasks = append(tasks, toTaskDTO(t)) @@ -136,6 +149,7 @@ func newDocument(p semantic.Plan, opts Options) (document, error) { Header: toHeaderDTO(prepared.Header), HelmAction: prepared.HelmAction.String(), Changes: changes, + Drift: drift, Tasks: tasks, ChartCRDs: crds, Summary: toSummaryDTO(prepared.Summary), @@ -168,6 +182,20 @@ func toChangeDTO(c semantic.ResourceChange) changeDTO { } } +func toDriftDTO(d semantic.DriftChange) driftDTO { + fields := make([]fieldDTO, 0, len(d.Fields)) + for _, f := range d.Fields { + fields = append(fields, toFieldDTO(f)) + } + return driftDTO{ + Resource: toResourceDTO(d.Resource), + Action: d.Action.String(), + Previous: snapshotJSON(d.Previous), + Live: snapshotJSON(d.Live), + Fields: fields, + } +} + func toFieldDTO(f semantic.FieldChange) fieldDTO { dto := fieldDTO{Path: f.Path, Op: f.Op.String()} switch f.Op { diff --git a/internal/plan/view/json_test.go b/internal/plan/view/json_test.go index ea3b008..73acc2e 100644 --- a/internal/plan/view/json_test.go +++ b/internal/plan/view/json_test.go @@ -54,6 +54,7 @@ func TestWriteJSON_SchemaAndTasks(t *testing.T) { }, "helmAction": "none", "changes": [], + "drift": [], "tasks": [], "chartCRDs": [], "summary": { @@ -118,6 +119,7 @@ func TestWriteJSON_TasksContract(t *testing.T) { }, "helmAction": "upgrade", "changes": [], + "drift": [], "tasks": [{ "name": "migrate", "phase": "preDeploy", @@ -538,6 +540,126 @@ func assertNoJSONChangeKeys(t *testing.T, change map[string]any) { assertNoJSONFieldKeys(t, change["fields"]) } +func TestWriteJSON_DriftShapes(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + require.NoError(t, view.WriteJSON(&buf, sampleDriftPlan(t), view.Options{})) + raw := buf.Bytes() + validatePlanSchema(t, raw) + var doc map[string]any + require.NoError(t, json.Unmarshal(raw, &doc)) + _, hasChecked := doc["driftChecked"] + assert.False(t, hasChecked) + entries := jsonObjects(t, doc["drift"]) + require.Len(t, entries, 3) + + modified := entries[0] + assert.Equal(t, "modified", modified["action"]) + assert.NotNil(t, modified["previous"]) + assert.NotNil(t, modified["live"]) + assert.NotEmpty(t, modified["fields"]) + + unexpected := entries[1] + assert.Equal(t, "unexpected", unexpected["action"]) + assert.Nil(t, unexpected["previous"]) + assert.NotNil(t, unexpected["live"]) + assert.Empty(t, unexpected["fields"]) + + missing := entries[2] + assert.Equal(t, "missing", missing["action"]) + assert.NotNil(t, missing["previous"]) + assert.Nil(t, missing["live"]) + assert.Empty(t, missing["fields"]) + assertNoJSONKeysFromBytes(t, raw) +} + +func TestWriteJSON_DriftSecretRedaction(t *testing.T) { + t.Parallel() + p := secretDriftPlan(t) + tests := []struct { + name string + opts view.Options + has []string + omit []string + data string + before string + }{ + { + name: "redacted", + has: []string{"(redacted)"}, + omit: []string{"b2xk", "bmV3", "c2VjcmV0", "kubectl.kubernetes.io/last-applied-configuration"}, + data: `{"password":"(redacted)"}`, + before: `"(redacted)"`, + }, + { + name: "shown", + opts: view.Options{ShowSecrets: true}, + has: []string{"b2xk", "bmV3", "c2VjcmV0"}, + omit: []string{"kubectl.kubernetes.io/last-applied-configuration"}, + data: `{"password":"bmV3"}`, + before: `"b2xk"`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + require.NoError(t, view.WriteJSON(&buf, p, tt.opts)) + text := buf.String() + for _, want := range tt.has { + assert.Contains(t, text, want) + } + for _, omit := range tt.omit { + assert.NotContains(t, text, omit) + } + assertJSONAt(t, buf.Bytes(), tt.data, "drift", 0, "live", "data") + assertJSONAt(t, buf.Bytes(), tt.before, "drift", 0, "fields", 0, "before") + validatePlanSchema(t, buf.Bytes()) + require.Equal(t, "b2xk", p.Drift[0].Fields[0].Before) + }) + } +} + +func secretDriftPlan(t *testing.T) semantic.Plan { + t.Helper() + p, err := semantic.AttachDrift(mustPlan(t, semantic.HelmNone, nil), []semantic.DriftChange{ + { + Resource: ref("Secret", "db"), + Action: semantic.DriftModified, + Previous: snap(map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{"name": "db", "namespace": "prod"}, + "stringData": map[string]any{"password": "old"}, + }), + Live: snap(map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{"name": "db", "namespace": "prod"}, + "data": map[string]any{"password": "bmV3"}, + }), + Fields: []semantic.FieldChange{{ + Path: "/data/password", + Op: semantic.FieldReplace, + Before: "b2xk", + After: "bmV3", + }}, + }, + { + Resource: ref("Secret", "leaked"), + Action: semantic.DriftUnexpected, + Live: snap(map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{"name": "leaked", "namespace": "prod"}, + "data": map[string]any{"password": "c2VjcmV0"}, + }), + }, + }) + require.NoError(t, err) + return p +} + func assertNoJSONTaskKeys(t *testing.T, task map[string]any) { t.Helper() assertNoJSONKeys(t, task) diff --git a/internal/plan/view/prepare.go b/internal/plan/view/prepare.go index 313a42a..4fa0012 100644 --- a/internal/plan/view/prepare.go +++ b/internal/plan/view/prepare.go @@ -50,6 +50,18 @@ func validateRenderable(p semantic.Plan) error { } } } + for i, d := range p.Drift { + if err := requireEnum(fmt.Sprintf("drift %d action", i), d.Action.String(), + semantic.DriftModified.String(), semantic.DriftMissing.String(), semantic.DriftUnexpected.String()); err != nil { + return err + } + for j, f := range d.Fields { + if err := requireEnum(fmt.Sprintf("drift %d field %d op", i, j), f.Op.String(), + semantic.FieldAdd.String(), semantic.FieldRemove.String(), semantic.FieldReplace.String()); err != nil { + return err + } + } + } owned := make(map[string]string) changeKeys := make(map[string]int, len(p.Changes)) for i, c := range p.Changes { diff --git a/internal/plan/view/project.go b/internal/plan/view/project.go index 2f22d0a..c432f8c 100644 --- a/internal/plan/view/project.go +++ b/internal/plan/view/project.go @@ -40,48 +40,36 @@ func projectFields(before, after map[string]any, fields []semantic.FieldChange) } switch f.Op { case semantic.FieldAdd: - projectInto(afterRoot, after, tokens, f.After, true) + projectInto(afterRoot, after, before, tokens, f.After) case semantic.FieldRemove: - projectInto(beforeRoot, before, tokens, f.Before, true) + projectInto(beforeRoot, before, after, tokens, f.Before) case semantic.FieldReplace: - projectInto(beforeRoot, before, tokens, f.Before, true) - projectInto(afterRoot, after, tokens, f.After, true) + projectInto(beforeRoot, before, after, tokens, f.Before) + projectInto(afterRoot, after, before, tokens, f.After) } } return asObject(beforeRoot.toValue()), asObject(afterRoot.toValue()) } -func projectInto(n *projNode, src any, tokens []string, leaf any, includeLeaf bool) { +// projectInto puts leaf at tokens even when src has no such path. +// src supplies list or map shape and any name sibling. other supplies +// that shape when src does not. Otherwise the rest are map keys. +func projectInto(n *projNode, src, other any, tokens []string, leaf any) { if n == nil { return } if len(tokens) == 0 { - if includeLeaf { - n.leaf = copyJSONValue(leaf) - n.hasLeaf = true - } + n.leaf = copyJSONValue(leaf) + n.hasLeaf = true return } - switch s := src.(type) { - case map[string]any: - if n.m == nil { - n.m = map[string]*projNode{} - } - key := tokens[0] - child, ok := n.m[key] - if !ok { - child = &projNode{} - n.m[key] = child - } - projectInto(child, s[key], tokens[1:], leaf, includeLeaf) - case map[string]string: - asAny := make(map[string]any, len(s)) - for k, v := range s { - asAny[k] = v - } - projectInto(n, asAny, tokens, leaf, includeLeaf) - case []any: - idx, err := strconv.Atoi(tokens[0]) + if asMap, ok := stringMap(src); ok { + projectInto(n, asMap, other, tokens, leaf) + return + } + token := tokens[0] + if projectionIsList(src, other) { + idx, err := strconv.Atoi(token) if err != nil || idx < 0 { return } @@ -92,36 +80,83 @@ func projectInto(n *projNode, src any, tokens []string, leaf any, includeLeaf bo if !ok { child = &projNode{} n.a[idx] = child - if idx < len(s) { - copyNameSibling(child, s[idx]) + if !copyNameSibling(child, listItem(src, idx)) { + copyNameSibling(child, listItem(other, idx)) } } - var next any - if idx < len(s) { - next = s[idx] - } - projectInto(child, next, tokens[1:], leaf, includeLeaf) - default: - if includeLeaf && len(tokens) == 0 { - n.leaf = copyJSONValue(leaf) - n.hasLeaf = true + projectInto(child, listItem(src, idx), listItem(other, idx), tokens[1:], leaf) + return + } + if n.m == nil { + n.m = map[string]*projNode{} + } + child, ok := n.m[token] + if !ok { + child = &projNode{} + n.m[token] = child + } + projectInto(child, mapItem(src, token), mapItem(other, token), tokens[1:], leaf) +} + +func projectionIsList(src, other any) bool { + if _, ok := src.([]any); ok { + return true + } + if _, ok := src.(map[string]any); ok { + return false + } + _, ok := other.([]any) + return ok +} + +func stringMap(v any) (map[string]any, bool) { + m, ok := v.(map[string]string) + if !ok { + return nil, false + } + out := make(map[string]any, len(m)) + for k, val := range m { + out[k] = val + } + return out, true +} + +func mapItem(v any, key string) any { + switch node := v.(type) { + case map[string]any: + return node[key] + case map[string]string: + if val, ok := node[key]; ok { + return val } } + return nil } -func copyNameSibling(n *projNode, item any) { +func listItem(v any, idx int) any { + list, ok := v.([]any) + if !ok || idx < 0 || idx >= len(list) { + return nil + } + return list[idx] +} + +func copyNameSibling(n *projNode, item any) bool { m, ok := item.(map[string]any) if !ok { - return + return false } name, ok := m["name"] if !ok { - return + return false } switch name.(type) { case string, int, int32, int64, float32, float64, bool: n.hasName = true n.name = copyJSONValue(name) + return true + default: + return false } } diff --git a/internal/plan/view/project_internal_test.go b/internal/plan/view/project_internal_test.go index 75d8208..450e377 100644 --- a/internal/plan/view/project_internal_test.go +++ b/internal/plan/view/project_internal_test.go @@ -169,3 +169,52 @@ func TestProjectFields_WholeNamedListElement(t *testing.T) { }) } } + +func TestProjectFields_PathAbsentFromBeforeSnapshot(t *testing.T) { + t.Parallel() + before := map[string]any{ + "stringData": map[string]any{"password": "old"}, + } + after := map[string]any{ + "data": map[string]any{"password": "bmV3"}, + } + gotBefore, gotAfter := projectFields(before, after, []semantic.FieldChange{{ + Path: "/data/password", + Op: semantic.FieldReplace, + Before: "b2xk", + After: "bmV3", + }}) + want := func(password string) map[string]any { + return map[string]any{"data": map[string]any{"password": password}} + } + assert.Equal(t, want("b2xk"), gotBefore) + assert.Equal(t, want("bmV3"), gotAfter) +} + +func TestProjectFields_ListShapeFromOtherSide(t *testing.T) { + t.Parallel() + after := map[string]any{ + "spec": map[string]any{ + "containers": []any{ + map[string]any{"name": "api", "image": "new"}, + }, + }, + } + gotBefore, gotAfter := projectFields(map[string]any{}, after, []semantic.FieldChange{{ + Path: "/spec/containers/0/image", + Op: semantic.FieldReplace, + Before: "old", + After: "new", + }}) + want := func(image string) map[string]any { + return map[string]any{ + "spec": map[string]any{ + "containers": []any{ + map[string]any{"name": "api", "image": image}, + }, + }, + } + } + assert.Equal(t, want("old"), gotBefore) + assert.Equal(t, want("new"), gotAfter) +} diff --git a/internal/plan/view/redact.go b/internal/plan/view/redact.go index a343f7f..01c0fee 100644 --- a/internal/plan/view/redact.go +++ b/internal/plan/view/redact.go @@ -56,6 +56,19 @@ func copyPlan(p semantic.Plan) semantic.Plan { } } } + out.Drift = slices.Clone(p.Drift) + if out.Drift == nil { + out.Drift = []semantic.DriftChange{} + } + for i := range out.Drift { + d := &out.Drift[i] + d.Previous = copySnapshot(d.Previous) + d.Live = copySnapshot(d.Live) + d.Fields = copyFields(d.Fields) + if d.Fields == nil { + d.Fields = []semantic.FieldChange{} + } + } out.ChartCRDs = slices.Clone(p.ChartCRDs) if out.ChartCRDs == nil { out.ChartCRDs = []semantic.ChartCRD{} @@ -138,6 +151,21 @@ func redactPlan(p *semantic.Plan) { c.Fields[j].After = redactSecretValue(c.Fields[j].After) } } + for i := range p.Drift { + d := &p.Drift[i] + if !isCoreSecret(d.Resource) { + continue + } + redactSnapshot(d.Previous) + redactSnapshot(d.Live) + for j := range d.Fields { + if !isSecretDataPath(d.Fields[j].Path) { + continue + } + d.Fields[j].Before = redactSecretValue(d.Fields[j].Before) + d.Fields[j].After = redactSecretValue(d.Fields[j].After) + } + } for i := range p.Tasks { for j := range p.Tasks[i].Definitions { d := &p.Tasks[i].Definitions[j] diff --git a/internal/plan/view/redact_test.go b/internal/plan/view/redact_test.go index 9d58431..f517548 100644 --- a/internal/plan/view/redact_test.go +++ b/internal/plan/view/redact_test.go @@ -432,3 +432,40 @@ func TestSecretRedaction_OmitsPlaintext(t *testing.T) { }) } } + +func TestSecretRedaction_DriftDefaultAndShowSecrets(t *testing.T) { + t.Parallel() + p := secretDriftPlan(t) + tests := []struct { + name string + opts view.Options + has []string + omit []string + }{ + { + name: "redacted", + has: []string{"password: (redacted)"}, + omit: []string{"b2xk", "bmV3", "c2VjcmV0", "old", "kubectl.kubernetes.io/last-applied-configuration"}, + }, + { + name: "shown", + opts: view.Options{ShowSecrets: true}, + has: []string{"b2xk", "bmV3", "c2VjcmV0"}, + omit: []string{"kubectl.kubernetes.io/last-applied-configuration"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + require.NoError(t, view.WriteHuman(&buf, p, tt.opts)) + text := buf.String() + for _, want := range tt.has { + assert.Contains(t, text, want) + } + for _, omit := range tt.omit { + assert.NotContains(t, text, omit) + } + }) + } +} diff --git a/internal/plan/view/schema/semantic_plan.v1.json b/internal/plan/view/schema/semantic_plan.v1.json index 2f8de10..111639f 100644 --- a/internal/plan/view/schema/semantic_plan.v1.json +++ b/internal/plan/view/schema/semantic_plan.v1.json @@ -10,6 +10,7 @@ "header", "helmAction", "changes", + "drift", "tasks", "chartCRDs", "summary" @@ -32,6 +33,12 @@ "$ref": "#/$defs/ResourceChange" } }, + "drift": { + "type": "array", + "items": { + "$ref": "#/$defs/Drift" + } + }, "tasks": { "type": "array", "items": { @@ -119,6 +126,28 @@ } } }, + { + "if": { + "properties": { + "header": { + "properties": { + "freshInstall": { + "const": true + } + }, + "required": ["freshInstall"] + } + }, + "required": ["header"] + }, + "then": { + "properties": { + "drift": { + "maxItems": 0 + } + } + } + }, { "if": { "properties": { @@ -392,6 +421,118 @@ } ] }, + "Drift": { + "type": "object", + "additionalProperties": false, + "required": ["resource", "action", "previous", "live", "fields"], + "properties": { + "resource": { + "allOf": [ + { + "$ref": "#/$defs/Resource" + }, + { + "properties": { + "name": { + "minLength": 1 + } + }, + "required": ["name"], + "not": { + "required": ["generateName"] + } + } + ] + }, + "action": { + "type": "string", + "enum": ["modified", "missing", "unexpected"] + }, + "previous": { + "$ref": "#/$defs/Snapshot" + }, + "live": { + "$ref": "#/$defs/Snapshot" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/$defs/FieldChange" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "action": { + "const": "modified" + } + }, + "required": ["action"] + }, + "then": { + "properties": { + "previous": { + "type": "object" + }, + "live": { + "type": "object" + }, + "fields": { + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "action": { + "const": "missing" + } + }, + "required": ["action"] + }, + "then": { + "properties": { + "previous": { + "type": "object" + }, + "live": { + "type": "null" + }, + "fields": { + "maxItems": 0 + } + } + } + }, + { + "if": { + "properties": { + "action": { + "const": "unexpected" + } + }, + "required": ["action"] + }, + "then": { + "properties": { + "previous": { + "type": "null" + }, + "live": { + "type": "object" + }, + "fields": { + "maxItems": 0 + } + } + } + } + ] + }, "Task": { "type": "object", "additionalProperties": false, diff --git a/internal/plan/view/schema_test.go b/internal/plan/view/schema_test.go index a759365..b2bb995 100644 --- a/internal/plan/view/schema_test.go +++ b/internal/plan/view/schema_test.go @@ -303,6 +303,92 @@ func TestSchemaV1_ChartCRDs(t *testing.T) { } } +func TestSchemaV1_AcceptsHelmNoneWithDrift(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + require.NoError(t, view.WriteJSON(&buf, sampleDriftPlan(t), view.Options{})) + validatePlanSchema(t, buf.Bytes()) +} + +func TestSchemaV1_RejectsMalformedDrift(t *testing.T) { + t.Parallel() + base := mustPlanDoc(t, sampleDriftPlan(t)) + install := mustPlanDoc(t, mustPlanWithHeader(t, semantic.Header{ + Project: "web", + Environment: "prod", + Release: "web", + Namespace: "prod", + FreshInstall: true, + }, semantic.HelmInstall, nil, nil)) + tests := []struct { + name string + raw []byte + }{ + {name: "missing with live", raw: patched(t, base, func(d map[string]any) { + items, ok := d["drift"].([]any) + require.True(t, ok) + asObject(t, items[2])["live"] = map[string]any{"kind": "ConfigMap"} + })}, + {name: "unexpected with previous", raw: patched(t, base, func(d map[string]any) { + items, ok := d["drift"].([]any) + require.True(t, ok) + asObject(t, items[1])["previous"] = map[string]any{"kind": "ConfigMap"} + })}, + {name: "modified without fields", raw: patched(t, base, func(d map[string]any) { + items, ok := d["drift"].([]any) + require.True(t, ok) + asObject(t, items[0])["fields"] = []any{} + })}, + {name: "unknown action", raw: patched(t, base, func(d map[string]any) { + items, ok := d["drift"].([]any) + require.True(t, ok) + asObject(t, items[0])["action"] = "update" + })}, + {name: "missing drift key", raw: patched(t, base, func(d map[string]any) { + delete(d, "drift") + })}, + {name: "fresh install with drift", raw: patched(t, install, func(d map[string]any) { + d["drift"] = base["drift"] + })}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assertSchemaRejects(t, tt.raw) + }) + } +} + +func sampleDriftPlan(t *testing.T) semantic.Plan { + t.Helper() + p, err := semantic.AttachDrift(mustPlan(t, semantic.HelmNone, nil), []semantic.DriftChange{ + { + Resource: ref("ConfigMap", "app"), + Action: semantic.DriftModified, + Previous: snap(cm("app", "old")), + Live: snap(cm("app", "new")), + Fields: []semantic.FieldChange{{ + Path: "/data/key", + Op: semantic.FieldReplace, + Before: "old", + After: "new", + }}, + }, + { + Resource: ref("ConfigMap", "other"), + Action: semantic.DriftMissing, + Previous: snap(cm("other", "gone")), + }, + { + Resource: ref("ConfigMap", "extra"), + Action: semantic.DriftUnexpected, + Live: snap(cm("extra", "live")), + }, + }) + require.NoError(t, err) + return p +} + func firstChartCRD(t *testing.T, doc map[string]any) map[string]any { t.Helper() crds, ok := doc["chartCRDs"].([]any) diff --git a/internal/plan/view/testdata/golden/human_drift.golden b/internal/plan/view/testdata/golden/human_drift.golden new file mode 100644 index 0000000..7641cde --- /dev/null +++ b/internal/plan/view/testdata/golden/human_drift.golden @@ -0,0 +1,35 @@ +Plan for project "web" on environment "prod" + +Context: production-eu +Namespace: prod +Release: web +Revision: 12 + +Drift + + ~ modified v1/ConfigMap "app" + data: + - key: old + + key: new + + + unexpected v1/ConfigMap "extra" + + apiVersion: v1 + + kind: ConfigMap + + metadata: + + name: extra + + namespace: prod + + data: + + key: live + + - missing v1/ConfigMap "other" + - apiVersion: v1 + - kind: ConfigMap + - metadata: + - name: other + - namespace: prod + - data: + - key: gone + +Summary + Resources: 0 create, 0 update, 0 delete + Drift: 1 modified, 1 missing, 1 unexpected diff --git a/internal/plan/view/testdata/golden/json_update.golden b/internal/plan/view/testdata/golden/json_update.golden index ef28383..cc819d0 100644 --- a/internal/plan/view/testdata/golden/json_update.golden +++ b/internal/plan/view/testdata/golden/json_update.golden @@ -40,6 +40,7 @@ ] } ], + "drift": [], "tasks": [], "chartCRDs": [], "summary": {