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
20 changes: 7 additions & 13 deletions internal/plan/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.

// Package plan computes and renders read-only deployment plans.
// Planning looks at release changes. It does not decide whether
// `deployah deploy` runs Helm.
// Package plan builds a read-only deployment plan from a rendered
// release and, on upgrade, from live cluster objects.
//
// [BuildSemanticPlan] renders with
// [SemanticBuildClient.RenderManifestsWithPrep]. It picks install,
// upgrade, or none from the Helm operation and from comparing the
// previous release with the render and its hooks. Resource changes
// are Previous to Desired. [RESTMapper] supplies scope only. An
// existing release reads Live through [LiveReader] with GET and LIST,
// so Drift is Previous to Live. Chart CRD lifecycle comes from the
// loaded documents, not from object diffs. The result is a
// [deployah.dev/deployah/internal/plan/semantic.Plan]. Those types
// live in plan/semantic. Their rendering lives in plan/view.
// [BuildSemanticPlan] returns a
// [deployah.dev/deployah/internal/plan/semantic.Plan]. Task assembly
// records what changed. [deployah.dev/deployah/internal/plan/semantic.New]
// decides whether each task will run. Human text and JSON live in
// plan/view.
//
// [LastSuccessfulRelease] remains for deploy hostname and workload
// guards. [BuildSemanticPlan] does not use it.
Expand Down
30 changes: 1 addition & 29 deletions internal/plan/helm_action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,33 +99,7 @@ func configMapManifest(name, data string) string {
return "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: " + name + "\n namespace: prod\ndata:\n key: " + data + "\n"
}

func TestStampTaskWillRun_UnchangedHook(t *testing.T) {
t.Parallel()
unchanged := []semantic.TaskPlan{{
Name: "seed",
Phase: semantic.TaskPreDeploy,
Action: semantic.TaskUnchanged,
}}

tests := []struct {
name string
action semantic.HelmAction
wantRun bool
}{
{name: "none leaves idle", action: semantic.HelmNone},
{name: "upgrade will run", action: semantic.HelmUpgrade, wantRun: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tasks := append([]semantic.TaskPlan(nil), unchanged...)
stampTaskWillRun(tasks, tt.action)
assert.Equal(t, tt.wantRun, tasks[0].WillRun)
})
}
}

func TestAssembleTasks_DoesNotStampWillRun(t *testing.T) {
func TestAssembleTasks_LeavesWillRunFalse(t *testing.T) {
t.Parallel()
resolved := resolvedWithTasks(map[string]spec.ResolvedTask{
"migrate": hookResolved(spec.TaskOnPreDeploy, 1),
Expand All @@ -136,6 +110,4 @@ func TestAssembleTasks_DoesNotStampWillRun(t *testing.T) {
require.NoError(t, err)
require.Len(t, tasks, 1)
assert.False(t, tasks[0].WillRun)
stampTaskWillRun(tasks, semantic.HelmInstall)
assert.True(t, tasks[0].WillRun)
}
99 changes: 46 additions & 53 deletions internal/plan/semantic/chart_crd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
package semantic_test

import (
"reflect"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -24,11 +23,8 @@ import (
"deployah.dev/deployah/internal/plan/semantic"
)

func TestAttachChartCRDs_Lifecycle(t *testing.T) {
func TestNew_ChartCRDLifecycle(t *testing.T) {
t.Parallel()
base, err := semantic.New(semantic.Header{FreshInstall: true}, semantic.HelmInstall, nil, nil)
require.NoError(t, err)
changesBefore := len(base.Changes)
tests := []struct {
name string
lifecycle semantic.ChartCRDLifecycle
Expand All @@ -41,32 +37,36 @@ func TestAttachChartCRDs_Lifecycle(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
p, attachErr := semantic.AttachChartCRDs(base, []semantic.ChartCRD{{
Source: ".deployah/crds/widget.yaml",
Kind: "CustomResourceDefinition",
Name: "widgets.example.com",
Lifecycle: tc.lifecycle,
WillProcess: tc.willProcess,
}})
require.NoError(t, attachErr)
p, err := semantic.New(semantic.Input{
Header: semantic.Header{FreshInstall: true},
HelmAction: semantic.HelmInstall,
ChartCRDs: []semantic.ChartCRD{{
Source: ".deployah/crds/widget.yaml",
Kind: "CustomResourceDefinition",
Name: "widgets.example.com",
Lifecycle: tc.lifecycle,
WillProcess: tc.willProcess,
}},
})
require.NoError(t, err)
require.Len(t, p.ChartCRDs, 1)
assert.Equal(t, "CustomResourceDefinition", p.ChartCRDs[0].Kind)
assert.Equal(t, "widgets.example.com", p.ChartCRDs[0].Name)
assert.Equal(t, tc.lifecycle, p.ChartCRDs[0].Lifecycle)
assert.Equal(t, tc.willProcess, p.ChartCRDs[0].WillProcess)
assert.Equal(t, changesBefore, len(p.Changes))
assert.Empty(t, p.Changes)
})
}
}

func TestAttachChartCRDs_PreservesOrder(t *testing.T) {
func TestNew_ChartCRDPreservesOrder(t *testing.T) {
t.Parallel()
base, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, nil)
require.NoError(t, err)
p, err := semantic.AttachChartCRDs(base, []semantic.ChartCRD{
{Kind: "CustomResourceDefinition", Name: "one.example.com", Lifecycle: semantic.ChartCRDUpgrade},
{Kind: "CustomResourceDefinition", Name: "two.example.com", Index: 1, Lifecycle: semantic.ChartCRDUpgrade},
p, err := semantic.New(semantic.Input{
HelmAction: semantic.HelmUpgrade,
ChartCRDs: []semantic.ChartCRD{
{Kind: "CustomResourceDefinition", Name: "one.example.com", Lifecycle: semantic.ChartCRDUpgrade},
{Kind: "CustomResourceDefinition", Name: "two.example.com", Index: 1, Lifecycle: semantic.ChartCRDUpgrade},
},
})
require.NoError(t, err)
require.Len(t, p.ChartCRDs, 2)
Expand All @@ -76,51 +76,52 @@ func TestAttachChartCRDs_PreservesOrder(t *testing.T) {
assert.Equal(t, 1, p.ChartCRDs[1].Index)
}

func TestAttachChartCRDs_HasEffectsFollowsWillProcess(t *testing.T) {
func TestNew_ChartCRDHasEffectsFollowsWillProcess(t *testing.T) {
t.Parallel()
fresh, err := semantic.New(semantic.Header{FreshInstall: true}, semantic.HelmInstall, nil, nil)
require.NoError(t, err)
idle, err := semantic.New(semantic.Header{}, semantic.HelmNone, nil, nil)
require.NoError(t, err)
require.True(t, idle.IsNoOp())

tests := []struct {
name string
base semantic.Plan
header semantic.Header
action semantic.HelmAction
lifecycle semantic.ChartCRDLifecycle
willProcess bool
wantEffects bool
wantNoOp bool
}{
{
name: "process",
base: fresh,
header: semantic.Header{FreshInstall: true},
action: semantic.HelmInstall,
lifecycle: semantic.ChartCRDProcess,
willProcess: true,
wantEffects: true,
},
{
name: "skip",
base: fresh,
header: semantic.Header{FreshInstall: true},
action: semantic.HelmInstall,
lifecycle: semantic.ChartCRDSkip,
},
{
name: "upgrade",
base: idle,
action: semantic.HelmNone,
lifecycle: semantic.ChartCRDUpgrade,
wantNoOp: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
p, attachErr := semantic.AttachChartCRDs(tc.base, []semantic.ChartCRD{{
Kind: "CustomResourceDefinition",
Name: "widgets.example.com",
Lifecycle: tc.lifecycle,
WillProcess: tc.willProcess,
}})
require.NoError(t, attachErr)
p, err := semantic.New(semantic.Input{
Header: tc.header,
HelmAction: tc.action,
ChartCRDs: []semantic.ChartCRD{{
Kind: "CustomResourceDefinition",
Name: "widgets.example.com",
Lifecycle: tc.lifecycle,
WillProcess: tc.willProcess,
}},
})
require.NoError(t, err)
assert.Equal(t, tc.wantEffects, p.HasEffects())
assert.Equal(t, tc.wantNoOp, p.IsNoOp())
assert.Empty(t, p.Changes)
Expand All @@ -129,10 +130,8 @@ func TestAttachChartCRDs_HasEffectsFollowsWillProcess(t *testing.T) {
}
}

func TestAttachChartCRDs_Validation(t *testing.T) {
func TestNew_ChartCRDValidation(t *testing.T) {
t.Parallel()
base, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, nil, nil)
require.NoError(t, err)
tests := []struct {
name string
crd semantic.ChartCRD
Expand All @@ -148,18 +147,12 @@ func TestAttachChartCRDs_Validation(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, attachErr := semantic.AttachChartCRDs(base, []semantic.ChartCRD{tc.crd})
require.Error(t, attachErr)
assert.ErrorContains(t, attachErr, tc.wantErr)
_, err := semantic.New(semantic.Input{
HelmAction: semantic.HelmUpgrade,
ChartCRDs: []semantic.ChartCRD{tc.crd},
})
require.Error(t, err)
assert.ErrorContains(t, err, tc.wantErr)
})
}
}

func TestChartCRD_HasNoMutationFields(t *testing.T) {
t.Parallel()
rt := reflect.TypeFor[semantic.ChartCRD]()
for _, name := range []string{"APIVersion", "Action", "Before", "After", "Fields", "Namespace", "Scope", "Spec"} {
_, has := rt.FieldByName(name)
assert.False(t, has, name)
}
}
12 changes: 7 additions & 5 deletions internal/plan/semantic/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.

// Package semantic is Deployah's unredacted semantic plan model.
// Package semantic is the unredacted model of one deployment plan.
//
// [New] is the only constructor. It ignores a caller's [TaskPlan.WillRun]
// and derives that flag from the Helm action and the task. This
// package does not render charts and does not read the cluster.
//
// This package is not the deployah plan command and is not a JSON API.
// Snapshots and field values stay complete. Serialization and secret
// redaction belong to deployah.dev/deployah/internal/plan/view.
// [deployah.dev/deployah/internal/plan/view.WriteJSON] is the only
// machine-readable output contract.
//
// Do not treat encoding/json of these types as a supported output path.
// machine-readable output. encoding/json of these types is not a
// supported output path.
package semantic
38 changes: 15 additions & 23 deletions internal/plan/semantic/drift.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func driftActionRank(a DriftAction) int {
}

// DriftChange is one difference between Previous and Live.
// [AttachDrift] checks and stores it. It is cluster state, separate
// [New] checks and stores it. It is cluster state, separate
// from a release change.
//
// Name is required, and GenerateName must be empty. Modified and
Expand All @@ -85,7 +85,7 @@ func driftActionRank(a DriftAction) int {
// Previous is unset for Unexpected and is never rewritten. Live is
// unset for Missing. Modified Live keeps only the declared surface.
// Unexpected Live has server bookkeeping removed. Only Modified has
// Fields. [AttachDrift] keeps them and does not recompute them.
// Fields. [New] keeps them and does not recompute them.
type DriftChange struct {
Resource ResourceRef
Action DriftAction
Expand All @@ -100,33 +100,25 @@ func (p Plan) HasDrift() bool {
return len(p.Drift) > 0
}

// AttachDrift returns a copy of p with drift checked and sorted. A
// fresh install ([Header.FreshInstall]) accepts only an empty
// collection. Other plan fields stay as they are, including whether
// the plan has effects or is a no-op.
//
// Entries sort by group, kind, namespace, and name, then by apiVersion
// and action. Group comes from apiVersion. The same group, kind,
// namespace, and name twice is an error.
func AttachDrift(p Plan, drift []DriftChange) (Plan, error) {
copied := copyDrift(drift)
if p.Header.FreshInstall && len(copied) > 0 {
return Plan{}, fmt.Errorf("fresh install must not include drift")
// validateDriftSet checks drift. A fresh install accepts only an empty
// collection. The same group, kind, namespace, and name twice is an
// error. Group comes from apiVersion.
func validateDriftSet(header Header, drift []DriftChange) error {
if header.FreshInstall && len(drift) > 0 {
return fmt.Errorf("fresh install must not include drift")
}
seen := make(map[string]struct{}, len(copied))
for i := range copied {
if err := validateDrift(copied[i]); err != nil {
return Plan{}, fmt.Errorf("drift %d: %w", i, err)
seen := make(map[string]struct{}, len(drift))
for i := range drift {
if err := validateDrift(drift[i]); err != nil {
return fmt.Errorf("drift %d: %w", i, err)
}
key := driftIdentityKey(copied[i].Resource)
key := driftIdentityKey(drift[i].Resource)
if _, dup := seen[key]; dup {
return Plan{}, fmt.Errorf("drift %s: duplicate logical identity", copied[i].Resource)
return fmt.Errorf("drift %s: duplicate logical identity", drift[i].Resource)
}
seen[key] = struct{}{}
}
sortDrift(copied)
p.Drift = copied
return p, nil
return nil
}

func validateDrift(d DriftChange) error {
Expand Down
Loading
Loading