diff --git a/internal/cmd/deploy/deploy.go b/internal/cmd/deploy/deploy.go index 7f26a49..e0e4a98 100644 --- a/internal/cmd/deploy/deploy.go +++ b/internal/cmd/deploy/deploy.go @@ -16,6 +16,7 @@ import ( "deployah.dev/deployah/internal/cmd/cmdopts" "deployah.dev/deployah/internal/extras" "deployah.dev/deployah/internal/k8s" + "deployah.dev/deployah/internal/plan/semantic" "deployah.dev/deployah/internal/readiness" "deployah.dev/deployah/internal/render" "deployah.dev/deployah/internal/session" @@ -70,12 +71,15 @@ deployah plan prod --offline`), } // deployPlan bundles the shown diff with the render that produced it, so -// callers reuse one render instead of recomputing. cleanup releases the -// chart temp dir behind result.ChartPath; runDeploy defers it. +// callers reuse one render instead of recomputing. helmAction is the +// semantic install, upgrade, or none decision for that same render. +// cleanup releases the chart temp dir behind result.ChartPath; runDeploy +// defers it. type deployPlan struct { - diff *planengine.Plan - result *render.RenderResult - cleanup func() + diff *planengine.Plan + result *render.RenderResult + helmAction semantic.HelmAction + cleanup func() } func runDeploy(c *nabat.Context) error { @@ -285,7 +289,12 @@ func computePlan(c *nabat.Context, helmClient session.HelmClient, cluster *sessi cleanup() return nil, fmt.Errorf("%w%s", err, cmdopts.ClusterHint(err)) } - return &deployPlan{diff: p, result: result, cleanup: cleanup}, nil + helmAction, err := planengine.HelmActionFromRender(result) + if err != nil { + cleanup() + return nil, fmt.Errorf("determine helm release intent: %w", err) + } + return &deployPlan{diff: p, result: result, helmAction: helmAction, cleanup: cleanup}, nil } // skipDeploy handles an unchanged upgrade: Helm is never invoked, but pod diff --git a/internal/cmd/deploy/deploy_flow_test.go b/internal/cmd/deploy/deploy_flow_test.go index ac7c0ea..0568653 100644 --- a/internal/cmd/deploy/deploy_flow_test.go +++ b/internal/cmd/deploy/deploy_flow_test.go @@ -21,12 +21,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "helm.sh/helm/v4/pkg/release/common" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/fake" "nabat.dev/nabat" "nabat.dev/nabat/nabattest" "deployah.dev/deployah/internal/extras" + "deployah.dev/deployah/internal/plan/semantic" "deployah.dev/deployah/internal/render" "deployah.dev/deployah/internal/session" "deployah.dev/deployah/internal/spec" @@ -34,6 +36,7 @@ import ( "deployah.dev/deployah/internal/testing/nabatctx" planengine "deployah.dev/deployah/internal/plan" + v1 "helm.sh/helm/v4/pkg/release/v1" corev1 "k8s.io/api/core/v1" ) @@ -385,3 +388,115 @@ func TestDeployFlags_SkipCRDsAcceptedCRDsRemoved(t *testing.T) { var opts Options assert.False(t, opts.SkipCRDs) } + +func TestComputePlan_HelmAction(t *testing.T) { + t.Parallel() + same := deployFlowManifestV1 + tests := []struct { + name string + release *v1.Release + result *render.RenderResult + want semantic.HelmAction + }{ + { + name: "fresh install", + result: testRenderResult(same), + want: semantic.HelmInstall, + }, + { + name: "unchanged upgrade", + release: deployedRelease(same), + result: upgradeRenderResult(same, same), + want: semantic.HelmNone, + }, + { + name: "changed upgrade", + release: deployedRelease(deployFlowManifestV1), + result: upgradeRenderResult(deployFlowManifestV1, deployFlowManifestV2), + want: semantic.HelmUpgrade, + }, + { + name: "previous wins over last successful release", + release: deployedRelease(deployFlowManifestV1), + result: upgradeRenderResult(deployFlowManifestV2, deployFlowManifestV2), + want: semantic.HelmNone, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stub := &stubHelmClient{release: tt.release, renderResults: []*render.RenderResult{tt.result}} + _, cluster := newClusterWithStub(t, stub, nil) + h := nabatctx.New(t, "test") + plan, err := computePlan(h.Context, stub, cluster, deployResolved(), nil, nil) + require.NoError(t, err) + t.Cleanup(plan.cleanup) + assert.Equal(t, tt.want, plan.helmAction) + assert.Same(t, tt.result, plan.result) + }) + } +} + +// TestComputePlan_LegacyDiffIgnoresArgsReorder documents the transitional +// divergence: dyff ignores a pure argument reorder, while the semantic +// action is an upgrade. The execution gate still uses the legacy diff. +func TestComputePlan_LegacyDiffIgnoresArgsReorder(t *testing.T) { + t.Parallel() + previous := deploymentArgs("--foo", "--bar") + desired := deploymentArgs("--bar", "--foo") + stub := &stubHelmClient{ + release: deployedRelease(previous), + renderResults: []*render.RenderResult{upgradeRenderResult(previous, desired)}, + } + _, cluster := newClusterWithStub(t, stub, nil) + h := nabatctx.New(t, "test") + plan, err := computePlan(h.Context, stub, cluster, deployResolved(), nil, nil) + require.NoError(t, err) + t.Cleanup(plan.cleanup) + assert.False(t, plan.diff.HasChanges()) + assert.Equal(t, semantic.HelmUpgrade, plan.helmAction) +} + +func TestComputePlan_UpgradeWithoutPrevious(t *testing.T) { + t.Parallel() + result := testRenderResult(deployFlowManifestV1) + result.IsUpgrade = true + stub := &stubHelmClient{ + release: deployedRelease(deployFlowManifestV1), + renderResults: []*render.RenderResult{result}, + } + _, cluster := newClusterWithStub(t, stub, nil) + h := nabatctx.New(t, "test") + plan, err := computePlan(h.Context, stub, cluster, deployResolved(), nil, nil) + require.Error(t, err) + assert.Nil(t, plan) + assert.ErrorContains(t, err, "determine helm release intent") + assert.Equal(t, 1, stub.cleanupCalls) +} + +func deployResolved() *spec.ResolvedSpec { + return &spec.ResolvedSpec{ + Spec: &spec.Spec{Project: "web"}, + Env: spec.EnvIdentity{Original: "production"}, + } +} + +func deployedRelease(manifest string) *v1.Release { + return &v1.Release{ + Version: 2, + Manifest: manifest, + Info: &v1.Info{Status: common.StatusDeployed}, + } +} + +func upgradeRenderResult(previous, desired string) *render.RenderResult { + result := testRenderResult(desired) + result.IsUpgrade = true + result.Revision = 3 + result.Previous = &render.ReleaseIntent{Manifest: previous} + return result +} + +func deploymentArgs(first, second string) string { + return "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: web\n namespace: default\nspec:\n template:\n spec:\n containers:\n - name: api\n args:\n - " + first + "\n - " + second + "\n" +} diff --git a/internal/cmd/deploy/deploy_test.go b/internal/cmd/deploy/deploy_test.go index fe92913..92d5405 100644 --- a/internal/cmd/deploy/deploy_test.go +++ b/internal/cmd/deploy/deploy_test.go @@ -37,6 +37,7 @@ type stubHelmClient struct { renderResults []*render.RenderResult renderErr error renderCallCount int + cleanupCalls int installErr error installCallCount int @@ -55,14 +56,14 @@ func (s *stubHelmClient) InstallApp(_ context.Context, _ bool, _ *spec.ResolvedS func (s *stubHelmClient) RenderManifests(context.Context, *spec.ResolvedSpec, postrenderer.PostRenderer, []extras.RawFile) (*render.RenderResult, func(), error) { if s.renderErr != nil { - return nil, func() {}, s.renderErr + return nil, func() { s.cleanupCalls++ }, s.renderErr } i := s.renderCallCount s.renderCallCount++ if i >= len(s.renderResults) { panic(fmt.Sprintf("unexpected RenderManifests call #%d: only %d result(s) configured", i+1, len(s.renderResults))) } - return s.renderResults[i], func() {}, nil + return s.renderResults[i], func() { s.cleanupCalls++ }, nil } func (s *stubHelmClient) RenderOffline(context.Context, *spec.ResolvedSpec, postrenderer.PostRenderer, []extras.RawFile) (*render.RenderResult, func(), error) { diff --git a/internal/helm/identity_test.go b/internal/helm/identity_test.go index 6a34159..33cf96d 100644 --- a/internal/helm/identity_test.go +++ b/internal/helm/identity_test.go @@ -127,6 +127,8 @@ func TestRenderOffline_ChartAndReleaseAgree(t *testing.T) { assert.Equal(t, wantName, result.ReleaseName) assert.Equal(t, GenerateReleaseName(resolved.Spec.Project, resolved.Env.Original), result.ReleaseName) + assert.Nil(t, result.Previous) + assert.False(t, result.IsUpgrade) dep := findRenderedDeployment(t, result.Manifest, "-api") assert.Equal(t, resolved.Spec.Project, dep.Labels[spec.LabelProject]) diff --git a/internal/helm/preflight_test.go b/internal/helm/preflight_test.go index 2b720d6..4411e07 100644 --- a/internal/helm/preflight_test.go +++ b/internal/helm/preflight_test.go @@ -27,6 +27,7 @@ import ( "helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage/driver" + "deployah.dev/deployah/internal/render" "deployah.dev/deployah/internal/spec" chartcommon "helm.sh/helm/v4/pkg/chart/common" @@ -222,13 +223,36 @@ func TestRenderManifestsWithPrep_FreshInstall(t *testing.T) { assert.Equal(t, 1, prep.NextRevision) assert.False(t, result.IsUpgrade) assert.Equal(t, 1, result.Revision) + assert.Nil(t, result.Previous) } func TestRenderManifestsWithPrep_Upgrade(t *testing.T) { t.Parallel() c, cfg, resolved, releaseName := memoryHelmApp(t, "upgrade-prep") - seedRelease(t, cfg, releaseName, 2, common.StatusDeployed, applySSA) + ran := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + manifest := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n" + hooks := []*v1.Hook{ + { + Name: "migrate", + Kind: "Job", + Path: "templates/migrate.yaml", + Manifest: "kind: Job\nmetadata:\n name: migrate\n", + Events: []v1.HookEvent{v1.HookPreUpgrade}, + Weight: 1, + DeletePolicies: []v1.HookDeletePolicy{v1.HookBeforeHookCreation}, + OutputLogPolicies: []v1.HookOutputLogPolicy{v1.HookOutputOnSucceeded}, + }, + { + Name: "smoke", + Kind: "Pod", + Manifest: "kind: Pod\nmetadata:\n name: smoke\n", + Events: []v1.HookEvent{v1.HookPostUpgrade}, + Weight: 2, + LastRun: v1.HookExecution{StartedAt: ran, CompletedAt: ran, Phase: v1.HookPhaseSucceeded}, + }, + } + seedReleaseIntent(t, cfg, releaseName, 2, common.StatusDeployed, applySSA, manifest, hooks) result, prep, cleanup, err := c.RenderManifestsWithPrep(t.Context(), resolved, nil, nil) if cleanup != nil { @@ -242,14 +266,39 @@ func TestRenderManifestsWithPrep_Upgrade(t *testing.T) { assert.Equal(t, 3, prep.NextRevision) assert.True(t, result.IsUpgrade) assert.Equal(t, 3, result.Revision) + require.NotNil(t, result.Previous) + assert.Equal(t, prep.Current.Manifest, result.Previous.Manifest) + assert.Equal(t, []*render.HookIntent{ + { + Name: "migrate", + Kind: "Job", + Path: "templates/migrate.yaml", + Manifest: "kind: Job\nmetadata:\n name: migrate\n", + Events: []v1.HookEvent{v1.HookPreUpgrade}, + Weight: 1, + DeletePolicies: []v1.HookDeletePolicy{v1.HookBeforeHookCreation}, + OutputLogPolicies: []v1.HookOutputLogPolicy{v1.HookOutputOnSucceeded}, + }, + { + Name: "smoke", + Kind: "Pod", + Manifest: "kind: Pod\nmetadata:\n name: smoke\n", + Events: []v1.HookEvent{v1.HookPostUpgrade}, + Weight: 2, + }, + }, result.Previous.Hooks) } func TestRenderManifestsWithPrep_NewestDiffersFromCurrent(t *testing.T) { t.Parallel() c, cfg, resolved, releaseName := memoryHelmApp(t, "newest-current") - seedRelease(t, cfg, releaseName, 3, common.StatusDeployed, applySSA) - seedRelease(t, cfg, releaseName, 4, common.StatusFailed, applySSA) + currentManifest := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: current\n" + newestManifest := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: newest\n" + currentHook := &v1.Hook{Name: "current", Manifest: "kind: Job\nmetadata:\n name: current\n"} + newestHook := &v1.Hook{Name: "newest", Manifest: "kind: Job\nmetadata:\n name: newest\n"} + seedReleaseIntent(t, cfg, releaseName, 3, common.StatusDeployed, applySSA, currentManifest, []*v1.Hook{currentHook}) + seedReleaseIntent(t, cfg, releaseName, 4, common.StatusFailed, applySSA, newestManifest, []*v1.Hook{newestHook}) result, prep, cleanup, err := c.RenderManifestsWithPrep(t.Context(), resolved, nil, nil) if cleanup != nil { @@ -265,13 +314,42 @@ func TestRenderManifestsWithPrep_NewestDiffersFromCurrent(t *testing.T) { assert.Equal(t, 5, prep.NextRevision) assert.True(t, result.IsUpgrade) assert.Equal(t, 5, result.Revision) + require.NotNil(t, result.Previous) + assert.Equal(t, currentManifest, result.Previous.Manifest) + assert.Equal(t, []*render.HookIntent{{Name: "current", Manifest: currentHook.Manifest}}, result.Previous.Hooks) + assert.NotEqual(t, newestManifest, result.Previous.Manifest) +} + +func TestRenderManifestsWithPrep_FailedOnlyHistory(t *testing.T) { + t.Parallel() + + c, cfg, resolved, releaseName := memoryHelmApp(t, "failed-only") + manifest := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: failed\n" + hook := &v1.Hook{Name: "failed", Kind: "Job", Manifest: "kind: Job\nmetadata:\n name: failed\n"} + seedReleaseIntent(t, cfg, releaseName, 1, common.StatusFailed, applySSA, manifest, []*v1.Hook{hook}) + + result, prep, cleanup, err := c.RenderManifestsWithPrep(t.Context(), resolved, nil, nil) + if cleanup != nil { + t.Cleanup(cleanup) + } + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, prep.Current) + assert.Equal(t, OperationUpgrade, prep.Operation) + assert.Equal(t, 1, prep.Current.Version) + assert.Equal(t, common.StatusFailed, prep.Current.Info.Status) + require.NotNil(t, result.Previous) + assert.Equal(t, manifest, result.Previous.Manifest) + assert.Equal(t, []*render.HookIntent{{Name: "failed", Kind: "Job", Manifest: hook.Manifest}}, result.Previous.Hooks) } func TestRenderManifests_MatchesWithPrepResult(t *testing.T) { t.Parallel() c, cfg, resolved, releaseName := memoryHelmApp(t, "compat-render") - seedRelease(t, cfg, releaseName, 1, common.StatusDeployed, applySSA) + manifest := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: compat\n" + hook := &v1.Hook{Name: "compat", Manifest: "kind: Job\nmetadata:\n name: compat\n"} + seedReleaseIntent(t, cfg, releaseName, 1, common.StatusDeployed, applySSA, manifest, []*v1.Hook{hook}) withPrep, prep, cleanup, err := c.RenderManifestsWithPrep(t.Context(), resolved, nil, nil) if cleanup != nil { @@ -291,6 +369,8 @@ func TestRenderManifests_MatchesWithPrepResult(t *testing.T) { assert.Equal(t, withPrep.Revision, wrapped.Revision) assert.Equal(t, withPrep.ReleaseName, wrapped.ReleaseName) assert.Equal(t, withPrep.Namespace, wrapped.Namespace) + require.NotNil(t, withPrep.Previous) + assert.Equal(t, withPrep.Previous, wrapped.Previous) } func memoryHelmApp(t *testing.T, project string) (*Client, *action.Configuration, *spec.ResolvedSpec, string) { @@ -321,6 +401,11 @@ func memoryHelmApp(t *testing.T, project string) (*Client, *action.Configuration func seedRelease(t *testing.T, cfg *action.Configuration, name string, version int, status common.Status, applyMethod string) { t.Helper() + seedReleaseIntent(t, cfg, name, version, status, applyMethod, "", nil) +} + +func seedReleaseIntent(t *testing.T, cfg *action.Configuration, name string, version int, status common.Status, applyMethod, manifest string, hooks []*v1.Hook) { + t.Helper() now := time.Now() require.NoError(t, cfg.Releases.Create(&v1.Release{ @@ -343,6 +428,8 @@ func seedRelease(t *testing.T, cfg *action.Configuration, name string, version i }, Version: version, Namespace: "default", + Manifest: manifest, + Hooks: hooks, ApplyMethod: applyMethod, })) } diff --git a/internal/helm/render.go b/internal/helm/render.go index 318b2a0..eaa5c94 100644 --- a/internal/helm/render.go +++ b/internal/helm/render.go @@ -39,10 +39,11 @@ func (c *Client) RenderManifests(ctx context.Context, resolved *spec.ResolvedSpe return result, cleanup, err } -// RenderManifestsWithPrep renders the chart from [spec.ResolvedSpec] -// client-side and returns the [ReleasePrep] used to choose install or -// upgrade. crds are written into the per-invocation chart copy. Cleanup is -// nil on error; on success the caller must run it once. +// RenderManifestsWithPrep renders [spec.ResolvedSpec] and returns the +// [ReleasePrep] for that install or upgrade. result.Previous is +// prep.Current's manifest and hooks when a current release exists. +// crds are copied into the chart. Cleanup is nil on error; the caller +// runs it once on success. func (c *Client) RenderManifestsWithPrep(ctx context.Context, resolved *spec.ResolvedSpec, postRenderer postrenderer.PostRenderer, crds []extras.RawFile) (*render.RenderResult, ReleasePrep, func(), error) { releaseName, labels, err := releaseIdentity(resolved) if err != nil { @@ -75,6 +76,12 @@ func (c *Client) RenderManifestsWithPrep(ctx context.Context, resolved *spec.Res return nil, ReleasePrep{}, nil, err } result.ChartPath = chartPath + if prep.Current != nil { + result.Previous = &render.ReleaseIntent{ + Manifest: prep.Current.Manifest, + Hooks: render.HookIntents(prep.Current.Hooks), + } + } return result, prep, cleanup, nil } diff --git a/internal/plan/helm_action.go b/internal/plan/helm_action.go index 1588fec..d9a7f8b 100644 --- a/internal/plan/helm_action.go +++ b/internal/plan/helm_action.go @@ -20,6 +20,7 @@ import ( "deployah.dev/deployah/internal/helm" "deployah.dev/deployah/internal/plan/semantic" + "deployah.dev/deployah/internal/render" v1 "helm.sh/helm/v4/pkg/release/v1" ) @@ -47,3 +48,37 @@ func deriveHelmAction(prep helm.ReleasePrep, desiredManifest string, desiredHook return 0, fmt.Errorf("invalid helm operation %d", prep.Operation) } } + +// HelmActionFromRender picks install, upgrade, or none for result using +// the same release-intent comparison as [BuildSemanticPlan]. It returns +// an error when result is nil or an upgrade render has no Previous. +func HelmActionFromRender(result *render.RenderResult) (semantic.HelmAction, error) { + if result == nil { + return 0, errors.New("render result is required") + } + prep := helm.ReleasePrep{Operation: helm.OperationInstall} + if result.IsUpgrade { + if result.Previous == nil { + return 0, errors.New("upgrade render requires a previous release intent") + } + prep.Operation = helm.OperationUpgrade + prep.Current = &v1.Release{ + Manifest: result.Previous.Manifest, + Hooks: releaseHooks(result.Previous.Hooks), + } + } + return deriveHelmAction(prep, result.Manifest, result.Hooks) +} + +// releaseHooks rebuilds temporary Helm hooks from declarative intents. +// Nil entries stay nil. +func releaseHooks(hooks []*render.HookIntent) []*v1.Hook { + if hooks == nil { + return nil + } + out := make([]*v1.Hook, 0, len(hooks)) + for _, h := range hooks { + out = append(out, h.Hook()) + } + return out +} diff --git a/internal/plan/helm_action_test.go b/internal/plan/helm_action_test.go index 220795f..d825216 100644 --- a/internal/plan/helm_action_test.go +++ b/internal/plan/helm_action_test.go @@ -15,6 +15,7 @@ package plan import ( + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -22,7 +23,9 @@ import ( "deployah.dev/deployah/internal/helm" "deployah.dev/deployah/internal/plan/semantic" + "deployah.dev/deployah/internal/render" "deployah.dev/deployah/internal/spec" + "deployah.dev/deployah/internal/testing/testpath" v1 "helm.sh/helm/v4/pkg/release/v1" ) @@ -95,6 +98,83 @@ func TestDeriveHelmAction_Errors(t *testing.T) { } } +func TestHelmActionFromRender(t *testing.T) { + t.Parallel() + same := configMapManifest("app", "same") + changed := configMapManifest("app", "new") + hookA := testHook("Job", "migrate", "migrate", "busybox", 1) + hookB := testHook("Job", "migrate", "migrate", "alpine", 1) + argsDir := filepath.Join(releaseIntentFixtures, "args-order") + hookDir := filepath.Join(releaseIntentFixtures, "hook-formatting") + argsPrev := testpath.ReadFile(t, argsDir, "previous.yaml") + argsNext := testpath.ReadFile(t, argsDir, "desired.yaml") + fmtPrev := testpath.ReadFile(t, hookDir, "previous.yaml") + fmtNext := testpath.ReadFile(t, hookDir, "desired.yaml") + fmtPrevHooks := readHookFixtures(t, filepath.Join(hookDir, "previous.hooks")) + fmtNextHooks := readHookFixtures(t, filepath.Join(hookDir, "desired.hooks")) + + tests := []struct { + name string + result *render.RenderResult + want semantic.HelmAction + }{ + {name: "install", result: &render.RenderResult{Manifest: changed}, want: semantic.HelmInstall}, + {name: "unchanged", result: upgradeRender(same, []*v1.Hook{hookA}, same, []*v1.Hook{hookA}), want: semantic.HelmNone}, + {name: "manifest change", result: upgradeRender(same, []*v1.Hook{hookA}, changed, []*v1.Hook{hookA}), want: semantic.HelmUpgrade}, + {name: "hook change", result: upgradeRender(same, []*v1.Hook{hookA}, same, []*v1.Hook{hookB}), want: semantic.HelmUpgrade}, + {name: "args reorder", result: upgradeRender(argsPrev, nil, argsNext, nil), want: semantic.HelmUpgrade}, + {name: "hook formatting", result: upgradeRender(fmtPrev, fmtPrevHooks, fmtNext, fmtNextHooks), want: semantic.HelmNone}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := HelmActionFromRender(tt.result) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestHelmActionFromRender_Errors(t *testing.T) { + t.Parallel() + tests := []struct { + name string + result *render.RenderResult + want string + }{ + {name: "nil result", want: "render result is required"}, + {name: "upgrade without previous", result: &render.RenderResult{IsUpgrade: true}, want: "upgrade render requires a previous release intent"}, + { + name: "nil previous hook", + result: &render.RenderResult{ + IsUpgrade: true, + Previous: &render.ReleaseIntent{Hooks: []*render.HookIntent{nil}}, + }, + want: "previous hook 1 is nil", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := HelmActionFromRender(tt.result) + require.Error(t, err) + assert.ErrorContains(t, err, tt.want) + }) + } +} + +func upgradeRender(previous string, previousHooks []*v1.Hook, desired string, desiredHooks []*v1.Hook) *render.RenderResult { + return &render.RenderResult{ + IsUpgrade: true, + Manifest: desired, + Hooks: desiredHooks, + Previous: &render.ReleaseIntent{ + Manifest: previous, + Hooks: render.HookIntents(previousHooks), + }, + } +} + func configMapManifest(name, data string) string { return "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: " + name + "\n namespace: prod\ndata:\n key: " + data + "\n" } diff --git a/internal/render/result.go b/internal/render/result.go index f145b53..0a2137f 100644 --- a/internal/render/result.go +++ b/internal/render/result.go @@ -20,6 +20,8 @@ package render import ( + "slices" + v1 "helm.sh/helm/v4/pkg/release/v1" ) @@ -39,6 +41,11 @@ type RenderResult struct { Manifest string // Hooks are the Helm hooks declared by the chart for this release. Hooks []*v1.Hook + // Previous is the declarative Helm release this render upgrades from. + // It is nil on a fresh install. On an upgrade it is Helm's current + // upgrade baseline, not the newest revision and not the last + // successful release. + Previous *ReleaseIntent // IsUpgrade is true when an existing release was found and the render // used the upgrade action, so Revision is that release's version plus // one. It is false for a fresh install, where Revision is always 1. @@ -46,3 +53,79 @@ type RenderResult struct { // Revision is the release revision this render corresponds to. Revision int } + +// ReleaseIntent is the declarative part of a Helm release used to compare +// a previous release with the desired render. It has no runtime state such +// as status, revision, or hook execution. +type ReleaseIntent struct { + // Manifest is the rendered, "---"-concatenated Kubernetes YAML. + Manifest string + // Hooks are the chart hooks for this release, in release order. A nil + // entry is kept so comparison can fail closed. + Hooks []*HookIntent +} + +// HookIntent is the declarative part of one Helm hook. It omits execution +// state such as LastRun. +type HookIntent struct { + // Name is the hook name. + Name string + // Kind is the Kubernetes kind of the hook resource. + Kind string + // Path is the chart-relative path of the hook template. + Path string + // Manifest is the rendered hook YAML. + Manifest string + // Events are the Helm hook events, in release order. + Events []v1.HookEvent + // Weight is the hook sort weight. + Weight int + // DeletePolicies say when Helm deletes the hook resource. + DeletePolicies []v1.HookDeletePolicy + // OutputLogPolicies say when hook logs are copied to the main process. + OutputLogPolicies []v1.HookOutputLogPolicy +} + +// HookIntents copies the declarative fields of hooks, in order. A nil hook +// stays nil. Slice fields are cloned. LastRun is left behind. +func HookIntents(hooks []*v1.Hook) []*HookIntent { + if hooks == nil { + return nil + } + out := make([]*HookIntent, 0, len(hooks)) + for _, h := range hooks { + if h == nil { + out = append(out, nil) + continue + } + out = append(out, &HookIntent{ + Name: h.Name, + Kind: h.Kind, + Path: h.Path, + Manifest: h.Manifest, + Events: slices.Clone(h.Events), + Weight: h.Weight, + DeletePolicies: slices.Clone(h.DeletePolicies), + OutputLogPolicies: slices.Clone(h.OutputLogPolicies), + }) + } + return out +} + +// Hook returns a Helm hook with this intent's declarative fields. Slice +// fields are cloned. A nil intent returns nil. +func (h *HookIntent) Hook() *v1.Hook { + if h == nil { + return nil + } + return &v1.Hook{ + Name: h.Name, + Kind: h.Kind, + Path: h.Path, + Manifest: h.Manifest, + Events: slices.Clone(h.Events), + Weight: h.Weight, + DeletePolicies: slices.Clone(h.DeletePolicies), + OutputLogPolicies: slices.Clone(h.OutputLogPolicies), + } +} diff --git a/internal/render/result_test.go b/internal/render/result_test.go new file mode 100644 index 0000000..293c676 --- /dev/null +++ b/internal/render/result_test.go @@ -0,0 +1,92 @@ +// 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 render_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "deployah.dev/deployah/internal/render" + + v1 "helm.sh/helm/v4/pkg/release/v1" +) + +func TestHookIntents_NilEntryAndSliceCopy(t *testing.T) { + t.Parallel() + + events := []v1.HookEvent{v1.HookPreUpgrade} + policies := []v1.HookDeletePolicy{v1.HookBeforeHookCreation} + logs := []v1.HookOutputLogPolicy{v1.HookOutputOnSucceeded} + hooks := []*v1.Hook{ + nil, + { + Name: "migrate", + Events: events, + DeletePolicies: policies, + OutputLogPolicies: logs, + }, + } + + got := render.HookIntents(hooks) + require.Len(t, got, 2) + assert.Nil(t, got[0]) + require.NotNil(t, got[1]) + + events[0] = v1.HookPostUpgrade + policies[0] = v1.HookFailed + logs[0] = v1.HookOutputOnFailed + assert.Equal(t, []v1.HookEvent{v1.HookPreUpgrade}, got[1].Events) + assert.Equal(t, []v1.HookDeletePolicy{v1.HookBeforeHookCreation}, got[1].DeletePolicies) + assert.Equal(t, []v1.HookOutputLogPolicy{v1.HookOutputOnSucceeded}, got[1].OutputLogPolicies) + assert.Nil(t, render.HookIntents(nil)) + assert.Nil(t, (*render.HookIntent)(nil).Hook()) + + hook := got[1].Hook() + require.NotNil(t, hook) + got[1].Events[0] = v1.HookPostDelete + assert.Equal(t, []v1.HookEvent{v1.HookPreUpgrade}, hook.Events) +} + +func TestHookIntent_RoundTrip(t *testing.T) { + t.Parallel() + + ran := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + src := &v1.Hook{ + Name: "migrate", + Kind: "Job", + Path: "templates/migrate.yaml", + Manifest: "kind: Job\nmetadata:\n name: migrate\n", + Events: []v1.HookEvent{v1.HookPreUpgrade, v1.HookPostUpgrade}, + LastRun: v1.HookExecution{ + StartedAt: ran, + CompletedAt: ran, + Phase: v1.HookPhaseSucceeded, + }, + Weight: 3, + DeletePolicies: []v1.HookDeletePolicy{v1.HookBeforeHookCreation, v1.HookSucceeded}, + OutputLogPolicies: []v1.HookOutputLogPolicy{v1.HookOutputOnSucceeded, v1.HookOutputOnFailed}, + } + + got := render.HookIntents([]*v1.Hook{src}) + require.Len(t, got, 1) + require.NotNil(t, got[0]) + + want := *src + want.LastRun = v1.HookExecution{} + assert.Equal(t, &want, got[0].Hook()) +}