From e77ff2e5abc4210e72a5eecf3f7464223b69a80e Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Fri, 25 Sep 2026 00:30:45 +0200 Subject: [PATCH 1/3] derive helm action from release intent - compare previous and desired manifests and hooks - skip write prediction when helm will not run - keep live drift from deciding a helm upgrade --- internal/plan/helm_action.go | 44 ++-- internal/plan/helm_action_test.go | 79 +++++-- internal/plan/prediction.go | 34 --- internal/plan/prediction_test.go | 25 +++ internal/plan/release_intent.go | 136 ++++++++++++ internal/plan/release_intent_test.go | 137 ++++++++++++ internal/plan/semantic/types.go | 4 +- internal/plan/semantic_build.go | 90 +++++--- internal/plan/semantic_build_test.go | 210 +++++++++++++++++- internal/plan/tasks_test.go | 32 +-- .../release_intent/args-order/desired.yaml | 13 ++ .../release_intent/args-order/previous.yaml | 13 ++ .../release_intent/args-order/result.golden | 1 + .../document-added/desired.yaml | 15 ++ .../document-added/previous.yaml | 7 + .../document-added/result.golden | 1 + .../document-removed/desired.yaml | 7 + .../document-removed/previous.yaml | 15 ++ .../document-removed/result.golden | 1 + .../empty-documents/desired.yaml | 10 + .../empty-documents/previous.yaml | 7 + .../empty-documents/result.golden | 1 + .../error-desired-manifest/desired.yaml | 2 + .../error-desired-manifest/error.golden | 1 + .../error-desired-manifest/previous.yaml | 7 + .../error-duplicate-key/desired.yaml | 8 + .../error-duplicate-key/error.golden | 1 + .../error-duplicate-key/previous.yaml | 7 + .../desired.hooks/migrate.yaml | 2 + .../error-hook-manifest/desired.yaml | 7 + .../error-hook-manifest/error.golden | 1 + .../error-hook-manifest/previous.yaml | 7 + .../desired.hooks/migrate.yaml | 2 + .../desired.yaml | 7 + .../error.golden | 1 + .../previous.yaml | 7 + .../error-previous-manifest/desired.yaml | 7 + .../error-previous-manifest/error.golden | 1 + .../error-previous-manifest/previous.yaml | 2 + .../release_intent/field-change/desired.yaml | 7 + .../release_intent/field-change/previous.yaml | 7 + .../release_intent/field-change/result.golden | 1 + .../generatename-changed/desired.yaml | 7 + .../generatename-changed/previous.yaml | 7 + .../generatename-changed/result.golden | 1 + .../generatename-unchanged/desired.yaml | 7 + .../generatename-unchanged/previous.yaml | 7 + .../generatename-unchanged/result.golden | 1 + .../hook-added/desired.hooks/migrate.yaml | 13 ++ .../release_intent/hook-added/desired.yaml | 7 + .../release_intent/hook-added/previous.yaml | 7 + .../release_intent/hook-added/result.golden | 1 + .../hook-field/desired.hooks/migrate.yaml | 13 ++ .../release_intent/hook-field/desired.yaml | 7 + .../hook-field/previous.hooks/migrate.yaml | 13 ++ .../release_intent/hook-field/previous.yaml | 7 + .../release_intent/hook-field/result.golden | 1 + .../desired.hooks/migrate.yaml | 13 ++ .../hook-formatting/desired.yaml | 7 + .../previous.hooks/migrate.yaml | 13 ++ .../hook-formatting/previous.yaml | 7 + .../hook-formatting/result.golden | 1 + .../hook-only/desired.hooks/migrate.yaml | 13 ++ .../release_intent/hook-only/desired.yaml | 7 + .../hook-only/previous.hooks/migrate.yaml | 13 ++ .../release_intent/hook-only/previous.yaml | 7 + .../release_intent/hook-only/result.golden | 1 + .../hook-order/desired.hooks/01-b.yaml | 13 ++ .../hook-order/desired.hooks/02-a.yaml | 13 ++ .../release_intent/hook-order/desired.yaml | 7 + .../hook-order/previous.hooks/01-a.yaml | 13 ++ .../hook-order/previous.hooks/02-b.yaml | 13 ++ .../release_intent/hook-order/previous.yaml | 7 + .../release_intent/hook-order/result.golden | 1 + .../release_intent/hook-removed/desired.yaml | 7 + .../hook-removed/previous.hooks/migrate.yaml | 13 ++ .../release_intent/hook-removed/previous.yaml | 7 + .../release_intent/hook-removed/result.golden | 1 + .../release_intent/identical/desired.yaml | 7 + .../release_intent/identical/previous.yaml | 7 + .../release_intent/identical/result.golden | 1 + .../integer-beyond-float64/desired.yaml | 7 + .../integer-beyond-float64/previous.yaml | 7 + .../integer-beyond-float64/result.golden | 1 + .../release_intent/key-order/desired.yaml | 7 + .../release_intent/key-order/previous.yaml | 7 + .../release_intent/key-order/result.golden | 1 + .../source-comment/desired.yaml | 8 + .../source-comment/previous.yaml | 7 + .../source-comment/result.golden | 1 + .../release_intent/whitespace/desired.yaml | 7 + .../release_intent/whitespace/previous.yaml | 7 + .../release_intent/whitespace/result.golden | 1 + internal/testing/testpath/path.go | 12 + 94 files changed, 1204 insertions(+), 127 deletions(-) create mode 100644 internal/plan/release_intent.go create mode 100644 internal/plan/release_intent_test.go create mode 100644 internal/plan/testdata/release_intent/args-order/desired.yaml create mode 100644 internal/plan/testdata/release_intent/args-order/previous.yaml create mode 100644 internal/plan/testdata/release_intent/args-order/result.golden create mode 100644 internal/plan/testdata/release_intent/document-added/desired.yaml create mode 100644 internal/plan/testdata/release_intent/document-added/previous.yaml create mode 100644 internal/plan/testdata/release_intent/document-added/result.golden create mode 100644 internal/plan/testdata/release_intent/document-removed/desired.yaml create mode 100644 internal/plan/testdata/release_intent/document-removed/previous.yaml create mode 100644 internal/plan/testdata/release_intent/document-removed/result.golden create mode 100644 internal/plan/testdata/release_intent/empty-documents/desired.yaml create mode 100644 internal/plan/testdata/release_intent/empty-documents/previous.yaml create mode 100644 internal/plan/testdata/release_intent/empty-documents/result.golden create mode 100644 internal/plan/testdata/release_intent/error-desired-manifest/desired.yaml create mode 100644 internal/plan/testdata/release_intent/error-desired-manifest/error.golden create mode 100644 internal/plan/testdata/release_intent/error-desired-manifest/previous.yaml create mode 100644 internal/plan/testdata/release_intent/error-duplicate-key/desired.yaml create mode 100644 internal/plan/testdata/release_intent/error-duplicate-key/error.golden create mode 100644 internal/plan/testdata/release_intent/error-duplicate-key/previous.yaml create mode 100644 internal/plan/testdata/release_intent/error-hook-manifest/desired.hooks/migrate.yaml create mode 100644 internal/plan/testdata/release_intent/error-hook-manifest/desired.yaml create mode 100644 internal/plan/testdata/release_intent/error-hook-manifest/error.golden create mode 100644 internal/plan/testdata/release_intent/error-hook-manifest/previous.yaml create mode 100644 internal/plan/testdata/release_intent/error-hook-when-manifest-changed/desired.hooks/migrate.yaml create mode 100644 internal/plan/testdata/release_intent/error-hook-when-manifest-changed/desired.yaml create mode 100644 internal/plan/testdata/release_intent/error-hook-when-manifest-changed/error.golden create mode 100644 internal/plan/testdata/release_intent/error-hook-when-manifest-changed/previous.yaml create mode 100644 internal/plan/testdata/release_intent/error-previous-manifest/desired.yaml create mode 100644 internal/plan/testdata/release_intent/error-previous-manifest/error.golden create mode 100644 internal/plan/testdata/release_intent/error-previous-manifest/previous.yaml create mode 100644 internal/plan/testdata/release_intent/field-change/desired.yaml create mode 100644 internal/plan/testdata/release_intent/field-change/previous.yaml create mode 100644 internal/plan/testdata/release_intent/field-change/result.golden create mode 100644 internal/plan/testdata/release_intent/generatename-changed/desired.yaml create mode 100644 internal/plan/testdata/release_intent/generatename-changed/previous.yaml create mode 100644 internal/plan/testdata/release_intent/generatename-changed/result.golden create mode 100644 internal/plan/testdata/release_intent/generatename-unchanged/desired.yaml create mode 100644 internal/plan/testdata/release_intent/generatename-unchanged/previous.yaml create mode 100644 internal/plan/testdata/release_intent/generatename-unchanged/result.golden create mode 100644 internal/plan/testdata/release_intent/hook-added/desired.hooks/migrate.yaml create mode 100644 internal/plan/testdata/release_intent/hook-added/desired.yaml create mode 100644 internal/plan/testdata/release_intent/hook-added/previous.yaml create mode 100644 internal/plan/testdata/release_intent/hook-added/result.golden create mode 100644 internal/plan/testdata/release_intent/hook-field/desired.hooks/migrate.yaml create mode 100644 internal/plan/testdata/release_intent/hook-field/desired.yaml create mode 100644 internal/plan/testdata/release_intent/hook-field/previous.hooks/migrate.yaml create mode 100644 internal/plan/testdata/release_intent/hook-field/previous.yaml create mode 100644 internal/plan/testdata/release_intent/hook-field/result.golden create mode 100644 internal/plan/testdata/release_intent/hook-formatting/desired.hooks/migrate.yaml create mode 100644 internal/plan/testdata/release_intent/hook-formatting/desired.yaml create mode 100644 internal/plan/testdata/release_intent/hook-formatting/previous.hooks/migrate.yaml create mode 100644 internal/plan/testdata/release_intent/hook-formatting/previous.yaml create mode 100644 internal/plan/testdata/release_intent/hook-formatting/result.golden create mode 100644 internal/plan/testdata/release_intent/hook-only/desired.hooks/migrate.yaml create mode 100644 internal/plan/testdata/release_intent/hook-only/desired.yaml create mode 100644 internal/plan/testdata/release_intent/hook-only/previous.hooks/migrate.yaml create mode 100644 internal/plan/testdata/release_intent/hook-only/previous.yaml create mode 100644 internal/plan/testdata/release_intent/hook-only/result.golden create mode 100644 internal/plan/testdata/release_intent/hook-order/desired.hooks/01-b.yaml create mode 100644 internal/plan/testdata/release_intent/hook-order/desired.hooks/02-a.yaml create mode 100644 internal/plan/testdata/release_intent/hook-order/desired.yaml create mode 100644 internal/plan/testdata/release_intent/hook-order/previous.hooks/01-a.yaml create mode 100644 internal/plan/testdata/release_intent/hook-order/previous.hooks/02-b.yaml create mode 100644 internal/plan/testdata/release_intent/hook-order/previous.yaml create mode 100644 internal/plan/testdata/release_intent/hook-order/result.golden create mode 100644 internal/plan/testdata/release_intent/hook-removed/desired.yaml create mode 100644 internal/plan/testdata/release_intent/hook-removed/previous.hooks/migrate.yaml create mode 100644 internal/plan/testdata/release_intent/hook-removed/previous.yaml create mode 100644 internal/plan/testdata/release_intent/hook-removed/result.golden create mode 100644 internal/plan/testdata/release_intent/identical/desired.yaml create mode 100644 internal/plan/testdata/release_intent/identical/previous.yaml create mode 100644 internal/plan/testdata/release_intent/identical/result.golden create mode 100644 internal/plan/testdata/release_intent/integer-beyond-float64/desired.yaml create mode 100644 internal/plan/testdata/release_intent/integer-beyond-float64/previous.yaml create mode 100644 internal/plan/testdata/release_intent/integer-beyond-float64/result.golden create mode 100644 internal/plan/testdata/release_intent/key-order/desired.yaml create mode 100644 internal/plan/testdata/release_intent/key-order/previous.yaml create mode 100644 internal/plan/testdata/release_intent/key-order/result.golden create mode 100644 internal/plan/testdata/release_intent/source-comment/desired.yaml create mode 100644 internal/plan/testdata/release_intent/source-comment/previous.yaml create mode 100644 internal/plan/testdata/release_intent/source-comment/result.golden create mode 100644 internal/plan/testdata/release_intent/whitespace/desired.yaml create mode 100644 internal/plan/testdata/release_intent/whitespace/previous.yaml create mode 100644 internal/plan/testdata/release_intent/whitespace/result.golden diff --git a/internal/plan/helm_action.go b/internal/plan/helm_action.go index 8c2a249..1588fec 100644 --- a/internal/plan/helm_action.go +++ b/internal/plan/helm_action.go @@ -15,33 +15,35 @@ package plan import ( + "errors" + "fmt" + "deployah.dev/deployah/internal/helm" "deployah.dev/deployah/internal/plan/semantic" -) -func deriveHelmAction(op helm.Operation, changes []semantic.ResourceChange, tasks []semantic.TaskPlan) semantic.HelmAction { - if op == helm.OperationInstall { - return semantic.HelmInstall - } - if requiresHelmUpgrade(changes, tasks) { - return semantic.HelmUpgrade - } - return semantic.HelmNone -} + v1 "helm.sh/helm/v4/pkg/release/v1" +) -func requiresHelmUpgrade(changes []semantic.ResourceChange, tasks []semantic.TaskPlan) bool { - for _, c := range changes { - if c.Origin.Kind == semantic.OriginHelm { - return true +// deriveHelmAction picks install, upgrade, or none by comparing +// prep.Current with the rendered manifest and hooks. Live state never +// affects the result. +func deriveHelmAction(prep helm.ReleasePrep, desiredManifest string, desiredHooks []*v1.Hook) (semantic.HelmAction, error) { + switch prep.Operation { + case helm.OperationInstall: + return semantic.HelmInstall, nil + case helm.OperationUpgrade: + if prep.Current == nil { + return 0, errors.New("upgrade prep requires a current release") } - } - for _, t := range tasks { - if t.Phase == semantic.TaskSchedule { - continue + changed, err := releaseIntentChanged(prep.Current.Manifest, prep.Current.Hooks, desiredManifest, desiredHooks) + if err != nil { + return 0, err } - if t.Action != semantic.TaskUnchanged { - return true + if changed { + return semantic.HelmUpgrade, nil } + return semantic.HelmNone, nil + default: + return 0, fmt.Errorf("invalid helm operation %d", prep.Operation) } - return false } diff --git a/internal/plan/helm_action_test.go b/internal/plan/helm_action_test.go index 4c6bcfc..220795f 100644 --- a/internal/plan/helm_action_test.go +++ b/internal/plan/helm_action_test.go @@ -29,41 +29,76 @@ import ( func TestDeriveHelmAction(t *testing.T) { t.Parallel() - helmChange := labeledCreate("app", "app") - nsChange := semantic.ResourceChange{ - Resource: semantic.ResourceRef{APIVersion: "v1", Kind: "Namespace", Name: "prod"}, - Origin: semantic.ResourceOrigin{Kind: semantic.OriginNamespace}, - Action: semantic.Create, + same := configMapManifest("app", "same") + changed := configMapManifest("app", "new") + hookA := testHook("Job", "migrate", "migrate", "busybox", 1) + hookB := testHook("Job", "migrate", "migrate", "alpine", 1) + hookC := testHook("Job", "smoke", "smoke", "busybox", 2) + upgrade := func(manifest string, hooks []*v1.Hook) helm.ReleasePrep { + return helm.ReleasePrep{ + Operation: helm.OperationUpgrade, + Current: &v1.Release{Manifest: manifest, Hooks: hooks}, + } } - unchanged := semantic.TaskPlan{Name: "seed", Phase: semantic.TaskPreDeploy, Action: semantic.TaskUnchanged} - hookCreate := semantic.TaskPlan{Name: "migrate", Phase: semantic.TaskPreDeploy, Action: semantic.TaskCreate} - hookDelete := semantic.TaskPlan{Name: "old", Phase: semantic.TaskPreDeploy, Action: semantic.TaskDelete} - schedule := semantic.TaskPlan{Name: "cleanup", Phase: semantic.TaskSchedule, Action: semantic.TaskCreate} tests := []struct { name string - op helm.Operation - changes []semantic.ResourceChange - tasks []semantic.TaskPlan + prep helm.ReleasePrep + desired string + hooks []*v1.Hook want semantic.HelmAction }{ - {name: "install", op: helm.OperationInstall, want: semantic.HelmInstall}, - {name: "install ignores changes", op: helm.OperationInstall, changes: []semantic.ResourceChange{nsChange}, want: semantic.HelmInstall}, - {name: "upgrade with helm change", op: helm.OperationUpgrade, changes: []semantic.ResourceChange{helmChange}, want: semantic.HelmUpgrade}, - {name: "upgrade with hook create", op: helm.OperationUpgrade, tasks: []semantic.TaskPlan{hookCreate}, want: semantic.HelmUpgrade}, - {name: "upgrade with hook delete", op: helm.OperationUpgrade, tasks: []semantic.TaskPlan{hookDelete}, want: semantic.HelmUpgrade}, - {name: "namespace plus helm upgrades", op: helm.OperationUpgrade, changes: []semantic.ResourceChange{nsChange, helmChange}, want: semantic.HelmUpgrade}, - {name: "namespace change does not upgrade", op: helm.OperationUpgrade, changes: []semantic.ResourceChange{nsChange}, want: semantic.HelmNone}, - {name: "schedule change does not upgrade", op: helm.OperationUpgrade, tasks: []semantic.TaskPlan{schedule}, want: semantic.HelmNone}, - {name: "unchanged hooks none", op: helm.OperationUpgrade, tasks: []semantic.TaskPlan{unchanged}, want: semantic.HelmNone}, + {name: "install", prep: helm.ReleasePrep{Operation: helm.OperationInstall}, desired: changed, want: semantic.HelmInstall}, + { + name: "install ignores previous release", + prep: helm.ReleasePrep{ + Operation: helm.OperationInstall, + Current: &v1.Release{Manifest: same, Hooks: []*v1.Hook{hookA}}, + }, + desired: changed, + hooks: []*v1.Hook{hookB}, + want: semantic.HelmInstall, + }, + {name: "unchanged", prep: upgrade(same, []*v1.Hook{hookA}), desired: same, hooks: []*v1.Hook{hookA}, want: semantic.HelmNone}, + {name: "manifest change", prep: upgrade(same, []*v1.Hook{hookA}), desired: changed, hooks: []*v1.Hook{hookA}, want: semantic.HelmUpgrade}, + {name: "hook change", prep: upgrade(same, []*v1.Hook{hookA}), desired: same, hooks: []*v1.Hook{hookB}, want: semantic.HelmUpgrade}, + {name: "hook added", prep: upgrade(same, nil), desired: same, hooks: []*v1.Hook{hookA}, want: semantic.HelmUpgrade}, + {name: "hook removed", prep: upgrade(same, []*v1.Hook{hookA, hookC}), desired: same, hooks: []*v1.Hook{hookA}, want: semantic.HelmUpgrade}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tt.want, deriveHelmAction(tt.op, tt.changes, tt.tasks)) + got, err := deriveHelmAction(tt.prep, tt.desired, tt.hooks) + require.NoError(t, err) + assert.Equal(t, tt.want, got) }) } } +func TestDeriveHelmAction_Errors(t *testing.T) { + t.Parallel() + tests := []struct { + name string + prep helm.ReleasePrep + want string + }{ + {name: "invalid operation", prep: helm.ReleasePrep{}, want: "invalid helm operation"}, + {name: "nil current", prep: helm.ReleasePrep{Operation: helm.OperationUpgrade}, want: "upgrade prep requires a current release"}, + {name: "unparseable manifest", prep: helm.ReleasePrep{Operation: helm.OperationUpgrade, Current: &v1.Release{Manifest: ":\n - ["}}, want: "previous manifest:"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := deriveHelmAction(tt.prep, "", nil) + require.Error(t, err) + assert.ErrorContains(t, err, tt.want) + }) + } +} + +func configMapManifest(name, data string) string { + return "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: " + name + "\n namespace: prod\ndata:\n key: " + data + "\n" +} + func TestApplyHelmWillRun_UnchangedHook(t *testing.T) { t.Parallel() unchanged := []semantic.TaskPlan{{ diff --git a/internal/plan/prediction.go b/internal/plan/prediction.go index a8dd007..c3d65a2 100644 --- a/internal/plan/prediction.go +++ b/internal/plan/prediction.go @@ -25,40 +25,6 @@ import ( "deployah.dev/deployah/internal/predict" ) -// semanticPlanFromResults maps [predict.Result] values onto a semantic -// [semantic.Plan]. It does not mutate results or the unstructured objects -// they hold. -// -// Write FieldManager values come from [kube.ManagedFieldsManager]. This -// helper always emits [semantic.WriteServerSide] writes. [semantic.New] -// rejects those without a field manager, so callers must import -// [deployah.dev/deployah/internal/helm] (or otherwise pin that global) -// before calling this function. -func semanticPlanFromResults(header semantic.Header, results []predict.Result) (semantic.Plan, error) { - origin := semantic.ResourceOrigin{ - Kind: semantic.OriginHelm, - Helm: &semantic.HelmOrigin{ - Release: header.Release, - Namespace: header.Namespace, - }, - } - - changes, diags, err := mapPredictResults(origin, results) - if err != nil { - return semantic.Plan{}, err - } - if orderErr := stampHelmApplyOrder(changes); orderErr != nil { - return semantic.Plan{}, orderErr - } - helmAction := semantic.HelmUpgrade - if header.FreshInstall { - helmAction = semantic.HelmInstall - } else if len(changes) == 0 { - helmAction = semantic.HelmNone - } - return semantic.New(header, helmAction, changes, nil, diags) -} - func mapPredictResults(origin semantic.ResourceOrigin, results []predict.Result) ([]semantic.ResourceChange, []semantic.Diagnostic, error) { changes := make([]semantic.ResourceChange, 0, len(results)) diags := make([]semantic.Diagnostic, 0) diff --git a/internal/plan/prediction_test.go b/internal/plan/prediction_test.go index c05f5fa..a88ebf4 100644 --- a/internal/plan/prediction_test.go +++ b/internal/plan/prediction_test.go @@ -29,6 +29,31 @@ import ( _ "deployah.dev/deployah/internal/helm" ) +// semanticPlanFromResults maps predict results onto a semantic plan for +// tests. HelmAction is fixed from the header: install when FreshInstall +// is set, otherwise upgrade. It is not derived from the results. +func semanticPlanFromResults(header semantic.Header, results []predict.Result) (semantic.Plan, error) { + origin := semantic.ResourceOrigin{ + Kind: semantic.OriginHelm, + Helm: &semantic.HelmOrigin{ + Release: header.Release, + Namespace: header.Namespace, + }, + } + changes, diags, err := mapPredictResults(origin, results) + if err != nil { + return semantic.Plan{}, err + } + if orderErr := stampHelmApplyOrder(changes); orderErr != nil { + return semantic.Plan{}, orderErr + } + helmAction := semantic.HelmUpgrade + if header.FreshInstall { + helmAction = semantic.HelmInstall + } + return semantic.New(header, helmAction, changes, nil, diags) +} + func TestSemanticPlanFromResults_ActionMapping(t *testing.T) { t.Parallel() header := semantic.Header{Release: "web", Namespace: "prod"} diff --git a/internal/plan/release_intent.go b/internal/plan/release_intent.go new file mode 100644 index 0000000..aaaa417 --- /dev/null +++ b/internal/plan/release_intent.go @@ -0,0 +1,136 @@ +// 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 ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" + + "github.com/wI2L/jsondiff" + + v1 "helm.sh/helm/v4/pkg/release/v1" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" + sigsyaml "sigs.k8s.io/yaml" +) + +// releaseIntentChanged reports whether the Desired manifest or hooks differ +// from Previous. Order matters. Bad input (a nil hook, invalid YAML, a +// duplicate key) is an error even when the manifests already differ. +func releaseIntentChanged(previousManifest string, previousHooks []*v1.Hook, desiredManifest string, desiredHooks []*v1.Hook) (bool, error) { + previousDocs, err := manifestJSON(previousManifest) + if err != nil { + return false, fmt.Errorf("previous manifest: %w", err) + } + desiredDocs, err := manifestJSON(desiredManifest) + if err != nil { + return false, fmt.Errorf("desired manifest: %w", err) + } + previousHookDocs, err := hooksJSON("previous", previousHooks) + if err != nil { + return false, err + } + desiredHookDocs, err := hooksJSON("desired", desiredHooks) + if err != nil { + return false, err + } + + same, err := jsonStructurallyEqual(previousDocs, desiredDocs) + if err != nil { + return false, fmt.Errorf("compare manifests: %w", err) + } + if !same { + return true, nil + } + same, err = jsonStructurallyEqual(previousHookDocs, desiredHookDocs) + if err != nil { + return false, fmt.Errorf("compare hooks: %w", err) + } + return !same, nil +} + +func manifestJSON(manifest string) ([]json.RawMessage, error) { + reader := yamlutil.NewYAMLReader(bufio.NewReader(strings.NewReader(manifest))) + var docs []json.RawMessage + docNum := 0 + for { + body, err := reader.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("document %d: %w", docNum+1, err) + } + docNum++ + raw, convErr := sigsyaml.YAMLToJSONStrict(body) + if convErr != nil { + return nil, fmt.Errorf("document %d: %w", docNum, convErr) + } + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + continue + } + docs = append(docs, json.RawMessage(raw)) + } + return docs, nil +} + +func hooksJSON(side string, hooks []*v1.Hook) ([]json.RawMessage, error) { + var docs []json.RawMessage + for i, h := range hooks { + if h == nil { + return nil, fmt.Errorf("%s hook %d is nil", side, i+1) + } + hookDocs, err := manifestJSON(h.Manifest) + if err != nil { + return nil, fmt.Errorf("%s hook %s: %w", side, hookLabel(h, i), err) + } + docs = append(docs, hookDocs...) + } + return docs, nil +} + +func hookLabel(h *v1.Hook, index int) string { + if name := strings.TrimSpace(h.Name); name != "" { + return name + } + return strconv.Itoa(index + 1) +} + +func jsonStructurallyEqual(previous, desired []json.RawMessage) (bool, error) { + prev, err := json.Marshal(previous) + if err != nil { + return false, err + } + next, err := json.Marshal(desired) + if err != nil { + return false, err + } + patch, err := jsondiff.CompareJSON(prev, next, jsondiff.UnmarshalFunc(unmarshalUseNumber)) + if err != nil { + return false, err + } + return len(patch) == 0, nil +} + +func unmarshalUseNumber(data []byte, v any) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + return dec.Decode(v) +} diff --git a/internal/plan/release_intent_test.go b/internal/plan/release_intent_test.go new file mode 100644 index 0000000..7f22870 --- /dev/null +++ b/internal/plan/release_intent_test.go @@ -0,0 +1,137 @@ +// 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 ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "deployah.dev/deployah/internal/testing/testpath" + + v1 "helm.sh/helm/v4/pkg/release/v1" +) + +const releaseIntentFixtures = "testdata/release_intent" + +// TestReleaseIntentChanged runs each testdata/release_intent directory. +// result.golden holds "changed" or "unchanged"; error.golden holds the +// expected error text. +func TestReleaseIntentChanged(t *testing.T) { + t.Parallel() + entries, err := os.ReadDir(releaseIntentFixtures) + require.NoError(t, err) + var ran int + for _, entry := range entries { + if !entry.IsDir() { + continue + } + ran++ + name := entry.Name() + t.Run(name, func(t *testing.T) { + t.Parallel() + dir := filepath.Join(releaseIntentFixtures, name) + got, cmpErr := releaseIntentChanged( + testpath.ReadFile(t, dir, "previous.yaml"), + readHookFixtures(t, filepath.Join(dir, "previous.hooks")), + testpath.ReadFile(t, dir, "desired.yaml"), + readHookFixtures(t, filepath.Join(dir, "desired.hooks")), + ) + _, hasResult := readOptionalFixture(t, dir, "result.golden") + wantErr, hasError := readOptionalFixture(t, dir, "error.golden") + require.NotEqualf(t, hasResult, hasError, "%s must contain exactly one of result.golden and error.golden", name) + if hasError { + require.Errorf(t, cmpErr, "releaseIntentChanged(%s) error = nil, want %q", name, wantErr) + assert.ErrorContains(t, cmpErr, strings.TrimSpace(wantErr), "releaseIntentChanged(%s)", name) + return + } + require.NoErrorf(t, cmpErr, "releaseIntentChanged(%s)", name) + want := strings.TrimSpace(testpath.ReadFile(t, dir, "result.golden")) + require.Containsf(t, []string{"changed", "unchanged"}, want, "%s/result.golden", name) + assert.Equalf(t, want == "changed", got, "releaseIntentChanged(%s) = %v, want %s", name, got, want) + }) + } + require.NotZero(t, ran, "no fixtures under %s", releaseIntentFixtures) +} + +func TestReleaseIntentChanged_NilHook(t *testing.T) { + t.Parallel() + hook := testHook("Job", "migrate", "migrate", "busybox", 1) + tests := []struct { + name string + prevHooks []*v1.Hook + desiredHooks []*v1.Hook + want string + }{ + {name: "nil previous hook", prevHooks: []*v1.Hook{hook, nil}, want: "previous hook 2 is nil"}, + {name: "nil desired hook", desiredHooks: []*v1.Hook{nil}, want: "desired hook 1 is nil"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := releaseIntentChanged("", tt.prevHooks, "", tt.desiredHooks) + require.Error(t, err) + assert.ErrorContains(t, err, tt.want) + }) + } +} + +func readOptionalFixture(tb testing.TB, dir, name string) (string, bool) { + tb.Helper() + _, err := os.Stat(filepath.Join(dir, name)) + if errors.Is(err, fs.ErrNotExist) { + return "", false + } + require.NoError(tb, err) + return testpath.ReadFile(tb, dir, name), true +} + +// readHookFixtures loads *.yaml files from dir in lexical order, so a +// numeric prefix such as "01-" sets hook order. A missing dir means no +// hooks. +func readHookFixtures(tb testing.TB, dir string) []*v1.Hook { + tb.Helper() + entries, err := os.ReadDir(dir) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + require.NoError(tb, err) + var hooks []*v1.Hook + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" { + continue + } + hooks = append(hooks, &v1.Hook{ + Name: hookFixtureName(entry.Name()), + Manifest: testpath.ReadFile(tb, dir, entry.Name()), + }) + } + return hooks +} + +func hookFixtureName(file string) string { + base := strings.TrimSuffix(file, filepath.Ext(file)) + prefix, rest, ok := strings.Cut(base, "-") + if ok && prefix != "" && strings.Trim(prefix, "0123456789") == "" { + return rest + } + return base +} diff --git a/internal/plan/semantic/types.go b/internal/plan/semantic/types.go index 2923d06..312ef3b 100644 --- a/internal/plan/semantic/types.go +++ b/internal/plan/semantic/types.go @@ -76,8 +76,8 @@ const ( // HelmInstall is a fresh Helm install. It requires // [Header.FreshInstall]. HelmInstall - // HelmUpgrade is a Helm upgrade. Zero known effects remain valid - // for a future reapply. + // HelmUpgrade is a Helm upgrade. Zero resource consequences remain + // valid when release intent changed and Live already matches Desired. HelmUpgrade ) diff --git a/internal/plan/semantic_build.go b/internal/plan/semantic_build.go index 82cb579..4e938d5 100644 --- a/internal/plan/semantic_build.go +++ b/internal/plan/semantic_build.go @@ -64,17 +64,21 @@ type SemanticBuildInput struct { SkipCRDs bool } -// BuildSemanticPlan renders [spec.ResolvedSpec], predicts Live to -// Predicted resource changes, and returns a [semantic.Plan]. It uses -// the [helm.ReleasePrep] from [SemanticBuildClient.RenderManifestsWithPrep] -// and does not look up Helm history itself. PostRenderer, when non-nil, -// is forwarded once to the render client. +// BuildSemanticPlan renders [spec.ResolvedSpec], derives Helm's action +// from Previous and Desired release intent, and returns a [semantic.Plan]. +// It uses the [helm.ReleasePrep] from +// [SemanticBuildClient.RenderManifestsWithPrep] and does not look up Helm +// history itself. PostRenderer, when non-nil, is forwarded once to the +// render client. // -// The install target Namespace is predicted on cluster first. Helm -// [predict.Predict] then runs against a plan-local wrapper so a -// same-deploy missing target namespace is not fatal. Chart CRD files -// are forwarded to Helm only; they are not parsed into resource changes. -// Presentation identity comes from [SemanticBuildInput.CRDDocs]. +// Helm runs on a fresh install, or on an upgrade whose Previous release +// intent differs from the rendered Desired manifest or hooks. An unchanged +// release is [semantic.HelmNone] and runs no Namespace or Helm write +// prediction. When Helm will run, the install target Namespace is predicted +// on cluster first, then Helm [predict.Predict] runs against a plan-local +// wrapper so a same-deploy missing target namespace is not fatal. Chart CRD +// files are forwarded to Helm only; they are not parsed into resource +// changes. Presentation identity comes from [SemanticBuildInput.CRDDocs]. // // The caller must invoke the returned cleanup func once. Cleanup is // always non-nil. On error the plan is empty and the render result is @@ -108,9 +112,9 @@ func BuildSemanticPlan( return semantic.Plan{}, nil, cleanup, fmt.Errorf("render preparation: %w", err) } - predInput, err := predict.InputFromPrep(&prep, result.ReleaseName, result.Namespace, result.Manifest) + helmAction, err := deriveHelmAction(prep, result.Manifest, result.Hooks) if err != nil { - return semantic.Plan{}, nil, cleanup, fmt.Errorf("build prediction input: %w", err) + return semantic.Plan{}, nil, cleanup, fmt.Errorf("determine helm release intent: %w", err) } if prep.Operation == helm.OperationInstall { @@ -119,17 +123,6 @@ func BuildSemanticPlan( } } - nsChange, missingNS, err := predictNamespace(ctx, cluster, prep.Operation, result.Namespace) - if err != nil { - return semantic.Plan{}, nil, cleanup, err - } - - wrapper := newPrereqCluster(cluster, missingNS, result.Namespace) - results, err := predict.Predict(ctx, wrapper, predInput) - if err != nil { - return semantic.Plan{}, nil, cleanup, fmt.Errorf("predict resources: %w", err) - } - header := semantic.Header{ Project: input.Resolved.Spec.Project, Environment: input.Resolved.Env.Original, @@ -146,13 +139,15 @@ func BuildSemanticPlan( Namespace: header.Namespace, }, } - helmChanges, diags, err := mapPredictResults(origin, results) - if err != nil { - return semantic.Plan{}, nil, cleanup, fmt.Errorf("assemble semantic plan: %w", err) - } - diags = append(diags, limitationDiagnostics(wrapper, results)...) - if orderErr := stampHelmApplyOrder(helmChanges); orderErr != nil { - return semantic.Plan{}, nil, cleanup, fmt.Errorf("assemble semantic plan: %w", orderErr) + + var nsChange *semantic.ResourceChange + var helmChanges []semantic.ResourceChange + var diags []semantic.Diagnostic + if helmAction != semantic.HelmNone { + nsChange, helmChanges, diags, err = predictWriteConsequences(ctx, cluster, prep, result, origin) + if err != nil { + return semantic.Plan{}, nil, cleanup, err + } } var changes []semantic.ResourceChange if nsChange != nil { @@ -163,7 +158,6 @@ func BuildSemanticPlan( if err != nil { return semantic.Plan{}, nil, cleanup, fmt.Errorf("assemble semantic plan: %w", err) } - helmAction := deriveHelmAction(prep.Operation, helmChanges, tasks) applyHelmWillRun(tasks, helmAction) p, err := semantic.New(header, helmAction, changes, tasks, diags) if err != nil { @@ -176,6 +170,40 @@ func BuildSemanticPlan( return p, result, cleanup, nil } +func predictWriteConsequences( + ctx context.Context, + cluster predict.Cluster, + prep helm.ReleasePrep, + result *render.RenderResult, + origin semantic.ResourceOrigin, +) (*semantic.ResourceChange, []semantic.ResourceChange, []semantic.Diagnostic, error) { + nsChange, missingNS, err := predictNamespace(ctx, cluster, prep.Operation, result.Namespace) + if err != nil { + return nil, nil, nil, err + } + + predInput, err := predict.InputFromPrep(&prep, result.ReleaseName, result.Namespace, result.Manifest) + if err != nil { + return nil, nil, nil, fmt.Errorf("build prediction input: %w", err) + } + + wrapper := newPrereqCluster(cluster, missingNS, result.Namespace) + results, err := predict.Predict(ctx, wrapper, predInput) + if err != nil { + return nil, nil, nil, fmt.Errorf("predict resources: %w", err) + } + + helmChanges, diags, err := mapPredictResults(origin, results) + if err != nil { + return nil, nil, nil, fmt.Errorf("assemble semantic plan: %w", err) + } + diags = append(diags, limitationDiagnostics(wrapper, results)...) + if orderErr := stampHelmApplyOrder(helmChanges); orderErr != nil { + return nil, nil, nil, fmt.Errorf("assemble semantic plan: %w", orderErr) + } + return nsChange, helmChanges, diags, nil +} + func chartCRDsFromDocs(docs []extras.CRDDoc, op helm.Operation, skipCRDs bool) []semantic.ChartCRD { lifecycle := semantic.ChartCRDProcess willProcess := true diff --git a/internal/plan/semantic_build_test.go b/internal/plan/semantic_build_test.go index 3cf48bc..369c3f2 100644 --- a/internal/plan/semantic_build_test.go +++ b/internal/plan/semantic_build_test.go @@ -545,7 +545,7 @@ func TestBuildSemanticPlan_InvalidPrep(t *testing.T) { p, result, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.Error(t, err) - assert.ErrorContains(t, err, "build prediction input:") + assert.ErrorContains(t, err, "determine helm release intent:") assert.ErrorContains(t, err, "upgrade prep requires a current release") assert.Zero(t, p) assert.Nil(t, result) @@ -824,6 +824,214 @@ func TestBuildSemanticPlan_ScheduleCronJobChange(t *testing.T) { assert.Equal(t, semantic.Update, p.Changes[0].Action) } +func TestBuildSemanticPlan_HelmActionIgnoresLive(t *testing.T) { + t.Parallel() + tests := []struct { + name string + previous string + desired string + live string + wantAction semantic.HelmAction + wantActions []semantic.Action + wantApplies int + }{ + {name: "unchanged live matches", previous: "same", desired: "same", live: "same", wantAction: semantic.HelmNone, wantActions: []semantic.Action{}}, + {name: "drift only", previous: "same", desired: "same", live: "drift", wantAction: semantic.HelmNone, wantActions: []semantic.Action{}}, + {name: "missing live", previous: "same", desired: "same", wantAction: semantic.HelmNone, wantActions: []semantic.Action{}}, + {name: "release changed live desired", previous: "old", desired: "new", live: "new", wantAction: semantic.HelmUpgrade, wantActions: []semantic.Action{}, wantApplies: 1}, + {name: "release changed live differs", previous: "old", desired: "new", live: "old", wantAction: semantic.HelmUpgrade, wantActions: []semantic.Action{semantic.Update}, wantApplies: 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + current := &v1.Release{Version: 3, Manifest: configMapYAML("app", "prod", tt.previous)} + client := &fakeBuildClient{ + result: upgradeResult(configMapYAML("app", "prod", tt.desired), 4), + prep: helm.ReleasePrep{ + Operation: helm.OperationUpgrade, + Current: current, + Newest: current, + NextRevision: 4, + }, + cleanup: func() {}, + } + cluster := readyCluster() + if tt.live != "" { + cluster.store(ownedConfigMap("app", "prod", "web", tt.live)) + } + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) + t.Cleanup(cleanup) + require.NoError(t, err) + assert.Equal(t, tt.wantAction, p.HelmAction) + actions := make([]semantic.Action, 0, len(p.Changes)) + for _, c := range p.Changes { + actions = append(actions, c.Action) + } + assert.Equal(t, tt.wantActions, actions) + assert.Len(t, cluster.applies, tt.wantApplies) + }) + } +} + +func TestBuildSemanticPlan_UnchangedReleaseSkipsWritePrediction(t *testing.T) { + t.Parallel() + manifest := configMapYAML("app", "prod", "same") + current := &v1.Release{Version: 3, Manifest: manifest} + client := &fakeBuildClient{ + result: upgradeResult(manifest, 4), + prep: helm.ReleasePrep{ + Operation: helm.OperationUpgrade, + Current: current, + Newest: current, + NextRevision: 4, + }, + cleanup: func() {}, + } + cluster := readyCluster() + id := predict.Identity{Version: "v1", Kind: "ConfigMap", Namespace: "prod", Name: "app"} + cluster.getErr[clusterKey(id)] = errors.New("get failed") + cluster.applyErr = errors.New("apply failed") + cluster.createErr = errors.New("create failed") + + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) + t.Cleanup(cleanup) + require.NoError(t, err) + assert.Equal(t, semantic.HelmNone, p.HelmAction) + assert.Empty(t, p.Changes) + assert.Empty(t, cluster.applies) + assert.Empty(t, cluster.creates) +} + +func TestBuildSemanticPlan_UnchangedReleaseSkipsNamespacePrediction(t *testing.T) { + t.Parallel() + manifest := configMapYAML("app", "prod", "same") + current := &v1.Release{Version: 3, Manifest: manifest} + client := &fakeBuildClient{ + result: upgradeResult(manifest, 4), + prep: helm.ReleasePrep{ + Operation: helm.OperationUpgrade, + Current: current, + Newest: current, + NextRevision: 4, + }, + cleanup: func() {}, + } + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, newFakeCluster(), buildInput("ctx", resolvedSpec(), nil)) + t.Cleanup(cleanup) + require.NoError(t, err) + assert.Equal(t, semantic.HelmNone, p.HelmAction) +} + +func TestBuildSemanticPlan_UpgradeGenerateName(t *testing.T) { + t.Parallel() + unchanged := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n generateName: app-\n namespace: prod\ndata:\n key: v1\n" + changed := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n generateName: app-\n namespace: prod\ndata:\n key: v2\n" + tests := []struct { + name string + desired string + want semantic.HelmAction + }{ + {name: "unchanged", desired: unchanged, want: semantic.HelmNone}, + {name: "changed", desired: changed, want: semantic.HelmUpgrade}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + current := &v1.Release{Version: 3, Manifest: unchanged} + client := &fakeBuildClient{ + result: upgradeResult(tt.desired, 4), + prep: helm.ReleasePrep{ + Operation: helm.OperationUpgrade, + Current: current, + Newest: current, + NextRevision: 4, + }, + cleanup: func() {}, + } + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, readyCluster(), buildInput("ctx", resolvedSpec(), nil)) + t.Cleanup(cleanup) + require.NoError(t, err) + assert.Equal(t, tt.want, p.HelmAction) + }) + } +} + +func TestBuildSemanticPlan_ReleaseChangeLiveCurrentUnchangedHookWillRun(t *testing.T) { + t.Parallel() + hook := planHook("migrate", "busybox") + result := upgradeResult(configMapYAML("app", "prod", "new"), 4) + result.Hooks = []*v1.Hook{hook} + resolved := resolvedSpec() + resolved.Tasks = map[string]spec.ResolvedTask{ + "migrate": {Task: spec.Task{On: spec.TaskOnPreDeploy}, HookWeight: 1}, + } + client := &fakeBuildClient{ + result: result, + prep: upgradePrepWithHook(configMapYAML("app", "prod", "old"), 3, 4, hook), + cleanup: func() {}, + } + cluster := readyCluster() + cluster.store(ownedConfigMap("app", "prod", "web", "new")) + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolved, nil)) + t.Cleanup(cleanup) + require.NoError(t, err) + assert.Equal(t, semantic.HelmUpgrade, p.HelmAction) + assert.Empty(t, p.Changes) + require.Len(t, p.Tasks, 1) + assert.Equal(t, semantic.TaskUnchanged, p.Tasks[0].Action) + assert.True(t, p.Tasks[0].WillRun) +} + +func TestBuildSemanticPlan_DriftUnchangedHooksWillNotRun(t *testing.T) { + t.Parallel() + manifest := configMapYAML("app", "prod", "same") + pre := planHook("migrate", "busybox") + post := planHook("smoke", "busybox") + post.Events = []v1.HookEvent{v1.HookPostInstall, v1.HookPostUpgrade} + result := upgradeResult(manifest, 4) + result.Hooks = []*v1.Hook{pre, post} + resolved := resolvedSpec() + resolved.Tasks = map[string]spec.ResolvedTask{ + "migrate": {Task: spec.Task{On: spec.TaskOnPreDeploy}, HookWeight: 1}, + "smoke": {Task: spec.Task{On: spec.TaskOnPostDeploy}, HookWeight: 2}, + } + current := &v1.Release{ + Version: 3, + Manifest: manifest, + Hooks: []*v1.Hook{pre, post}, + Config: map[string]any{ + "deployah": map[string]any{ + "resolved": map[string]any{ + "tasks": map[string]any{ + "migrate": map[string]any{"on": "preDeploy", "hookWeight": 1}, + "smoke": map[string]any{"on": "postDeploy", "hookWeight": 2}, + }, + }, + }, + }, + } + client := &fakeBuildClient{ + result: result, + prep: helm.ReleasePrep{ + Operation: helm.OperationUpgrade, + Current: current, + Newest: current, + NextRevision: 4, + }, + cleanup: func() {}, + } + cluster := readyCluster() + cluster.store(ownedConfigMap("app", "prod", "web", "drift")) + p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolved, nil)) + t.Cleanup(cleanup) + require.NoError(t, err) + assert.Equal(t, semantic.HelmNone, p.HelmAction) + require.Len(t, p.Tasks, 2) + for _, task := range p.Tasks { + assert.False(t, task.WillRun) + } +} + func planHook(name, image string) *v1.Hook { return &v1.Hook{ Name: name, diff --git a/internal/plan/tasks_test.go b/internal/plan/tasks_test.go index f33b5b3..da89878 100644 --- a/internal/plan/tasks_test.go +++ b/internal/plan/tasks_test.go @@ -42,7 +42,7 @@ func TestAssembleTasks_FreshInstallPrePost(t *testing.T) { testHook("Job", "smoke", "smoke", "v1", 2), }, nil) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, nil, tasks)) + applyHelmWillRun(tasks, semantic.HelmInstall) byName := taskByName(t, tasks) require.Contains(t, byName, "migrate") require.Contains(t, byName, "smoke") @@ -65,7 +65,7 @@ func TestAssembleTasks_ManifestChangeUnchangedHookWillRun(t *testing.T) { prep := upgradePrep(map[string]any{"seed": map[string]any{"on": "preDeploy", "hookWeight": 1}}, []*v1.Hook{hook}, nil) tasks, err := assembleTasks(resolved, prep, []*v1.Hook{hook}, []semantic.ResourceChange{labeledCreate("api", "api")}) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, []semantic.ResourceChange{labeledCreate("api", "api")}, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) byName := taskByName(t, tasks) require.Contains(t, byName, "seed") assert.Equal(t, semantic.TaskUnchanged, byName["seed"].Action) @@ -83,7 +83,7 @@ func TestAssembleTasks_HookDefinitionOnly(t *testing.T) { prep := upgradePrep(map[string]any{"migrate": map[string]any{"on": "preDeploy", "hookWeight": 1}}, []*v1.Hook{prev}, nil) tasks, err := assembleTasks(resolved, prep, []*v1.Hook{desired}, nil) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, nil, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) byName := taskByName(t, tasks) require.Contains(t, byName, "migrate") assert.Equal(t, semantic.TaskUpdate, byName["migrate"].Action) @@ -104,7 +104,7 @@ func TestAssembleTasks_HookCreateAndDelete(t *testing.T) { }, []*v1.Hook{prev}, nil) tasks, err := assembleTasks(resolved, prep, []*v1.Hook{desired}, nil) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, nil, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) byName := taskByName(t, tasks) require.Contains(t, byName, "smoke") require.Contains(t, byName, "old") @@ -131,7 +131,7 @@ func TestAssembleTasks_AllCurrentHooksRunOnHelmChange(t *testing.T) { changes := []semantic.ResourceChange{labeledCreate("api", "api")} tasks, err := assembleTasks(resolved, prep, []*v1.Hook{migrate, seed}, changes) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, changes, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) byName := taskByName(t, tasks) assert.True(t, byName["migrate"].WillRun) assert.True(t, byName["seed"].WillRun) @@ -151,7 +151,7 @@ func TestAssembleTasks_NewestHooksIgnored(t *testing.T) { prep := upgradePrep(map[string]any{"migrate": map[string]any{"on": "preDeploy", "hookWeight": 1}}, []*v1.Hook{currentHook}, newest) tasks, err := assembleTasks(resolved, prep, []*v1.Hook{desired}, nil) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, nil, tasks)) + applyHelmWillRun(tasks, semantic.HelmNone) byName := taskByName(t, tasks) assert.Equal(t, semantic.TaskUnchanged, byName["migrate"].Action) assert.Empty(t, byName["migrate"].Definitions) @@ -169,7 +169,7 @@ func TestAssembleTasks_MultiResourceBundleOnlyChangedDefinition(t *testing.T) { prep := upgradePrep(map[string]any{"migrate": map[string]any{"on": "preDeploy", "hookWeight": 1}}, []*v1.Hook{prevCM, prevJob}, nil) tasks, err := assembleTasks(resolved, prep, []*v1.Hook{desiredCM, desiredJob}, nil) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, nil, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) byName := taskByName(t, tasks) require.Len(t, byName["migrate"].Definitions, 1) assert.Equal(t, "migrate-env", byName["migrate"].Definitions[0].Resource.Name) @@ -201,7 +201,7 @@ func TestAssembleTasks_LeftoverHookWithoutConfig(t *testing.T) { prep := upgradePrep(map[string]any{}, []*v1.Hook{hook}, nil) tasks, err := assembleTasks(resolvedWithTasks(nil), prep, nil, nil) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, nil, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) byName := taskByName(t, tasks) require.Contains(t, byName, "orphan") assert.Equal(t, semantic.TaskDelete, byName["orphan"].Action) @@ -237,7 +237,7 @@ spec: prep := upgradePrep(map[string]any{}, []*v1.Hook{hook}, nil) tasks, err := assembleTasks(resolvedWithTasks(nil), prep, nil, nil) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, nil, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) byName := taskByName(t, tasks) require.Contains(t, byName, "orphan") assert.Equal(t, semantic.TaskDelete, byName["orphan"].Action) @@ -388,7 +388,7 @@ func TestAssembleTasks_HookChangeRunsAllCurrentHooks(t *testing.T) { prep := upgradePrep(tt.previous, tt.prev, nil) tasks, err := assembleTasks(resolvedWithTasks(tt.resolved), prep, tt.desired, nil) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, nil, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) byName := taskByName(t, tasks) require.Len(t, byName, len(tt.want)) for name, want := range tt.want { @@ -413,7 +413,7 @@ func TestAssembleTasks_RemovedScheduleDoesNotCaptureComponent(t *testing.T) { changes := []semantic.ResourceChange{cron, comp} tasks, err := assembleTasks(resolved, prep, nil, changes) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, changes, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) byName := taskByName(t, tasks) require.Contains(t, byName, "cleanup") assert.Equal(t, semantic.TaskDelete, byName["cleanup"].Action) @@ -477,7 +477,7 @@ func TestAssembleTasks_TransitionToManual(t *testing.T) { prep := upgradePrep(tt.previous, tt.prevHooks, nil) tasks, err := assembleTasks(resolved, prep, nil, tt.changes) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, tt.changes, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) byName := taskByName(t, tasks) require.Contains(t, byName, tt.task) got := byName[tt.task] @@ -528,7 +528,7 @@ func TestAssembleTasks_HookPhaseChange(t *testing.T) { }, []*v1.Hook{tt.prev}, nil) tasks, err := assembleTasks(resolved, prep, []*v1.Hook{tt.desired}, nil) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, nil, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) got := taskByName(t, tasks)["migrate"] assert.Equal(t, tt.wantPhase, got.Phase) assert.Equal(t, semantic.TaskUpdate, got.Action) @@ -648,7 +648,7 @@ func TestAssembleTasks_HookScheduleTransition(t *testing.T) { prep := upgradePrep(tt.previous, tt.prevHooks, nil) tasks, err := assembleTasks(resolved, prep, tt.desired, tt.changes) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, tt.changes, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, tt.changes, tasks, nil) require.NoError(t, err) byName := taskByName(t, p.Tasks) @@ -678,7 +678,7 @@ func TestAssembleTasks_ScheduleReferencesAndOmitsUnchanged(t *testing.T) { changes := []semantic.ResourceChange{cron, labeledCreate("api", "api")} tasks, err := assembleTasks(resolved, prep, nil, changes) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, changes, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) byName := taskByName(t, tasks) require.Contains(t, byName, "cleanup") assert.NotContains(t, byName, "keep") @@ -734,7 +734,7 @@ func TestAssembleTasks_ScheduleProvenance(t *testing.T) { prep := upgradePrep(tt.previous, nil, nil) tasks, err := assembleTasks(resolvedWithTasks(tt.resolved), prep, nil, tt.changes) require.NoError(t, err) - applyHelmWillRun(tasks, deriveHelmAction(prep.Operation, tt.changes, tasks)) + applyHelmWillRun(tasks, semantic.HelmUpgrade) byName := taskByName(t, tasks) require.Contains(t, byName, "cleanup") got := byName["cleanup"] diff --git a/internal/plan/testdata/release_intent/args-order/desired.yaml b/internal/plan/testdata/release_intent/args-order/desired.yaml new file mode 100644 index 0000000..aea6131 --- /dev/null +++ b/internal/plan/testdata/release_intent/args-order/desired.yaml @@ -0,0 +1,13 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web + namespace: prod +spec: + template: + spec: + containers: + - name: api + args: + - --bar + - --foo diff --git a/internal/plan/testdata/release_intent/args-order/previous.yaml b/internal/plan/testdata/release_intent/args-order/previous.yaml new file mode 100644 index 0000000..0b37385 --- /dev/null +++ b/internal/plan/testdata/release_intent/args-order/previous.yaml @@ -0,0 +1,13 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web + namespace: prod +spec: + template: + spec: + containers: + - name: api + args: + - --foo + - --bar diff --git a/internal/plan/testdata/release_intent/args-order/result.golden b/internal/plan/testdata/release_intent/args-order/result.golden new file mode 100644 index 0000000..5ea2ed4 --- /dev/null +++ b/internal/plan/testdata/release_intent/args-order/result.golden @@ -0,0 +1 @@ +changed diff --git a/internal/plan/testdata/release_intent/document-added/desired.yaml b/internal/plan/testdata/release_intent/document-added/desired.yaml new file mode 100644 index 0000000..36d3acd --- /dev/null +++ b/internal/plan/testdata/release_intent/document-added/desired.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same +--- +apiVersion: v1 +kind: ConfigMap +metadata: + generateName: app- + namespace: prod +data: + key: v1 diff --git a/internal/plan/testdata/release_intent/document-added/previous.yaml b/internal/plan/testdata/release_intent/document-added/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/document-added/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/document-added/result.golden b/internal/plan/testdata/release_intent/document-added/result.golden new file mode 100644 index 0000000..5ea2ed4 --- /dev/null +++ b/internal/plan/testdata/release_intent/document-added/result.golden @@ -0,0 +1 @@ +changed diff --git a/internal/plan/testdata/release_intent/document-removed/desired.yaml b/internal/plan/testdata/release_intent/document-removed/desired.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/document-removed/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/document-removed/previous.yaml b/internal/plan/testdata/release_intent/document-removed/previous.yaml new file mode 100644 index 0000000..36d3acd --- /dev/null +++ b/internal/plan/testdata/release_intent/document-removed/previous.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same +--- +apiVersion: v1 +kind: ConfigMap +metadata: + generateName: app- + namespace: prod +data: + key: v1 diff --git a/internal/plan/testdata/release_intent/document-removed/result.golden b/internal/plan/testdata/release_intent/document-removed/result.golden new file mode 100644 index 0000000..5ea2ed4 --- /dev/null +++ b/internal/plan/testdata/release_intent/document-removed/result.golden @@ -0,0 +1 @@ +changed diff --git a/internal/plan/testdata/release_intent/empty-documents/desired.yaml b/internal/plan/testdata/release_intent/empty-documents/desired.yaml new file mode 100644 index 0000000..aa3744b --- /dev/null +++ b/internal/plan/testdata/release_intent/empty-documents/desired.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same +--- +# only a comment +--- diff --git a/internal/plan/testdata/release_intent/empty-documents/previous.yaml b/internal/plan/testdata/release_intent/empty-documents/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/empty-documents/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/empty-documents/result.golden b/internal/plan/testdata/release_intent/empty-documents/result.golden new file mode 100644 index 0000000..4eea88a --- /dev/null +++ b/internal/plan/testdata/release_intent/empty-documents/result.golden @@ -0,0 +1 @@ +unchanged diff --git a/internal/plan/testdata/release_intent/error-desired-manifest/desired.yaml b/internal/plan/testdata/release_intent/error-desired-manifest/desired.yaml new file mode 100644 index 0000000..97ff414 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-desired-manifest/desired.yaml @@ -0,0 +1,2 @@ +: + - [ diff --git a/internal/plan/testdata/release_intent/error-desired-manifest/error.golden b/internal/plan/testdata/release_intent/error-desired-manifest/error.golden new file mode 100644 index 0000000..fb9acfc --- /dev/null +++ b/internal/plan/testdata/release_intent/error-desired-manifest/error.golden @@ -0,0 +1 @@ +desired manifest: diff --git a/internal/plan/testdata/release_intent/error-desired-manifest/previous.yaml b/internal/plan/testdata/release_intent/error-desired-manifest/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-desired-manifest/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/error-duplicate-key/desired.yaml b/internal/plan/testdata/release_intent/error-duplicate-key/desired.yaml new file mode 100644 index 0000000..d4597a5 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-duplicate-key/desired.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + name: other + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/error-duplicate-key/error.golden b/internal/plan/testdata/release_intent/error-duplicate-key/error.golden new file mode 100644 index 0000000..923a71a --- /dev/null +++ b/internal/plan/testdata/release_intent/error-duplicate-key/error.golden @@ -0,0 +1 @@ +desired manifest: document 1: diff --git a/internal/plan/testdata/release_intent/error-duplicate-key/previous.yaml b/internal/plan/testdata/release_intent/error-duplicate-key/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-duplicate-key/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/error-hook-manifest/desired.hooks/migrate.yaml b/internal/plan/testdata/release_intent/error-hook-manifest/desired.hooks/migrate.yaml new file mode 100644 index 0000000..97ff414 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-hook-manifest/desired.hooks/migrate.yaml @@ -0,0 +1,2 @@ +: + - [ diff --git a/internal/plan/testdata/release_intent/error-hook-manifest/desired.yaml b/internal/plan/testdata/release_intent/error-hook-manifest/desired.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-hook-manifest/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/error-hook-manifest/error.golden b/internal/plan/testdata/release_intent/error-hook-manifest/error.golden new file mode 100644 index 0000000..c461b44 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-hook-manifest/error.golden @@ -0,0 +1 @@ +desired hook migrate: diff --git a/internal/plan/testdata/release_intent/error-hook-manifest/previous.yaml b/internal/plan/testdata/release_intent/error-hook-manifest/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-hook-manifest/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/error-hook-when-manifest-changed/desired.hooks/migrate.yaml b/internal/plan/testdata/release_intent/error-hook-when-manifest-changed/desired.hooks/migrate.yaml new file mode 100644 index 0000000..97ff414 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-hook-when-manifest-changed/desired.hooks/migrate.yaml @@ -0,0 +1,2 @@ +: + - [ diff --git a/internal/plan/testdata/release_intent/error-hook-when-manifest-changed/desired.yaml b/internal/plan/testdata/release_intent/error-hook-when-manifest-changed/desired.yaml new file mode 100644 index 0000000..83dbebe --- /dev/null +++ b/internal/plan/testdata/release_intent/error-hook-when-manifest-changed/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: other diff --git a/internal/plan/testdata/release_intent/error-hook-when-manifest-changed/error.golden b/internal/plan/testdata/release_intent/error-hook-when-manifest-changed/error.golden new file mode 100644 index 0000000..c461b44 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-hook-when-manifest-changed/error.golden @@ -0,0 +1 @@ +desired hook migrate: diff --git a/internal/plan/testdata/release_intent/error-hook-when-manifest-changed/previous.yaml b/internal/plan/testdata/release_intent/error-hook-when-manifest-changed/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-hook-when-manifest-changed/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/error-previous-manifest/desired.yaml b/internal/plan/testdata/release_intent/error-previous-manifest/desired.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-previous-manifest/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/error-previous-manifest/error.golden b/internal/plan/testdata/release_intent/error-previous-manifest/error.golden new file mode 100644 index 0000000..8eeb015 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-previous-manifest/error.golden @@ -0,0 +1 @@ +previous manifest: diff --git a/internal/plan/testdata/release_intent/error-previous-manifest/previous.yaml b/internal/plan/testdata/release_intent/error-previous-manifest/previous.yaml new file mode 100644 index 0000000..97ff414 --- /dev/null +++ b/internal/plan/testdata/release_intent/error-previous-manifest/previous.yaml @@ -0,0 +1,2 @@ +: + - [ diff --git a/internal/plan/testdata/release_intent/field-change/desired.yaml b/internal/plan/testdata/release_intent/field-change/desired.yaml new file mode 100644 index 0000000..83dbebe --- /dev/null +++ b/internal/plan/testdata/release_intent/field-change/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: other diff --git a/internal/plan/testdata/release_intent/field-change/previous.yaml b/internal/plan/testdata/release_intent/field-change/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/field-change/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/field-change/result.golden b/internal/plan/testdata/release_intent/field-change/result.golden new file mode 100644 index 0000000..5ea2ed4 --- /dev/null +++ b/internal/plan/testdata/release_intent/field-change/result.golden @@ -0,0 +1 @@ +changed diff --git a/internal/plan/testdata/release_intent/generatename-changed/desired.yaml b/internal/plan/testdata/release_intent/generatename-changed/desired.yaml new file mode 100644 index 0000000..009e19e --- /dev/null +++ b/internal/plan/testdata/release_intent/generatename-changed/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + generateName: app- + namespace: prod +data: + key: v2 diff --git a/internal/plan/testdata/release_intent/generatename-changed/previous.yaml b/internal/plan/testdata/release_intent/generatename-changed/previous.yaml new file mode 100644 index 0000000..4b92970 --- /dev/null +++ b/internal/plan/testdata/release_intent/generatename-changed/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + generateName: app- + namespace: prod +data: + key: v1 diff --git a/internal/plan/testdata/release_intent/generatename-changed/result.golden b/internal/plan/testdata/release_intent/generatename-changed/result.golden new file mode 100644 index 0000000..5ea2ed4 --- /dev/null +++ b/internal/plan/testdata/release_intent/generatename-changed/result.golden @@ -0,0 +1 @@ +changed diff --git a/internal/plan/testdata/release_intent/generatename-unchanged/desired.yaml b/internal/plan/testdata/release_intent/generatename-unchanged/desired.yaml new file mode 100644 index 0000000..4b92970 --- /dev/null +++ b/internal/plan/testdata/release_intent/generatename-unchanged/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + generateName: app- + namespace: prod +data: + key: v1 diff --git a/internal/plan/testdata/release_intent/generatename-unchanged/previous.yaml b/internal/plan/testdata/release_intent/generatename-unchanged/previous.yaml new file mode 100644 index 0000000..4b92970 --- /dev/null +++ b/internal/plan/testdata/release_intent/generatename-unchanged/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + generateName: app- + namespace: prod +data: + key: v1 diff --git a/internal/plan/testdata/release_intent/generatename-unchanged/result.golden b/internal/plan/testdata/release_intent/generatename-unchanged/result.golden new file mode 100644 index 0000000..4eea88a --- /dev/null +++ b/internal/plan/testdata/release_intent/generatename-unchanged/result.golden @@ -0,0 +1 @@ +unchanged diff --git a/internal/plan/testdata/release_intent/hook-added/desired.hooks/migrate.yaml b/internal/plan/testdata/release_intent/hook-added/desired.hooks/migrate.yaml new file mode 100644 index 0000000..4d2044e --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-added/desired.hooks/migrate.yaml @@ -0,0 +1,13 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migrate + namespace: prod + labels: + deployah.dev/task: migrate +spec: + template: + spec: + containers: + - name: job + image: busybox diff --git a/internal/plan/testdata/release_intent/hook-added/desired.yaml b/internal/plan/testdata/release_intent/hook-added/desired.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-added/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/hook-added/previous.yaml b/internal/plan/testdata/release_intent/hook-added/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-added/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/hook-added/result.golden b/internal/plan/testdata/release_intent/hook-added/result.golden new file mode 100644 index 0000000..5ea2ed4 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-added/result.golden @@ -0,0 +1 @@ +changed diff --git a/internal/plan/testdata/release_intent/hook-field/desired.hooks/migrate.yaml b/internal/plan/testdata/release_intent/hook-field/desired.hooks/migrate.yaml new file mode 100644 index 0000000..149c732 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-field/desired.hooks/migrate.yaml @@ -0,0 +1,13 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migrate + namespace: prod + labels: + deployah.dev/task: migrate +spec: + template: + spec: + containers: + - name: job + image: alpine diff --git a/internal/plan/testdata/release_intent/hook-field/desired.yaml b/internal/plan/testdata/release_intent/hook-field/desired.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-field/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/hook-field/previous.hooks/migrate.yaml b/internal/plan/testdata/release_intent/hook-field/previous.hooks/migrate.yaml new file mode 100644 index 0000000..4d2044e --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-field/previous.hooks/migrate.yaml @@ -0,0 +1,13 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migrate + namespace: prod + labels: + deployah.dev/task: migrate +spec: + template: + spec: + containers: + - name: job + image: busybox diff --git a/internal/plan/testdata/release_intent/hook-field/previous.yaml b/internal/plan/testdata/release_intent/hook-field/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-field/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/hook-field/result.golden b/internal/plan/testdata/release_intent/hook-field/result.golden new file mode 100644 index 0000000..5ea2ed4 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-field/result.golden @@ -0,0 +1 @@ +changed diff --git a/internal/plan/testdata/release_intent/hook-formatting/desired.hooks/migrate.yaml b/internal/plan/testdata/release_intent/hook-formatting/desired.hooks/migrate.yaml new file mode 100644 index 0000000..2f533cc --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-formatting/desired.hooks/migrate.yaml @@ -0,0 +1,13 @@ +apiVersion: batch/v1 +kind: Job +metadata: + namespace: prod + name: migrate + labels: + deployah.dev/task: migrate +spec: + template: + spec: + containers: + - name: job + image: busybox diff --git a/internal/plan/testdata/release_intent/hook-formatting/desired.yaml b/internal/plan/testdata/release_intent/hook-formatting/desired.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-formatting/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/hook-formatting/previous.hooks/migrate.yaml b/internal/plan/testdata/release_intent/hook-formatting/previous.hooks/migrate.yaml new file mode 100644 index 0000000..4d2044e --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-formatting/previous.hooks/migrate.yaml @@ -0,0 +1,13 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migrate + namespace: prod + labels: + deployah.dev/task: migrate +spec: + template: + spec: + containers: + - name: job + image: busybox diff --git a/internal/plan/testdata/release_intent/hook-formatting/previous.yaml b/internal/plan/testdata/release_intent/hook-formatting/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-formatting/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/hook-formatting/result.golden b/internal/plan/testdata/release_intent/hook-formatting/result.golden new file mode 100644 index 0000000..4eea88a --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-formatting/result.golden @@ -0,0 +1 @@ +unchanged diff --git a/internal/plan/testdata/release_intent/hook-only/desired.hooks/migrate.yaml b/internal/plan/testdata/release_intent/hook-only/desired.hooks/migrate.yaml new file mode 100644 index 0000000..149c732 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-only/desired.hooks/migrate.yaml @@ -0,0 +1,13 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migrate + namespace: prod + labels: + deployah.dev/task: migrate +spec: + template: + spec: + containers: + - name: job + image: alpine diff --git a/internal/plan/testdata/release_intent/hook-only/desired.yaml b/internal/plan/testdata/release_intent/hook-only/desired.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-only/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/hook-only/previous.hooks/migrate.yaml b/internal/plan/testdata/release_intent/hook-only/previous.hooks/migrate.yaml new file mode 100644 index 0000000..4d2044e --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-only/previous.hooks/migrate.yaml @@ -0,0 +1,13 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migrate + namespace: prod + labels: + deployah.dev/task: migrate +spec: + template: + spec: + containers: + - name: job + image: busybox diff --git a/internal/plan/testdata/release_intent/hook-only/previous.yaml b/internal/plan/testdata/release_intent/hook-only/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-only/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/hook-only/result.golden b/internal/plan/testdata/release_intent/hook-only/result.golden new file mode 100644 index 0000000..5ea2ed4 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-only/result.golden @@ -0,0 +1 @@ +changed diff --git a/internal/plan/testdata/release_intent/hook-order/desired.hooks/01-b.yaml b/internal/plan/testdata/release_intent/hook-order/desired.hooks/01-b.yaml new file mode 100644 index 0000000..149c732 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-order/desired.hooks/01-b.yaml @@ -0,0 +1,13 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migrate + namespace: prod + labels: + deployah.dev/task: migrate +spec: + template: + spec: + containers: + - name: job + image: alpine diff --git a/internal/plan/testdata/release_intent/hook-order/desired.hooks/02-a.yaml b/internal/plan/testdata/release_intent/hook-order/desired.hooks/02-a.yaml new file mode 100644 index 0000000..4d2044e --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-order/desired.hooks/02-a.yaml @@ -0,0 +1,13 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migrate + namespace: prod + labels: + deployah.dev/task: migrate +spec: + template: + spec: + containers: + - name: job + image: busybox diff --git a/internal/plan/testdata/release_intent/hook-order/desired.yaml b/internal/plan/testdata/release_intent/hook-order/desired.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-order/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/hook-order/previous.hooks/01-a.yaml b/internal/plan/testdata/release_intent/hook-order/previous.hooks/01-a.yaml new file mode 100644 index 0000000..4d2044e --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-order/previous.hooks/01-a.yaml @@ -0,0 +1,13 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migrate + namespace: prod + labels: + deployah.dev/task: migrate +spec: + template: + spec: + containers: + - name: job + image: busybox diff --git a/internal/plan/testdata/release_intent/hook-order/previous.hooks/02-b.yaml b/internal/plan/testdata/release_intent/hook-order/previous.hooks/02-b.yaml new file mode 100644 index 0000000..149c732 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-order/previous.hooks/02-b.yaml @@ -0,0 +1,13 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migrate + namespace: prod + labels: + deployah.dev/task: migrate +spec: + template: + spec: + containers: + - name: job + image: alpine diff --git a/internal/plan/testdata/release_intent/hook-order/previous.yaml b/internal/plan/testdata/release_intent/hook-order/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-order/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/hook-order/result.golden b/internal/plan/testdata/release_intent/hook-order/result.golden new file mode 100644 index 0000000..5ea2ed4 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-order/result.golden @@ -0,0 +1 @@ +changed diff --git a/internal/plan/testdata/release_intent/hook-removed/desired.yaml b/internal/plan/testdata/release_intent/hook-removed/desired.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-removed/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/hook-removed/previous.hooks/migrate.yaml b/internal/plan/testdata/release_intent/hook-removed/previous.hooks/migrate.yaml new file mode 100644 index 0000000..4d2044e --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-removed/previous.hooks/migrate.yaml @@ -0,0 +1,13 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: migrate + namespace: prod + labels: + deployah.dev/task: migrate +spec: + template: + spec: + containers: + - name: job + image: busybox diff --git a/internal/plan/testdata/release_intent/hook-removed/previous.yaml b/internal/plan/testdata/release_intent/hook-removed/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-removed/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/hook-removed/result.golden b/internal/plan/testdata/release_intent/hook-removed/result.golden new file mode 100644 index 0000000..5ea2ed4 --- /dev/null +++ b/internal/plan/testdata/release_intent/hook-removed/result.golden @@ -0,0 +1 @@ +changed diff --git a/internal/plan/testdata/release_intent/identical/desired.yaml b/internal/plan/testdata/release_intent/identical/desired.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/identical/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/identical/previous.yaml b/internal/plan/testdata/release_intent/identical/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/identical/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/identical/result.golden b/internal/plan/testdata/release_intent/identical/result.golden new file mode 100644 index 0000000..4eea88a --- /dev/null +++ b/internal/plan/testdata/release_intent/identical/result.golden @@ -0,0 +1 @@ +unchanged diff --git a/internal/plan/testdata/release_intent/integer-beyond-float64/desired.yaml b/internal/plan/testdata/release_intent/integer-beyond-float64/desired.yaml new file mode 100644 index 0000000..908be45 --- /dev/null +++ b/internal/plan/testdata/release_intent/integer-beyond-float64/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + n: 9007199254740995 diff --git a/internal/plan/testdata/release_intent/integer-beyond-float64/previous.yaml b/internal/plan/testdata/release_intent/integer-beyond-float64/previous.yaml new file mode 100644 index 0000000..886b3c9 --- /dev/null +++ b/internal/plan/testdata/release_intent/integer-beyond-float64/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + n: 9007199254740993 diff --git a/internal/plan/testdata/release_intent/integer-beyond-float64/result.golden b/internal/plan/testdata/release_intent/integer-beyond-float64/result.golden new file mode 100644 index 0000000..5ea2ed4 --- /dev/null +++ b/internal/plan/testdata/release_intent/integer-beyond-float64/result.golden @@ -0,0 +1 @@ +changed diff --git a/internal/plan/testdata/release_intent/key-order/desired.yaml b/internal/plan/testdata/release_intent/key-order/desired.yaml new file mode 100644 index 0000000..e39152d --- /dev/null +++ b/internal/plan/testdata/release_intent/key-order/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + namespace: prod + name: web +data: + key: same diff --git a/internal/plan/testdata/release_intent/key-order/previous.yaml b/internal/plan/testdata/release_intent/key-order/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/key-order/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/key-order/result.golden b/internal/plan/testdata/release_intent/key-order/result.golden new file mode 100644 index 0000000..4eea88a --- /dev/null +++ b/internal/plan/testdata/release_intent/key-order/result.golden @@ -0,0 +1 @@ +unchanged diff --git a/internal/plan/testdata/release_intent/source-comment/desired.yaml b/internal/plan/testdata/release_intent/source-comment/desired.yaml new file mode 100644 index 0000000..e3dd622 --- /dev/null +++ b/internal/plan/testdata/release_intent/source-comment/desired.yaml @@ -0,0 +1,8 @@ +# Source: chart/templates/cm.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/source-comment/previous.yaml b/internal/plan/testdata/release_intent/source-comment/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/source-comment/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/source-comment/result.golden b/internal/plan/testdata/release_intent/source-comment/result.golden new file mode 100644 index 0000000..4eea88a --- /dev/null +++ b/internal/plan/testdata/release_intent/source-comment/result.golden @@ -0,0 +1 @@ +unchanged diff --git a/internal/plan/testdata/release_intent/whitespace/desired.yaml b/internal/plan/testdata/release_intent/whitespace/desired.yaml new file mode 100644 index 0000000..296fb17 --- /dev/null +++ b/internal/plan/testdata/release_intent/whitespace/desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/whitespace/previous.yaml b/internal/plan/testdata/release_intent/whitespace/previous.yaml new file mode 100644 index 0000000..b3d4696 --- /dev/null +++ b/internal/plan/testdata/release_intent/whitespace/previous.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web + namespace: prod +data: + key: same diff --git a/internal/plan/testdata/release_intent/whitespace/result.golden b/internal/plan/testdata/release_intent/whitespace/result.golden new file mode 100644 index 0000000..4eea88a --- /dev/null +++ b/internal/plan/testdata/release_intent/whitespace/result.golden @@ -0,0 +1 @@ +unchanged diff --git a/internal/testing/testpath/path.go b/internal/testing/testpath/path.go index afab9ca..ce6f6a4 100644 --- a/internal/testing/testpath/path.go +++ b/internal/testing/testpath/path.go @@ -35,6 +35,18 @@ func Dir(tb testing.TB, elems ...string) string { return dir } +// ReadFile joins elems, reads that file, and returns its contents. It +// fails the test if the file cannot be read. +func ReadFile(tb testing.TB, elems ...string) string { + tb.Helper() + + path := filepath.Clean(filepath.Join(elems...)) + raw, err := os.ReadFile(path) // #nosec G304 -- test fixture path chosen by the caller + require.NoErrorf(tb, err, "read fixture %s", path) + + return string(raw) +} + // AbsDir returns the absolute path to the fixture directory formed by elems. func AbsDir(tb testing.TB, elems ...string) string { tb.Helper() From 1bfb182bef99b914dfd7cbdfa4e98478f4864387 Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Fri, 25 Sep 2026 12:35:48 +0200 Subject: [PATCH 2/3] update semantic plan package comment --- internal/plan/doc.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/internal/plan/doc.go b/internal/plan/doc.go index 6eaea0f..b5e5836 100644 --- a/internal/plan/doc.go +++ b/internal/plan/doc.go @@ -14,8 +14,9 @@ // Package plan computes and renders a preview of the changes a deploy // would make. [BuildPlan] diffs the rendered manifest against the last -// successful Helm release. [BuildSemanticPlan] predicts Live to Predicted -// resource changes from that same render. +// successful Helm release. [BuildSemanticPlan] renders Desired state, +// derives whether Helm runs from Previous and Desired release intent, +// and predicts write consequences only when Helm will actually run. // // [ComputeDiff] is the legacy diff engine: it parses two rendered // Kubernetes manifests, matches resources by (apiVersion, kind, namespace, @@ -26,11 +27,16 @@ // would use. [BuildPlan] and [ComputeDiff] remain during migration to the // semantic plan model. // -// [BuildSemanticPlan] is the semantic pipeline: it renders through -// [SemanticBuildClient.RenderManifestsWithPrep], predicts CRDs and the -// install target Namespace on [predict.Cluster], then predicts Helm -// resources with [deployah.dev/deployah/internal/predict.Predict] against -// a plan-local wrapper. It returns a +// [BuildSemanticPlan] renders Desired state through +// [SemanticBuildClient.RenderManifestsWithPrep]. Whether Helm runs comes +// from the Helm operation and a comparison of the previous release +// (prep.Current) with the rendered manifest and hooks. Live state and +// predictor output do not take part in that decision. When Helm will not +// run, Namespace and Helm write prediction are skipped, and the plan has +// no Helm write consequences. When Helm will install or upgrade, the +// existing Namespace and [deployah.dev/deployah/internal/predict.Predict] +// bridge still produces those consequences. Chart CRD files are forwarded +// to Helm only. It returns a // [deployah.dev/deployah/internal/plan/semantic.Plan]. Semantic types // live in plan/semantic. New rendering lives in plan/view. // From 32934347b7223d9a4f06fcf457ed6b5335b47c41 Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Fri, 25 Sep 2026 12:37:25 +0200 Subject: [PATCH 3/3] rewrite plan package comment --- internal/plan/doc.go | 45 +++++++++++++++++++++----------------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/internal/plan/doc.go b/internal/plan/doc.go index b5e5836..95c5650 100644 --- a/internal/plan/doc.go +++ b/internal/plan/doc.go @@ -15,32 +15,29 @@ // Package plan computes and renders a preview of the changes a deploy // would make. [BuildPlan] diffs the rendered manifest against the last // successful Helm release. [BuildSemanticPlan] renders Desired state, -// derives whether Helm runs from Previous and Desired release intent, -// and predicts write consequences only when Helm will actually run. +// decides whether Helm runs by comparing the previous release with that +// render, and predicts writes only when Helm will run. // -// [ComputeDiff] is the legacy diff engine: it parses two rendered -// Kubernetes manifests, matches resources by (apiVersion, kind, namespace, -// name), and runs [github.com/homeport/dyff] field-by-field on resources -// present on both sides. [Plan] is the resulting domain model, consumed by -// a text renderer ([RenderText]) and a JSON renderer ([NewJSONDocument]). -// [DeploymentIntent] holds the mutation and executability flags a deploy -// would use. [BuildPlan] and [ComputeDiff] remain during migration to the -// semantic plan model. +// [ComputeDiff] is the older diff: it parses two rendered Kubernetes +// manifests, matches resources by (apiVersion, kind, namespace, name), and +// runs [github.com/homeport/dyff] field by field on resources present on +// both sides. [Plan] is that model. [RenderText] prints it, and +// [NewJSONDocument] encodes it as JSON. [DeploymentIntent] holds the flags +// a deploy uses to decide what it may change. [BuildPlan] and +// [ComputeDiff] stay while callers move to the semantic plan. // -// [BuildSemanticPlan] renders Desired state through -// [SemanticBuildClient.RenderManifestsWithPrep]. Whether Helm runs comes -// from the Helm operation and a comparison of the previous release -// (prep.Current) with the rendered manifest and hooks. Live state and -// predictor output do not take part in that decision. When Helm will not -// run, Namespace and Helm write prediction are skipped, and the plan has -// no Helm write consequences. When Helm will install or upgrade, the -// existing Namespace and [deployah.dev/deployah/internal/predict.Predict] -// bridge still produces those consequences. Chart CRD files are forwarded -// to Helm only. It returns a -// [deployah.dev/deployah/internal/plan/semantic.Plan]. Semantic types +// [BuildSemanticPlan] renders with +// [SemanticBuildClient.RenderManifestsWithPrep], then picks install, +// upgrade, or none from the Helm operation and a comparison of the +// previous release (prep.Current) with the rendered manifest and hooks. +// Live state and predictor output do not make that choice. If Helm will +// not run, Namespace and Helm write prediction are skipped, so the plan +// has no Helm writes. If Helm will install or upgrade, Namespace +// prediction and [deployah.dev/deployah/internal/predict.Predict] still +// produce those writes. Chart CRDs are passed through to Helm. The result +// is a [deployah.dev/deployah/internal/plan/semantic.Plan]. Semantic types // live in plan/semantic. New rendering lives in plan/view. // -// Rendering the chart itself lives on [deployah.dev/deployah/internal/helm.Client] -// instead, since `deployah plan` and `deployah deploy` share that one -// rendering engine. +// Chart rendering is on [deployah.dev/deployah/internal/helm.Client]. +// `deployah plan` and `deployah deploy` share that engine. package plan