Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions internal/hotfix/finalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,18 @@ func WithTrunkStateReader(r trunkStateReader) FinalizeOption {
}
}

// WithTipReader injects the reader Finalize uses to cross-check the merge SHA
// against the resolution branch tip. The default reads the local env-branch tip
// via git. The what-if simulator injects a record-only reader so finalize can
// run without a git checkout.
func WithTipReader(r gitTipReader) FinalizeOption {
return func(f *Finalizer) {
if r != nil {
f.tipReader = r
}
}
}

// NewFinalizer constructs a Finalizer over the manifest at opts.ConfigPath.
func NewFinalizer(opts FinalizerOptions, options ...FinalizeOption) (*Finalizer, error) {
key := opts.ManifestKey
Expand Down
190 changes: 158 additions & 32 deletions internal/simulate/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,22 @@ package simulate
import (
"fmt"
"os"
"strings"

"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
)
// commonFlags holds the flags shared by every simulate subcommand. They are
// 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
}

const simulateLong = `Run a hypothetical action against a clone of your manifest and print what
would happen, without changing anything.
Expand All @@ -45,62 +42,191 @@ no containers. No on-disk state is changed.`

// NewCommand builds the simulate parent command and its subcommands.
func NewCommand() *cobra.Command {
cf := &commonFlags{}

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("")
if cf.config == "" {
cf.config = 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.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.AddCommand(newPromoteCommand())
cmd.AddCommand(newPromoteCommand(cf))
cmd.AddCommand(newRollbackCommand(cf))
cmd.AddCommand(newReleaseCommand(cf))
cmd.AddCommand(newHotfixCommand(cf))

return cmd
}

// 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))
if err != nil {
return err
}

result, err := engine.Simulate(a)
if err != nil {
return err
}

if cf.json {
return result.RenderJSON(os.Stdout)
}
return result.RenderHuman(os.Stdout)
}

// newPromoteCommand builds the `simulate promote` subcommand.
func newPromoteCommand() *cobra.Command {
func newPromoteCommand(cf *commonFlags) *cobra.Command {
var (
mode string
target string
)

cmd := &cobra.Command{
Use: "promote",
Short: "Simulate a promotion",
Long: promoteLong,
RunE: func(cmd *cobra.Command, args []string) error {
mode, err := parseMode(flagMode)
m, err := parseMode(mode)
if err != nil {
return err
}
return runSimulation(cf, NewPromoteAction(m, target))
},
}

engine, err := NewEngine(flagConfig, WithActor(flagActor))
if err != nil {
return err
}
cmd.Flags().StringVar(&mode, "mode", "default", "Promotion mode: default or cascade")
cmd.Flags().StringVar(&target, "target", "", "Cascade target (for example dev-to-prod)")

result, err := engine.Simulate(NewPromoteAction(mode, flagTarget))
if err != nil {
return err
return cmd
}

const rollbackLong = `Simulate a rollback against a clone of your manifest.

This replays the real rollback target resolution and state revert in record-only
mode and prints the resulting state diff plus an ordered effect sequence. Target
resolution is pinned to the in-state deploy-history ring. It validates the
orchestration transitions, not your deploy scripts.
It touches no GitHub and no containers. No on-disk state is changed.`

const releaseLong = `Simulate a release crossing against a clone of your manifest.

This replays the real promotion state-machine across the release boundary in
record-only mode and prints the state diff plus an ordered effect sequence,
including the prerelease or publish marker the orchestration would emit. It
validates the orchestration transitions and the release decision, not the GitHub
release API calls, and touches no GitHub and no containers. No on-disk state is
changed.`

const hotfixLong = `Simulate a hotfix against a clone of your manifest.

This replays the real hotfix finalize state-machine in record-only mode: it
allocates the next hotfix version, snapshots the prior state, and writes the
divergence fields, then 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.`

// newRollbackCommand builds the `simulate rollback` subcommand.
func newRollbackCommand(cf *commonFlags) *cobra.Command {
var (
env string
to string
deployable string
)

cmd := &cobra.Command{
Use: "rollback",
Short: "Simulate a rollback",
Long: rollbackLong,
RunE: func(cmd *cobra.Command, args []string) error {
if env == "" {
return fmt.Errorf("rollback requires --env")
}
return runSimulation(cf, NewRollbackAction(env, to, deployable))
},
}

cmd.Flags().StringVar(&env, "env", "", "Environment to roll back")
cmd.Flags().StringVar(&to, "to", "", "Target SHA or version (default: previous distinct state)")
cmd.Flags().StringVar(&deployable, "deployable", "", "Scope the rollback to a single deployable")

return cmd
}

// newReleaseCommand builds the `simulate release` subcommand.
func newReleaseCommand(cf *commonFlags) *cobra.Command {
return &cobra.Command{
Use: "release",
Short: "Simulate a release crossing",
Long: releaseLong,
RunE: func(cmd *cobra.Command, args []string) error {
return runSimulation(cf, NewReleaseAction())
},
}
}

if flagJSON {
return result.RenderJSON(os.Stdout)
// newHotfixCommand builds the `simulate hotfix` subcommand.
func newHotfixCommand(cf *commonFlags) *cobra.Command {
var (
env string
fix string
mergeSHA string
)

cmd := &cobra.Command{
Use: "hotfix",
Short: "Simulate a hotfix",
Long: hotfixLong,
RunE: func(cmd *cobra.Command, args []string) error {
if env == "" {
return fmt.Errorf("hotfix requires --env")
}
return result.RenderHuman(os.Stdout)
fixSHAs, err := parseCommaList(fix)
if err != nil {
return fmt.Errorf("invalid --fix: %w", err)
}
return runSimulation(cf, NewHotfixAction(env, fixSHAs, mergeSHA))
},
}

cmd.Flags().StringVar(&flagMode, "mode", "default", "Promotion mode: default or cascade")
cmd.Flags().StringVar(&flagTarget, "target", "", "Cascade target (for example dev-to-prod)")
cmd.Flags().StringVar(&env, "env", "", "Environment to hotfix")
cmd.Flags().StringVar(&fix, "fix", "", "Comma-separated trunk commit SHAs the hotfix carries")
cmd.Flags().StringVar(&mergeSHA, "merge-sha", "", "Resolution-branch tip (default: first fix commit)")

return cmd
}

// parseCommaList splits a comma-separated input into a trimmed, non-empty slice.
// It rejects empty input and any blank entry so a hotfix simulation always names
// at least one concrete commit.
func parseCommaList(input string) ([]string, error) {
if strings.TrimSpace(input) == "" {
return nil, fmt.Errorf("no commit refs given")
}
parts := strings.Split(input, ",")
out := make([]string, 0, len(parts))
for _, raw := range parts {
ref := strings.TrimSpace(raw)
if ref == "" {
return nil, fmt.Errorf("empty commit ref in %q", input)
}
out = append(out, ref)
}
return out, nil
}

// parseMode maps the flag string to a promote.PromotionMode.
func parseMode(s string) (promote.PromotionMode, error) {
switch s {
Expand Down
50 changes: 50 additions & 0 deletions internal/simulate/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,53 @@ func TestSimulatePromote_InvalidMode(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid mode")
}

func TestSimulateSubcommands_Registered(t *testing.T) {
t.Parallel()

out := helpText(t, "--help")
for _, sub := range []string{"promote", "rollback", "release", "hotfix"} {
assert.Contains(t, out, sub)
}
}

func TestSimulateRollbackHelp_MentionsScopeAndIsolation(t *testing.T) {
t.Parallel()

out := strings.ToLower(helpText(t, "rollback", "--help"))
assert.Contains(t, out, "orchestration")
assert.Contains(t, out, "no github")
assert.Contains(t, out, "no containers")
}

func TestSimulateReleaseHelp_MentionsScopeAndIsolation(t *testing.T) {
t.Parallel()

out := strings.ToLower(helpText(t, "release", "--help"))
assert.Contains(t, out, "orchestration")
assert.Contains(t, out, "no github")
assert.Contains(t, out, "no containers")
}

func TestSimulateHotfixHelp_MentionsScopeAndIsolation(t *testing.T) {
t.Parallel()

out := strings.ToLower(helpText(t, "hotfix", "--help"))
assert.Contains(t, out, "orchestration")
assert.Contains(t, out, "no github")
assert.Contains(t, out, "no containers")
}

func TestParseCommaList(t *testing.T) {
t.Parallel()

got, err := parseCommaList(" a , b ,c")
require.NoError(t, err)
assert.Equal(t, []string{"a", "b", "c"}, got)

_, err = parseCommaList("")
require.Error(t, err)

_, err = parseCommaList("a,,b")
require.Error(t, err)
}
67 changes: 67 additions & 0 deletions internal/simulate/effect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,73 @@ func TestEffectsFromResult_ReleaseMarkerAdvanceIsWriteStateNotDeploy(t *testing.
assert.Equal(t, "prod", effects[0].Target)
}

func TestReleaseMarkerEffect_PrereleaseAndPublish(t *testing.T) {
t.Parallel()

cases := []struct {
name string
action string
wantAction string
}{
{name: "prerelease", action: "prerelease", wantAction: "release prerelease"},
{name: "publish", action: "publish", wantAction: "release publish"},
}

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

result := &promote.PromotionResult{
Success: true,
Mode: promote.ModeDefault,
ReleaseAction: tc.action,
ReleaseData: &promote.ReleaseData{SHA: "a1b2c3d4e5f6", RCVersion: "v1.0.0-rc.3", SemVersion: "v1.0.0"},
}

marker, ok := releaseMarkerEffect(result)
require.True(t, ok)
assert.Equal(t, tc.wantAction, marker.Action)
assert.Equal(t, "v1.0.0", marker.Target, "the target is the semver being marked")
assert.Equal(t, DispositionRun, marker.Disposition)
assert.Contains(t, marker.Detail, "v1.0.0-rc.3")
})
}
}

func TestReleaseMarkerEffect_NoMarkerWhenEmpty(t *testing.T) {
t.Parallel()

_, ok := releaseMarkerEffect(&promote.PromotionResult{Success: true})
assert.False(t, ok)

_, ok = releaseMarkerEffect(nil)
assert.False(t, ok)
}

func TestEffectsFromResult_AppendsReleaseMarkerAfterPromotions(t *testing.T) {
t.Parallel()

result := &promote.PromotionResult{
Success: true,
Mode: promote.ModeDefault,
Promotions: []promote.EnvPromotion{
{Environment: "uat", SourceEnv: "dev", SHA: "a1b2c3d", Version: "v1.0.0-rc.0", NeedsDeploy: true},
},
ReleaseAction: "prerelease",
ReleaseData: &promote.ReleaseData{SHA: "a1b2c3d", RCVersion: "v1.0.0-rc.0", SemVersion: "v1.0.0"},
SkippedEnvs: []string{"prod"},
}

effects := effectsFromResult(result)
require.Len(t, effects, 4)
assert.Equal(t, "deploy", effects[0].Action)
assert.Equal(t, "write state", effects[1].Action)
assert.Equal(t, "release prerelease", effects[2].Action)
assert.Equal(t, DispositionSkip, effects[3].Disposition)
assert.Equal(t, "prod", effects[3].Target)
}

func TestEffectsFromResult_SkippedEnvs(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading