diff --git a/docs/src/content/docs/workflows.md b/docs/src/content/docs/workflows.md index 15b7c085..862aa5bb 100644 --- a/docs/src/content/docs/workflows.md +++ b/docs/src/content/docs/workflows.md @@ -354,7 +354,9 @@ For the trunk branch, `cascade branch-protection` emits the full JSON body to PU ## Rollback -cascade generates a standalone `cascade-rollback.yaml` workflow whenever the manifest declares at least one environment. It re-deploys a prior version or SHA to a target environment, defaulting to the previous version (N-1). A read-only preflight resolves the target, the deploy stage re-runs the configured deploy callbacks keyed on the resolved SHA, and finalize writes the rolled-back state back to trunk. +cascade generates a standalone `cascade-rollback.yaml` workflow whenever the manifest declares at least two environments. It re-deploys a prior version or SHA to a target environment, defaulting to the previous version (N-1). A read-only preflight resolves the target, the deploy stage re-runs the configured deploy callbacks keyed on the resolved SHA, and finalize writes the rolled-back state back to trunk. + +Rollback covers the promoted environments only. The first environment tracks trunk and is never promoted into, so it keeps no deploy history to roll back to: roll it forward by reverting the offending change on the trunk branch instead. The workflow dropdown offers only the promoted environments, and a rollback aimed at the first environment fails fast with that guidance. By default the workflow is triggered by manual dispatch only (`workflow_dispatch`). diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index 2afacd0c..a6eb92a8 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -212,7 +212,10 @@ type PromoteStep struct { // failure (for example a rollback whose preflight cannot resolve a target), // mirroring PromoteStep.ExpectFailure. ExpectSource, when non-empty, asserts the // resolved-target source label that the preflight job echoes to its job log -// (one of "state", "previous-ring", or "git-history"). +// (one of "state", "previous-ring", or "git-history"). ExpectLog, when set +// alongside ExpectFailure, asserts the failing run's logs contain the given +// substring, so a scenario can prove the run failed for the expected reason (for +// example the first-environment guard message) rather than an unrelated fault. type RollbackStep struct { Environment string `yaml:"environment"` Target string `yaml:"target,omitempty"` @@ -220,6 +223,7 @@ type RollbackStep struct { DryRun bool `yaml:"dry_run,omitempty"` ExpectFailure bool `yaml:"expect_failure,omitempty"` ExpectSource string `yaml:"expect_source,omitempty"` + ExpectLog string `yaml:"expect_log,omitempty"` } // VerifyStep defines a verify action: a read-only `cascade verify` run in the diff --git a/e2e/harness/rollback_actions.go b/e2e/harness/rollback_actions.go index 5f51c34a..6e17b978 100644 --- a/e2e/harness/rollback_actions.go +++ b/e2e/harness/rollback_actions.go @@ -3,6 +3,7 @@ package harness import ( "context" "fmt" + "strings" ) // rollbackWorkflowPath is the generated rollback workflow's path inside the repo. @@ -86,6 +87,13 @@ func (r *Runner) executeRollback(ctx context.Context, rollback *RollbackStep, co // Handle expected failures (mirrors executePromote's ExpectFailure path). if rollback.ExpectFailure { if result.Conclusion == "failure" { + // When ExpectLog is set, assert the failure logs carry the expected + // marker so the scenario proves the run failed for the intended reason + // (for example the first-environment guard) and not an unrelated fault. + if rollback.ExpectLog != "" && !strings.Contains(result.Logs, rollback.ExpectLog) { + r.t.Logf(" Rollback workflow logs:\n%s", result.Logs) + return fmt.Errorf("rollback failed as expected but logs did not contain %q", rollback.ExpectLog) + } r.t.Log(" Rollback: workflow failed as expected") return nil } diff --git a/e2e/scenarios/rollback/rollback-first-env-guard.yaml b/e2e/scenarios/rollback/rollback-first-env-guard.yaml new file mode 100644 index 00000000..c57daa79 --- /dev/null +++ b/e2e/scenarios/rollback/rollback-first-env-guard.yaml @@ -0,0 +1,123 @@ +name: "Rollback refuses the first environment, allows a promoted one" +description: | + Proves the first-environment rollback guard end to end. The first environment + tracks trunk and is never promoted into, so it keeps no deploy-history ring to + resolve a prior target from. A rollback there must fail fast with an actionable + message rather than silently re-point at an empty or stale ring. A promoted + environment still rolls back normally. + + dev is the first environment. prod is advanced through two published versions + so it has a real prior target. The scenario then dispatches two rollbacks: + + 1. Rollback dev (the first env): the preflight refuses it. The run concludes + in failure and its logs carry the guard message, asserted via expect_log. + 2. Rollback prod (a promoted env): resolves N-1 from the previous-deploy ring, + re-deploys at that SHA, and lands prod back on the first version. + +config: + trunk_branch: main + environments: [dev, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + # Reusable deploy whose inner job echoes the resolved env/sha, so the deploy + # job runs observably under act without a checkout step. act keys it by the + # inner job id appdeploy, which the harness assertion targets. + - name: app + workflow: .github/workflows/deploy-app.yaml + triggers: ["**"] + +steps: + - name: "Commit the first version source" + action: commit + commit: + message: "feat: first version" + files: + src/app.go: | + package main + func main() {} + .github/workflows/deploy-app.yaml: | + name: deploy-app + on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + jobs: + appdeploy: + runs-on: ubuntu-latest + steps: + - run: echo "deployed env=${{ inputs.environment }} sha=${{ inputs.sha }}" + + - name: "Orchestrate the first commit into dev" + action: orchestrate + expect: + state: + dev: + sha: commit1 + + - name: "Promote the first version from dev to prod (establishes the prior target)" + action: promote + promote: + mode: cascade + target: prod + expect: + state: + prod: + sha: commit1 + + - name: "Commit a second version source" + action: commit + commit: + message: "feat: second version" + files: + src/app.go: | + package main + func main() { _ = 2 } + + - name: "Orchestrate the second commit into dev" + action: orchestrate + expect: + state: + dev: + sha: commit2 + + - name: "Promote the second version from dev to prod (advances past the prior target)" + action: promote + promote: + mode: cascade + target: prod + expect: + state: + prod: + sha: commit2 + + # Rollback the first environment: the preflight refuses it before resolving any + # target. The run concludes in failure and its logs carry the guard message. + - name: "Rollback dev (the first environment) fails fast with the guard message" + action: rollback + rollback: + environment: dev + expect_failure: true + expect_log: "is the first environment" + + # Rollback a promoted environment: resolves N-1 from the previous-deploy ring, + # re-deploys at that SHA, and prod state lands back on commit1. + - name: "Rollback prod (a promoted environment) succeeds" + action: rollback + rollback: + environment: prod + expect: + state: + prod: + sha: commit1 + jobs: + preflight: success + appdeploy: success + finalize: success diff --git a/internal/generate/rollback.go b/internal/generate/rollback.go index 095f6130..06a46d77 100644 --- a/internal/generate/rollback.go +++ b/internal/generate/rollback.go @@ -32,11 +32,14 @@ func NewRollbackGenerator(cfg *config.TrunkConfig, baseDir string) *RollbackGene } } -// Enabled reports whether the rollback workflow should be emitted. It is emitted -// when the manifest declares at least one environment, since a rollback re-points -// an environment at a prior deployment. +// Enabled reports whether the rollback workflow should be emitted. It requires +// at least two environments: a rollback re-points a promoted environment at a +// prior deployment, and the first environment tracks trunk (it is never promoted +// into, so it has no rollback history and reverts via a merge to trunk instead). +// A single-environment project therefore has no rollbackable environment, so the +// workflow is not emitted, mirroring the hotfix generator. func (g *RollbackGenerator) Enabled() bool { - return g.config != nil && len(g.config.Environments) >= 1 + return g.config != nil && len(g.config.Environments) >= 2 } // dispatchTrigger returns the configured opt-in repository_dispatch trigger, or @@ -128,14 +131,21 @@ func (g *RollbackGenerator) writeTriggers(sb *strings.Builder) { sb.WriteString(" workflow_dispatch:\n") sb.WriteString(" inputs:\n") - // environment: enumerate the configured environments as a choice so the - // operator picks from the declared set rather than free-typing. + // environment: enumerate the promoted environments as a choice so the operator + // picks from the declared set rather than free-typing. The first environment + // is excluded: it tracks trunk and is refused by the rollback runtime guard, so + // offering it in the dropdown would only surface a guaranteed failure. Enabled + // gates emission on at least two environments, so Environments[1:] is non-empty. sb.WriteString(" environment:\n") sb.WriteString(" description: 'Environment to roll back'\n") sb.WriteString(" required: true\n") sb.WriteString(" type: choice\n") sb.WriteString(" options:\n") - for _, env := range g.config.Environments { + promoted := g.config.Environments + if len(promoted) > 0 { + promoted = promoted[1:] + } + for _, env := range promoted { fmt.Fprintf(sb, " - %s\n", env) } diff --git a/internal/generate/rollback_test.go b/internal/generate/rollback_test.go index 659137e5..f7c02842 100644 --- a/internal/generate/rollback_test.go +++ b/internal/generate/rollback_test.go @@ -27,9 +27,19 @@ func rollbackTestConfig() *config.TrunkConfig { } } -func TestRollbackGenerator_Enabled_TrueWithOneEnv(t *testing.T) { +func TestRollbackGenerator_Enabled_FalseWithOneEnv(t *testing.T) { + // A single-environment project's only env is the first (trunk-tracking) + // environment, which reverts via a merge to trunk, not a rollback. With no + // promoted environment to roll back, the workflow is not emitted, mirroring + // the hotfix generator. cfg := &config.TrunkConfig{Environments: []string{"prod"}} g := NewRollbackGenerator(cfg, "") + assert.False(t, g.Enabled()) +} + +func TestRollbackGenerator_Enabled_TrueWithTwoEnvs(t *testing.T) { + cfg := &config.TrunkConfig{Environments: []string{"dev", "prod"}} + g := NewRollbackGenerator(cfg, "") assert.True(t, g.Enabled()) } @@ -39,6 +49,25 @@ func TestRollbackGenerator_Enabled_FalseWithZeroEnv(t *testing.T) { assert.False(t, g.Enabled()) } +func TestRollbackGenerator_EnvironmentChoices_ExcludeFirstEnv(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "staging", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "services", Workflow: ".github/workflows/deploy.yaml"}, + }, + } + content, err := NewRollbackGenerator(cfg, "").Generate() + assert.NoError(t, err) + + // The first env tracks trunk and is refused by the runtime guard, so the + // dropdown must not offer it. The promoted envs remain selectable. + assert.NotContains(t, content, " - dev\n", + "first environment must not be a rollback choice") + assert.Contains(t, content, " - staging\n") + assert.Contains(t, content, " - prod\n") +} + func TestRollbackGenerator_DispatchInputs(t *testing.T) { g := NewRollbackGenerator(rollbackTestConfig(), "") content, err := g.Generate() diff --git a/internal/rollback/first_env_guard_test.go b/internal/rollback/first_env_guard_test.go new file mode 100644 index 00000000..03935727 --- /dev/null +++ b/internal/rollback/first_env_guard_test.go @@ -0,0 +1,105 @@ +package rollback + +import ( + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" +) + +// The first environment tracks trunk and is never promoted into, so its +// deploy-history ring is structurally always empty. A rollback there would +// resolve a target from an empty or stale ring, which is a silent wrong target. +// The guard makes that case fail fast with an actionable error. dev is the first +// environment in the manifest writeManifest builds; prod is a promoted env. + +func TestPlan_FirstEnvironment_NoTarget_Guarded(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "prodsha9999999", "v1.9.0") + rb := newRollbacker(t, path, fakeHistory{}) + + _, err := rb.Plan("dev", "", "") + if err == nil { + t.Fatalf("expected guard error rolling back the first environment, got nil") + } + if !strings.Contains(err.Error(), "first environment") { + t.Errorf("error = %q, want it to name the first environment", err.Error()) + } + if !strings.Contains(err.Error(), "dev") { + t.Errorf("error = %q, want it to name the env (dev)", err.Error()) + } + if !strings.Contains(err.Error(), "trunk") { + t.Errorf("error = %q, want it to point at the trunk revert path", err.Error()) + } +} + +func TestPlan_FirstEnvironment_WithTarget_Guarded(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "prodsha9999999", "v1.9.0") + rb := newRollbacker(t, path, fakeHistory{}) + + // Even an explicit --to value that matches the first env's live state must be + // refused: the trunk-tracking env reverts via a merge, not a ring rollback. + _, err := rb.Plan("dev", "devsha1234567", "") + if err == nil { + t.Fatalf("expected guard error rolling back the first environment with --to, got nil") + } + if !strings.Contains(err.Error(), "first environment") { + t.Errorf("error = %q, want it to name the first environment", err.Error()) + } +} + +func TestPlan_FirstEnvironment_Deployable_Guarded(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "prodsha9999999", "v1.9.0") + rb := newRollbacker(t, path, fakeHistory{}) + + _, err := rb.Plan("dev", "", "services") + if err == nil { + t.Fatalf("expected guard error for a deployable-scoped first-env rollback, got nil") + } + if !strings.Contains(err.Error(), "first environment") { + t.Errorf("error = %q, want it to name the first environment", err.Error()) + } +} + +func TestPlan_PromotedEnvironment_NotGuarded(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "prodsha9999999", "v1.9.0") + rb := newRollbacker(t, path, fakeHistory{}) + + // prod is a promoted (non-first) env: the guard must not fire and the + // no-target default path must still resolve through the normal sources. + if _, err := rb.Plan("prod", "v1.9.0", ""); err != nil { + t.Fatalf("Plan on a promoted env should not be guarded: %v", err) + } +} + +func TestApply_FirstEnvironment_Guarded(t *testing.T) { + dir := t.TempDir() + path := writeManifest(t, dir, "prodsha9999999", "v1.9.0") + rb := newRollbacker(t, path, fakeHistory{}) + + // A plan constructed directly for the first env (bypassing Plan) must still be + // refused by Apply: the guard is defense in depth on the only mutating path. + plan := &Plan{ + Environment: "dev", + Target: Target{SHA: "devsha1234567", Version: "v2.0.0-rc.1", Source: "state"}, + } + err := rb.Apply(plan) + if err == nil { + t.Fatalf("expected Apply to refuse a first-environment rollback, got nil") + } + if !strings.Contains(err.Error(), "first environment") { + t.Errorf("error = %q, want it to name the first environment", err.Error()) + } +} + +// Guards must be inert when there is no parsed config to identify the first +// environment, so a state-only manifest still resolves through the normal path. +func TestFirstEnvErr_NoConfig_Inert(t *testing.T) { + rb := &Rollbacker{cicdFile: &config.CICDFile{}} + if err := rb.firstEnvErr("dev"); err != nil { + t.Errorf("firstEnvErr with no config should be nil, got %v", err) + } +} diff --git a/internal/rollback/rollback.go b/internal/rollback/rollback.go index ebfc2d96..d0436360 100644 --- a/internal/rollback/rollback.go +++ b/internal/rollback/rollback.go @@ -173,6 +173,10 @@ func (r *Rollbacker) Plan(env, to, deployable string) (*Plan, error) { return nil, fmt.Errorf("unknown environment %q (not declared in config.environments and has no recorded state)", env) } + if err := r.firstEnvErr(env); err != nil { + return nil, err + } + current := r.cicdFile.State[env] plan := &Plan{ Environment: env, @@ -390,6 +394,9 @@ func (r *Rollbacker) Apply(plan *Plan) error { if plan == nil { return fmt.Errorf("nil plan") } + if err := r.firstEnvErr(plan.Environment); err != nil { + return err + } if plan.NoOp { return nil } @@ -484,6 +491,24 @@ func (r *Rollbacker) writeConfig() error { return nil } +// firstEnvErr returns a guard error when env is the first (build target) +// environment and nil otherwise. The first environment tracks trunk and is +// never promoted into, so its deploy-history ring is structurally always empty: +// a rollback there has no recorded prior target and would resolve a silent wrong +// one from an empty or stale ring. The trunk-native undo for the first +// environment is a revert merge to the trunk branch, not a rollback. The guard +// is inert when no parsed config is available to identify the first environment, +// so a state-only manifest still resolves through the normal path. +func (r *Rollbacker) firstEnvErr(env string) error { + if r.cicdFile == nil || r.cicdFile.Config == nil { + return nil + } + if r.cicdFile.Config.IsFirstEnvironment(env) { + return fmt.Errorf("environment %q is the first environment; it tracks trunk and is never promoted into, so it has no rollback history. Revert it with a merge to the trunk branch instead of a rollback", env) + } + return nil +} + // knownEnvironment reports whether env is declared in config.environments or // has recorded state. func (r *Rollbacker) knownEnvironment(env string) bool {