Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 22 additions & 19 deletions internal/plan/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,30 @@

// 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,
// 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] 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
// [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
44 changes: 23 additions & 21 deletions internal/plan/helm_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
79 changes: 57 additions & 22 deletions internal/plan/helm_action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{{
Expand Down
34 changes: 0 additions & 34 deletions internal/plan/prediction.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions internal/plan/prediction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
Loading
Loading