diff --git a/internal/e2e/e2e_semantic_plan_test.go b/internal/e2e/e2e_semantic_plan_test.go index f9ff8d4..77713f0 100644 --- a/internal/e2e/e2e_semantic_plan_test.go +++ b/internal/e2e/e2e_semantic_plan_test.go @@ -76,7 +76,6 @@ func (s *E2ESuite) TestSemanticPlanPrerequisites() { assert.Equal(t, semantic.OriginHelm, p.Changes[1].Origin.Kind) assert.Equal(t, "ConfigMap", p.Changes[1].Resource.Kind) assert.Equal(t, semantic.Create, p.Changes[1].Action) - assert.Equal(t, semantic.CompletenessPartial, p.Completeness) require.NotEmpty(t, p.Diagnostics) cs, _ := s.kubeClients(t) diff --git a/internal/plan/prediction.go b/internal/plan/prediction.go index 04ca124..a8dd007 100644 --- a/internal/plan/prediction.go +++ b/internal/plan/prediction.go @@ -27,7 +27,7 @@ import ( // semanticPlanFromResults maps [predict.Result] values onto a semantic // [semantic.Plan]. It does not mutate results or the unstructured objects -// they hold. Current predictor actions never produce [semantic.Replace]. +// they hold. // // Write FieldManager values come from [kube.ManagedFieldsManager]. This // helper always emits [semantic.WriteServerSide] writes. [semantic.New] diff --git a/internal/plan/prediction_test.go b/internal/plan/prediction_test.go index 71b430a..c05f5fa 100644 --- a/internal/plan/prediction_test.go +++ b/internal/plan/prediction_test.go @@ -63,12 +63,10 @@ func TestSemanticPlanFromResults_ActionMapping(t *testing.T) { require.Len(t, p.Changes, 3) assert.NotNil(t, p.Tasks) assert.Empty(t, p.Tasks) - assert.Equal(t, semantic.CompletenessComplete, p.Completeness) byName := map[string]semantic.ResourceChange{} for _, c := range p.Changes { byName[c.Resource.Name] = c - assert.NotEqual(t, semantic.Replace, c.Action) } require.Contains(t, byName, "app") assert.Equal(t, semantic.Create, byName["app"].Action) @@ -109,7 +107,6 @@ func TestSemanticPlanFromResults_NoOpOmitted(t *testing.T) { }}) require.NoError(t, err) assert.Empty(t, p.Changes) - assert.Equal(t, semantic.CompletenessComplete, p.Completeness) } func TestSemanticPlanFromResults_LimitationKeepsPredicted(t *testing.T) { @@ -126,7 +123,6 @@ func TestSemanticPlanFromResults_LimitationKeepsPredicted(t *testing.T) { assert.Equal(t, semantic.Update, p.Changes[0].Action) require.NotNil(t, p.Changes[0].After) assert.Equal(t, "new", predictionObjectString(t, p.Changes[0].After.Object, "data", "key")) - assert.Equal(t, semantic.CompletenessPartial, p.Completeness) require.Len(t, p.Diagnostics, 1) assert.Equal(t, semantic.CategoryPredictionLimitation, p.Diagnostics[0].Category) assert.Contains(t, p.Diagnostics[0].Message, predict.LimitationManagedFieldsMigration) @@ -144,7 +140,8 @@ func TestSemanticPlanFromResults_LimitationDoesNotFabricateAfter(t *testing.T) { require.Len(t, p.Changes, 1) assert.Nil(t, p.Changes[0].After) assert.Empty(t, p.Changes[0].Fields) - assert.Equal(t, semantic.CompletenessPartial, p.Completeness) + require.Len(t, p.Diagnostics, 1) + assert.Equal(t, semantic.CategoryPredictionLimitation, p.Diagnostics[0].Category) } func TestSemanticPlanFromResults_UnknownAction(t *testing.T) { @@ -255,7 +252,6 @@ func TestSemanticPlanFromResults_NoOpWithLimitation(t *testing.T) { }}) require.NoError(t, err) assert.Empty(t, p.Changes) - assert.Equal(t, semantic.CompletenessPartial, p.Completeness) require.Len(t, p.Diagnostics, 1) assert.Equal(t, semantic.CategoryPredictionLimitation, p.Diagnostics[0].Category) assert.Contains(t, p.Diagnostics[0].Message, predict.LimitationManagedFieldsMigration) @@ -296,20 +292,6 @@ func TestSemanticPlanFromResults_NilObjectSnapshot(t *testing.T) { assert.Nil(t, p.Changes[0].After.Object) } -func TestSemanticPlanFromResults_NeverEmitsReplace(t *testing.T) { - t.Parallel() - p, err := semanticPlanFromResults(semantic.Header{Release: "web", Namespace: "prod"}, []predict.Result{ - {Identity: predictionIdentity("a"), Action: predict.ActionCreate, Predicted: predictionConfigMap("a", "prod", "1")}, - {Identity: predictionIdentity("b"), Action: predict.ActionUpdate, Live: predictionConfigMap("b", "prod", "1"), Predicted: predictionConfigMap("b", "prod", "2")}, - {Identity: predictionIdentity("c"), Action: predict.ActionDelete, Live: predictionConfigMap("c", "prod", "1")}, - {Identity: predictionIdentity("d"), Action: predict.ActionNoOp, Live: predictionConfigMap("d", "prod", "1"), Predicted: predictionConfigMap("d", "prod", "1")}, - }) - require.NoError(t, err) - for _, c := range p.Changes { - assert.NotEqual(t, semantic.Replace, c.Action) - } -} - func predictionObjectString(tb testing.TB, obj map[string]any, keys ...string) string { tb.Helper() var cur any = obj diff --git a/internal/plan/semantic/change.go b/internal/plan/semantic/change.go index 5a85c0a..f15b6a3 100644 --- a/internal/plan/semantic/change.go +++ b/internal/plan/semantic/change.go @@ -14,7 +14,8 @@ package semantic -// ResourceChange is one Live -> Predicted Kubernetes resource mutation. +// ResourceChange describes a visible Kubernetes resource consequence +// of this invocation. type ResourceChange struct { Resource ResourceRef Origin ResourceOrigin diff --git a/internal/plan/semantic/doc.go b/internal/plan/semantic/doc.go index 3d8d4d4..35e62ee 100644 --- a/internal/plan/semantic/doc.go +++ b/internal/plan/semantic/doc.go @@ -12,8 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package semantic is Deployah's unredacted domain model for a Live -> -// Predicted plan. +// Package semantic is Deployah's unredacted semantic plan model. // // This package is not the deployah plan command and is not a JSON API. // Snapshots and field values stay complete. Serialization and secret diff --git a/internal/plan/semantic/helpers_test.go b/internal/plan/semantic/helpers_test.go index b0b69a5..25aaf9c 100644 --- a/internal/plan/semantic/helpers_test.go +++ b/internal/plan/semantic/helpers_test.go @@ -165,14 +165,3 @@ func deleteChange(name, value string) semantic.ResourceChange { Apply: deleteApply(), } } - -func replaceChange(name, before, after string) semantic.ResourceChange { - return semantic.ResourceChange{ - Resource: ref("ConfigMap", name), - Origin: helmOrigin(), - Action: semantic.Replace, - Before: &semantic.ResourceSnapshot{Object: cm(name, before)}, - After: &semantic.ResourceSnapshot{Object: cm(name, after)}, - Apply: bothApply(), - } -} diff --git a/internal/plan/semantic/order_test.go b/internal/plan/semantic/order_test.go index ee81534..4cf87b7 100644 --- a/internal/plan/semantic/order_test.go +++ b/internal/plan/semantic/order_test.go @@ -112,14 +112,6 @@ func TestNew_ActionRank(t *testing.T) { Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, Apply: deleteApply(), }, - { - Resource: res, - Origin: helmOrigin(), - Action: semantic.Replace, - Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, - Apply: bothApply(), - }, { Resource: res, Origin: helmOrigin(), @@ -137,11 +129,11 @@ func TestNew_ActionRank(t *testing.T) { }, }, nil, nil) require.NoError(t, err) - require.Len(t, p.Changes, 4) + require.Len(t, p.Changes, 3) assert.Equal(t, []semantic.Action{ - semantic.Create, semantic.Update, semantic.Replace, semantic.Delete, + semantic.Create, semantic.Update, semantic.Delete, }, []semantic.Action{ - p.Changes[0].Action, p.Changes[1].Action, p.Changes[2].Action, p.Changes[3].Action, + p.Changes[0].Action, p.Changes[1].Action, p.Changes[2].Action, }) } diff --git a/internal/plan/semantic/plan.go b/internal/plan/semantic/plan.go index 8e2e8da..4ebbe38 100644 --- a/internal/plan/semantic/plan.go +++ b/internal/plan/semantic/plan.go @@ -19,27 +19,27 @@ import ( "slices" ) -// Plan is the Live -> Predicted semantic result. Construct it with [New]. -// Chart CRDs are attached with [AttachChartCRDs]; [New] leaves them empty. -// Snapshots and field values are unredacted. This type is not a JSON -// rendering contract. +// Plan is the semantic plan for one invocation: Deployah's intended +// operations and their visible resource consequences. Construct it with +// [New]. Chart CRDs are attached with [AttachChartCRDs]; [New] leaves +// them empty. Snapshots and field values are unredacted. This type is +// not a JSON rendering contract. type Plan struct { - Header Header - HelmAction HelmAction - Changes []ResourceChange - Tasks []TaskPlan - ChartCRDs []ChartCRD - Diagnostics []Diagnostic - Summary Summary - Completeness Completeness + Header Header + HelmAction HelmAction + Changes []ResourceChange + Tasks []TaskPlan + ChartCRDs []ChartCRD + Diagnostics []Diagnostic + Summary Summary } // New validates helmAction, header, changes, tasks, and diagnostics, -// sorts them, derives [Summary] and [Completeness], and returns a plan -// whose slices are non-nil. ChartCRDs is empty; call [AttachChartCRDs] -// to set them. Task references to [ResourceChange] values must be -// consistent; it fails closed on dangling or duplicate ownership. -// helmAction is stored as provided; [New] does not derive or mutate it. +// sorts them, derives [Summary], and returns a plan whose slices are +// non-nil. ChartCRDs is empty; call [AttachChartCRDs] to set them. Task +// references to [ResourceChange] values must be consistent; it fails +// closed on dangling or duplicate ownership. helmAction is stored as +// provided; [New] does not derive or mutate it. func New(header Header, helmAction HelmAction, changes []ResourceChange, tasks []TaskPlan, diagnostics []Diagnostic) (Plan, error) { if !helmAction.valid() { return Plan{}, fmt.Errorf("invalid helm action %s", helmAction) @@ -92,14 +92,13 @@ func New(header Header, helmAction HelmAction, changes []ResourceChange, tasks [ sortDiagnostics(copiedDiags) return Plan{ - Header: header, - HelmAction: helmAction, - Changes: copiedChanges, - Tasks: copiedTasks, - ChartCRDs: []ChartCRD{}, - Diagnostics: copiedDiags, - Summary: Summarize(copiedChanges), - Completeness: deriveCompleteness(copiedChanges, copiedDiags), + Header: header, + HelmAction: helmAction, + Changes: copiedChanges, + Tasks: copiedTasks, + ChartCRDs: []ChartCRD{}, + Diagnostics: copiedDiags, + Summary: Summarize(copiedChanges), }, nil } @@ -152,11 +151,10 @@ func (p Plan) HasEffects() bool { return false } -// IsNoOp reports whether the plan is a complete, non-install HelmNone -// with no known effects. Keep this expression verbatim. +// IsNoOp reports whether the plan is a non-install [HelmNone] with no +// known effects. func (p Plan) IsNoOp() bool { return !p.Header.FreshInstall && - p.Completeness == CompletenessComplete && p.HelmAction == HelmNone && !p.HasEffects() } @@ -168,7 +166,7 @@ func normalizeChange(c ResourceChange) (ResourceChange, error) { c.Apply = copyApply(c.Apply) c.Fields = copyFields(c.Fields) switch { - case (c.Action == Update || c.Action == Replace) && c.After != nil: + case c.Action == Update && c.After != nil: fields, err := DiffFields(snapshotObject(c.Before), snapshotObject(c.After)) if err != nil { return ResourceChange{}, err @@ -323,20 +321,6 @@ func validateDefinition(d HookDefinition) error { return nil } -func deriveCompleteness(changes []ResourceChange, diags []Diagnostic) Completeness { - for _, d := range diags { - if d.Category == CategoryPredictionLimitation { - return CompletenessPartial - } - } - for _, c := range changes { - if c.Action == Update && c.After == nil { - return CompletenessPartial - } - } - return CompletenessComplete -} - func validateChange(c ResourceChange, diags []Diagnostic) error { if !c.Action.valid() { return fmt.Errorf("invalid action %s", c.Action) @@ -428,17 +412,6 @@ func validateApply(action Action, apply ApplySemantics) error { return fmt.Errorf("delete requires delete semantics") } return validateDelete(*apply.Delete) - case Replace: - if apply.Write == nil { - return fmt.Errorf("replace requires write semantics") - } - if apply.Delete == nil { - return fmt.Errorf("replace requires delete semantics") - } - if err := validateWrite(*apply.Write); err != nil { - return err - } - return validateDelete(*apply.Delete) default: return fmt.Errorf("invalid action %s", action) } @@ -538,13 +511,6 @@ func validateSnapshots(c ResourceChange, diags []Diagnostic) error { if c.After != nil { return fmt.Errorf("delete must not have an after snapshot") } - case Replace: - if c.Before == nil { - return fmt.Errorf("replace requires a before snapshot") - } - if c.After == nil { - return fmt.Errorf("replace requires an after snapshot") - } } return nil } diff --git a/internal/plan/semantic/plan_test.go b/internal/plan/semantic/plan_test.go index d2866ce..d3b6036 100644 --- a/internal/plan/semantic/plan_test.go +++ b/internal/plan/semantic/plan_test.go @@ -64,18 +64,6 @@ func TestNew_SnapshotInvariants(t *testing.T) { before: true, delete: true, }, - { - name: "replace", - change: replaceChange("app", "v1", "v2"), - action: semantic.Replace, - before: true, - after: true, - fields: true, - write: true, - delete: true, - fieldPath: "/data/key", - fieldOp: semantic.FieldReplace, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -98,7 +86,6 @@ func TestNew_SnapshotInvariants(t *testing.T) { assert.Equal(t, tt.delete, c.Apply.Delete != nil) assert.Equal(t, tt.fieldPath, path) assert.Equal(t, tt.fieldOp, op) - assert.Equal(t, semantic.CompletenessComplete, p.Completeness) }) } } @@ -123,7 +110,6 @@ func TestNew_UpdateWithoutAfterRequiresLimitation(t *testing.T) { require.NoError(t, err) assert.Nil(t, p.Changes[0].After) assert.Empty(t, p.Changes[0].Fields) - assert.Equal(t, semantic.CompletenessPartial, p.Completeness) } func TestNew_TasksAlwaysNonNil(t *testing.T) { @@ -138,7 +124,6 @@ func TestNew_TasksAlwaysNonNil(t *testing.T) { assert.Empty(t, p.Diagnostics) require.NotNil(t, p.ChartCRDs) assert.Empty(t, p.ChartCRDs) - assert.Equal(t, semantic.CompletenessComplete, p.Completeness) assert.Equal(t, semantic.HelmUpgrade, p.HelmAction) assert.Equal(t, 0, p.Summary.Total()) } @@ -149,11 +134,10 @@ func TestNew_SummaryDerived(t *testing.T) { createChange("a", "1"), updateChange("b", "1", "2"), deleteChange("c", "1"), - replaceChange("d", "1", "2"), }, nil, nil) require.NoError(t, err) - assert.Equal(t, semantic.Summary{Create: 1, Update: 1, Delete: 1, Replace: 1}, p.Summary) - assert.Equal(t, 4, p.Summary.Total()) + assert.Equal(t, semantic.Summary{Create: 1, Update: 1, Delete: 1}, p.Summary) + assert.Equal(t, 3, p.Summary.Total()) } func TestNew_DeterministicOrder(t *testing.T) { @@ -226,10 +210,16 @@ func TestNew_DoesNotAliasCallerInputs(t *testing.T) { p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: res, Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm, Helm: helm}, - Action: semantic.Replace, + Action: semantic.Update, Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, - Apply: semantic.ApplySemantics{Write: write, Delete: del}, + Apply: semantic.ApplySemantics{Write: write}, + }, { + Resource: ref("ConfigMap", "gone"), + Origin: helmOrigin(), + Action: semantic.Delete, + Before: &semantic.ResourceSnapshot{Object: cm("gone", "v1")}, + Apply: semantic.ApplySemantics{Delete: del}, }}, nil, []semantic.Diagnostic{diag}) require.NoError(t, err) @@ -238,10 +228,10 @@ func TestNew_DoesNotAliasCallerInputs(t *testing.T) { del.Propagation = 0 diag.Resource.Name = "mutated" - require.Len(t, p.Changes, 1) + require.Len(t, p.Changes, 2) assert.Equal(t, "web", p.Changes[0].Origin.Helm.Release) assert.Equal(t, "deployah", p.Changes[0].Apply.Write.FieldManager) - assert.Equal(t, semantic.PropagationBackground, p.Changes[0].Apply.Delete.Propagation) + assert.Equal(t, semantic.PropagationBackground, p.Changes[1].Apply.Delete.Propagation) require.Len(t, p.Diagnostics, 1) assert.Equal(t, "app", p.Diagnostics[0].Resource.Name) } @@ -253,10 +243,10 @@ func TestSummarize(t *testing.T) { {Action: semantic.Create}, {Action: semantic.Update}, {Action: semantic.Delete}, - {Action: semantic.Replace}, + {Action: semantic.Action(99)}, }) - assert.Equal(t, semantic.Summary{Create: 2, Update: 1, Delete: 1, Replace: 1}, got) - assert.Equal(t, 5, got.Total()) + assert.Equal(t, semantic.Summary{Create: 2, Update: 1, Delete: 1}, got) + assert.Equal(t, 4, got.Total()) } func TestNew_RejectsInvalid(t *testing.T) { @@ -354,58 +344,6 @@ func TestNew_RejectsInvalid(t *testing.T) { }, wantErr: "delete must not have an after snapshot", }, - { - name: "replace missing before", - change: semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Replace, - After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, - Apply: bothApply(), - }, - wantErr: "replace requires a before snapshot", - }, - { - name: "replace missing after", - change: semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Replace, - Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: bothApply(), - }, - wantErr: "replace requires an after snapshot", - }, - { - name: "replace invalid write", - change: semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Replace, - Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, - Apply: semantic.ApplySemantics{ - Write: &semantic.WriteSemantics{Method: semantic.WriteServerSide}, - Delete: deleteApply().Delete, - }, - }, - wantErr: "field manager", - }, - { - name: "replace invalid delete", - change: semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Replace, - Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, - Apply: semantic.ApplySemantics{ - Write: writeApply().Write, - Delete: &semantic.DeleteSemantics{}, - }, - }, - wantErr: "invalid delete propagation", - }, { name: "create rejects delete", change: semantic.ResourceChange{ @@ -460,41 +398,6 @@ func TestNew_RejectsInvalid(t *testing.T) { }, wantErr: "requires delete semantics", }, - { - name: "replace rejects write only", - change: semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Replace, - Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, - Apply: writeApply(), - }, - wantErr: "requires delete semantics", - }, - { - name: "replace rejects delete only", - change: semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Replace, - Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, - Apply: deleteApply(), - }, - wantErr: "requires write semantics", - }, - { - name: "replace rejects nil apply", - change: semantic.ResourceChange{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Replace, - Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, - }, - wantErr: "requires write semantics", - }, { name: "invalid write method", change: semantic.ResourceChange{ @@ -636,54 +539,6 @@ func TestNew_LimitationMatching(t *testing.T) { } } -func TestNew_Completeness(t *testing.T) { - t.Parallel() - res := ref("ConfigMap", "app") - tests := []struct { - name string - changes []semantic.ResourceChange - diags []semantic.Diagnostic - want semantic.Completeness - }{ - { - name: "complete create", - changes: []semantic.ResourceChange{createChange("app", "v1")}, - want: semantic.CompletenessComplete, - }, - { - name: "limitation on create", - changes: []semantic.ResourceChange{createChange("app", "v1")}, - diags: []semantic.Diagnostic{limitation(res)}, - want: semantic.CompletenessPartial, - }, - { - name: "limitation without changes", - diags: []semantic.Diagnostic{limitation(res)}, - want: semantic.CompletenessPartial, - }, - { - name: "update without after", - changes: []semantic.ResourceChange{{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Update, - Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, - Apply: writeApply(), - }}, - diags: []semantic.Diagnostic{limitation(res)}, - want: semantic.CompletenessPartial, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - p, err := semantic.New(semantic.Header{}, semantic.HelmUpgrade, tt.changes, nil, tt.diags) - require.NoError(t, err) - assert.Equal(t, tt.want, p.Completeness) - }) - } -} - func TestNew_CopiesNestedSnapshotShapes(t *testing.T) { t.Parallel() labels := map[string]string{"app": "web"} @@ -1161,16 +1016,17 @@ func TestPlan_IsNoOp(t *testing.T) { diags []semantic.Diagnostic want bool }{ - {name: "A complete helm none", helmAction: semantic.HelmNone, want: true}, - {name: "B helm upgrade zero effects", helmAction: semantic.HelmUpgrade}, + {name: "helm none", helmAction: semantic.HelmNone, want: true}, + {name: "helm upgrade zero effects", helmAction: semantic.HelmUpgrade}, { - name: "C fresh install", + name: "fresh install", header: semantic.Header{FreshInstall: true}, helmAction: semantic.HelmInstall, }, { - name: "D partial helm none", + name: "helm none with limitation diagnostic", helmAction: semantic.HelmNone, + want: true, diags: []semantic.Diagnostic{{ Severity: semantic.DiagnosticWarning, Category: semantic.CategoryPredictionLimitation, diff --git a/internal/plan/semantic/summary.go b/internal/plan/semantic/summary.go index a54176f..3dea32b 100644 --- a/internal/plan/semantic/summary.go +++ b/internal/plan/semantic/summary.go @@ -17,15 +17,14 @@ package semantic // Summary counts [ResourceChange] values by action. [New] always sets it // from [Summarize]; callers cannot supply an independent summary. type Summary struct { - Create int - Update int - Delete int - Replace int + Create int + Update int + Delete int } // Total returns the number of resource changes. func (s Summary) Total() int { - return s.Create + s.Update + s.Delete + s.Replace + return s.Create + s.Update + s.Delete } // Summarize counts changes by action. It is the only summary calculation. @@ -39,8 +38,6 @@ func Summarize(changes []ResourceChange) Summary { s.Update++ case Delete: s.Delete++ - case Replace: - s.Replace++ } } return s diff --git a/internal/plan/semantic/task_test.go b/internal/plan/semantic/task_test.go index b7de83a..168ea72 100644 --- a/internal/plan/semantic/task_test.go +++ b/internal/plan/semantic/task_test.go @@ -154,14 +154,14 @@ func TestNew_TaskValidation(t *testing.T) { wantErr: "invalid action", }, { - name: "hook replace action", + name: "hook unknown action", tasks: []semantic.TaskPlan{{ Name: "migrate", Phase: semantic.TaskPreDeploy, Action: semantic.TaskUpdate, Definitions: []semantic.HookDefinition{{ Resource: app, - Action: semantic.Replace, + Action: semantic.Action(0), Before: &semantic.ResourceSnapshot{Object: cm("app", "v1")}, After: &semantic.ResourceSnapshot{Object: cm("app", "v2")}, }}, diff --git a/internal/plan/semantic/types.go b/internal/plan/semantic/types.go index 19ccb79..2923d06 100644 --- a/internal/plan/semantic/types.go +++ b/internal/plan/semantic/types.go @@ -28,9 +28,6 @@ const ( Update // Delete is a prune of a live resource. Delete - // Replace is a delete of the live object plus a write of a - // replacement. Stage C stores the action; it does not generate it. - Replace ) func (a Action) String() string { @@ -41,8 +38,6 @@ func (a Action) String() string { return "update" case Delete: return "delete" - case Replace: - return "replace" default: return fmt.Sprintf("Action(%d)", int(a)) } @@ -50,7 +45,7 @@ func (a Action) String() string { func (a Action) valid() bool { switch a { - case Create, Update, Delete, Replace: + case Create, Update, Delete: return true default: return false @@ -63,35 +58,10 @@ func actionRank(a Action) int { return 0 case Update: return 1 - case Replace: - return 2 case Delete: - return 3 - default: - return 4 - } -} - -// Completeness says whether every predicted After is exact. The zero -// value is invalid. -type Completeness int - -const ( - // CompletenessComplete means every resource change has an exact - // prediction. - CompletenessComplete Completeness = iota + 1 - // CompletenessPartial means at least one prediction is not exact. - CompletenessPartial -) - -func (c Completeness) String() string { - switch c { - case CompletenessComplete: - return "complete" - case CompletenessPartial: - return "partial" + return 2 default: - return fmt.Sprintf("Completeness(%d)", int(c)) + return 3 } } @@ -174,7 +144,7 @@ func (r ResourceRef) String() string { type OriginKind int const ( - // OriginHelm is a Helm-predicted resource. + // OriginHelm is a resource rendered by the Helm release. OriginHelm OriginKind = iota + 1 // OriginNamespace is the target Namespace created as part of a // Helm install. It is invalid with [HelmNone] or [HelmUpgrade]. @@ -281,9 +251,8 @@ type DeleteSemantics struct { Propagation DeletePropagation } -// ApplySemantics is the mutation semantics that produced a prediction. -// Create and Update set Write only. Delete sets Delete only. Replace -// sets both. +// ApplySemantics is the mutation semantics of a resource consequence. +// Create and Update set Write only. Delete sets Delete only. type ApplySemantics struct { Write *WriteSemantics Delete *DeleteSemantics @@ -469,7 +438,7 @@ func (l ChartCRDLifecycle) valid() bool { } // ChartCRD is one chart CRD document for plan presentation. It is Helm -// chart-CRD lifecycle, not a predicted Kubernetes mutation. +// chart-CRD lifecycle, not a resource consequence. type ChartCRD struct { // Source is the display path under .deployah/crds/. Source string diff --git a/internal/plan/semantic/types_test.go b/internal/plan/semantic/types_test.go index 7ae607e..faca2a1 100644 --- a/internal/plan/semantic/types_test.go +++ b/internal/plan/semantic/types_test.go @@ -32,11 +32,7 @@ func TestEnumString(t *testing.T) { {name: "create", got: semantic.Create.String(), want: "create"}, {name: "update", got: semantic.Update.String(), want: "update"}, {name: "delete", got: semantic.Delete.String(), want: "delete"}, - {name: "action replace", got: semantic.Replace.String(), want: "replace"}, {name: "unknown action", got: semantic.Action(0).String(), want: "Action(0)"}, - {name: "complete", got: semantic.CompletenessComplete.String(), want: "complete"}, - {name: "partial", got: semantic.CompletenessPartial.String(), want: "partial"}, - {name: "unknown completeness", got: semantic.Completeness(0).String(), want: "Completeness(0)"}, {name: "none", got: semantic.HelmNone.String(), want: "none"}, {name: "install", got: semantic.HelmInstall.String(), want: "install"}, {name: "upgrade", got: semantic.HelmUpgrade.String(), want: "upgrade"}, diff --git a/internal/plan/semantic_build_test.go b/internal/plan/semantic_build_test.go index 02888b0..3cf48bc 100644 --- a/internal/plan/semantic_build_test.go +++ b/internal/plan/semantic_build_test.go @@ -627,7 +627,6 @@ func TestBuildSemanticPlan_NoChange(t *testing.T) { require.NoError(t, err) assert.Empty(t, p.Changes) assert.Equal(t, 0, p.Summary.Total()) - assert.Equal(t, semantic.CompletenessComplete, p.Completeness) assert.Equal(t, semantic.HelmNone, p.HelmAction) assert.False(t, p.HasEffects()) assert.True(t, p.IsNoOp()) diff --git a/internal/plan/semantic_prereq_test.go b/internal/plan/semantic_prereq_test.go index ce2d1a1..343abb4 100644 --- a/internal/plan/semantic_prereq_test.go +++ b/internal/plan/semantic_prereq_test.go @@ -78,7 +78,6 @@ func TestBuildSemanticPlan_InstallCreatesNamespace(t *testing.T) { assert.Equal(t, semantic.Create, p.Changes[0].Action) assert.Equal(t, semantic.WriteServerSide, p.Changes[0].Apply.Write.Method) assert.False(t, p.Changes[0].Apply.Write.ForceConflicts) - assert.Equal(t, semantic.CompletenessPartial, p.Completeness) require.NotEmpty(t, p.Diagnostics) assert.Contains(t, p.Diagnostics[0].Message, "target namespace is created earlier in this deployment") require.Len(t, cluster.applies, 1) @@ -392,7 +391,6 @@ data: p, _, cleanup, err := plan.BuildSemanticPlan(t.Context(), client, cluster, buildInput("ctx", resolvedSpec(), nil)) t.Cleanup(cleanup) require.NoError(t, err) - assert.Equal(t, semantic.CompletenessPartial, p.Completeness) var gens []string for _, d := range p.Diagnostics { if d.Resource != nil { diff --git a/internal/plan/view/helpers_test.go b/internal/plan/view/helpers_test.go index e984038..ee3eadf 100644 --- a/internal/plan/view/helpers_test.go +++ b/internal/plan/view/helpers_test.go @@ -61,13 +61,6 @@ func deleteApply() semantic.ApplySemantics { } } -func bothApply() semantic.ApplySemantics { - return semantic.ApplySemantics{ - Write: writeApply().Write, - Delete: deleteApply().Delete, - } -} - func snap(obj map[string]any) *semantic.ResourceSnapshot { return &semantic.ResourceSnapshot{Object: obj} } diff --git a/internal/plan/view/human.go b/internal/plan/view/human.go index 91739ba..f8cbc28 100644 --- a/internal/plan/view/human.go +++ b/internal/plan/view/human.go @@ -27,14 +27,14 @@ import ( "deployah.dev/deployah/internal/plan/semantic" ) -// yamlDiffContextLines is small because Update and Replace diffs are +// yamlDiffContextLines is small because Update diffs are // ancestor-projected, not whole-object dumps. const yamlDiffContextLines = 3 const headerLabelWidth = 10 // WriteHuman writes a deterministic YAML-oriented rendering of p. It -// does not mutate p. Resource headings use +, ~, -, and -/+ markers. +// does not mutate p. Resource headings use +, ~, and - markers. // The footer is a Summary of resource counts and, when tasks exist, // task counts. func WriteHuman(w io.Writer, p semantic.Plan, opts Options) error { @@ -45,14 +45,6 @@ func WriteHuman(w io.Writer, p semantic.Plan, opts Options) error { if herr := writeHumanHeader(w, prepared.Header, opts); herr != nil { return herr } - if prepared.Completeness == semantic.CompletenessPartial { - if werr := writeln(w, opts, theme.StatusWarning, "Warning: prediction is incomplete"); werr != nil { - return werr - } - if _, ferr := fmt.Fprintln(w); ferr != nil { - return ferr - } - } owned := ownedResourceKeys(prepared.Tasks) indexed := indexChanges(prepared.Changes) wroteBody := false @@ -311,46 +303,11 @@ func writeActionBody( } beforeProj, afterProj := projectFields(beforeObj, afterObj, renderFields) return writeProjectedDiff(w, beforeProj, afterProj, indent, opts) - case semantic.Replace: - if err := writeReplaceIdentity(w, afterObj, indent, opts); err != nil { - return err - } - beforeProj, afterProj := projectFields(beforeObj, afterObj, restFields(renderFields)) - return writeProjectedDiff(w, beforeProj, afterProj, indent, opts) default: return nil } } -func restFields(fields []semantic.FieldChange) []semantic.FieldChange { - out := make([]semantic.FieldChange, 0, len(fields)) - for _, f := range fields { - if f.Path == "/apiVersion" || f.Path == "/kind" || f.Path == "/metadata" || strings.HasPrefix(f.Path, "/metadata/") { - continue - } - out = append(out, f) - } - return out -} - -func writeReplaceIdentity(w io.Writer, after map[string]any, indent string, opts Options) error { - id := map[string]any{} - if v, ok := after["apiVersion"]; ok { - id["apiVersion"] = v - } - if v, ok := after["kind"]; ok { - id["kind"] = v - } - if v, ok := after["metadata"]; ok { - id["metadata"] = v - } - text, err := marshalOrderedYAML(id) - if err != nil { - return err - } - return writePrefixedYAML(w, text, indent+" ", theme.TextMuted, opts) -} - func writeProjectedDiff(w io.Writer, before, after map[string]any, indent string, opts Options) error { beforeYAML, err := marshalOrderedYAML(before) if err != nil { @@ -436,7 +393,7 @@ func writeHumanFooter(w io.Writer, p semantic.Plan, opts Options) error { return err } s := p.Summary - resourceLine := fmt.Sprintf(" Resources: %d create, %d update, %d delete, %d replace", s.Create, s.Update, s.Delete, s.Replace) + resourceLine := fmt.Sprintf(" Resources: %d create, %d update, %d delete", s.Create, s.Update, s.Delete) if err := writeln(w, opts, theme.TextPrimary, resourceLine); err != nil { return err } @@ -503,8 +460,6 @@ func actionMarker(action semantic.Action) string { return "~" case semantic.Delete: return "-" - case semantic.Replace: - return "-/+" default: return "" } @@ -518,8 +473,6 @@ func actionHeadingToken(action semantic.Action) theme.Token { return theme.StatusError case semantic.Update: return theme.StatusWarning - case semantic.Replace: - return theme.AccentPrimary default: return theme.StatusWarning } diff --git a/internal/plan/view/human_internal_test.go b/internal/plan/view/human_internal_test.go index 330cbae..861ae4c 100644 --- a/internal/plan/view/human_internal_test.go +++ b/internal/plan/view/human_internal_test.go @@ -31,7 +31,6 @@ func TestActionMarker(t *testing.T) { {action: semantic.Create, want: "+"}, {action: semantic.Update, want: "~"}, {action: semantic.Delete, want: "-"}, - {action: semantic.Replace, want: "-/+"}, {action: 0, want: ""}, } for _, tt := range tests { diff --git a/internal/plan/view/human_norm_test.go b/internal/plan/view/human_norm_test.go index 8045e51..55cad21 100644 --- a/internal/plan/view/human_norm_test.go +++ b/internal/plan/view/human_norm_test.go @@ -184,7 +184,7 @@ func TestWriteHuman_OmitsBookkeepingFields(t *testing.T) { assert.Equal(t, "11", objectString(t, p.Changes[0].Before.Object, "metadata", "resourceVersion")) } -func TestWriteHuman_OmitsBookkeepingOnCreateDeleteReplace(t *testing.T) { +func TestWriteHuman_OmitsBookkeepingOnCreateDelete(t *testing.T) { t.Parallel() tests := []struct { name string @@ -213,18 +213,6 @@ func TestWriteHuman_OmitsBookkeepingOnCreateDeleteReplace(t *testing.T) { }}, want: []string{"- key: v1", "- labels:", "- annotations:"}, }, - { - name: "replace", - changes: []semantic.ResourceChange{{ - Resource: ref("ConfigMap", "web"), - Origin: helmOrigin(), - Action: semantic.Replace, - Before: snap(noisyCM("web", "v1", "11", "u-live")), - After: snap(noisyCM("web", "v2", "22", "u-pred")), - Apply: bothApply(), - }}, - want: []string{"- key: v1", "+ key: v2"}, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/plan/view/human_test.go b/internal/plan/view/human_test.go index d53380f..853c4bb 100644 --- a/internal/plan/view/human_test.go +++ b/internal/plan/view/human_test.go @@ -28,7 +28,7 @@ import ( "deployah.dev/deployah/internal/plan/view" ) -func TestWriteHuman_CreateUpdateDeleteReplace(t *testing.T) { +func TestWriteHuman_CreateUpdateDelete(t *testing.T) { t.Parallel() p := allActionsPlan(t) var buf bytes.Buffer @@ -47,7 +47,6 @@ func TestWriteHuman_CreateUpdateDeleteReplace(t *testing.T) { assert.Contains(t, text, "- example.com/keep: yes") assert.Contains(t, text, "- app: web") assert.Contains(t, text, "- key: v1") - assert.Contains(t, text, `-/+ replace v1/ConfigMap "rs"`) assert.Contains(t, text, "Tasks") assert.Contains(t, text, " preDeploy") assert.Contains(t, text, "~ migrate changed, will run") @@ -75,12 +74,11 @@ func TestWriteHuman_CreateUpdateDeleteReplace(t *testing.T) { assert.Contains(t, text, "+ schedule: 0 3 * * *") assert.NotContains(t, text, "jobTemplate:") assert.Equal(t, 1, strings.Count(text, `batch/v1/CronJob "web-cleanup"`)) - assert.Contains(t, text, " Resources: 1 create, 2 update, 1 delete, 1 replace") + assert.Contains(t, text, " Resources: 1 create, 2 update, 1 delete") assert.Contains(t, text, " Tasks: 3 to run, 1 schedule changed") assert.NotContains(t, text, "~ ConfigMap/prod/rs") - assert.NotContains(t, text, "-/+ ConfigMap/prod/web") + assert.NotContains(t, text, "-/+") assert.NotContains(t, strings.ToLower(text), "recreate") - assert.NotContains(t, text, "-/+ apiVersion") assert.NotContains(t, text, "Actions:") assert.NotContains(t, text, "create helm") assert.NotContains(t, text, "completeness:") @@ -92,8 +90,6 @@ func TestWriteHuman_CreateUpdateDeleteReplace(t *testing.T) { assert.Contains(t, text, "- apiVersion: v1") assert.Contains(t, text, "- key: v1") assert.Contains(t, text, "+ key: v2") - assert.Contains(t, text, " kind: ConfigMap") - assert.Contains(t, text, " apiVersion: v1") assert.NotContains(t, text, "before:") assert.NotContains(t, text, "after:") assert.NotContains(t, text, "--- before") @@ -110,11 +106,10 @@ func TestWriteHuman_NoOpLimitationDiagnosticsOnly(t *testing.T) { Message: "prediction is not exact: prediction used the currently installed CRD, but that CRD's spec changes before Helm executes", Resource: &widget, }}) - assert.Equal(t, semantic.CompletenessPartial, p.Completeness) assert.False(t, p.IsNoOp()) text := writeHuman(t, p) assertGolden(t, "human_noop_limitation", text) - assert.Contains(t, text, "Warning: prediction is incomplete") + assert.NotContains(t, text, "Warning: prediction is incomplete") assert.Contains(t, text, "Diagnostics") assert.Contains(t, text, `example.com/v1/Widget "app"`) assert.NotContains(t, text, "\nResources\n") @@ -168,16 +163,15 @@ func TestWriteHuman_Prerequisites(t *testing.T) { assert.Contains(t, text, "Diagnostics") } -func TestWriteHuman_DiagnosticPartial(t *testing.T) { +func TestWriteHuman_UpdateWithLimitationDiagnostic(t *testing.T) { t.Parallel() res := ref("ConfigMap", "web") p := mustPlanWithHeader(t, humanHeader(), semantic.HelmUpgrade, []semantic.ResourceChange{ knownConfigMapUpdate(res), }, nil, []semantic.Diagnostic{predictionLimitation(res)}) - assert.Equal(t, semantic.CompletenessPartial, p.Completeness) text := writeHuman(t, p) - assertGolden(t, "human_partial", text) // full-output contract; Contains below are invariants only - assert.Contains(t, text, "Warning: prediction is incomplete") + assertGolden(t, "human_limitation", text) // full-output contract; Contains below are invariants only + assert.NotContains(t, text, "Warning: prediction is incomplete") assert.Contains(t, text, `~ update v1/ConfigMap "web"`) assert.Contains(t, text, "- key: v1") assert.Contains(t, text, "+ key: v2") @@ -186,24 +180,22 @@ func TestWriteHuman_DiagnosticPartial(t *testing.T) { assert.NotContains(t, text, "Actions:") } -func TestWriteHuman_PartialPreservesKnownDetails(t *testing.T) { +func TestWriteHuman_LimitationDiagnosticPreservesKnownDetails(t *testing.T) { t.Parallel() header, changes, tasks := allActionsInputs() - complete := mustPlanWithHeader(t, header, semantic.HelmUpgrade, changes, tasks, nil) - partial := mustPlanWithHeader(t, header, semantic.HelmUpgrade, changes, tasks, []semantic.Diagnostic{ + plain := mustPlanWithHeader(t, header, semantic.HelmUpgrade, changes, tasks, nil) + withDiag := mustPlanWithHeader(t, header, semantic.HelmUpgrade, changes, tasks, []semantic.Diagnostic{ predictionLimitation(ref("ConfigMap", "web")), }) - assert.Equal(t, semantic.CompletenessComplete, complete.Completeness) - assert.Equal(t, semantic.CompletenessPartial, partial.Completeness) - assert.Equal(t, complete.Summary, partial.Summary) + assert.Equal(t, plain.Summary, withDiag.Summary) - completeText := writeHuman(t, complete) - partialText := writeHuman(t, partial) - assert.Contains(t, partialText, "Warning: prediction is incomplete") - assert.Contains(t, partialText, "Diagnostics") - assert.NotContains(t, completeText, "Warning: prediction is incomplete") - assert.NotContains(t, completeText, "Diagnostics") - require.Equal(t, completeText, stripPartialPresentation(t, partialText)) + plainText := writeHuman(t, plain) + withDiagText := writeHuman(t, withDiag) + assert.Contains(t, withDiagText, "Diagnostics") + assert.NotContains(t, plainText, "Warning: prediction is incomplete") + assert.NotContains(t, withDiagText, "Warning: prediction is incomplete") + assert.NotContains(t, plainText, "Diagnostics") + require.Equal(t, plainText, stripDiagnostics(t, withDiagText)) } func TestWriteHuman_DeterministicMapOrder(t *testing.T) { @@ -252,7 +244,7 @@ func TestWriteHuman_InvalidZero(t *testing.T) { t.Parallel() err := view.WriteHuman(&bytes.Buffer{}, semantic.Plan{}, view.Options{}) require.Error(t, err) - assert.ErrorContains(t, err, "invalid completeness") + assert.ErrorContains(t, err, "invalid helmAction") } func TestWriteHuman_DoesNotMutatePlan(t *testing.T) { @@ -472,7 +464,7 @@ func TestWriteHuman_EmptyPlan(t *testing.T) { assert.NotContains(t, text, "completeness:") assert.NotContains(t, text, "Executions:") assertHumanLayout(t, text) - assert.Contains(t, text, " Resources: 0 create, 0 update, 0 delete, 0 replace") + assert.Contains(t, text, " Resources: 0 create, 0 update, 0 delete") assert.NotContains(t, text, " Tasks:") assert.NotContains(t, text, "create helm") assert.NotContains(t, text, "Actions:") @@ -545,38 +537,6 @@ func TestWriteHuman_UnknownGVKActions(t *testing.T) { }, contains: []string{`- delete example.com/v1/Widget "d"`, "- apiVersion: example.com/v1", "size: large"}, }, - { - name: "replace identity then projected rest", - change: semantic.ResourceChange{ - Resource: semantic.ResourceRef{APIVersion: "example.com/v1", Kind: "Widget", Namespace: "prod", Name: "w"}, - Origin: helmOrigin(), - Action: semantic.Replace, - Before: snap(map[string]any{ - "apiVersion": "example.com/v1", - "kind": "Widget", - "metadata": map[string]any{"name": "w", "namespace": "prod"}, - "spec": map[string]any{"color": "blue", "keep": "yes"}, - "status": map[string]any{"ready": true}, - }), - After: snap(map[string]any{ - "apiVersion": "example.com/v1", - "kind": "Widget", - "metadata": map[string]any{"name": "w", "namespace": "prod"}, - "spec": map[string]any{"color": "red", "keep": "yes"}, - "status": map[string]any{"ready": true}, - }), - Apply: bothApply(), - }, - contains: []string{ - `-/+ replace example.com/v1/Widget "w"`, - "apiVersion: example.com/v1", - "kind: Widget", - "metadata:", - "color: blue", - "color: red", - }, - omits: []string{"keep: yes"}, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -810,7 +770,7 @@ func TestWriteHuman_ScheduledTaskNotDuplicated(t *testing.T) { require.Greater(t, tasksIdx, resourcesIdx) assert.NotContains(t, text[resourcesIdx:tasksIdx], `CronJob "cleanup"`) assertHumanLayout(t, text) - assert.Contains(t, text, " Resources: 1 create, 1 update, 0 delete, 0 replace") + assert.Contains(t, text, " Resources: 1 create, 1 update, 0 delete") assert.Contains(t, text, " Tasks: 0 to run, 1 schedule changed") } @@ -850,7 +810,7 @@ func TestWriteHuman_ScheduleCreateDeleteRendersFullObject(t *testing.T) { contains: append([]string{ "+ cleanup new", `+ create batch/v1/CronJob "web-cleanup"`, - " Resources: 1 create, 0 update, 0 delete, 0 replace", + " Resources: 1 create, 0 update, 0 delete", }, body...), omits: []string{"will run", "\nResources\n"}, }, @@ -872,7 +832,7 @@ func TestWriteHuman_ScheduleCreateDeleteRendersFullObject(t *testing.T) { contains: append([]string{ "- cleanup removed", `- delete batch/v1/CronJob "web-cleanup"`, - " Resources: 0 create, 0 update, 1 delete, 0 replace", + " Resources: 0 create, 0 update, 1 delete", }, body...), omits: []string{"will run", "\nResources\n"}, }, @@ -963,7 +923,7 @@ func TestWriteHuman_TaskFooter(t *testing.T) { require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() assertHumanLayout(t, text) - assert.Contains(t, text, " Resources: 0 create, 0 update, 0 delete, 0 replace") + assert.Contains(t, text, " Resources: 0 create, 0 update, 0 delete") for _, s := range tt.wantContains { assert.Contains(t, text, s) } @@ -1002,7 +962,7 @@ func TestWriteHuman_BlockSpacing(t *testing.T) { }, nil), contains: []string{ "+ key: v1\n\n + create v1/ConfigMap \"old\"", - " Resources: 2 create, 0 update, 0 delete, 0 replace", + " Resources: 2 create, 0 update, 0 delete", }, }, { @@ -1089,7 +1049,7 @@ func TestWriteHuman_SummaryOmitsTasksWhenAbsent(t *testing.T) { require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() assertHumanLayout(t, text) - assert.Contains(t, text, " Resources: 1 create, 0 update, 0 delete, 0 replace") + assert.Contains(t, text, " Resources: 1 create, 0 update, 0 delete") assert.NotContains(t, text, " Tasks:") } @@ -1127,14 +1087,6 @@ func allActionsInputs() (semantic.Header, []semantic.ResourceChange, []semantic. Before: snap(cmWithMeta("old", "v1")), Apply: deleteApply(), }, - { - Resource: ref("ConfigMap", "rs"), - Origin: helmOrigin(), - Action: semantic.Replace, - Before: snap(cm("rs", "v1")), - After: snap(cm("rs", "v2")), - Apply: bothApply(), - }, { Resource: cron, Origin: helmOrigin(), @@ -1226,11 +1178,8 @@ func predictionLimitation(res semantic.ResourceRef) semantic.Diagnostic { } } -func stripPartialPresentation(t *testing.T, text string) string { +func stripDiagnostics(t *testing.T, text string) string { t.Helper() - const warning = "Warning: prediction is incomplete\n\n" - require.Contains(t, text, warning) - text = strings.Replace(text, warning, "", 1) diagStart := strings.Index(text, "\nDiagnostics\n") summaryStart := strings.Index(text, "\nSummary\n") require.Greater(t, diagStart, -1) diff --git a/internal/plan/view/json.go b/internal/plan/view/json.go index ffbb37a..5ae011a 100644 --- a/internal/plan/view/json.go +++ b/internal/plan/view/json.go @@ -23,15 +23,14 @@ import ( ) type document struct { - Schema string `json:"schema"` - Header headerDTO `json:"header"` - HelmAction string `json:"helmAction"` - Changes []changeDTO `json:"changes"` - Tasks []taskDTO `json:"tasks"` - ChartCRDs []chartCRDDTO `json:"chartCRDs"` - Diagnostics []diagDTO `json:"diagnostics"` - Summary summaryDTO `json:"summary"` - Completeness string `json:"completeness"` + Schema string `json:"schema"` + Header headerDTO `json:"header"` + HelmAction string `json:"helmAction"` + Changes []changeDTO `json:"changes"` + Tasks []taskDTO `json:"tasks"` + ChartCRDs []chartCRDDTO `json:"chartCRDs"` + Diagnostics []diagDTO `json:"diagnostics"` + Summary summaryDTO `json:"summary"` } type headerDTO struct { @@ -102,11 +101,10 @@ type diagDTO struct { } type summaryDTO struct { - Create int `json:"create"` - Update int `json:"update"` - Delete int `json:"delete"` - Replace int `json:"replace"` - Total int `json:"total"` + Create int `json:"create"` + Update int `json:"update"` + Delete int `json:"delete"` + Total int `json:"total"` } type taskDTO struct { @@ -173,15 +171,14 @@ func newDocument(p semantic.Plan, opts Options) (document, error) { crds = append(crds, toChartCRDDTO(c)) } return document{ - Schema: SchemaV1ID, - Header: toHeaderDTO(prepared.Header), - HelmAction: prepared.HelmAction.String(), - Changes: changes, - Tasks: tasks, - ChartCRDs: crds, - Diagnostics: diags, - Summary: toSummaryDTO(prepared.Summary), - Completeness: prepared.Completeness.String(), + Schema: SchemaV1ID, + Header: toHeaderDTO(prepared.Header), + HelmAction: prepared.HelmAction.String(), + Changes: changes, + Tasks: tasks, + ChartCRDs: crds, + Diagnostics: diags, + Summary: toSummaryDTO(prepared.Summary), }, nil } @@ -270,11 +267,10 @@ func toDiagDTO(d semantic.Diagnostic) diagDTO { func toSummaryDTO(s semantic.Summary) summaryDTO { return summaryDTO{ - Create: s.Create, - Update: s.Update, - Delete: s.Delete, - Replace: s.Replace, - Total: s.Total(), + Create: s.Create, + Update: s.Update, + Delete: s.Delete, + Total: s.Total(), } } diff --git a/internal/plan/view/json_test.go b/internal/plan/view/json_test.go index 23be782..67bf7a2 100644 --- a/internal/plan/view/json_test.go +++ b/internal/plan/view/json_test.go @@ -60,11 +60,17 @@ func TestWriteJSON_SchemaAndTasks(t *testing.T) { "create": 0, "update": 0, "delete": 0, - "replace": 0, "total": 0 - }, - "completeness": "complete" + } }`, buf.String()) + var doc map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &doc)) + _, hasCompleteness := doc["completeness"] + assert.False(t, hasCompleteness) + summary, ok := doc["summary"].(map[string]any) + require.True(t, ok) + _, hasReplace := summary["replace"] + assert.False(t, hasReplace) assertNoJSONKeysFromBytes(t, buf.Bytes()) validatePlanSchema(t, buf.Bytes()) } @@ -137,8 +143,7 @@ func TestWriteJSON_TasksContract(t *testing.T) { }], "chartCRDs": [], "diagnostics": [], - "summary": {"create": 0, "update": 0, "delete": 0, "replace": 0, "total": 0}, - "completeness": "complete" + "summary": {"create": 0, "update": 0, "delete": 0, "total": 0} }`, buf.String()) validatePlanSchema(t, buf.Bytes()) assertNoJSONKeysFromBytes(t, buf.Bytes()) @@ -264,7 +269,7 @@ func TestWriteJSON_InvalidZero(t *testing.T) { t.Parallel() err := view.WriteJSON(&bytes.Buffer{}, semantic.Plan{}, view.Options{}) require.Error(t, err) - assert.ErrorContains(t, err, "invalid completeness") + assert.ErrorContains(t, err, "invalid helmAction") } func TestWriteJSON_ShowSecrets(t *testing.T) { @@ -391,21 +396,13 @@ func TestWriteJSON_MatchesSchemaForRepresentativePlans(t *testing.T) { Before: snap(cm("app", "v1")), Apply: deleteApply(), }}, nil)}, - {name: "partial update", plan: mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ + {name: "update without after", plan: mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: res, Origin: helmOrigin(), Action: semantic.Update, Before: snap(cm("app", "v1")), Apply: writeApply(), }}, []semantic.Diagnostic{limitationFor(res)})}, - {name: "replace", plan: mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Replace, - Before: snap(cm("app", "v1")), - After: snap(cm("app", "v2")), - Apply: bothApply(), - }}, nil)}, {name: "create write create", plan: mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: res, Origin: helmOrigin(), diff --git a/internal/plan/view/prepare.go b/internal/plan/view/prepare.go index 317e7fa..a300f83 100644 --- a/internal/plan/view/prepare.go +++ b/internal/plan/view/prepare.go @@ -33,10 +33,6 @@ func prepareRender(p semantic.Plan, opts Options) (semantic.Plan, error) { } func validateRenderable(p semantic.Plan) error { - if err := requireEnum("completeness", p.Completeness.String(), - semantic.CompletenessComplete.String(), semantic.CompletenessPartial.String()); err != nil { - return err - } if err := requireEnum("helmAction", p.HelmAction.String(), semantic.HelmNone.String(), semantic.HelmInstall.String(), semantic.HelmUpgrade.String()); err != nil { return err @@ -44,7 +40,7 @@ func validateRenderable(p semantic.Plan) error { for i, c := range p.Changes { if err := requireEnum(fmt.Sprintf("change %d action", i), c.Action.String(), semantic.Create.String(), semantic.Update.String(), - semantic.Delete.String(), semantic.Replace.String()); err != nil { + semantic.Delete.String()); err != nil { return err } if err := requireEnum(fmt.Sprintf("change %d origin", i), c.Origin.Kind.String(), diff --git a/internal/plan/view/prepare_test.go b/internal/plan/view/prepare_test.go index 9c9d626..120e356 100644 --- a/internal/plan/view/prepare_test.go +++ b/internal/plan/view/prepare_test.go @@ -41,25 +41,18 @@ func TestPrepareRender_InvalidEnums(t *testing.T) { plan semantic.Plan wantErr string }{ - { - name: "invalid completeness", - plan: semantic.Plan{}, - wantErr: "invalid completeness", - }, { name: "invalid action", plan: semantic.Plan{ - Completeness: semantic.CompletenessComplete, - HelmAction: semantic.HelmUpgrade, - Changes: []semantic.ResourceChange{{Origin: helmOrigin()}}, + HelmAction: semantic.HelmUpgrade, + Changes: []semantic.ResourceChange{{Origin: helmOrigin()}}, }, wantErr: "invalid change 0 action", }, { name: "invalid origin", plan: semantic.Plan{ - Completeness: semantic.CompletenessComplete, - HelmAction: semantic.HelmUpgrade, + HelmAction: semantic.HelmUpgrade, Changes: []semantic.ResourceChange{{ Action: semantic.Create, }}, @@ -69,8 +62,7 @@ func TestPrepareRender_InvalidEnums(t *testing.T) { { name: "invalid write method", plan: semantic.Plan{ - Completeness: semantic.CompletenessComplete, - HelmAction: semantic.HelmUpgrade, + HelmAction: semantic.HelmUpgrade, Changes: []semantic.ResourceChange{{ Action: semantic.Create, Origin: helmOrigin(), @@ -82,8 +74,7 @@ func TestPrepareRender_InvalidEnums(t *testing.T) { { name: "invalid delete propagation", plan: semantic.Plan{ - Completeness: semantic.CompletenessComplete, - HelmAction: semantic.HelmUpgrade, + HelmAction: semantic.HelmUpgrade, Changes: []semantic.ResourceChange{{ Action: semantic.Delete, Origin: helmOrigin(), @@ -97,15 +88,14 @@ func TestPrepareRender_InvalidEnums(t *testing.T) { plan: func() semantic.Plan { c := validChange() c.Fields = []semantic.FieldChange{{Path: "/data/key"}} - return semantic.Plan{Completeness: semantic.CompletenessComplete, HelmAction: semantic.HelmUpgrade, Changes: []semantic.ResourceChange{c}} + return semantic.Plan{HelmAction: semantic.HelmUpgrade, Changes: []semantic.ResourceChange{c}} }(), wantErr: "invalid change 0 field 0 op", }, { name: "invalid diagnostic severity", plan: semantic.Plan{ - Completeness: semantic.CompletenessComplete, - HelmAction: semantic.HelmUpgrade, + HelmAction: semantic.HelmUpgrade, Diagnostics: []semantic.Diagnostic{{ Category: semantic.CategoryPredictionLimitation, Message: "x", @@ -116,8 +106,7 @@ func TestPrepareRender_InvalidEnums(t *testing.T) { { name: "invalid diagnostic category", plan: semantic.Plan{ - Completeness: semantic.CompletenessComplete, - HelmAction: semantic.HelmUpgrade, + HelmAction: semantic.HelmUpgrade, Diagnostics: []semantic.Diagnostic{{ Severity: semantic.DiagnosticWarning, Message: "x", diff --git a/internal/plan/view/redact_test.go b/internal/plan/view/redact_test.go index f2e1d6d..a00a41a 100644 --- a/internal/plan/view/redact_test.go +++ b/internal/plan/view/redact_test.go @@ -284,7 +284,6 @@ func TestWriteRenderers_CopyIsolation(t *testing.T) { t.Parallel() helm := &semantic.HelmOrigin{Release: "web", Namespace: "prod"} write := &semantic.WriteSemantics{Method: semantic.WriteServerSide, FieldManager: "deployah"} - del := &semantic.DeleteSemantics{Propagation: semantic.PropagationBackground} res := ref("Secret", "s") diag := semantic.Diagnostic{ Severity: semantic.DiagnosticWarning, @@ -302,10 +301,10 @@ func TestWriteRenderers_CopyIsolation(t *testing.T) { p, err := semantic.New(semantic.Header{Release: "web", Namespace: "prod"}, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: res, Origin: semantic.ResourceOrigin{Kind: semantic.OriginHelm, Helm: helm}, - Action: semantic.Replace, + Action: semantic.Update, Before: snap(beforeObj), After: snap(secretObj("s", "new", "tok2")), - Apply: semantic.ApplySemantics{Write: write, Delete: del}, + Apply: semantic.ApplySemantics{Write: write}, }}, nil, []semantic.Diagnostic{diag}) require.NoError(t, err) require.NotEmpty(t, p.Changes[0].Fields) @@ -313,7 +312,6 @@ func TestWriteRenderers_CopyIsolation(t *testing.T) { fieldAfter := p.Changes[0].Fields[0].After helmRelease := p.Changes[0].Origin.Helm.Release fieldManager := p.Changes[0].Apply.Write.FieldManager - propagation := p.Changes[0].Apply.Delete.Propagation diagName := p.Diagnostics[0].Resource.Name require.NoError(t, view.WriteHuman(&bytes.Buffer{}, p, view.Options{})) @@ -321,7 +319,6 @@ func TestWriteRenderers_CopyIsolation(t *testing.T) { assert.Equal(t, helmRelease, p.Changes[0].Origin.Helm.Release) assert.Equal(t, fieldManager, p.Changes[0].Apply.Write.FieldManager) - assert.Equal(t, propagation, p.Changes[0].Apply.Delete.Propagation) assert.Equal(t, diagName, p.Diagnostics[0].Resource.Name) assert.Equal(t, "old", objectString(t, p.Changes[0].Before.Object, "stringData", "password")) assert.Equal(t, "new", objectString(t, p.Changes[0].After.Object, "stringData", "password")) @@ -334,9 +331,8 @@ func TestWriteRenderers_CopyIsolation(t *testing.T) { func TestWriteRenderers_ManualSnapshotShapes(t *testing.T) { t.Parallel() p := semantic.Plan{ - Completeness: semantic.CompletenessComplete, - HelmAction: semantic.HelmUpgrade, - Header: semantic.Header{Release: "web"}, + HelmAction: semantic.HelmUpgrade, + Header: semantic.Header{Release: "web"}, Changes: []semantic.ResourceChange{ { Resource: ref("ConfigMap", "app"), @@ -376,9 +372,8 @@ func TestWriteRenderers_ManualSnapshotShapes(t *testing.T) { func TestWriteRenderers_NilTasks(t *testing.T) { t.Parallel() p := semantic.Plan{ - Completeness: semantic.CompletenessComplete, - HelmAction: semantic.HelmNone, - Header: semantic.Header{Release: "web"}, + HelmAction: semantic.HelmNone, + Header: semantic.Header{Release: "web"}, } assert.Nil(t, p.Tasks) require.NoError(t, view.WriteHuman(&bytes.Buffer{}, p, view.Options{})) diff --git a/internal/plan/view/schema/semantic_plan.v1.json b/internal/plan/view/schema/semantic_plan.v1.json index 37f052b..f2eb86c 100644 --- a/internal/plan/view/schema/semantic_plan.v1.json +++ b/internal/plan/view/schema/semantic_plan.v1.json @@ -13,8 +13,7 @@ "tasks", "chartCRDs", "diagnostics", - "summary", - "completeness" + "summary" ], "properties": { "schema": { @@ -54,10 +53,6 @@ }, "summary": { "$ref": "#/$defs/Summary" - }, - "completeness": { - "type": "string", - "enum": ["complete", "partial"] } }, "allOf": [ @@ -218,7 +213,7 @@ }, "action": { "type": "string", - "enum": ["create", "update", "delete", "replace"] + "enum": ["create", "update", "delete"] }, "before": { "$ref": "#/$defs/Snapshot" @@ -327,29 +322,6 @@ } } } - }, - { - "if": { - "properties": { - "action": { - "const": "replace" - } - }, - "required": ["action"] - }, - "then": { - "properties": { - "before": { - "type": "object" - }, - "after": { - "type": "object" - }, - "apply": { - "required": ["write", "delete"] - } - } - } } ] }, @@ -600,7 +572,7 @@ "Summary": { "type": "object", "additionalProperties": false, - "required": ["create", "update", "delete", "replace", "total"], + "required": ["create", "update", "delete", "total"], "properties": { "create": { "type": "integer", @@ -614,10 +586,6 @@ "type": "integer", "minimum": 0 }, - "replace": { - "type": "integer", - "minimum": 0 - }, "total": { "type": "integer", "minimum": 0 diff --git a/internal/plan/view/schema_test.go b/internal/plan/view/schema_test.go index 0ea69c5..6bd59ad 100644 --- a/internal/plan/view/schema_test.go +++ b/internal/plan/view/schema_test.go @@ -41,9 +41,18 @@ func TestSchemaV1ID_MatchesEmbeddedAndRendered(t *testing.T) { required, ok := sch["required"].([]any) require.True(t, ok) assert.Contains(t, required, "chartCRDs") + assert.NotContains(t, required, "completeness") + _, hasCompleteness := props["completeness"] + assert.False(t, hasCompleteness) defs, ok := sch["$defs"].(map[string]any) require.True(t, ok) + summary, ok := defs["Summary"].(map[string]any) + require.True(t, ok) + summaryProps, ok := summary["properties"].(map[string]any) + require.True(t, ok) + _, hasReplace := summaryProps["replace"] + assert.False(t, hasReplace) origin, ok := defs["Origin"].(map[string]any) require.True(t, ok) originProps, ok := origin["properties"].(map[string]any) @@ -82,14 +91,6 @@ func TestSchemaV1_RejectsMalformedDocuments(t *testing.T) { Before: snap(cm("app", "v1")), Apply: deleteApply(), }}, nil)) - replace := mustPlanDoc(t, mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ - Resource: res, - Origin: helmOrigin(), - Action: semantic.Replace, - Before: snap(cm("app", "v1")), - After: snap(cm("app", "v2")), - Apply: bothApply(), - }}, nil)) createWrite := mustPlanDoc(t, mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ Resource: res, Origin: helmOrigin(), @@ -182,17 +183,11 @@ func TestSchemaV1_RejectsMalformedDocuments(t *testing.T) { {name: "delete missing delete", raw: patched(t, deleteDoc, func(d map[string]any) { delete(applyOf(t, d), "delete") })}, - {name: "replace with after null", raw: patched(t, replace, func(d map[string]any) { - firstChange(t, d)["after"] = nil - })}, - {name: "replace with before null", raw: patched(t, replace, func(d map[string]any) { - firstChange(t, d)["before"] = nil + {name: "top-level completeness", raw: patched(t, update, func(d map[string]any) { + d["completeness"] = "complete" })}, - {name: "replace missing write", raw: patched(t, replace, func(d map[string]any) { - delete(applyOf(t, d), "write") - })}, - {name: "replace missing delete", raw: patched(t, replace, func(d map[string]any) { - delete(applyOf(t, d), "delete") + {name: "action replace", raw: patched(t, update, func(d map[string]any) { + firstChange(t, d)["action"] = "replace" })}, {name: "unknown top-level executions", raw: patched(t, update, func(d map[string]any) { d["executions"] = []any{} diff --git a/internal/plan/view/testdata/golden/human_all_actions.golden b/internal/plan/view/testdata/golden/human_all_actions.golden index c4d0868..b2b3e34 100644 --- a/internal/plan/view/testdata/golden/human_all_actions.golden +++ b/internal/plan/view/testdata/golden/human_all_actions.golden @@ -33,16 +33,6 @@ Resources - data: - key: v1 - -/+ replace v1/ConfigMap "rs" - apiVersion: v1 - kind: ConfigMap - metadata: - name: rs - namespace: prod - data: - - key: v1 - + key: v2 - ~ update v1/ConfigMap "web" data: - key: v1 @@ -121,5 +111,5 @@ Tasks + schedule: 0 3 * * * Summary - Resources: 1 create, 2 update, 1 delete, 1 replace + Resources: 1 create, 2 update, 1 delete Tasks: 3 to run, 1 schedule changed diff --git a/internal/plan/view/testdata/golden/human_partial.golden b/internal/plan/view/testdata/golden/human_limitation.golden similarity index 78% rename from internal/plan/view/testdata/golden/human_partial.golden rename to internal/plan/view/testdata/golden/human_limitation.golden index 2c83b68..3df5d22 100644 --- a/internal/plan/view/testdata/golden/human_partial.golden +++ b/internal/plan/view/testdata/golden/human_limitation.golden @@ -5,8 +5,6 @@ Namespace: prod Release: web Revision: 12 -Warning: prediction is incomplete - Resources ~ update v1/ConfigMap "web" @@ -19,4 +17,4 @@ Diagnostics warning prediction_limitation v1/ConfigMap "web": prediction is not exact: managed-fields-migration Summary - Resources: 0 create, 1 update, 0 delete, 0 replace + Resources: 0 create, 1 update, 0 delete diff --git a/internal/plan/view/testdata/golden/human_noop_limitation.golden b/internal/plan/view/testdata/golden/human_noop_limitation.golden index 7dcb50f..6cd6d46 100644 --- a/internal/plan/view/testdata/golden/human_noop_limitation.golden +++ b/internal/plan/view/testdata/golden/human_noop_limitation.golden @@ -5,11 +5,9 @@ Namespace: prod Release: web Revision: 12 -Warning: prediction is incomplete - Diagnostics warning prediction_limitation example.com/v1/Widget "app": prediction is not exact: prediction used the currently installed CRD, but that CRD's spec changes before Helm executes Summary - Resources: 0 create, 0 update, 0 delete, 0 replace + Resources: 0 create, 0 update, 0 delete diff --git a/internal/plan/view/testdata/golden/human_prerequisites.golden b/internal/plan/view/testdata/golden/human_prerequisites.golden index 6a19b3e..ea93219 100644 --- a/internal/plan/view/testdata/golden/human_prerequisites.golden +++ b/internal/plan/view/testdata/golden/human_prerequisites.golden @@ -5,8 +5,6 @@ Namespace: prod Release: web Revision: 1 -Warning: prediction is incomplete - Resources + create v1/Namespace "prod" @@ -32,4 +30,4 @@ Diagnostics warning prediction_limitation example.com/v1/Widget "app": target namespace is created earlier in this deployment Summary - Resources: 2 create, 0 update, 0 delete, 0 replace + Resources: 2 create, 0 update, 0 delete diff --git a/internal/plan/view/testdata/golden/human_semantic_plan.golden b/internal/plan/view/testdata/golden/human_semantic_plan.golden index d33aae1..fec8f22 100644 --- a/internal/plan/view/testdata/golden/human_semantic_plan.golden +++ b/internal/plan/view/testdata/golden/human_semantic_plan.golden @@ -312,5 +312,5 @@ Tasks + schedule: 0 3 * * * Summary - Resources: 1 create, 2 update, 1 delete, 0 replace + Resources: 1 create, 2 update, 1 delete Tasks: 2 to run, 1 schedule changed diff --git a/internal/plan/view/testdata/golden/json_update.golden b/internal/plan/view/testdata/golden/json_update.golden index 21bdbe4..55f8aa3 100644 --- a/internal/plan/view/testdata/golden/json_update.golden +++ b/internal/plan/view/testdata/golden/json_update.golden @@ -61,8 +61,6 @@ "create": 0, "update": 1, "delete": 0, - "replace": 0, "total": 1 - }, - "completeness": "complete" + } }