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
2 changes: 2 additions & 0 deletions cmd/cascade/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Expand Down
39 changes: 39 additions & 0 deletions internal/simulate/action.go
Original file line number Diff line number Diff line change
@@ -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)
}
114 changes: 114 additions & 0 deletions internal/simulate/command.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
50 changes: 50 additions & 0 deletions internal/simulate/command_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading