diff --git a/internal/simulate/action.go b/internal/simulate/action.go index 5f924a8b..cbdc9477 100644 --- a/internal/simulate/action.go +++ b/internal/simulate/action.go @@ -10,6 +10,13 @@ type ActionContext struct { // Actor is the identity that performs the hypothetical action. Actor string + + // Deploys is the deploy-stub model for the manifest's build and deploy + // callbacks. The simulator validates orchestration, not the user's real + // build and deploy scripts, so an action records each callback as a stubbed + // effect with a simulated outcome and gates finalize on the result rather + // than executing anything. It is never nil when supplied by the engine. + Deploys *DeployStub } // ActionOutcome is what an Action returns after replaying orchestration. It diff --git a/internal/simulate/command.go b/internal/simulate/command.go index 5f616ba7..e3f5acae 100644 --- a/internal/simulate/command.go +++ b/internal/simulate/command.go @@ -15,9 +15,24 @@ import ( // bound per NewCommand invocation rather than as package globals so concurrent // command construction (for example parallel tests) never races on shared state. type commonFlags struct { - config string - json bool - actor string + config string + json bool + actor string + deployResults []string +} + +// engineOptions builds the engine options shared by every subcommand from the +// common flags, parsing the repeatable --deploy-result pairs. +func (cf *commonFlags) engineOptions() ([]Option, error) { + opts := []Option{WithActor(cf.actor)} + outcomes, err := ParseDeployResults(cf.deployResults) + if err != nil { + return nil, err + } + if len(outcomes) > 0 { + opts = append(opts, WithDeployResults(outcomes)) + } + return opts, nil } const simulateLong = `Run a hypothetical action against a clone of your manifest and print what @@ -59,6 +74,7 @@ func NewCommand() *cobra.Command { cmd.PersistentFlags().StringVar(&cf.config, "config", "", "Path to manifest file (default: .github/manifest.yaml)") cmd.PersistentFlags().BoolVar(&cf.json, "json", false, "Output result as JSON") cmd.PersistentFlags().StringVar(&cf.actor, "actor", "", "Actor performing the hypothetical action") + cmd.PersistentFlags().StringArrayVar(&cf.deployResults, "deploy-result", nil, "Simulated outcome for a build or deploy callback, name=success|failure|skipped (repeatable)") cmd.AddCommand(newPromoteCommand(cf)) cmd.AddCommand(newRollbackCommand(cf)) @@ -71,7 +87,11 @@ func NewCommand() *cobra.Command { // runSimulation builds the engine and renders the action result, shared by every // subcommand so output formatting stays identical across actions. func runSimulation(cf *commonFlags, a Action) error { - engine, err := NewEngine(cf.config, WithActor(cf.actor)) + opts, err := cf.engineOptions() + if err != nil { + return err + } + engine, err := NewEngine(cf.config, opts...) if err != nil { return err } diff --git a/internal/simulate/command_test.go b/internal/simulate/command_test.go index 576d7204..b809c961 100644 --- a/internal/simulate/command_test.go +++ b/internal/simulate/command_test.go @@ -85,6 +85,31 @@ func TestSimulateHotfixHelp_MentionsScopeAndIsolation(t *testing.T) { assert.Contains(t, out, "no containers") } +func TestSimulateHelp_MentionsDeployResultFlag(t *testing.T) { + t.Parallel() + + out := helpText(t, "--help") + assert.Contains(t, out, "--deploy-result") +} + +func TestCommonFlags_EngineOptions_InvalidDeployResult(t *testing.T) { + t.Parallel() + + cf := &commonFlags{deployResults: []string{"services=maybe"}} + _, err := cf.engineOptions() + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown outcome") +} + +func TestCommonFlags_EngineOptions_DefaultIsActorOnly(t *testing.T) { + t.Parallel() + + cf := &commonFlags{actor: "tester"} + opts, err := cf.engineOptions() + require.NoError(t, err) + assert.Len(t, opts, 1, "no deploy-result pairs adds no extra option") +} + func TestParseCommaList(t *testing.T) { t.Parallel() diff --git a/internal/simulate/deploy_stub.go b/internal/simulate/deploy_stub.go new file mode 100644 index 00000000..934b7694 --- /dev/null +++ b/internal/simulate/deploy_stub.go @@ -0,0 +1,184 @@ +package simulate + +import ( + "fmt" + "strings" +) + +// DeployOutcome is the simulated result of a single build or deploy callback. +// The simulator never runs the user's real build and deploy scripts, so each +// callback resolves to one of these recorded outcomes instead of an execution. +type DeployOutcome string + +const ( + // OutcomeSuccess marks a callback the simulation treats as having succeeded. + // It is the default when no outcome is injected for a callback. + OutcomeSuccess DeployOutcome = "success" + + // OutcomeFailure marks a callback the simulation treats as having failed. + // A failed deploy gates the downstream finalize, matching how the real + // finalizers refuse to record state when a deploy did not succeed. + OutcomeFailure DeployOutcome = "failure" + + // OutcomeSkipped marks a callback the simulation treats as not run. A + // skipped deploy is never a failure, but it does not count as a success + // either, so a step whose only deploys were skipped still gates. + OutcomeSkipped DeployOutcome = "skipped" +) + +// validOutcome reports whether s is one of the outcomes a caller may inject. +func validOutcome(s DeployOutcome) bool { + switch s { + case OutcomeSuccess, OutcomeFailure, OutcomeSkipped: + return true + default: + return false + } +} + +// DeployStub is the simulate-side model of the build and deploy callbacks a +// manifest declares. It exists to make one boundary explicit: the simulator +// validates cascade's ORCHESTRATION, meaning the run, skip, and gate decisions +// and the state transitions, not the user's real build and deploy scripts. Those +// scripts never execute in a what-if. Each callback is therefore recorded as a +// stubbed effect carrying a simulated outcome rather than run. +// +// Outcomes default to success, so the orchestration sequences exactly as it +// would with real callbacks that all passed. A caller can inject a failure or +// skipped outcome per callback to preview the orchestration's gating behavior. +// The gate mirrors the DEPLOY_RESULT_ inputs the real finalizers read from +// the environment (see internal/promote/finalize.go and +// internal/rollback/command_subcommands.go): a deploy that did not succeed +// refuses to advance trunk state, so the simulated finalize is held back. +type DeployStub struct { + builds []string + deploys []string + outcomes map[string]DeployOutcome +} + +// newDeployStub builds a DeployStub for the manifest's build and deploy names +// and the injected per-callback outcomes. A nil or absent outcome resolves to +// success. The name slices are copied so the stub does not alias caller state. +func newDeployStub(builds, deploys []string, outcomes map[string]DeployOutcome) *DeployStub { + cp := func(in []string) []string { + if len(in) == 0 { + return nil + } + out := make([]string, len(in)) + copy(out, in) + return out + } + merged := make(map[string]DeployOutcome, len(outcomes)) + for name, outcome := range outcomes { + merged[name] = outcome + } + return &DeployStub{builds: cp(builds), deploys: cp(deploys), outcomes: merged} +} + +// outcomeFor returns the resolved outcome for a callback, defaulting to success. +func (s *DeployStub) outcomeFor(name string) DeployOutcome { + if s == nil { + return OutcomeSuccess + } + if o, ok := s.outcomes[name]; ok { + return o + } + return OutcomeSuccess +} + +// hasCallbacks reports whether the manifest declared any build or deploy +// callback for the stub to record. When false, the orchestration carries no +// stubbed effects and the generic deploy marker stands on its own. +func (s *DeployStub) hasCallbacks() bool { + return s != nil && (len(s.builds) > 0 || len(s.deploys) > 0) +} + +// recordedEffects returns the ordered stubbed effects for the configured build +// and deploy callbacks: builds first, then deploys, each in manifest order. A +// successful or failed callback is recorded as run with its simulated outcome in +// the detail; a skipped callback is recorded as a skip. Nothing is executed. +func (s *DeployStub) recordedEffects() []Effect { + if !s.hasCallbacks() { + return nil + } + effects := make([]Effect, 0) + for _, name := range s.builds { + effects = append(effects, s.callbackEffect("build", name)) + } + for _, name := range s.deploys { + effects = append(effects, s.callbackEffect("deploy", name)) + } + return effects +} + +// callbackEffect renders one build or deploy callback as a stubbed effect. +func (s *DeployStub) callbackEffect(kind, name string) Effect { + outcome := s.outcomeFor(name) + disposition := DispositionRun + if outcome == OutcomeSkipped { + disposition = DispositionSkip + } + return Effect{ + Disposition: disposition, + Action: kind, + Target: name, + Detail: fmt.Sprintf("simulated %s (not executed)", outcome), + } +} + +// gate decides whether the simulated finalize may record state after the deploy +// callbacks ran, using the same rules the real finalizers apply to deploy +// results. It returns the blocking reason, or the empty string when finalize may +// proceed. The rules are: +// +// - No deploys configured: nothing to gate on, so finalize proceeds. +// - Any deploy outcome of failure aborts finalize, naming the deploy. +// - If deploys are configured but none succeeded (all skipped), nothing was +// deployed, so finalize is held back. +// - Otherwise at least one deploy succeeded and none failed, so finalize +// proceeds. +func (s *DeployStub) gate() (blockedReason string) { + if s == nil || len(s.deploys) == 0 { + return "" + } + anySucceeded := false + for _, name := range s.deploys { + switch s.outcomeFor(name) { + case OutcomeFailure: + return fmt.Sprintf("deploy %q simulated failure; trunk state left unchanged", name) + case OutcomeSuccess: + anySucceeded = true + case OutcomeSkipped: + // Skipped is never a failure, but it is not a success either. + } + } + if !anySucceeded { + return "no simulated deploy succeeded; trunk state left unchanged" + } + return "" +} + +// ParseDeployResults parses repeatable "name=outcome" pairs (for example +// "services=failure") into a per-callback outcome map. Outcomes are limited to +// success, failure, and skipped. It rejects a malformed pair, a blank name, and +// an unknown outcome so a typo never silently resolves to the success default. +func ParseDeployResults(pairs []string) (map[string]DeployOutcome, error) { + if len(pairs) == 0 { + return nil, nil + } + out := make(map[string]DeployOutcome, len(pairs)) + for _, raw := range pairs { + name, value, ok := strings.Cut(raw, "=") + name = strings.TrimSpace(name) + value = strings.TrimSpace(value) + if !ok || name == "" || value == "" { + return nil, fmt.Errorf("invalid deploy-result %q: want name=success|failure|skipped", raw) + } + outcome := DeployOutcome(strings.ToLower(value)) + if !validOutcome(outcome) { + return nil, fmt.Errorf("invalid deploy-result %q: unknown outcome %q (want success, failure, or skipped)", raw, value) + } + out[name] = outcome + } + return out, nil +} diff --git a/internal/simulate/deploy_stub_test.go b/internal/simulate/deploy_stub_test.go new file mode 100644 index 00000000..e73b0801 --- /dev/null +++ b/internal/simulate/deploy_stub_test.go @@ -0,0 +1,144 @@ +package simulate + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeployStub_RecordedEffects_DefaultSuccess(t *testing.T) { + t.Parallel() + + stub := newDeployStub([]string{"app"}, []string{"services"}, nil) + + effects := stub.recordedEffects() + require.Len(t, effects, 2) + + assert.Equal(t, Effect{ + Disposition: DispositionRun, + Action: "build", + Target: "app", + Detail: "simulated success (not executed)", + }, effects[0]) + assert.Equal(t, Effect{ + Disposition: DispositionRun, + Action: "deploy", + Target: "services", + Detail: "simulated success (not executed)", + }, effects[1]) + + assert.Empty(t, stub.gate(), "all-success deploys do not gate finalize") +} + +func TestDeployStub_RecordedEffects_NoCallbacks(t *testing.T) { + t.Parallel() + + stub := newDeployStub(nil, nil, nil) + + assert.Nil(t, stub.recordedEffects()) + assert.False(t, stub.hasCallbacks()) + assert.Empty(t, stub.gate(), "with no deploys there is nothing to gate on") +} + +func TestDeployStub_InjectedFailure_Gates(t *testing.T) { + t.Parallel() + + stub := newDeployStub([]string{"app"}, []string{"services"}, map[string]DeployOutcome{ + "services": OutcomeFailure, + }) + + effects := stub.recordedEffects() + require.Len(t, effects, 2) + assert.Equal(t, DispositionRun, effects[1].Disposition) + assert.Equal(t, "deploy", effects[1].Action) + assert.Contains(t, effects[1].Detail, "simulated failure") + + reason := stub.gate() + require.NotEmpty(t, reason, "a failed deploy must gate finalize") + assert.Contains(t, reason, "services") +} + +func TestDeployStub_AllSkipped_Gates(t *testing.T) { + t.Parallel() + + stub := newDeployStub(nil, []string{"services"}, map[string]DeployOutcome{ + "services": OutcomeSkipped, + }) + + effects := stub.recordedEffects() + require.Len(t, effects, 1) + assert.Equal(t, DispositionSkip, effects[0].Disposition) + + reason := stub.gate() + require.NotEmpty(t, reason, "a step whose only deploy was skipped deploys nothing") + assert.Contains(t, reason, "no simulated deploy succeeded") +} + +func TestDeployStub_OneSucceedsOneSkipped_Proceeds(t *testing.T) { + t.Parallel() + + stub := newDeployStub(nil, []string{"infra", "services"}, map[string]DeployOutcome{ + "infra": OutcomeSkipped, + }) + + assert.Empty(t, stub.gate(), "one succeeding deploy is enough to advance finalize") +} + +func TestDeployStub_DoesNotAliasInputs(t *testing.T) { + t.Parallel() + + builds := []string{"app"} + deploys := []string{"services"} + stub := newDeployStub(builds, deploys, nil) + + builds[0] = "mutated" + deploys[0] = "mutated" + + effects := stub.recordedEffects() + require.Len(t, effects, 2) + assert.Equal(t, "app", effects[0].Target) + assert.Equal(t, "services", effects[1].Target) +} + +func TestParseDeployResults(t *testing.T) { + t.Parallel() + + t.Run("valid pairs", func(t *testing.T) { + t.Parallel() + got, err := ParseDeployResults([]string{"services=failure", "infra=skipped", "app=SUCCESS"}) + require.NoError(t, err) + assert.Equal(t, map[string]DeployOutcome{ + "services": OutcomeFailure, + "infra": OutcomeSkipped, + "app": OutcomeSuccess, + }, got) + }) + + t.Run("nil for empty input", func(t *testing.T) { + t.Parallel() + got, err := ParseDeployResults(nil) + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("rejects malformed pair", func(t *testing.T) { + t.Parallel() + _, err := ParseDeployResults([]string{"services"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "name=success") + }) + + t.Run("rejects blank name", func(t *testing.T) { + t.Parallel() + _, err := ParseDeployResults([]string{"=failure"}) + require.Error(t, err) + }) + + t.Run("rejects unknown outcome", func(t *testing.T) { + t.Parallel() + _, err := ParseDeployResults([]string{"services=maybe"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown outcome") + }) +} diff --git a/internal/simulate/effect_test.go b/internal/simulate/effect_test.go index f72b96cb..b82691e9 100644 --- a/internal/simulate/effect_test.go +++ b/internal/simulate/effect_test.go @@ -20,7 +20,7 @@ func TestEffectsFromResult_DeployAndWriteState(t *testing.T) { }, } - effects := effectsFromResult(result) + effects := effectsFromResult(result, nil) require.Len(t, effects, 2) assert.Equal(t, DispositionRun, effects[0].Disposition) @@ -43,7 +43,7 @@ func TestEffectsFromResult_ReleaseMarkerAdvanceIsWriteStateNotDeploy(t *testing. }, } - effects := effectsFromResult(result) + effects := effectsFromResult(result, nil) require.Len(t, effects, 1) assert.Equal(t, DispositionRun, effects[0].Disposition) assert.Equal(t, "write state", effects[0].Action) @@ -108,7 +108,7 @@ func TestEffectsFromResult_AppendsReleaseMarkerAfterPromotions(t *testing.T) { SkippedEnvs: []string{"prod"}, } - effects := effectsFromResult(result) + effects := effectsFromResult(result, nil) require.Len(t, effects, 4) assert.Equal(t, "deploy", effects[0].Action) assert.Equal(t, "write state", effects[1].Action) @@ -126,7 +126,7 @@ func TestEffectsFromResult_SkippedEnvs(t *testing.T) { SkippedEnvs: []string{"prod"}, } - effects := effectsFromResult(result) + effects := effectsFromResult(result, nil) require.Len(t, effects, 1) assert.Equal(t, DispositionSkip, effects[0].Disposition) assert.Equal(t, "prod", effects[0].Target) @@ -145,7 +145,7 @@ func TestEffectsFromResult_OrderedPromotionsThenSkips(t *testing.T) { SkippedEnvs: []string{"sandbox"}, } - effects := effectsFromResult(result) + effects := effectsFromResult(result, nil) require.Len(t, effects, 5) assert.Equal(t, "deploy", effects[0].Action) diff --git a/internal/simulate/engine.go b/internal/simulate/engine.go index 5548e640..0ca59b79 100644 --- a/internal/simulate/engine.go +++ b/internal/simulate/engine.go @@ -8,6 +8,11 @@ import ( "github.com/stablekernel/cascade/internal/config" ) +// boundaryNote states the orchestration-not-deploys boundary in the simulation +// output. The simulator validates the run, skip, and gate decisions, not the +// user's real build and deploy scripts, which never execute in a what-if. +const boundaryNote = "Note: build and deploy results are simulated, not executed. cascade validates orchestration, not your build and deploy scripts." + // Result is the outcome of one simulation: the action identity, the before and // after state diff, and the ordered effect sequence. type Result struct { @@ -22,14 +27,19 @@ type Result struct { // Effects is the ordered list of orchestration steps. Effects []Effect `json:"effects"` + + // Note states the orchestration-not-deploys boundary so a green simulation + // is never read as a passing deploy. + Note string `json:"note,omitempty"` } // 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 + manifestPath string + actor string + deployOutcomes map[string]DeployOutcome } // Option configures an Engine. @@ -44,6 +54,24 @@ func WithActor(actor string) Option { } } +// WithDeployResults injects per-callback simulated outcomes keyed by build or +// deploy name. Callbacks not named here default to success. Use it to preview +// the orchestration's gating behavior, for example a deploy failure holding back +// finalize. Real build and deploy scripts never run regardless of the outcome. +func WithDeployResults(outcomes map[string]DeployOutcome) Option { + return func(e *Engine) { + if len(outcomes) == 0 { + return + } + if e.deployOutcomes == nil { + e.deployOutcomes = make(map[string]DeployOutcome, len(outcomes)) + } + for name, outcome := range outcomes { + e.deployOutcomes[name] = outcome + } + } +} + // 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) { @@ -79,7 +107,13 @@ func (e *Engine) Simulate(a Action) (*Result, error) { } defer cleanup() - outcome, err := a.Apply(ActionContext{ClonePath: clonePath, Actor: e.actor}) + builds, deploys, err := parseCallbackNames(e.manifestPath) + if err != nil { + return nil, fmt.Errorf("parse callbacks: %w", err) + } + stub := newDeployStub(builds, deploys, e.deployOutcomes) + + outcome, err := a.Apply(ActionContext{ClonePath: clonePath, Actor: e.actor, Deploys: stub}) if err != nil { return nil, fmt.Errorf("apply action: %w", err) } @@ -98,6 +132,7 @@ func (e *Engine) Simulate(a Action) (*Result, error) { ActionDescribe: a.Describe(), Diff: DiffState(beforeState, afterState), Effects: outcome.Effects, + Note: boundaryNote, }, nil } @@ -135,6 +170,27 @@ func parseState(path string) (map[string]*config.EnvState, error) { return cicd.State, nil } +// parseCallbackNames reads a manifest file and returns the configured build and +// deploy callback names in declaration order. A manifest with no config or no +// callbacks yields empty slices, which leaves the deploy-stub model recording +// nothing. +func parseCallbackNames(path string) (builds, deploys []string, err error) { + cicd, err := config.ParseManifestFile(path, config.DefaultManifestKey) + if err != nil { + return nil, nil, err + } + if cicd.Config == nil { + return nil, nil, nil + } + for _, b := range cicd.Config.Builds { + builds = append(builds, b.Name) + } + for _, d := range cicd.Config.Deploys { + deploys = append(deploys, d.Name) + } + return builds, deploys, 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. diff --git a/internal/simulate/engine_deploy_stub_test.go b/internal/simulate/engine_deploy_stub_test.go new file mode 100644 index 00000000..58532f7b --- /dev/null +++ b/internal/simulate/engine_deploy_stub_test.go @@ -0,0 +1,123 @@ +package simulate + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/promote" +) + +// seedManifestWithCallbacks writes a manifest with dev populated and uat empty +// that also declares one build and one deploy callback, so a dev->uat promotion +// drives the deploy-stub model. +func seedManifestWithCallbacks(t *testing.T) string { + t.Helper() + + return writeManifest(t, &config.CICDFile{ + Config: &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "uat", "prod"}, + Builds: []config.BuildConfig{ + {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, + }, + Deploys: []config.DeployConfig{ + {Name: "services", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"deploy/**"}, DependsOn: []string{"app"}}, + }, + }, + State: map[string]*config.EnvState{ + "dev": {SHA: "a1b2c3d4e5f6", Version: "v1.2.0-rc.1"}, + "uat": {}, + }, + }) +} + +func TestEngine_Simulate_DeployStub_DefaultSuccess(t *testing.T) { + t.Parallel() + + path := seedManifestWithCallbacks(t) + + engine, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + + result, err := engine.Simulate(NewPromoteAction(promote.ModeDefault, "")) + require.NoError(t, err) + + assert.NotEmpty(t, result.Note, "output must carry the orchestration-not-deploys note") + assert.Contains(t, result.Note, "simulated") + assert.Contains(t, result.Note, "not executed") + + build, ok := findEffect(result.Effects, "build") + require.True(t, ok, "the configured build appears as a stubbed effect") + assert.Equal(t, "app", build.Target) + assert.Equal(t, DispositionRun, build.Disposition) + assert.Contains(t, build.Detail, "simulated success") + + deploy, ok := findRecordedDeploy(result.Effects, "services") + require.True(t, ok, "the configured deploy appears as a stubbed effect") + assert.Equal(t, DispositionRun, deploy.Disposition) + assert.Contains(t, deploy.Detail, "simulated success") + + // Finalize is reached: the env write-state effect runs. + ws, ok := findEffect(result.Effects, "write state") + require.True(t, ok) + assert.Equal(t, DispositionRun, ws.Disposition) +} + +func TestEngine_Simulate_DeployStub_InjectedFailureGatesFinalize(t *testing.T) { + t.Parallel() + + path := seedManifestWithCallbacks(t) + + engine, err := NewEngine(path, + WithActor("tester"), + WithDeployResults(map[string]DeployOutcome{"services": OutcomeFailure}), + ) + require.NoError(t, err) + + result, err := engine.Simulate(NewPromoteAction(promote.ModeDefault, "")) + require.NoError(t, err) + + deploy, ok := findRecordedDeploy(result.Effects, "services") + require.True(t, ok) + assert.Contains(t, deploy.Detail, "simulated failure") + + ws, ok := findEffect(result.Effects, "write state") + require.True(t, ok, "the gated finalize is still surfaced as an effect") + assert.Equal(t, DispositionGate, ws.Disposition, "a failed deploy gates finalize") + assert.Contains(t, ws.Detail, "services") +} + +func TestEngine_Simulate_DeployStub_NoCallbacksUnchanged(t *testing.T) { + t.Parallel() + + // The seed manifest declares no builds or deploys, so the stub records + // nothing and the generic deploy marker stands on its own. + path := seedManifest(t) + + engine, err := NewEngine(path, WithActor("tester")) + require.NoError(t, err) + + result, err := engine.Simulate(NewPromoteAction(promote.ModeDefault, "")) + require.NoError(t, err) + + _, ok := findEffect(result.Effects, "build") + assert.False(t, ok, "no build callback configured, so none is recorded") + + ws, ok := findEffect(result.Effects, "write state") + require.True(t, ok) + assert.Equal(t, DispositionRun, ws.Disposition, "no deploy gate without configured deploys") +} + +// findRecordedDeploy finds the stubbed deploy callback effect with the given +// target name, distinguishing it from the env-level deploy marker. +func findRecordedDeploy(effects []Effect, target string) (Effect, bool) { + for _, e := range effects { + if e.Action == "deploy" && e.Target == target { + return e, true + } + } + return Effect{}, false +} diff --git a/internal/simulate/promote_action.go b/internal/simulate/promote_action.go index 222a04ee..a883b53a 100644 --- a/internal/simulate/promote_action.go +++ b/internal/simulate/promote_action.go @@ -55,7 +55,7 @@ func (a *PromoteAction) Apply(ctx ActionContext) (*ActionOutcome, error) { } return &ActionOutcome{ - Effects: effectsFromResult(result), + Effects: effectsFromResult(result, ctx.Deploys), AfterStatePath: ctx.ClonePath, }, nil } @@ -65,13 +65,20 @@ func (a *PromoteAction) Apply(ctx ActionContext) (*ActionOutcome, error) { // 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 { +// +// When the manifest declares build or deploy callbacks, the deploy-stub model +// records each as a stubbed effect carrying its simulated outcome after the +// generic deploy marker, and a deploy that did not succeed gates the env's +// write-state effect (the simulated finalize), matching the real finalizers. +// Nothing is executed; the stub records outcomes only. +func effectsFromResult(result *promote.PromotionResult, stub *DeployStub) []Effect { if result == nil { return nil } var effects []Effect for _, p := range result.Promotions { + gated := "" if p.NeedsDeploy { effects = append(effects, Effect{ Disposition: DispositionRun, @@ -79,6 +86,17 @@ func effectsFromResult(result *promote.PromotionResult) []Effect { Target: p.Environment, Detail: fmt.Sprintf("from %s (sha %s, version %s)", p.SourceEnv, shortOrNone(p.SHA), orNone(p.Version)), }) + effects = append(effects, stub.recordedEffects()...) + gated = stub.gate() + } + if gated != "" { + effects = append(effects, Effect{ + Disposition: DispositionGate, + Action: "write state", + Target: p.Environment, + Detail: gated, + }) + continue } effects = append(effects, Effect{ Disposition: DispositionRun, diff --git a/internal/simulate/release_action.go b/internal/simulate/release_action.go index 079e159f..5dea3312 100644 --- a/internal/simulate/release_action.go +++ b/internal/simulate/release_action.go @@ -55,7 +55,7 @@ func (a *ReleaseAction) Apply(ctx ActionContext) (*ActionOutcome, error) { } return &ActionOutcome{ - Effects: effectsFromResult(result), + Effects: effectsFromResult(result, ctx.Deploys), AfterStatePath: ctx.ClonePath, }, nil } diff --git a/internal/simulate/render.go b/internal/simulate/render.go index 3f418a40..8dd74167 100644 --- a/internal/simulate/render.go +++ b/internal/simulate/render.go @@ -17,7 +17,15 @@ func (r *Result) RenderHuman(w io.Writer) error { if err := r.renderDiff(w); err != nil { return err } - return r.renderEffects(w) + if err := r.renderEffects(w); err != nil { + return err + } + if r.Note != "" { + if _, err := fmt.Fprintf(w, "\n%s\n", r.Note); err != nil { + return err + } + } + return nil } func (r *Result) renderDiff(w io.Writer) error { diff --git a/internal/simulate/testdata/promote_human.golden b/internal/simulate/testdata/promote_human.golden index 8831c938..98d594cc 100644 --- a/internal/simulate/testdata/promote_human.golden +++ b/internal/simulate/testdata/promote_human.golden @@ -8,3 +8,5 @@ Effects (in order): 2. [run] write state uat (sha a1b2c3d, version v1.2.0-rc.1) 3. [run] release prerelease v1.2.0 (rc v1.2.0-rc.1, sha a1b2c3d) 4. [skip] promote prod (no change required) + +Note: build and deploy results are simulated, not executed. cascade validates orchestration, not your build and deploy scripts. diff --git a/internal/simulate/testdata/promote_json.golden b/internal/simulate/testdata/promote_json.golden index a256262d..5e4f569b 100644 --- a/internal/simulate/testdata/promote_json.golden +++ b/internal/simulate/testdata/promote_json.golden @@ -57,5 +57,6 @@ "target": "prod", "detail": "no change required" } - ] + ], + "note": "Note: build and deploy results are simulated, not executed. cascade validates orchestration, not your build and deploy scripts." }