From 97a94718435c50efba10962a8c905d299dc06994 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 25 Jun 2026 11:05:59 -0400 Subject: [PATCH 1/2] feat(simulate): add what-if engine for state diff and effect sequence Signed-off-by: Joshua Temple --- cmd/cascade/main.go | 2 + internal/simulate/action.go | 39 ++++ internal/simulate/command.go | 114 ++++++++++ internal/simulate/command_test.go | 50 +++++ internal/simulate/diff.go | 207 ++++++++++++++++++ internal/simulate/diff_test.go | 158 +++++++++++++ internal/simulate/effect.go | 26 +++ internal/simulate/effect_test.go | 94 ++++++++ internal/simulate/engine.go | 173 +++++++++++++++ internal/simulate/engine_test.go | 118 ++++++++++ internal/simulate/promote_action.go | 111 ++++++++++ internal/simulate/render.go | 98 +++++++++ internal/simulate/render_test.go | 62 ++++++ .../simulate/testdata/promote_human.golden | 9 + .../simulate/testdata/promote_json.golden | 55 +++++ 15 files changed, 1316 insertions(+) create mode 100644 internal/simulate/action.go create mode 100644 internal/simulate/command.go create mode 100644 internal/simulate/command_test.go create mode 100644 internal/simulate/diff.go create mode 100644 internal/simulate/diff_test.go create mode 100644 internal/simulate/effect.go create mode 100644 internal/simulate/effect_test.go create mode 100644 internal/simulate/engine.go create mode 100644 internal/simulate/engine_test.go create mode 100644 internal/simulate/promote_action.go create mode 100644 internal/simulate/render.go create mode 100644 internal/simulate/render_test.go create mode 100644 internal/simulate/testdata/promote_human.golden create mode 100644 internal/simulate/testdata/promote_json.golden diff --git a/cmd/cascade/main.go b/cmd/cascade/main.go index aa89e7bb..70955b35 100644 --- a/cmd/cascade/main.go +++ b/cmd/cascade/main.go @@ -25,6 +25,7 @@ import ( "github.com/stablekernel/cascade/internal/reset" "github.com/stablekernel/cascade/internal/rollback" "github.com/stablekernel/cascade/internal/schema" + "github.com/stablekernel/cascade/internal/simulate" "github.com/stablekernel/cascade/internal/status" "github.com/stablekernel/cascade/internal/verify" versionpkg "github.com/stablekernel/cascade/internal/version" @@ -89,6 +90,7 @@ change detection, and changelog generation.`, rootCmd.AddCommand(reset.NewCommand()) rootCmd.AddCommand(rollback.NewCommand()) rootCmd.AddCommand(schema.NewCommand()) + rootCmd.AddCommand(simulate.NewCommand()) rootCmd.AddCommand(status.NewCommand()) rootCmd.AddCommand(versionpkg.NewCommand()) rootCmd.AddCommand(newVersionCmd()) diff --git a/internal/simulate/action.go b/internal/simulate/action.go new file mode 100644 index 00000000..5f924a8b --- /dev/null +++ b/internal/simulate/action.go @@ -0,0 +1,39 @@ +package simulate + +// ActionContext carries the inputs an Action needs to replay orchestration +// against the cloned manifest. ClonePath points at the temp copy of the user's +// manifest; the real file is never handed to an action. +type ActionContext struct { + // ClonePath is the path to the temp clone of the manifest the action may + // mutate. The real promoter writes its transitions here. + ClonePath string + + // Actor is the identity that performs the hypothetical action. + Actor string +} + +// ActionOutcome is what an Action returns after replaying orchestration. It +// carries the ordered effects plus the path holding the resolved after-state +// (the clone path, since the real promoter writes there). +type ActionOutcome struct { + // Effects is the ordered list of steps the orchestration would take. + Effects []Effect + + // AfterStatePath is the manifest path holding the after-state. + AfterStatePath string +} + +// Action is a hypothetical operation the what-if engine can replay against a +// cloned manifest. Implementations drive the real orchestration logic in +// record-only mode and report the effects it would produce. +type Action interface { + // Name is a short identifier for the action (for example "promote"). + Name() string + + // Describe returns a one-line human-readable summary of the action. + Describe() string + + // Apply replays the action against the clone manifest and returns the + // effects plus the after-state path. + Apply(ctx ActionContext) (*ActionOutcome, error) +} diff --git a/internal/simulate/command.go b/internal/simulate/command.go new file mode 100644 index 00000000..e0154d3d --- /dev/null +++ b/internal/simulate/command.go @@ -0,0 +1,114 @@ +package simulate + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/promote" +) + +// flags shared across the simulate subcommands. +var ( + flagConfig string + flagJSON bool + flagActor string +) + +// promote subcommand flags. +var ( + flagMode string + flagTarget string +) + +const simulateLong = `Run a hypothetical action against a clone of your manifest and print what +would happen, without changing anything. + +The engine replays the real orchestration logic (the same state transitions +cascade uses to promote environments) in record-only mode. It validates +ORCHESTRATION, meaning the state transitions, not your real deploy scripts. +It touches no GitHub and no containers, and it mutates no on-disk state: the +manifest is copied to a temp file, the transition is computed against that +copy, and the copy is discarded. + +The output has two parts: a before and after state diff, and an ordered +effect sequence describing each step the orchestration would take.` + +const promoteLong = `Simulate a promotion against a clone of your manifest. + +This replays the real promotion state-machine in record-only mode and prints +the resulting state diff plus an ordered effect sequence. It validates the +orchestration transitions, not your deploy scripts, and touches no GitHub and +no containers. No on-disk state is changed.` + +// NewCommand builds the simulate parent command and its subcommands. +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "simulate", + Short: "Preview a hypothetical action without changing anything", + Long: simulateLong, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if flagConfig == "" { + flagConfig = config.FindConfigFile("") + } + return nil + }, + } + + cmd.PersistentFlags().StringVar(&flagConfig, "config", "", "Path to manifest file (default: .github/manifest.yaml)") + cmd.PersistentFlags().BoolVar(&flagJSON, "json", false, "Output result as JSON") + cmd.PersistentFlags().StringVar(&flagActor, "actor", "", "Actor performing the hypothetical action") + + cmd.AddCommand(newPromoteCommand()) + + return cmd +} + +// newPromoteCommand builds the `simulate promote` subcommand. +func newPromoteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "promote", + Short: "Simulate a promotion", + Long: promoteLong, + RunE: func(cmd *cobra.Command, args []string) error { + mode, err := parseMode(flagMode) + if err != nil { + return err + } + + engine, err := NewEngine(flagConfig, WithActor(flagActor)) + if err != nil { + return err + } + + result, err := engine.Simulate(NewPromoteAction(mode, flagTarget)) + if err != nil { + return err + } + + if flagJSON { + return result.RenderJSON(os.Stdout) + } + return result.RenderHuman(os.Stdout) + }, + } + + cmd.Flags().StringVar(&flagMode, "mode", "default", "Promotion mode: default or cascade") + cmd.Flags().StringVar(&flagTarget, "target", "", "Cascade target (for example dev-to-prod)") + + return cmd +} + +// parseMode maps the flag string to a promote.PromotionMode. +func parseMode(s string) (promote.PromotionMode, error) { + switch s { + case string(promote.ModeDefault): + return promote.ModeDefault, nil + case string(promote.ModeCascade): + return promote.ModeCascade, nil + default: + return "", fmt.Errorf("invalid mode %q: want default or cascade", s) + } +} diff --git a/internal/simulate/command_test.go b/internal/simulate/command_test.go new file mode 100644 index 00000000..97fc0353 --- /dev/null +++ b/internal/simulate/command_test.go @@ -0,0 +1,50 @@ +package simulate + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func helpText(t *testing.T, args ...string) string { + t.Helper() + + cmd := NewCommand() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs(args) + require.NoError(t, cmd.Execute()) + return buf.String() +} + +func TestSimulateHelp_MentionsScopeAndIsolation(t *testing.T) { + t.Parallel() + + out := strings.ToLower(helpText(t, "--help")) + assert.Contains(t, out, "orchestration") + assert.Contains(t, out, "no github") + assert.Contains(t, out, "no containers") + assert.Contains(t, out, "not your real deploy scripts") +} + +func TestSimulatePromoteHelp_MentionsScopeAndIsolation(t *testing.T) { + t.Parallel() + + out := strings.ToLower(helpText(t, "promote", "--help")) + assert.Contains(t, out, "orchestration") + assert.Contains(t, out, "no github") + assert.Contains(t, out, "no containers") + assert.Contains(t, out, "not your deploy scripts") +} + +func TestSimulatePromote_InvalidMode(t *testing.T) { + t.Parallel() + + _, err := parseMode("bogus") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid mode") +} diff --git a/internal/simulate/diff.go b/internal/simulate/diff.go new file mode 100644 index 00000000..ded71ea4 --- /dev/null +++ b/internal/simulate/diff.go @@ -0,0 +1,207 @@ +package simulate + +import ( + "sort" + "strconv" + + "github.com/stablekernel/cascade/internal/config" +) + +// noneValue is the placeholder rendered for an empty field value. +const noneValue = "(none)" + +// FieldChange records a single field transitioning from one value to another. +// Changed is true only when From and To differ. +type FieldChange struct { + Field string `json:"field"` + From string `json:"from"` + To string `json:"to"` + Changed bool `json:"changed"` +} + +// newFieldChange builds a FieldChange, rendering empty values as (none). +func newFieldChange(field, from, to string) FieldChange { + return FieldChange{ + Field: field, + From: orNone(from), + To: orNone(to), + Changed: from != to, + } +} + +func orNone(s string) string { + if s == "" { + return noneValue + } + return s +} + +// DeployDiff captures the field changes for a single named deploy within an +// environment. +type DeployDiff struct { + Name string `json:"name"` + SHA FieldChange `json:"sha"` + Version FieldChange `json:"version"` +} + +// changed reports whether any tracked field of the deploy changed. +func (d DeployDiff) changed() bool { + return d.SHA.Changed || d.Version.Changed +} + +// EnvDiff captures the field-by-field changes for a single environment. The +// run-stamped CommittedAt and CommittedBy fields are deliberately excluded: +// timestamps are run-stamped and not part of the what-if outcome. +type EnvDiff struct { + Environment string `json:"environment"` + Version FieldChange `json:"version"` + SHA FieldChange `json:"sha"` + Divergence FieldChange `json:"divergence"` + PreviousRing FieldChange `json:"previous_ring"` + Deploys []DeployDiff `json:"deploys,omitempty"` +} + +// changed reports whether any tracked field of the environment changed. +func (e EnvDiff) changed() bool { + if e.Version.Changed || e.SHA.Changed || e.Divergence.Changed || e.PreviousRing.Changed { + return true + } + for _, d := range e.Deploys { + if d.changed() { + return true + } + } + return false +} + +// StateDiff is the difference between a before and after manifest state, keyed +// by environment name in deterministic (sorted) order. +type StateDiff struct { + // Envs holds the per-environment diffs in sorted-key order. + Envs []EnvDiff `json:"envs"` +} + +// Changed reports whether any environment in the diff changed. +func (s StateDiff) Changed() bool { + for _, e := range s.Envs { + if e.changed() { + return true + } + } + return false +} + +// Env returns the diff for a single environment by name. +func (s StateDiff) Env(name string) (EnvDiff, bool) { + for _, e := range s.Envs { + if e.Environment == name { + return e, true + } + } + return EnvDiff{}, false +} + +// DiffState computes the StateDiff between before and after environment states. +// Environment keys are sorted for deterministic output. Run-stamped timestamps +// (CommittedAt, CommittedBy) are ignored so output stays stable across runs. +func DiffState(before, after map[string]*config.EnvState) StateDiff { + names := unionKeys(before, after) + sort.Strings(names) + + diff := StateDiff{} + for _, name := range names { + b := before[name] + a := after[name] + ed := diffEnv(name, b, a) + if ed.changed() { + diff.Envs = append(diff.Envs, ed) + } + } + return diff +} + +func diffEnv(name string, before, after *config.EnvState) EnvDiff { + b := stateOrEmpty(before) + a := stateOrEmpty(after) + + ed := EnvDiff{ + Environment: name, + Version: newFieldChange("version", b.Version, a.Version), + SHA: newFieldChange("sha", b.SHA, a.SHA), + Divergence: newFieldChange("divergence", boolWord(before.IsDiverged()), boolWord(after.IsDiverged())), + PreviousRing: newFieldChange("previous_ring", strconv.Itoa(len(b.Previous)), strconv.Itoa(len(a.Previous))), + Deploys: diffDeploys(b.Deploys, a.Deploys), + } + return ed +} + +func diffDeploys(before, after map[string]*config.DeployState) []DeployDiff { + names := deployUnionKeys(before, after) + sort.Strings(names) + + var out []DeployDiff + for _, name := range names { + b := deployOrEmpty(before[name]) + a := deployOrEmpty(after[name]) + dd := DeployDiff{ + Name: name, + SHA: newFieldChange("sha", b.SHA, a.SHA), + Version: newFieldChange("version", b.Version, a.Version), + } + if dd.changed() { + out = append(out, dd) + } + } + return out +} + +func unionKeys(before, after map[string]*config.EnvState) []string { + seen := make(map[string]struct{}, len(before)+len(after)) + for k := range before { + seen[k] = struct{}{} + } + for k := range after { + seen[k] = struct{}{} + } + out := make([]string, 0, len(seen)) + for k := range seen { + out = append(out, k) + } + return out +} + +func deployUnionKeys(before, after map[string]*config.DeployState) []string { + seen := make(map[string]struct{}, len(before)+len(after)) + for k := range before { + seen[k] = struct{}{} + } + for k := range after { + seen[k] = struct{}{} + } + out := make([]string, 0, len(seen)) + for k := range seen { + out = append(out, k) + } + return out +} + +func stateOrEmpty(s *config.EnvState) *config.EnvState { + if s == nil { + return &config.EnvState{} + } + return s +} + +func deployOrEmpty(d *config.DeployState) *config.DeployState { + if d == nil { + return &config.DeployState{} + } + return d +} + +func boolWord(b bool) string { + if b { + return "yes" + } + return "no" +} diff --git a/internal/simulate/diff_test.go b/internal/simulate/diff_test.go new file mode 100644 index 00000000..513e98df --- /dev/null +++ b/internal/simulate/diff_test.go @@ -0,0 +1,158 @@ +package simulate + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" +) + +func TestDiffState_FieldChanges(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + before map[string]*config.EnvState + after map[string]*config.EnvState + wantEnv string + assertion func(t *testing.T, d EnvDiff) + }{ + { + name: "version bump", + before: map[string]*config.EnvState{ + "uat": {Version: "v1.0.0", SHA: "abc"}, + }, + after: map[string]*config.EnvState{ + "uat": {Version: "v1.1.0", SHA: "abc"}, + }, + wantEnv: "uat", + assertion: func(t *testing.T, d EnvDiff) { + t.Helper() + assert.True(t, d.Version.Changed) + assert.Equal(t, "v1.0.0", d.Version.From) + assert.Equal(t, "v1.1.0", d.Version.To) + assert.False(t, d.SHA.Changed) + }, + }, + { + name: "sha set from empty", + before: map[string]*config.EnvState{ + "uat": {}, + }, + after: map[string]*config.EnvState{ + "uat": {SHA: "a1b2c3d"}, + }, + wantEnv: "uat", + assertion: func(t *testing.T, d EnvDiff) { + t.Helper() + assert.True(t, d.SHA.Changed) + assert.Equal(t, "(none)", d.SHA.From) + assert.Equal(t, "a1b2c3d", d.SHA.To) + }, + }, + { + name: "per-deploy sha change", + before: map[string]*config.EnvState{ + "uat": {Deploys: map[string]*config.DeployState{"api": {SHA: "old"}}}, + }, + after: map[string]*config.EnvState{ + "uat": {Deploys: map[string]*config.DeployState{"api": {SHA: "new", Version: "v2"}}}, + }, + wantEnv: "uat", + assertion: func(t *testing.T, d EnvDiff) { + t.Helper() + require.Len(t, d.Deploys, 1) + dd := d.Deploys[0] + assert.Equal(t, "api", dd.Name) + assert.True(t, dd.SHA.Changed) + assert.Equal(t, "old", dd.SHA.From) + assert.Equal(t, "new", dd.SHA.To) + assert.True(t, dd.Version.Changed) + }, + }, + { + name: "divergence onset via ref", + before: map[string]*config.EnvState{ + "prod": {SHA: "abc"}, + }, + after: map[string]*config.EnvState{ + "prod": {SHA: "abc", Ref: "hotfix/x"}, + }, + wantEnv: "prod", + assertion: func(t *testing.T, d EnvDiff) { + t.Helper() + assert.True(t, d.Divergence.Changed) + assert.Equal(t, "no", d.Divergence.From) + assert.Equal(t, "yes", d.Divergence.To) + }, + }, + { + name: "divergence onset via patches", + before: map[string]*config.EnvState{ + "prod": {SHA: "abc"}, + }, + after: map[string]*config.EnvState{ + "prod": {SHA: "abc", Patches: []string{"p1"}}, + }, + wantEnv: "prod", + assertion: func(t *testing.T, d EnvDiff) { + t.Helper() + assert.True(t, d.Divergence.Changed) + }, + }, + { + name: "previous-ring growth", + before: map[string]*config.EnvState{ + "uat": {Previous: []config.EnvStateSnapshot{{SHA: "x"}}}, + }, + after: map[string]*config.EnvState{ + "uat": {Previous: []config.EnvStateSnapshot{{SHA: "x"}, {SHA: "y"}}}, + }, + wantEnv: "uat", + assertion: func(t *testing.T, d EnvDiff) { + t.Helper() + assert.True(t, d.PreviousRing.Changed) + assert.Equal(t, "1", d.PreviousRing.From) + assert.Equal(t, "2", d.PreviousRing.To) + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + diff := DiffState(tt.before, tt.after) + assert.True(t, diff.Changed(), "expected diff to report a change") + d, ok := diff.Env(tt.wantEnv) + require.True(t, ok, "expected env %q in diff", tt.wantEnv) + tt.assertion(t, d) + }) + } +} + +func TestDiffState_IgnoresRunStampedTimestamps(t *testing.T) { + t.Parallel() + + before := map[string]*config.EnvState{ + "uat": {SHA: "abc", Version: "v1", CommittedAt: "2026-01-01T00:00:00Z", CommittedBy: "alice"}, + } + after := map[string]*config.EnvState{ + "uat": {SHA: "abc", Version: "v1", CommittedAt: "2026-06-25T12:00:00Z", CommittedBy: "bob"}, + } + + diff := DiffState(before, after) + assert.False(t, diff.Changed(), "timestamp-only changes must not register as a diff") +} + +func TestDiffState_NoChange(t *testing.T) { + t.Parallel() + + before := map[string]*config.EnvState{"uat": {SHA: "abc", Version: "v1"}} + after := map[string]*config.EnvState{"uat": {SHA: "abc", Version: "v1"}} + + diff := DiffState(before, after) + assert.False(t, diff.Changed()) +} diff --git a/internal/simulate/effect.go b/internal/simulate/effect.go new file mode 100644 index 00000000..d01b11d3 --- /dev/null +++ b/internal/simulate/effect.go @@ -0,0 +1,26 @@ +package simulate + +// Disposition classifies how the orchestration treats a single effect when the +// hypothetical action is replayed in record-only mode. +type Disposition string + +const ( + // DispositionRun marks an effect the orchestration would carry out. + DispositionRun Disposition = "run" + + // DispositionSkip marks an effect the orchestration would skip as a no-op. + DispositionSkip Disposition = "skip" + + // DispositionGate marks an effect held back behind a gate or guard. + DispositionGate Disposition = "gate" +) + +// Effect is one ordered step the orchestration would take for the simulated +// action. It carries the disposition (run, skip, or gate), the kind of action, +// the target it acts on, and a human-readable detail string. +type Effect struct { + Disposition Disposition `json:"disposition"` + Action string `json:"action"` + Target string `json:"target"` + Detail string `json:"detail"` +} diff --git a/internal/simulate/effect_test.go b/internal/simulate/effect_test.go new file mode 100644 index 00000000..edced698 --- /dev/null +++ b/internal/simulate/effect_test.go @@ -0,0 +1,94 @@ +package simulate + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/promote" +) + +func TestEffectsFromResult_DeployAndWriteState(t *testing.T) { + t.Parallel() + + result := &promote.PromotionResult{ + Success: true, + Mode: promote.ModeDefault, + Promotions: []promote.EnvPromotion{ + {Environment: "uat", SourceEnv: "dev", SHA: "a1b2c3d", Version: "v1.2.0", NeedsDeploy: true}, + }, + } + + effects := effectsFromResult(result) + require.Len(t, effects, 2) + + assert.Equal(t, DispositionRun, effects[0].Disposition) + assert.Equal(t, "deploy", effects[0].Action) + assert.Equal(t, "uat", effects[0].Target) + + assert.Equal(t, DispositionRun, effects[1].Disposition) + assert.Equal(t, "write state", effects[1].Action) + assert.Equal(t, "uat", effects[1].Target) +} + +func TestEffectsFromResult_ReleaseMarkerAdvanceIsWriteStateNotDeploy(t *testing.T) { + t.Parallel() + + result := &promote.PromotionResult{ + Success: true, + Mode: promote.ModeDefault, + Promotions: []promote.EnvPromotion{ + {Environment: "prod", SourceEnv: "uat", SHA: "a1b2c3d", Version: "v1.2.0", NeedsDeploy: false}, + }, + } + + effects := effectsFromResult(result) + require.Len(t, effects, 1) + assert.Equal(t, DispositionRun, effects[0].Disposition) + assert.Equal(t, "write state", effects[0].Action) + assert.Equal(t, "prod", effects[0].Target) +} + +func TestEffectsFromResult_SkippedEnvs(t *testing.T) { + t.Parallel() + + result := &promote.PromotionResult{ + Success: true, + Mode: promote.ModeDefault, + SkippedEnvs: []string{"prod"}, + } + + effects := effectsFromResult(result) + require.Len(t, effects, 1) + assert.Equal(t, DispositionSkip, effects[0].Disposition) + assert.Equal(t, "prod", effects[0].Target) +} + +func TestEffectsFromResult_OrderedPromotionsThenSkips(t *testing.T) { + t.Parallel() + + result := &promote.PromotionResult{ + Success: true, + Mode: promote.ModeCascade, + Promotions: []promote.EnvPromotion{ + {Environment: "uat", SourceEnv: "dev", SHA: "sha1", Version: "v1", NeedsDeploy: true}, + {Environment: "prod", SourceEnv: "uat", SHA: "sha1", Version: "v1", NeedsDeploy: true}, + }, + SkippedEnvs: []string{"sandbox"}, + } + + effects := effectsFromResult(result) + require.Len(t, effects, 5) + + assert.Equal(t, "deploy", effects[0].Action) + assert.Equal(t, "uat", effects[0].Target) + assert.Equal(t, "write state", effects[1].Action) + assert.Equal(t, "uat", effects[1].Target) + assert.Equal(t, "deploy", effects[2].Action) + assert.Equal(t, "prod", effects[2].Target) + assert.Equal(t, "write state", effects[3].Action) + assert.Equal(t, "prod", effects[3].Target) + assert.Equal(t, DispositionSkip, effects[4].Disposition) + assert.Equal(t, "sandbox", effects[4].Target) +} diff --git a/internal/simulate/engine.go b/internal/simulate/engine.go new file mode 100644 index 00000000..5548e640 --- /dev/null +++ b/internal/simulate/engine.go @@ -0,0 +1,173 @@ +package simulate + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/stablekernel/cascade/internal/config" +) + +// Result is the outcome of one simulation: the action identity, the before and +// after state diff, and the ordered effect sequence. +type Result struct { + // ActionName is the short identifier of the simulated action. + ActionName string `json:"action"` + + // ActionDescribe is the one-line human-readable summary of the action. + ActionDescribe string `json:"describe"` + + // Diff is the before and after state difference. + Diff StateDiff `json:"diff"` + + // Effects is the ordered list of orchestration steps. + Effects []Effect `json:"effects"` +} + +// Engine runs hypothetical actions against a clone of the user's manifest and +// reports the resulting state diff and effect sequence. It never mutates the +// user's real manifest and never touches git or the network. +type Engine struct { + manifestPath string + actor string +} + +// Option configures an Engine. +type Option func(*Engine) + +// WithActor sets the actor identity used when replaying an action. +func WithActor(actor string) Option { + return func(e *Engine) { + if actor != "" { + e.actor = actor + } + } +} + +// NewEngine builds an Engine bound to the manifest at manifestPath. The path is +// validated by reading it; the file is never modified. +func NewEngine(manifestPath string, opts ...Option) (*Engine, error) { + if manifestPath == "" { + return nil, fmt.Errorf("manifest path is required") + } + if _, err := os.Stat(manifestPath); err != nil { + return nil, fmt.Errorf("manifest not readable: %w", err) + } + + e := &Engine{manifestPath: manifestPath} + for _, opt := range opts { + opt(e) + } + return e, nil +} + +// Simulate replays the action against a temp clone of the manifest and returns +// the before and after diff plus the ordered effects. The clone is created in a +// temp directory and removed when Simulate returns. +func (e *Engine) Simulate(a Action) (*Result, error) { + beforeState, err := parseState(e.manifestPath) + if err != nil { + return nil, fmt.Errorf("parse before-state: %w", err) + } + // Snapshot the before-state by value so the action cannot alias it through + // a shared pointer when it rewrites the clone. + beforeState = cloneStateMap(beforeState) + + clonePath, cleanup, err := e.cloneManifest() + if err != nil { + return nil, err + } + defer cleanup() + + outcome, err := a.Apply(ActionContext{ClonePath: clonePath, Actor: e.actor}) + if err != nil { + return nil, fmt.Errorf("apply action: %w", err) + } + + afterPath := outcome.AfterStatePath + if afterPath == "" { + afterPath = clonePath + } + afterState, err := parseState(afterPath) + if err != nil { + return nil, fmt.Errorf("parse after-state: %w", err) + } + + return &Result{ + ActionName: a.Name(), + ActionDescribe: a.Describe(), + Diff: DiffState(beforeState, afterState), + Effects: outcome.Effects, + }, nil +} + +// cloneManifest copies the manifest bytes into a fresh temp file and returns its +// path plus a cleanup func that removes the temp directory. +func (e *Engine) cloneManifest() (string, func(), error) { + data, err := os.ReadFile(e.manifestPath) + if err != nil { + return "", nil, fmt.Errorf("read manifest: %w", err) + } + + dir, err := os.MkdirTemp("", "cascade-simulate-") + if err != nil { + return "", nil, fmt.Errorf("create temp dir: %w", err) + } + cleanup := func() { _ = os.RemoveAll(dir) } + + clonePath := filepath.Join(dir, filepath.Base(e.manifestPath)) + if err := os.WriteFile(clonePath, data, 0o644); err != nil { + cleanup() + return "", nil, fmt.Errorf("write clone: %w", err) + } + return clonePath, cleanup, nil +} + +// parseState reads a manifest file and returns its environment state map. +func parseState(path string) (map[string]*config.EnvState, error) { + cicd, err := config.ParseManifestFile(path, config.DefaultManifestKey) + if err != nil { + return nil, err + } + if cicd.State == nil { + return map[string]*config.EnvState{}, nil + } + return cicd.State, nil +} + +// cloneStateMap returns a deep value copy of the environment state map so a +// later action cannot mutate the captured before-state through a shared +// pointer. +func cloneStateMap(in map[string]*config.EnvState) map[string]*config.EnvState { + out := make(map[string]*config.EnvState, len(in)) + for name, s := range in { + out[name] = cloneEnvState(s) + } + return out +} + +func cloneEnvState(s *config.EnvState) *config.EnvState { + if s == nil { + return nil + } + c := *s + + if s.Deploys != nil { + c.Deploys = make(map[string]*config.DeployState, len(s.Deploys)) + for k, d := range s.Deploys { + if d == nil { + c.Deploys[k] = nil + continue + } + dc := *d + c.Deploys[k] = &dc + } + } + if s.Patches != nil { + c.Patches = append([]string(nil), s.Patches...) + } + if s.Previous != nil { + c.Previous = append([]config.EnvStateSnapshot(nil), s.Previous...) + } + return &c +} diff --git a/internal/simulate/engine_test.go b/internal/simulate/engine_test.go new file mode 100644 index 00000000..c9cbb05c --- /dev/null +++ b/internal/simulate/engine_test.go @@ -0,0 +1,118 @@ +package simulate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/promote" +) + +// seedManifest writes a minimal manifest with dev populated and uat empty. +func seedManifest(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + + // dev populated, uat empty, prod present so the dev->uat crossing is a + // normal sequential deploy and not the publish boundary. + cicd := &config.CICDFile{ + Config: &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "uat", "prod"}, + }, + State: map[string]*config.EnvState{ + "dev": { + SHA: "a1b2c3d4e5f6", + Version: "v1.2.0-rc.1", + CommittedAt: "2026-01-01T10:00:00Z", + CommittedBy: "seed-user", + }, + "uat": {}, + }, + } + + wrapper := map[string]interface{}{"ci": cicd} + data, err := yaml.Marshal(wrapper) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o644)) + return path +} + +func TestEngine_Simulate_PromoteDefault(t *testing.T) { + t.Parallel() + + path := seedManifest(t) + + engine, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + + result, err := engine.Simulate(NewPromoteAction(promote.ModeDefault, "")) + require.NoError(t, err) + + assert.Equal(t, "promote", result.ActionName) + assert.True(t, result.Diff.Changed(), "uat should gain dev's sha/version") + + uat, ok := result.Diff.Env("uat") + require.True(t, ok) + assert.True(t, uat.SHA.Changed) + assert.Equal(t, "a1b2c3d4e5f6", uat.SHA.To) + assert.True(t, uat.Version.Changed) + assert.Equal(t, "v1.2.0-rc.1", uat.Version.To) + + require.GreaterOrEqual(t, len(result.Effects), 3) + assert.Equal(t, "deploy", result.Effects[0].Action) + assert.Equal(t, "uat", result.Effects[0].Target) + assert.Equal(t, "write state", result.Effects[1].Action) + assert.Equal(t, "uat", result.Effects[1].Target) + // prod is skipped as a no-op in this single-step default promotion. + last := result.Effects[len(result.Effects)-1] + assert.Equal(t, DispositionSkip, last.Disposition) + assert.Equal(t, "prod", last.Target) +} + +func TestEngine_Simulate_LeavesOriginalUntouched(t *testing.T) { + t.Parallel() + + path := seedManifest(t) + + before, err := os.ReadFile(path) + require.NoError(t, err) + + engine, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + + _, err = engine.Simulate(NewPromoteAction(promote.ModeDefault, "")) + require.NoError(t, err) + + after, err := os.ReadFile(path) + require.NoError(t, err) + + assert.Equal(t, before, after, "the original manifest bytes must be unchanged") +} + +func TestEngine_Simulate_Deterministic(t *testing.T) { + t.Parallel() + + path := seedManifest(t) + + engine, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + + first, err := engine.Simulate(NewPromoteAction(promote.ModeDefault, "")) + require.NoError(t, err) + + engine2, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + second, err := engine2.Simulate(NewPromoteAction(promote.ModeDefault, "")) + require.NoError(t, err) + + assert.Equal(t, first.Diff, second.Diff) + assert.Equal(t, first.Effects, second.Effects) +} diff --git a/internal/simulate/promote_action.go b/internal/simulate/promote_action.go new file mode 100644 index 00000000..6e55be36 --- /dev/null +++ b/internal/simulate/promote_action.go @@ -0,0 +1,111 @@ +package simulate + +import ( + "fmt" + + "github.com/stablekernel/cascade/internal/promote" +) + +// PromoteAction replays the real promotion orchestration against a cloned +// manifest. It drives promote.NewPromoter in non-dry-run mode so the genuine +// state-machine computes transitions and writes them to the clone only. +type PromoteAction struct { + mode promote.PromotionMode + target string +} + +// NewPromoteAction builds a PromoteAction for the given promotion mode and +// target. The target is only consulted for cascade mode. +func NewPromoteAction(mode promote.PromotionMode, target string) *PromoteAction { + return &PromoteAction{mode: mode, target: target} +} + +// Name returns the action identifier. +func (a *PromoteAction) Name() string { return "promote" } + +// Describe returns a one-line summary of the promotion being simulated. +func (a *PromoteAction) Describe() string { + if a.target != "" { + return fmt.Sprintf("promote (mode=%s, target=%s)", a.mode, a.target) + } + return fmt.Sprintf("promote (mode=%s)", a.mode) +} + +// Apply runs the real promoter against the clone manifest in non-dry-run mode +// and maps the returned PromotionResult into an ordered effect sequence. The +// promoter writes its transitions to the clone path via os.WriteFile only; no +// git or network call is made. +func (a *PromoteAction) Apply(ctx ActionContext) (*ActionOutcome, error) { + promoter, err := promote.NewPromoter(promote.PromoterOptions{ + ConfigPath: ctx.ClonePath, + DryRun: false, + Actor: ctx.Actor, + }) + if err != nil { + return nil, fmt.Errorf("build promoter: %w", err) + } + + result, err := promoter.Promote(a.mode, a.target) + if err != nil { + return nil, fmt.Errorf("run promotion: %w", err) + } + if result != nil && !result.Success && result.Error != "" { + return nil, fmt.Errorf("promotion failed: %s", result.Error) + } + + return &ActionOutcome{ + Effects: effectsFromResult(result), + AfterStatePath: ctx.ClonePath, + }, nil +} + +// effectsFromResult translates a PromotionResult into the ordered effect +// sequence. Each promotion yields a deploy effect (unless it is a state-only +// marker advance) followed by a write-state effect; skipped envs yield a single +// skip effect. The mapping stays faithful to the result and invents no steps it +// does not contain. +func effectsFromResult(result *promote.PromotionResult) []Effect { + if result == nil { + return nil + } + + var effects []Effect + for _, p := range result.Promotions { + if p.NeedsDeploy { + effects = append(effects, Effect{ + Disposition: DispositionRun, + Action: "deploy", + Target: p.Environment, + Detail: fmt.Sprintf("from %s (sha %s, version %s)", p.SourceEnv, shortOrNone(p.SHA), orNone(p.Version)), + }) + } + effects = append(effects, Effect{ + Disposition: DispositionRun, + Action: "write state", + Target: p.Environment, + Detail: fmt.Sprintf("sha %s, version %s", shortOrNone(p.SHA), orNone(p.Version)), + }) + } + + for _, env := range result.SkippedEnvs { + effects = append(effects, Effect{ + Disposition: DispositionSkip, + Action: "promote", + Target: env, + Detail: "no change required", + }) + } + + return effects +} + +// shortOrNone renders the first 7 characters of a SHA, or (none) when empty. +func shortOrNone(sha string) string { + if sha == "" { + return noneValue + } + if len(sha) > 7 { + return sha[:7] + } + return sha +} diff --git a/internal/simulate/render.go b/internal/simulate/render.go new file mode 100644 index 00000000..3f418a40 --- /dev/null +++ b/internal/simulate/render.go @@ -0,0 +1,98 @@ +package simulate + +import ( + "encoding/json" + "fmt" + "io" +) + +// RenderHuman writes a human-readable report of the simulation to w. The output +// is deterministic: environment keys are sorted and run-stamped timestamps are +// excluded from the diff. +func (r *Result) RenderHuman(w io.Writer) error { + if _, err := fmt.Fprintf(w, "Simulating: %s\n", r.ActionDescribe); err != nil { + return err + } + + if err := r.renderDiff(w); err != nil { + return err + } + return r.renderEffects(w) +} + +func (r *Result) renderDiff(w io.Writer) error { + if _, err := fmt.Fprintln(w, "State diff:"); err != nil { + return err + } + if !r.Diff.Changed() { + _, err := fmt.Fprintln(w, " (no state change)") + return err + } + + for _, env := range r.Diff.Envs { + if _, err := fmt.Fprintf(w, " %s:\n", env.Environment); err != nil { + return err + } + lines := envDiffLines(env) + for _, line := range lines { + if _, err := fmt.Fprintf(w, " %s\n", line); err != nil { + return err + } + } + } + return nil +} + +// envDiffLines returns the ordered, rendered change lines for one environment. +func envDiffLines(env EnvDiff) []string { + var lines []string + if env.Version.Changed { + lines = append(lines, fmt.Sprintf("version: %s -> %s", env.Version.From, env.Version.To)) + } + if env.SHA.Changed { + lines = append(lines, fmt.Sprintf("sha: %s -> %s", env.SHA.From, env.SHA.To)) + } + for _, d := range env.Deploys { + if d.SHA.Changed { + lines = append(lines, fmt.Sprintf("deploy/%s sha: %s -> %s", d.Name, d.SHA.From, d.SHA.To)) + } + if d.Version.Changed { + lines = append(lines, fmt.Sprintf("deploy/%s version: %s -> %s", d.Name, d.Version.From, d.Version.To)) + } + } + if env.Divergence.Changed { + lines = append(lines, fmt.Sprintf("divergence: %s -> %s", env.Divergence.From, env.Divergence.To)) + } + if env.PreviousRing.Changed { + lines = append(lines, fmt.Sprintf("previous ring: %s -> %s", env.PreviousRing.From, env.PreviousRing.To)) + } + return lines +} + +func (r *Result) renderEffects(w io.Writer) error { + if _, err := fmt.Fprintln(w, "Effects (in order):"); err != nil { + return err + } + if len(r.Effects) == 0 { + _, err := fmt.Fprintln(w, " (none)") + return err + } + for i, e := range r.Effects { + line := fmt.Sprintf(" %d. [%s] %s %s", i+1, e.Disposition, e.Action, e.Target) + if e.Detail != "" { + line += fmt.Sprintf(" (%s)", e.Detail) + } + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + } + return nil +} + +// RenderJSON writes the simulation result as deterministic, 2-space-indented +// JSON to w. +func (r *Result) RenderJSON(w io.Writer) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(r) +} diff --git a/internal/simulate/render_test.go b/internal/simulate/render_test.go new file mode 100644 index 00000000..8085c81c --- /dev/null +++ b/internal/simulate/render_test.go @@ -0,0 +1,62 @@ +package simulate + +import ( + "bytes" + "flag" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/promote" +) + +var update = flag.Bool("update", false, "update golden files") + +// knownResult builds a fixed simulation result for golden rendering. +func knownResult(t *testing.T) *Result { + t.Helper() + + path := seedManifest(t) + engine, err := NewEngine(path, WithActor("golden-actor")) + require.NoError(t, err) + result, err := engine.Simulate(NewPromoteAction(promote.ModeDefault, "")) + require.NoError(t, err) + return result +} + +func TestRenderHuman_Golden(t *testing.T) { + result := knownResult(t) + + var buf bytes.Buffer + require.NoError(t, result.RenderHuman(&buf)) + + goldenPath := filepath.Join("testdata", "promote_human.golden") + checkGolden(t, goldenPath, buf.Bytes()) +} + +func TestRenderJSON_Golden(t *testing.T) { + result := knownResult(t) + + var buf bytes.Buffer + require.NoError(t, result.RenderJSON(&buf)) + + goldenPath := filepath.Join("testdata", "promote_json.golden") + checkGolden(t, goldenPath, buf.Bytes()) +} + +// checkGolden compares got against the golden file, rewriting it under -update. +func checkGolden(t *testing.T, path string, got []byte) { + t.Helper() + + if *update { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, got, 0o644)) + return + } + + want, err := os.ReadFile(path) + require.NoError(t, err, "golden file missing; run with -update") + require.Equal(t, string(want), string(got)) +} diff --git a/internal/simulate/testdata/promote_human.golden b/internal/simulate/testdata/promote_human.golden new file mode 100644 index 00000000..de9798fd --- /dev/null +++ b/internal/simulate/testdata/promote_human.golden @@ -0,0 +1,9 @@ +Simulating: promote (mode=default) +State diff: + uat: + version: (none) -> v1.2.0-rc.1 + sha: (none) -> a1b2c3d4e5f6 +Effects (in order): + 1. [run] deploy uat (from dev (sha a1b2c3d, version v1.2.0-rc.1)) + 2. [run] write state uat (sha a1b2c3d, version v1.2.0-rc.1) + 3. [skip] promote prod (no change required) diff --git a/internal/simulate/testdata/promote_json.golden b/internal/simulate/testdata/promote_json.golden new file mode 100644 index 00000000..eaed207f --- /dev/null +++ b/internal/simulate/testdata/promote_json.golden @@ -0,0 +1,55 @@ +{ + "action": "promote", + "describe": "promote (mode=default)", + "diff": { + "envs": [ + { + "environment": "uat", + "version": { + "field": "version", + "from": "(none)", + "to": "v1.2.0-rc.1", + "changed": true + }, + "sha": { + "field": "sha", + "from": "(none)", + "to": "a1b2c3d4e5f6", + "changed": true + }, + "divergence": { + "field": "divergence", + "from": "no", + "to": "no", + "changed": false + }, + "previous_ring": { + "field": "previous_ring", + "from": "0", + "to": "0", + "changed": false + } + } + ] + }, + "effects": [ + { + "disposition": "run", + "action": "deploy", + "target": "uat", + "detail": "from dev (sha a1b2c3d, version v1.2.0-rc.1)" + }, + { + "disposition": "run", + "action": "write state", + "target": "uat", + "detail": "sha a1b2c3d, version v1.2.0-rc.1" + }, + { + "disposition": "skip", + "action": "promote", + "target": "prod", + "detail": "no change required" + } + ] +} From c7e3a46570ec73f2ad1ff209251c2542b73e34ac Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Thu, 25 Jun 2026 12:20:37 -0400 Subject: [PATCH 2/2] fix(simulate): drop overflow-prone map size hints in diff key unions Signed-off-by: Joshua Temple --- internal/simulate/diff.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/simulate/diff.go b/internal/simulate/diff.go index ded71ea4..50eee065 100644 --- a/internal/simulate/diff.go +++ b/internal/simulate/diff.go @@ -156,7 +156,7 @@ func diffDeploys(before, after map[string]*config.DeployState) []DeployDiff { } func unionKeys(before, after map[string]*config.EnvState) []string { - seen := make(map[string]struct{}, len(before)+len(after)) + seen := make(map[string]struct{}) for k := range before { seen[k] = struct{}{} } @@ -171,7 +171,7 @@ func unionKeys(before, after map[string]*config.EnvState) []string { } func deployUnionKeys(before, after map[string]*config.DeployState) []string { - seen := make(map[string]struct{}, len(before)+len(after)) + seen := make(map[string]struct{}) for k := range before { seen[k] = struct{}{} }