diff --git a/internal/plan/view/human.go b/internal/plan/view/human.go index 0f043c9..a69f5c2 100644 --- a/internal/plan/view/human.go +++ b/internal/plan/view/human.go @@ -22,7 +22,6 @@ import ( "github.com/aymanbagabas/go-udiff" "k8s.io/apimachinery/pkg/runtime/schema" - "nabat.dev/theme" "deployah.dev/deployah/internal/plan/semantic" ) @@ -31,6 +30,10 @@ import ( // ancestor-projected, not whole-object dumps. const yamlDiffContextLines = 3 +// humanTabWidth is the number of spaces that replace one tab in Human +// output. Expansion keeps column alignment. +const humanTabWidth = 4 + const headerLabelWidth = 10 // WriteHuman writes a deterministic YAML-oriented rendering of p. It @@ -86,7 +89,7 @@ func WriteHuman(w io.Writer, p semantic.Plan, opts Options) error { func writeHumanHeader(w io.Writer, h semantic.Header, opts Options) error { if h.Project != "" || h.Environment != "" { line := fmt.Sprintf("Plan for project %q on environment %q", h.Project, h.Environment) - if err := writeln(w, opts, theme.TextTitle, line); err != nil { + if err := writeln(w, opts, RoleTitle, line); err != nil { return err } if _, err := fmt.Fprintln(w); err != nil { @@ -105,12 +108,12 @@ func writeHumanHeader(w io.Writer, h semantic.Header, opts Options) error { if p.value == "" { continue } - if err := writeln(w, opts, theme.TextPrimary, padLabel(p.label)+" "+p.value); err != nil { + if err := writeln(w, opts, RolePrimary, padLabel(p.label)+" "+p.value); err != nil { return err } } if h.Revision > 0 { - if err := writeln(w, opts, theme.TextPrimary, padLabel("Revision:")+" "+strconv.Itoa(h.Revision)); err != nil { + if err := writeln(w, opts, RolePrimary, padLabel("Revision:")+" "+strconv.Itoa(h.Revision)); err != nil { return err } } @@ -136,7 +139,7 @@ func writeHumanResources(w io.Writer, changes []semantic.ResourceChange, owned m if len(visible) == 0 { return false, nil } - if err := writeln(w, opts, theme.TextTitle, "Resources"); err != nil { + if err := writeln(w, opts, RoleTitle, "Resources"); err != nil { return false, err } if err := writeBlank(w); err != nil { @@ -159,7 +162,7 @@ func writeHumanTasks(w io.Writer, p semantic.Plan, indexed map[string]semantic.R if len(p.Tasks) == 0 { return nil } - if err := writeln(w, opts, theme.TextTitle, "Tasks"); err != nil { + if err := writeln(w, opts, RoleTitle, "Tasks"); err != nil { return err } if err := writeBlank(w); err != nil { @@ -179,7 +182,7 @@ func writeHumanTasks(w io.Writer, p semantic.Plan, indexed map[string]semantic.R firstPhase = false firstInPhase = true current = t.Phase - if err := writeln(w, opts, theme.TextTitle, " "+t.Phase.String()); err != nil { + if err := writeln(w, opts, RoleTitle, " "+t.Phase.String()); err != nil { return err } if err := writeBlank(w); err != nil { @@ -200,7 +203,7 @@ func writeHumanTasks(w io.Writer, p semantic.Plan, indexed map[string]semantic.R } func writeHumanTask(w io.Writer, t semantic.TaskPlan, indexed map[string]semantic.ResourceChange, planNS string, opts Options) error { - if err := writeln(w, opts, taskHeadingToken(t), " "+taskTitle(t)); err != nil { + if err := writeln(w, opts, taskHeadingRole(t), " "+taskTitle(t)); err != nil { return err } for i := range t.Definitions { @@ -254,7 +257,7 @@ func taskTitle(t semantic.TaskPlan) string { func writeHumanDefinition(w io.Writer, d semantic.HookDefinition, planNS string, opts Options) error { line := resourceHeading(d.Action, d.Resource, planNS) - if err := writeln(w, opts, actionHeadingToken(d.Action), " "+line); err != nil { + if err := writeln(w, opts, actionHeadingRole(d.Action), " "+line); err != nil { return err } return writeActionBody(w, d.Action, d.Before, d.After, d.Fields, d.Resource, " ", opts) @@ -262,7 +265,7 @@ func writeHumanDefinition(w io.Writer, d semantic.HookDefinition, planNS string, func writeHumanChange(w io.Writer, c semantic.ResourceChange, planNS, indent string, opts Options) error { line := resourceHeading(c.Action, c.Resource, planNS) - if err := writeln(w, opts, actionHeadingToken(c.Action), indent+line); err != nil { + if err := writeln(w, opts, actionHeadingRole(c.Action), indent+line); err != nil { return err } return writeActionBody(w, c.Action, c.Before, c.After, c.Fields, c.Resource, indent, opts) @@ -272,7 +275,7 @@ func writeHumanDrift(w io.Writer, drift []semantic.DriftChange, planNS string, o if len(drift) == 0 { return nil } - if err := writeln(w, opts, theme.TextTitle, "Drift"); err != nil { + if err := writeln(w, opts, RoleTitle, "Drift"); err != nil { return err } if err := writeBlank(w); err != nil { @@ -293,7 +296,7 @@ func writeHumanDrift(w io.Writer, drift []semantic.DriftChange, planNS string, o func writeHumanDriftChange(w io.Writer, d semantic.DriftChange, planNS, indent string, opts Options) error { line := driftHeading(d.Action, d.Resource, planNS) - if err := writeln(w, opts, driftHeadingToken(d.Action), indent+line); err != nil { + if err := writeln(w, opts, driftHeadingRole(d.Action), indent+line); err != nil { return err } beforeObj, afterObj, renderFields := humanSides(d.Previous, d.Live, d.Fields, d.Resource, opts) @@ -302,9 +305,9 @@ func writeHumanDriftChange(w io.Writer, d semantic.DriftChange, planNS, indent s beforeProj, afterProj := projectFields(beforeObj, afterObj, renderFields) return writeProjectedDiff(w, beforeProj, afterProj, indent, opts) case semantic.DriftMissing: - return writeObjectYAML(w, beforeObj, indent+"- ", theme.StatusError, opts) + return writeObjectYAML(w, beforeObj, indent+"- ", RoleDiffRemoved, opts) case semantic.DriftUnexpected: - return writeObjectYAML(w, afterObj, indent+"+ ", theme.StatusSuccess, opts) + return writeObjectYAML(w, afterObj, indent+"+ ", RoleDiffAdded, opts) default: return nil } @@ -322,9 +325,9 @@ func writeActionBody( beforeObj, afterObj, renderFields := humanSides(before, after, fields, ref, opts) switch action { case semantic.Create: - return writeObjectYAML(w, afterObj, indent+"+ ", theme.StatusSuccess, opts) + return writeObjectYAML(w, afterObj, indent+"+ ", RoleDiffAdded, opts) case semantic.Delete: - return writeObjectYAML(w, beforeObj, indent+"- ", theme.StatusError, opts) + return writeObjectYAML(w, beforeObj, indent+"- ", RoleDiffRemoved, opts) case semantic.Update: beforeProj, afterProj := projectFields(beforeObj, afterObj, renderFields) return writeProjectedDiff(w, beforeProj, afterProj, indent, opts) @@ -349,12 +352,12 @@ func humanSides( return beforeObj, afterObj, renderFields } -func writeObjectYAML(w io.Writer, obj map[string]any, prefix string, token theme.Token, opts Options) error { +func writeObjectYAML(w io.Writer, obj map[string]any, prefix string, role Role, opts Options) error { text, err := marshalOrderedYAML(obj) if err != nil { return err } - return writePrefixedYAML(w, text, prefix, token, opts) + return writePrefixedYAML(w, text, prefix, role, opts) } func writeProjectedDiff(w io.Writer, before, after map[string]any, indent string, opts Options) error { @@ -375,9 +378,9 @@ func writeProjectedDiff(w io.Writer, before, after map[string]any, indent string return writeYAMLDiff(w, beforeYAML, afterYAML, indent, opts) } -func writePrefixedYAML(w io.Writer, text, prefix string, token theme.Token, opts Options) error { +func writePrefixedYAML(w io.Writer, text, prefix string, role Role, opts Options) error { for line := range strings.SplitSeq(strings.TrimRight(revealSecretSentinels(text), "\n"), "\n") { - if err := writeln(w, opts, token, prefix+line); err != nil { + if err := writeln(w, opts, role, prefix+line); err != nil { return err } } @@ -391,19 +394,19 @@ func writeYAMLDiff(w io.Writer, beforeYAML, afterYAML, indent string, opts Optio return fmt.Errorf("diff yaml: %w", err) } if len(diff.Hunks) == 0 { - return writePrefixedYAML(w, afterYAML, indent+" ", theme.TextMuted, opts) + return writePrefixedYAML(w, afterYAML, indent+" ", RoleDiffContext, opts) } for _, hunk := range diff.Hunks { for _, line := range hunk.Lines { content := revealSecretSentinels(strings.TrimRight(line.Content, "\n")) - prefix, token := indent+" ", theme.TextMuted + prefix, role := indent+" ", RoleDiffContext switch line.Kind { case udiff.Delete: - prefix, token = indent+"- ", theme.StatusError + prefix, role = indent+"- ", RoleDiffRemoved case udiff.Insert: - prefix, token = indent+"+ ", theme.StatusSuccess + prefix, role = indent+"+ ", RoleDiffAdded } - if werr := writeln(w, opts, token, prefix+content); werr != nil { + if werr := writeln(w, opts, role, prefix+content); werr != nil { return werr } } @@ -412,12 +415,12 @@ func writeYAMLDiff(w io.Writer, beforeYAML, afterYAML, indent string, opts Optio } func writeHumanFooter(w io.Writer, p semantic.Plan, opts Options) error { - if err := writeln(w, opts, theme.TextTitle, "Summary"); err != nil { + if err := writeln(w, opts, RoleTitle, "Summary"); err != nil { return err } s := p.Summary 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 { + if err := writeln(w, opts, RolePrimary, resourceLine); err != nil { return err } if p.HasDrift() { @@ -433,7 +436,7 @@ func writeHumanFooter(w io.Writer, p semantic.Plan, opts Options) error { } } driftLine := fmt.Sprintf(" Drift: %d modified, %d missing, %d unexpected", modified, missing, unexpected) - if err := writeln(w, opts, theme.TextPrimary, driftLine); err != nil { + if err := writeln(w, opts, RolePrimary, driftLine); err != nil { return err } } @@ -454,7 +457,7 @@ func writeHumanFooter(w io.Writer, p semantic.Plan, opts Options) error { if schedChanged > 0 { taskLine += fmt.Sprintf(", %d schedule changed", schedChanged) } - return writeln(w, opts, theme.TextPrimary, taskLine) + return writeln(w, opts, RolePrimary, taskLine) } func writeBlank(w io.Writer) error { @@ -513,14 +516,14 @@ func driftMarker(action semantic.DriftAction) string { } } -func driftHeadingToken(action semantic.DriftAction) theme.Token { +func driftHeadingRole(action semantic.DriftAction) Role { switch action { case semantic.DriftUnexpected: - return theme.StatusSuccess + return RoleDiffAdded case semantic.DriftMissing: - return theme.StatusError + return RoleDiffRemoved default: - return theme.StatusWarning + return RoleDiffModified } } @@ -537,29 +540,29 @@ func actionMarker(action semantic.Action) string { } } -func actionHeadingToken(action semantic.Action) theme.Token { +func actionHeadingRole(action semantic.Action) Role { switch action { case semantic.Create: - return theme.StatusSuccess + return RoleDiffAdded case semantic.Delete: - return theme.StatusError + return RoleDiffRemoved case semantic.Update: - return theme.StatusWarning + return RoleDiffModified default: - return theme.StatusWarning + return RoleDiffModified } } -func taskHeadingToken(t semantic.TaskPlan) theme.Token { +func taskHeadingRole(t semantic.TaskPlan) Role { switch t.Action { case semantic.TaskCreate: - return theme.StatusSuccess + return RoleDiffAdded case semantic.TaskDelete: - return theme.StatusError + return RoleDiffRemoved case semantic.TaskUpdate: - return theme.StatusWarning + return RoleDiffModified default: - return theme.TextPrimary + return RolePrimary } } @@ -592,11 +595,11 @@ func snapshotMap(s *semantic.ResourceSnapshot) map[string]any { return s.Object } -func style(opts Options, token theme.Token, s string) string { - return opts.Theme.Style(token).Render(s) -} - -func writeln(w io.Writer, opts Options, token theme.Token, s string) error { - _, err := fmt.Fprintln(w, style(opts, token, s)) +func writeln(w io.Writer, opts Options, role Role, s string) error { + line := strings.ReplaceAll(s, "\t", strings.Repeat(" ", humanTabWidth)) + if opts.Styler != nil { + line = opts.Styler.Style(role, line) + } + _, err := fmt.Fprintln(w, line) return err } diff --git a/internal/plan/view/human_style_test.go b/internal/plan/view/human_style_test.go new file mode 100644 index 0000000..e2c9980 --- /dev/null +++ b/internal/plan/view/human_style_test.go @@ -0,0 +1,330 @@ +// Copyright 2025 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 view_test + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "deployah.dev/deployah/internal/plan/semantic" + "deployah.dev/deployah/internal/plan/view" +) + +type styledLine struct { + role view.Role + line string +} + +type recordingStyler struct { + lines []styledLine +} + +func (s *recordingStyler) Style(role view.Role, line string) string { + s.lines = append(s.lines, styledLine{role: role, line: line}) + return line +} + +// markingStyler returns a different string from the one it receives, so +// tests can see that WriteHuman writes the result of Style. +type markingStyler struct{} + +func (markingStyler) Style(_ view.Role, line string) string { + return "<" + line + ">" +} + +func TestWriteHuman_StylerPreservesLayout(t *testing.T) { + t.Parallel() + tests := []struct { + name string + plan semantic.Plan + golden string + }{ + {name: "all actions", plan: allActionsPlan(t), golden: "human_all_actions"}, + {name: "drift", plan: humanDriftPlan(t), golden: "human_drift"}, + { + name: "semantic", + plan: semanticUpgradePlan(t, previousProductSpec(), currentProductSpec()), + golden: "human_semantic_plan", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + plain := assertRoleTrace(t, tt.golden+".roles", tt.plan) + assertGolden(t, tt.golden, plain) + }) + } +} + +func TestWriteHuman_StyleRoles(t *testing.T) { + t.Parallel() + header := humanHeader() + unexpected, err := semantic.AttachDrift(mustPlanWithHeader(t, header, semantic.HelmNone, nil, nil), []semantic.DriftChange{{ + Resource: ref("ConfigMap", "extra"), + Action: semantic.DriftUnexpected, + Live: snap(cm("extra", "live")), + }}) + require.NoError(t, err) + missing, err := semantic.AttachDrift(mustPlanWithHeader(t, header, semantic.HelmNone, nil, nil), []semantic.DriftChange{{ + Resource: ref("ConfigMap", "other"), + Action: semantic.DriftMissing, + Previous: snap(cm("other", "gone")), + }}) + require.NoError(t, err) + modified, err := semantic.AttachDrift(mustPlanWithHeader(t, header, semantic.HelmNone, nil, nil), []semantic.DriftChange{{ + Resource: ref("ConfigMap", "app"), + Action: semantic.DriftModified, + Previous: snap(cm("app", "old")), + Live: snap(cm("app", "new")), + Fields: []semantic.FieldChange{{ + Path: "/data/key", + Op: semantic.FieldReplace, + Before: "old", + After: "new", + }}, + }}) + require.NoError(t, err) + tasks := mustPlanWithHeader(t, header, semantic.HelmUpgrade, nil, []semantic.TaskPlan{ + { + Name: "migrate", + Phase: semantic.TaskPreDeploy, + Action: semantic.TaskUpdate, + WillRun: true, + Definitions: []semantic.HookDefinition{{ + Resource: ref("ConfigMap", "env"), + Action: semantic.Update, + Before: snap(cm("env", "v1")), + After: snap(cm("env", "v2")), + }}, + }, + { + Name: "seed", + Phase: semantic.TaskPreDeploy, + Action: semantic.TaskUnchanged, + WillRun: true, + HookWeight: 1, + }, + { + Name: "smoke", + Phase: semantic.TaskPostDeploy, + Action: semantic.TaskCreate, + WillRun: true, + HookWeight: 0, + Definitions: []semantic.HookDefinition{{ + Resource: ref("ConfigMap", "hook"), + Action: semantic.Create, + After: snap(cm("hook", "v1")), + }}, + }, + { + Name: "old-check", + Phase: semantic.TaskPostDeploy, + Action: semantic.TaskDelete, + HookWeight: 1, + Definitions: []semantic.HookDefinition{{ + Resource: ref("ConfigMap", "gone"), + Action: semantic.Delete, + Before: snap(cm("gone", "v1")), + }}, + }, + }) + tests := []struct { + name string + golden string + plan semantic.Plan + }{ + { + name: "create", + golden: "style_create", + plan: mustPlanWithHeader(t, header, semantic.HelmUpgrade, []semantic.ResourceChange{{ + Resource: ref("ConfigMap", "app"), + Action: semantic.Create, + After: snap(cm("app", "v1")), + }}, nil), + }, + { + name: "delete", + golden: "style_delete", + plan: mustPlanWithHeader(t, header, semantic.HelmUpgrade, []semantic.ResourceChange{{ + Resource: ref("ConfigMap", "old"), + Action: semantic.Delete, + Before: snap(cm("old", "v1")), + }}, nil), + }, + { + name: "update", + golden: "style_update", + plan: mustPlanWithHeader(t, header, semantic.HelmUpgrade, []semantic.ResourceChange{{ + Resource: ref("ConfigMap", "web"), + Action: semantic.Update, + Before: snap(cm("web", "v1")), + After: snap(cm("web", "v2")), + }}, nil), + }, + {name: "drift unexpected", golden: "style_drift_unexpected", plan: unexpected}, + {name: "drift missing", golden: "style_drift_missing", plan: missing}, + {name: "drift modified", golden: "style_drift_modified", plan: modified}, + {name: "tasks", golden: "style_tasks", plan: tasks}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assertRoleTrace(t, tt.golden, tt.plan) + }) + } +} + +func TestWriteHuman_ExpandsTabs(t *testing.T) { + t.Parallel() + tests := []struct { + name string + plan semantic.Plan + role view.Role + lines []string + }{ + { + name: "yaml literal", + plan: mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{{ + Resource: ref("ConfigMap", "app"), + Action: semantic.Create, + After: snap(cm("app", "line1\nline2\twith tab")), + }}), + role: view.RoleDiffAdded, + lines: []string{ + " + key: |-", + " + line2 with tab", + }, + }, + { + name: "header context", + plan: mustPlanWithHeader(t, semantic.Header{ + Project: "web", + Environment: "prod", + Release: "web", + Namespace: "prod", + Context: "prod\tuction-eu", + }, semantic.HelmNone, nil, nil), + role: view.RolePrimary, + lines: []string{"Context: prod uction-eu"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var plainBuf, styledBuf bytes.Buffer + require.NoError(t, view.WriteHuman(&plainBuf, tt.plan, view.Options{})) + rec := &recordingStyler{} + require.NoError(t, view.WriteHuman(&styledBuf, tt.plan, view.Options{Styler: rec})) + plain := plainBuf.String() + styled := styledBuf.String() + assert.Equal(t, plain, styled) + assert.NotContains(t, plain, "\t") + recorded := make(map[string]view.Role, len(rec.lines)) + for _, line := range rec.lines { + assert.NotContains(t, line.line, "\t") + recorded[line.line] = line.role + } + for _, line := range tt.lines { + assert.Contains(t, "\n"+plain, "\n"+line+"\n") + got, ok := recorded[line] + require.Truef(t, ok, "styled lines omit %q", line) + assert.Equal(t, tt.role, got) + } + }) + } +} + +func TestWriteHuman_WritesStylerResult(t *testing.T) { + t.Parallel() + p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{createChangeForHuman()}) + var plainBuf, styledBuf bytes.Buffer + require.NoError(t, view.WriteHuman(&plainBuf, p, view.Options{})) + require.NoError(t, view.WriteHuman(&styledBuf, p, view.Options{Styler: markingStyler{}})) + var want strings.Builder + for line := range strings.SplitSeq(strings.TrimRight(plainBuf.String(), "\n"), "\n") { + if line == "" { + want.WriteByte('\n') + continue + } + want.WriteString("<" + line + ">\n") + } + assert.Equal(t, want.String(), styledBuf.String()) +} + +func assertRoleTrace(t *testing.T, golden string, plan semantic.Plan) string { + t.Helper() + var plainBuf, styledBuf bytes.Buffer + require.NoError(t, view.WriteHuman(&plainBuf, plan, view.Options{})) + rec := &recordingStyler{} + require.NoError(t, view.WriteHuman(&styledBuf, plan, view.Options{Styler: rec})) + plain := plainBuf.String() + assert.Equal(t, plain, styledBuf.String()) + for _, line := range rec.lines { + assert.NotContains(t, line.line, "\t") + } + trace := roleTrace(rec.lines) + assertGolden(t, golden, trace) + var want, got []string + for line := range strings.SplitSeq(strings.TrimRight(plain, "\n"), "\n") { + if line != "" { + want = append(want, line) + } + } + for line := range strings.SplitSeq(strings.TrimRight(trace, "\n"), "\n") { + role, text, ok := strings.Cut(line, "\t") + require.Truef(t, ok, "role row %q has no tab", line) + require.NotEmpty(t, role) + require.NotContains(t, text, "\t") + got = append(got, text) + } + assert.Equal(t, want, got) + return plain +} + +// roleTrace renders one golden row per styled line: the role name, a +// tab, and the Human line. Blank lines are absent because WriteHuman +// does not style them. The text after the tab is the plain Human line. +func roleTrace(lines []styledLine) string { + var b strings.Builder + for _, line := range lines { + var name string + switch line.role { + case view.RoleTitle: + name = "Title" + case view.RolePrimary: + name = "Primary" + case view.RoleDiffAdded: + name = "DiffAdded" + case view.RoleDiffRemoved: + name = "DiffRemoved" + case view.RoleDiffModified: + name = "DiffModified" + case view.RoleDiffContext: + name = "DiffContext" + default: + name = fmt.Sprintf("Role(%d)", int(line.role)) + } + b.WriteString(name) + b.WriteByte('\t') + b.WriteString(line.line) + b.WriteByte('\n') + } + return b.String() +} diff --git a/internal/plan/view/human_test.go b/internal/plan/view/human_test.go index 7e6b8e8..f926197 100644 --- a/internal/plan/view/human_test.go +++ b/internal/plan/view/human_test.go @@ -333,7 +333,7 @@ func (errWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") } -func TestWriteHuman_ZeroThemeIsPlainText(t *testing.T) { +func TestWriteHuman_NilStylerIsPlainText(t *testing.T) { t.Parallel() p := mustPlan(t, semantic.HelmUpgrade, []semantic.ResourceChange{createChangeForHuman()}) var buf bytes.Buffer @@ -967,8 +967,8 @@ func allActionsInputs() (semantic.Header, []semantic.ResourceChange, []semantic. return humanHeader(), changes, tasks } -func TestWriteHuman_DriftSection(t *testing.T) { - t.Parallel() +func humanDriftPlan(t *testing.T) semantic.Plan { + t.Helper() p, err := semantic.AttachDrift(mustPlanWithHeader(t, humanHeader(), semantic.HelmNone, nil, nil), []semantic.DriftChange{ { Resource: ref("ConfigMap", "app"), @@ -994,6 +994,12 @@ func TestWriteHuman_DriftSection(t *testing.T) { }, }) require.NoError(t, err) + return p +} + +func TestWriteHuman_DriftSection(t *testing.T) { + t.Parallel() + p := humanDriftPlan(t) var buf bytes.Buffer require.NoError(t, view.WriteHuman(&buf, p, view.Options{})) text := buf.String() diff --git a/internal/plan/view/options.go b/internal/plan/view/options.go index 74f1dae..b2d4d98 100644 --- a/internal/plan/view/options.go +++ b/internal/plan/view/options.go @@ -14,16 +14,13 @@ package view -import "nabat.dev/theme" - -// Options controls renderer behavior. ShowSecrets is renderer-only and -// is not a CLI flag in Stage C. +// Options controls renderer behavior. ShowSecrets applies to both +// WriteHuman and WriteJSON. Styler applies only to WriteHuman. type Options struct { // ShowSecrets reveals Secret data and stringData values. The default // hides those values without hiding that a field changed. ShowSecrets bool - // Theme colors human headings, diff markers, and section titles. The - // zero value renders every style call as the terminal default, so - // golden tests stay plain text. - Theme theme.ResolvedTheme + // Styler decorates Human output lines by semantic role. Nil renders + // plain text. WriteJSON ignores it. + Styler Styler } diff --git a/internal/plan/view/style.go b/internal/plan/view/style.go new file mode 100644 index 0000000..800c2e6 --- /dev/null +++ b/internal/plan/view/style.go @@ -0,0 +1,53 @@ +// Copyright 2025 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 view + +// Role is the presentation intent of one Human output line. The zero +// value is not a role. [WriteHuman] does not pass it to [Styler]. +type Role int + +const ( + // RoleTitle marks a section or structural title, such as the plan + // heading, Resources, Tasks, a task phase, Drift, or Summary. + RoleTitle Role = iota + 1 + // RolePrimary marks ordinary body text with no diff meaning, such + // as header metadata and summary counts. + RolePrimary + // RoleDiffAdded marks content introduced on the after side of a + // diff. Create headings and bodies, inserted diff lines, unexpected + // drift, and task create headings use it. + RoleDiffAdded + // RoleDiffRemoved marks content present on the before side and + // absent from the after side. Delete headings and bodies, removed + // diff lines, missing drift, and task delete headings use it. + RoleDiffRemoved + // RoleDiffModified marks the heading of an object that changed. + // Lines inside an update body use [RoleDiffAdded], [RoleDiffRemoved], + // and [RoleDiffContext]. + RoleDiffModified + // RoleDiffContext marks an unchanged line kept around a changed + // region. + RoleDiffContext +) + +// Styler decorates one Human output line for a [Role]. +// +// line is one complete output line, including indentation and any diff +// marker. It has no trailing newline and contains no tab characters. +// Style returns the decorated line and must keep it on one line. +// [WriteHuman] calls Style in order, on the caller's goroutine. +type Styler interface { + Style(role Role, line string) string +} diff --git a/internal/plan/view/testdata/golden/human_all_actions.roles.golden b/internal/plan/view/testdata/golden/human_all_actions.roles.golden new file mode 100644 index 0000000..0e64d95 --- /dev/null +++ b/internal/plan/view/testdata/golden/human_all_actions.roles.golden @@ -0,0 +1,96 @@ +Title Plan for project "web" on environment "prod" +Primary Context: production-eu +Primary Namespace: prod +Primary Release: web +Primary Revision: 12 +Title Resources +DiffAdded + create v1/ConfigMap "app" +DiffAdded + apiVersion: v1 +DiffAdded + kind: ConfigMap +DiffAdded + metadata: +DiffAdded + name: app +DiffAdded + namespace: prod +DiffAdded + annotations: +DiffAdded + example.com/keep: yes +DiffAdded + labels: +DiffAdded + app: web +DiffAdded + data: +DiffAdded + key: v1 +DiffRemoved - delete v1/ConfigMap "old" +DiffRemoved - apiVersion: v1 +DiffRemoved - kind: ConfigMap +DiffRemoved - metadata: +DiffRemoved - name: old +DiffRemoved - namespace: prod +DiffRemoved - annotations: +DiffRemoved - example.com/keep: yes +DiffRemoved - labels: +DiffRemoved - app: web +DiffRemoved - data: +DiffRemoved - key: v1 +DiffModified ~ update v1/ConfigMap "web" +DiffContext data: +DiffRemoved - key: v1 +DiffAdded + key: v2 +Title Tasks +Title preDeploy +DiffModified ~ migrate changed, will run +DiffModified ~ update v1/ConfigMap "web-migrate-env" +DiffContext data: +DiffRemoved - key: v1 +DiffAdded + key: v2 +Primary seed will run +Title postDeploy +DiffAdded + smoke new, will run +DiffAdded + create batch/v1/Job "web-smoke" +DiffAdded + apiVersion: batch/v1 +DiffAdded + kind: Job +DiffAdded + metadata: +DiffAdded + name: web-smoke +DiffAdded + namespace: prod +DiffAdded + annotations: +DiffAdded + helm.sh/hook: post-install,post-upgrade +DiffAdded + helm.sh/hook-weight: "0" +DiffAdded + labels: +DiffAdded + deployah.dev/task: smoke +DiffAdded + spec: +DiffAdded + backoffLimit: 1 +DiffAdded + template: +DiffAdded + spec: +DiffAdded + containers: +DiffAdded + - command: +DiffAdded + - ./smoke +DiffAdded + image: ghcr.io/example/web:1.2.3 +DiffAdded + name: smoke +DiffAdded + restartPolicy: OnFailure +DiffRemoved - old-check removed +DiffRemoved - delete batch/v1/Job "web-old-check" +DiffRemoved - apiVersion: batch/v1 +DiffRemoved - kind: Job +DiffRemoved - metadata: +DiffRemoved - name: web-old-check +DiffRemoved - namespace: prod +DiffRemoved - annotations: +DiffRemoved - helm.sh/hook: post-install,post-upgrade +DiffRemoved - helm.sh/hook-weight: "1" +DiffRemoved - labels: +DiffRemoved - deployah.dev/task: old-check +DiffRemoved - spec: +DiffRemoved - backoffLimit: 1 +DiffRemoved - template: +DiffRemoved - spec: +DiffRemoved - containers: +DiffRemoved - - command: +DiffRemoved - - ./old-check +DiffRemoved - image: ghcr.io/example/web:1.2.3 +DiffRemoved - name: old-check +DiffRemoved - restartPolicy: OnFailure +Title schedule +DiffModified ~ cleanup changed +DiffModified ~ update batch/v1/CronJob "web-cleanup" +DiffContext spec: +DiffRemoved - schedule: 0 2 * * * +DiffAdded + schedule: 0 3 * * * +Title Summary +Primary Resources: 1 create, 2 update, 1 delete +Primary Tasks: 3 to run, 1 schedule changed diff --git a/internal/plan/view/testdata/golden/human_drift.roles.golden b/internal/plan/view/testdata/golden/human_drift.roles.golden new file mode 100644 index 0000000..2842992 --- /dev/null +++ b/internal/plan/view/testdata/golden/human_drift.roles.golden @@ -0,0 +1,29 @@ +Title Plan for project "web" on environment "prod" +Primary Context: production-eu +Primary Namespace: prod +Primary Release: web +Primary Revision: 12 +Title Drift +DiffModified ~ modified v1/ConfigMap "app" +DiffContext data: +DiffRemoved - key: old +DiffAdded + key: new +DiffAdded + unexpected v1/ConfigMap "extra" +DiffAdded + apiVersion: v1 +DiffAdded + kind: ConfigMap +DiffAdded + metadata: +DiffAdded + name: extra +DiffAdded + namespace: prod +DiffAdded + data: +DiffAdded + key: live +DiffRemoved - missing v1/ConfigMap "other" +DiffRemoved - apiVersion: v1 +DiffRemoved - kind: ConfigMap +DiffRemoved - metadata: +DiffRemoved - name: other +DiffRemoved - namespace: prod +DiffRemoved - data: +DiffRemoved - key: gone +Title Summary +Primary Resources: 0 create, 0 update, 0 delete +Primary Drift: 1 modified, 1 missing, 1 unexpected diff --git a/internal/plan/view/testdata/golden/human_semantic_plan.roles.golden b/internal/plan/view/testdata/golden/human_semantic_plan.roles.golden new file mode 100644 index 0000000..1b5aac6 --- /dev/null +++ b/internal/plan/view/testdata/golden/human_semantic_plan.roles.golden @@ -0,0 +1,294 @@ +Title Plan for project "web" on environment "prod" +Primary Context: kind-prod +Primary Namespace: prod +Primary Release: web-prod +Primary Revision: 2 +Title Resources +DiffModified ~ update apps/v1/Deployment "web-prod-api" +DiffContext template: +DiffContext spec: +DiffContext containers: +DiffRemoved - - image: ghcr.io/example/web:1.0 +DiffAdded + - image: ghcr.io/example/web:2.0 +DiffContext name: api +DiffAdded + create apps/v1/Deployment "web-prod-worker" +DiffAdded + apiVersion: apps/v1 +DiffAdded + kind: Deployment +DiffAdded + metadata: +DiffAdded + name: web-prod-worker +DiffAdded + namespace: prod +DiffAdded + annotations: +DiffAdded + deployah.dev/environment-instance: prod +DiffAdded + deployah.dev/project: web +DiffAdded + deployah.dev/source: spec +DiffAdded + labels: +DiffAdded + app.kubernetes.io/instance: web-prod +DiffAdded + app.kubernetes.io/managed-by: Helm +DiffAdded + app.kubernetes.io/name: worker +DiffAdded + deployah.dev/component: worker +DiffAdded + deployah.dev/environment: prod +DiffAdded + deployah.dev/instance: web-prod +DiffAdded + deployah.dev/project: web +DiffAdded + helm.sh/chart: worker-0.1.0 +DiffAdded + spec: +DiffAdded + replicas: 1 +DiffAdded + revisionHistoryLimit: 10 +DiffAdded + selector: +DiffAdded + matchLabels: +DiffAdded + app.kubernetes.io/instance: web-prod +DiffAdded + app.kubernetes.io/name: worker +DiffAdded + strategy: +DiffAdded + type: RollingUpdate +DiffAdded + template: +DiffAdded + metadata: +DiffAdded + annotations: +DiffAdded + deployah.dev/environment-instance: prod +DiffAdded + labels: +DiffAdded + app.kubernetes.io/instance: web-prod +DiffAdded + app.kubernetes.io/managed-by: Helm +DiffAdded + app.kubernetes.io/name: worker +DiffAdded + deployah.dev/component: worker +DiffAdded + deployah.dev/environment: prod +DiffAdded + deployah.dev/instance: web-prod +DiffAdded + deployah.dev/project: web +DiffAdded + helm.sh/chart: worker-0.1.0 +DiffAdded + spec: +DiffAdded + affinity: +DiffAdded + podAntiAffinity: +DiffAdded + preferredDuringSchedulingIgnoredDuringExecution: +DiffAdded + - podAffinityTerm: +DiffAdded + labelSelector: +DiffAdded + matchLabels: +DiffAdded + app.kubernetes.io/instance: web-prod +DiffAdded + app.kubernetes.io/name: worker +DiffAdded + topologyKey: kubernetes.io/hostname +DiffAdded + weight: 1 +DiffAdded + containers: +DiffAdded + - image: ghcr.io/example/worker:1.0 +DiffAdded + imagePullPolicy: IfNotPresent +DiffAdded + name: worker +DiffAdded + resources: +DiffAdded + limits: {} +DiffAdded + requests: +DiffAdded + cpu: 500m +DiffAdded + ephemeral-storage: 50Mi +DiffAdded + memory: 512Mi +DiffAdded + restartPolicy: Always +DiffAdded + serviceAccountName: default +DiffAdded + terminationGracePeriodSeconds: 60 +DiffRemoved - delete apps/v1/Deployment "web-prod-legacy" +DiffRemoved - apiVersion: apps/v1 +DiffRemoved - kind: Deployment +DiffRemoved - metadata: +DiffRemoved - name: web-prod-legacy +DiffRemoved - namespace: prod +DiffRemoved - annotations: +DiffRemoved - deployah.dev/environment-instance: prod +DiffRemoved - deployah.dev/project: web +DiffRemoved - deployah.dev/source: spec +DiffRemoved - labels: +DiffRemoved - app.kubernetes.io/instance: web-prod +DiffRemoved - app.kubernetes.io/managed-by: Helm +DiffRemoved - app.kubernetes.io/name: legacy +DiffRemoved - deployah.dev/component: legacy +DiffRemoved - deployah.dev/environment: prod +DiffRemoved - deployah.dev/instance: web-prod +DiffRemoved - deployah.dev/project: web +DiffRemoved - helm.sh/chart: legacy-0.1.0 +DiffRemoved - spec: +DiffRemoved - replicas: 1 +DiffRemoved - revisionHistoryLimit: 10 +DiffRemoved - selector: +DiffRemoved - matchLabels: +DiffRemoved - app.kubernetes.io/instance: web-prod +DiffRemoved - app.kubernetes.io/name: legacy +DiffRemoved - strategy: +DiffRemoved - type: RollingUpdate +DiffRemoved - template: +DiffRemoved - metadata: +DiffRemoved - annotations: +DiffRemoved - deployah.dev/environment-instance: prod +DiffRemoved - labels: +DiffRemoved - app.kubernetes.io/instance: web-prod +DiffRemoved - app.kubernetes.io/managed-by: Helm +DiffRemoved - app.kubernetes.io/name: legacy +DiffRemoved - deployah.dev/component: legacy +DiffRemoved - deployah.dev/environment: prod +DiffRemoved - deployah.dev/instance: web-prod +DiffRemoved - deployah.dev/project: web +DiffRemoved - helm.sh/chart: legacy-0.1.0 +DiffRemoved - spec: +DiffRemoved - affinity: +DiffRemoved - podAntiAffinity: +DiffRemoved - preferredDuringSchedulingIgnoredDuringExecution: +DiffRemoved - - podAffinityTerm: +DiffRemoved - labelSelector: +DiffRemoved - matchLabels: +DiffRemoved - app.kubernetes.io/instance: web-prod +DiffRemoved - app.kubernetes.io/name: legacy +DiffRemoved - topologyKey: kubernetes.io/hostname +DiffRemoved - weight: 1 +DiffRemoved - containers: +DiffRemoved - - image: ghcr.io/example/legacy:1.0 +DiffRemoved - imagePullPolicy: IfNotPresent +DiffRemoved - name: legacy +DiffRemoved - resources: +DiffRemoved - limits: {} +DiffRemoved - requests: +DiffRemoved - cpu: 500m +DiffRemoved - ephemeral-storage: 50Mi +DiffRemoved - memory: 512Mi +DiffRemoved - restartPolicy: Always +DiffRemoved - serviceAccountName: default +DiffRemoved - terminationGracePeriodSeconds: 60 +Title Tasks +Title preDeploy +DiffModified ~ migrate changed, will run +DiffModified ~ update batch/v1/Job "web-prod-migrate" +DiffContext template: +DiffContext spec: +DiffContext containers: +DiffRemoved - - image: ghcr.io/example/web:1.0 +DiffAdded + - command: +DiffAdded + - --check +DiffAdded + image: ghcr.io/example/web:2.0 +DiffContext name: migrate +Title postDeploy +DiffRemoved - old-check removed +DiffRemoved - delete batch/v1/Job "web-prod-old-check" +DiffRemoved - apiVersion: batch/v1 +DiffRemoved - kind: Job +DiffRemoved - metadata: +DiffRemoved - name: web-prod-old-check +DiffRemoved - namespace: prod +DiffRemoved - annotations: +DiffRemoved - deployah.dev/environment-instance: prod +DiffRemoved - deployah.dev/project: web +DiffRemoved - deployah.dev/source: spec +DiffRemoved - helm.sh/hook: post-install,post-upgrade +DiffRemoved - helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +DiffRemoved - helm.sh/hook-weight: "0" +DiffRemoved - labels: +DiffRemoved - app.kubernetes.io/instance: web-prod +DiffRemoved - app.kubernetes.io/managed-by: Helm +DiffRemoved - app.kubernetes.io/name: old-check +DiffRemoved - deployah.dev/component: old-check +DiffRemoved - deployah.dev/environment: prod +DiffRemoved - deployah.dev/instance: web-prod +DiffRemoved - deployah.dev/project: web +DiffRemoved - deployah.dev/task: old-check +DiffRemoved - helm.sh/chart: old-check-0.1.0 +DiffRemoved - spec: +DiffRemoved - activeDeadlineSeconds: 300 +DiffRemoved - backoffLimit: 3 +DiffRemoved - completionMode: Indexed +DiffRemoved - completions: 1 +DiffRemoved - parallelism: 1 +DiffRemoved - template: +DiffRemoved - metadata: +DiffRemoved - annotations: +DiffRemoved - deployah.dev/environment-instance: prod +DiffRemoved - labels: +DiffRemoved - app.kubernetes.io/instance: web-prod +DiffRemoved - app.kubernetes.io/managed-by: Helm +DiffRemoved - app.kubernetes.io/name: old-check +DiffRemoved - deployah.dev/component: old-check +DiffRemoved - deployah.dev/environment: prod +DiffRemoved - deployah.dev/instance: web-prod +DiffRemoved - deployah.dev/project: web +DiffRemoved - deployah.dev/task: old-check +DiffRemoved - helm.sh/chart: old-check-0.1.0 +DiffRemoved - spec: +DiffRemoved - automountServiceAccountToken: false +DiffRemoved - containers: +DiffRemoved - - command: +DiffRemoved - - ./old-check +DiffRemoved - env: +DiffRemoved - - name: LOG +DiffRemoved - value: info +DiffRemoved - image: ghcr.io/example/web:1.0 +DiffRemoved - imagePullPolicy: IfNotPresent +DiffRemoved - name: old-check +DiffRemoved - resources: +DiffRemoved - limits: {} +DiffRemoved - requests: +DiffRemoved - cpu: 500m +DiffRemoved - ephemeral-storage: 50Mi +DiffRemoved - memory: 512Mi +DiffRemoved - restartPolicy: OnFailure +DiffAdded + smoke new, will run +DiffAdded + create batch/v1/Job "web-prod-smoke" +DiffAdded + apiVersion: batch/v1 +DiffAdded + kind: Job +DiffAdded + metadata: +DiffAdded + name: web-prod-smoke +DiffAdded + namespace: prod +DiffAdded + annotations: +DiffAdded + deployah.dev/environment-instance: prod +DiffAdded + deployah.dev/project: web +DiffAdded + deployah.dev/source: spec +DiffAdded + helm.sh/hook: post-install,post-upgrade +DiffAdded + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +DiffAdded + helm.sh/hook-weight: "0" +DiffAdded + labels: +DiffAdded + app.kubernetes.io/instance: web-prod +DiffAdded + app.kubernetes.io/managed-by: Helm +DiffAdded + app.kubernetes.io/name: smoke +DiffAdded + deployah.dev/component: smoke +DiffAdded + deployah.dev/environment: prod +DiffAdded + deployah.dev/instance: web-prod +DiffAdded + deployah.dev/project: web +DiffAdded + deployah.dev/task: smoke +DiffAdded + helm.sh/chart: smoke-0.1.0 +DiffAdded + spec: +DiffAdded + activeDeadlineSeconds: 300 +DiffAdded + backoffLimit: 3 +DiffAdded + completionMode: Indexed +DiffAdded + completions: 1 +DiffAdded + parallelism: 1 +DiffAdded + template: +DiffAdded + metadata: +DiffAdded + annotations: +DiffAdded + deployah.dev/environment-instance: prod +DiffAdded + labels: +DiffAdded + app.kubernetes.io/instance: web-prod +DiffAdded + app.kubernetes.io/managed-by: Helm +DiffAdded + app.kubernetes.io/name: smoke +DiffAdded + deployah.dev/component: smoke +DiffAdded + deployah.dev/environment: prod +DiffAdded + deployah.dev/instance: web-prod +DiffAdded + deployah.dev/project: web +DiffAdded + deployah.dev/task: smoke +DiffAdded + helm.sh/chart: smoke-0.1.0 +DiffAdded + spec: +DiffAdded + automountServiceAccountToken: false +DiffAdded + containers: +DiffAdded + - command: +DiffAdded + - ./smoke +DiffAdded + env: +DiffAdded + - name: LOG +DiffAdded + value: info +DiffAdded + image: ghcr.io/example/web:2.0 +DiffAdded + imagePullPolicy: IfNotPresent +DiffAdded + name: smoke +DiffAdded + resources: +DiffAdded + limits: {} +DiffAdded + requests: +DiffAdded + cpu: 500m +DiffAdded + ephemeral-storage: 50Mi +DiffAdded + memory: 512Mi +DiffAdded + restartPolicy: OnFailure +Title schedule +DiffModified ~ cleanup changed +DiffModified ~ update batch/v1/CronJob "web-prod-cleanup" +DiffContext template: +DiffContext spec: +DiffContext containers: +DiffRemoved - - image: ghcr.io/example/web:1.0 +DiffAdded + - image: ghcr.io/example/web:2.0 +DiffContext name: cleanup +DiffRemoved - schedule: 0 2 * * * +DiffAdded + schedule: 0 3 * * * +Title Summary +Primary Resources: 1 create, 2 update, 1 delete +Primary Tasks: 2 to run, 1 schedule changed diff --git a/internal/plan/view/testdata/golden/style_create.golden b/internal/plan/view/testdata/golden/style_create.golden new file mode 100644 index 0000000..cfd21a2 --- /dev/null +++ b/internal/plan/view/testdata/golden/style_create.golden @@ -0,0 +1,16 @@ +Title Plan for project "web" on environment "prod" +Primary Context: production-eu +Primary Namespace: prod +Primary Release: web +Primary Revision: 12 +Title Resources +DiffAdded + create v1/ConfigMap "app" +DiffAdded + apiVersion: v1 +DiffAdded + kind: ConfigMap +DiffAdded + metadata: +DiffAdded + name: app +DiffAdded + namespace: prod +DiffAdded + data: +DiffAdded + key: v1 +Title Summary +Primary Resources: 1 create, 0 update, 0 delete diff --git a/internal/plan/view/testdata/golden/style_delete.golden b/internal/plan/view/testdata/golden/style_delete.golden new file mode 100644 index 0000000..1013654 --- /dev/null +++ b/internal/plan/view/testdata/golden/style_delete.golden @@ -0,0 +1,16 @@ +Title Plan for project "web" on environment "prod" +Primary Context: production-eu +Primary Namespace: prod +Primary Release: web +Primary Revision: 12 +Title Resources +DiffRemoved - delete v1/ConfigMap "old" +DiffRemoved - apiVersion: v1 +DiffRemoved - kind: ConfigMap +DiffRemoved - metadata: +DiffRemoved - name: old +DiffRemoved - namespace: prod +DiffRemoved - data: +DiffRemoved - key: v1 +Title Summary +Primary Resources: 0 create, 0 update, 1 delete diff --git a/internal/plan/view/testdata/golden/style_drift_missing.golden b/internal/plan/view/testdata/golden/style_drift_missing.golden new file mode 100644 index 0000000..debfc7d --- /dev/null +++ b/internal/plan/view/testdata/golden/style_drift_missing.golden @@ -0,0 +1,17 @@ +Title Plan for project "web" on environment "prod" +Primary Context: production-eu +Primary Namespace: prod +Primary Release: web +Primary Revision: 12 +Title Drift +DiffRemoved - missing v1/ConfigMap "other" +DiffRemoved - apiVersion: v1 +DiffRemoved - kind: ConfigMap +DiffRemoved - metadata: +DiffRemoved - name: other +DiffRemoved - namespace: prod +DiffRemoved - data: +DiffRemoved - key: gone +Title Summary +Primary Resources: 0 create, 0 update, 0 delete +Primary Drift: 0 modified, 1 missing, 0 unexpected diff --git a/internal/plan/view/testdata/golden/style_drift_modified.golden b/internal/plan/view/testdata/golden/style_drift_modified.golden new file mode 100644 index 0000000..f363860 --- /dev/null +++ b/internal/plan/view/testdata/golden/style_drift_modified.golden @@ -0,0 +1,13 @@ +Title Plan for project "web" on environment "prod" +Primary Context: production-eu +Primary Namespace: prod +Primary Release: web +Primary Revision: 12 +Title Drift +DiffModified ~ modified v1/ConfigMap "app" +DiffContext data: +DiffRemoved - key: old +DiffAdded + key: new +Title Summary +Primary Resources: 0 create, 0 update, 0 delete +Primary Drift: 1 modified, 0 missing, 0 unexpected diff --git a/internal/plan/view/testdata/golden/style_drift_unexpected.golden b/internal/plan/view/testdata/golden/style_drift_unexpected.golden new file mode 100644 index 0000000..ad7a214 --- /dev/null +++ b/internal/plan/view/testdata/golden/style_drift_unexpected.golden @@ -0,0 +1,17 @@ +Title Plan for project "web" on environment "prod" +Primary Context: production-eu +Primary Namespace: prod +Primary Release: web +Primary Revision: 12 +Title Drift +DiffAdded + unexpected v1/ConfigMap "extra" +DiffAdded + apiVersion: v1 +DiffAdded + kind: ConfigMap +DiffAdded + metadata: +DiffAdded + name: extra +DiffAdded + namespace: prod +DiffAdded + data: +DiffAdded + key: live +Title Summary +Primary Resources: 0 create, 0 update, 0 delete +Primary Drift: 0 modified, 0 missing, 1 unexpected diff --git a/internal/plan/view/testdata/golden/style_tasks.golden b/internal/plan/view/testdata/golden/style_tasks.golden new file mode 100644 index 0000000..9162767 --- /dev/null +++ b/internal/plan/view/testdata/golden/style_tasks.golden @@ -0,0 +1,35 @@ +Title Plan for project "web" on environment "prod" +Primary Context: production-eu +Primary Namespace: prod +Primary Release: web +Primary Revision: 12 +Title Tasks +Title preDeploy +DiffModified ~ migrate changed, will run +DiffModified ~ update v1/ConfigMap "env" +DiffContext data: +DiffRemoved - key: v1 +DiffAdded + key: v2 +Primary seed will run +Title postDeploy +DiffAdded + smoke new, will run +DiffAdded + create v1/ConfigMap "hook" +DiffAdded + apiVersion: v1 +DiffAdded + kind: ConfigMap +DiffAdded + metadata: +DiffAdded + name: hook +DiffAdded + namespace: prod +DiffAdded + data: +DiffAdded + key: v1 +DiffRemoved - old-check removed +DiffRemoved - delete v1/ConfigMap "gone" +DiffRemoved - apiVersion: v1 +DiffRemoved - kind: ConfigMap +DiffRemoved - metadata: +DiffRemoved - name: gone +DiffRemoved - namespace: prod +DiffRemoved - data: +DiffRemoved - key: v1 +Title Summary +Primary Resources: 0 create, 0 update, 0 delete +Primary Tasks: 3 to run diff --git a/internal/plan/view/testdata/golden/style_update.golden b/internal/plan/view/testdata/golden/style_update.golden new file mode 100644 index 0000000..1c70b3a --- /dev/null +++ b/internal/plan/view/testdata/golden/style_update.golden @@ -0,0 +1,12 @@ +Title Plan for project "web" on environment "prod" +Primary Context: production-eu +Primary Namespace: prod +Primary Release: web +Primary Revision: 12 +Title Resources +DiffModified ~ update v1/ConfigMap "web" +DiffContext data: +DiffRemoved - key: v1 +DiffAdded + key: v2 +Title Summary +Primary Resources: 0 create, 1 update, 0 delete