diff --git a/e2e/scenarios/55-component-env-subset.yaml b/e2e/scenarios/55-component-env-subset.yaml new file mode 100644 index 00000000..594aaf44 --- /dev/null +++ b/e2e/scenarios/55-component-env-subset.yaml @@ -0,0 +1,133 @@ +name: "Per-Component Environment Subset" +description: | + Proves a component promotes only through its own environment ladder when it + declares a shorter `environments` subset than the repo-global ladder (#297). The + manifest declares a global three-env ladder [dev, staging, prod] and two + components: "api" narrows its ladder to [dev, staging], while "web" inherits the + full global ladder. Each component owns a path subtree with its own strict tag + namespace and version line. + + The proof is asymmetric. api's last env, staging, is the terminal position of its + ladder, so promoting api into staging is the publish crossing: api's staging state + lands the stripped release version api-0.1.0, not the api-0.1.0-rc.0 an ordinary + intermediate advance would carry. A promotion runtime that ignored api's subset + and walked the global ladder would treat staging as an intermediate hop (carrying + the rc version) and still hold prod as a further target. A cascade of api into the + global-only prod env is therefore rejected: prod is not on api's ladder. The + sibling web, on the full ladder, still promotes all the way to prod on its own + web-0.1.0 line, and api's recorded staging state stays byte-identical across web's + entire cycle. + +config: + trunk_branch: main + environments: [dev, staging, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["services/**"] + deploys: + - name: app + workflow: deploy.yaml + triggers: ["services/**"] + components: + api: + path: services/api + tag_prefix: api- + environments: [dev, staging] + web: + path: services/web + tag_prefix: web- + +steps: + - name: "Seed both component subtrees" + action: commit + commit: + message: "feat: seed component sources" + files: + services/api/main.go: | + package main + + func main() {} + services/web/main.go: | + package main + + func main() {} + + # Confirm the multi-component generate then verify roundtrip is drift-free, so the + # per-component workflows executed below are the pristine generated output. + - name: "Regenerate the per-component set and confirm no drift" + action: verify + verify: + regenerate: true + expect_exit: 0 + + # Cut api's dev prerelease on its own version line. + - name: "Orchestrate api to cut its dev prerelease" + action: orchestrate + orchestrate: + component: api + + # Promote api from dev to staging, api's last (terminal) env. Because staging is + # the top of api's ladder, this is the publish crossing: staging records the + # stripped release version api-0.1.0. Under a runtime that walked the global + # ladder, staging would be an intermediate hop still carrying api-0.1.0-rc.0. web + # has not been touched, so its subtree must be absent. + - name: "Promote api from dev to staging (its terminal env)" + action: promote + promote: + mode: cascade + target: staging + component: api + expect: + state: + api-staging: + component: api + env: staging + version: "api-0.1.0" + web-staging: + component: web + env: staging + wiped: true + + # A cascade of api into the global-only prod env must be rejected: prod is not on + # api's ladder [dev, staging]. A runtime that ignored the subset would happily + # promote api into prod. The workflow fails at preflight. + - name: "Reject promoting api into the global-only prod env" + action: promote + promote: + mode: cascade + target: prod + component: api + expect_failure: true + + # Advance web through its own cycle. web inherits the full ladder, so it reaches + # prod. Cutting web's dev prerelease must not disturb api's recorded staging state. + - name: "Orchestrate web to cut its dev prerelease" + action: orchestrate + orchestrate: + component: web + expect: + state: + api-staging: + component: api + env: staging + unchanged: true + + # Promote web from dev to prod. web's full ladder reaches prod on its own web-0.1.0 + # line; api's staging subtree must survive byte-identical. + - name: "Promote web from dev to prod (full ladder)" + action: promote + promote: + mode: cascade + target: prod + component: web + expect: + state: + web-prod: + component: web + env: prod + version: "web-0.1.0" + api-staging: + component: api + env: staging + unchanged: true diff --git a/internal/promote/command_preflight.go b/internal/promote/command_preflight.go index f09d1323..f1a5b2e8 100644 --- a/internal/promote/command_preflight.go +++ b/internal/promote/command_preflight.go @@ -68,6 +68,13 @@ func runPreflight(cmd *cobra.Command, args []string) error { return err } + // Narrow the working ladder to the component's resolved environment subset so + // the plan advances and gates only along that component's own environments, + // never the global-only tail. A no-op for a single-component (empty) preflight. + if err := applyComponentLadder(cicdFile, componentName); err != nil { + return err + } + // Parse and validate mode // Mode can be "default" for sequential promotion, or a cascade target like "dev-to-prod" var mode PromotionMode diff --git a/internal/promote/component_env_subset_test.go b/internal/promote/component_env_subset_test.go new file mode 100644 index 00000000..3238c744 --- /dev/null +++ b/internal/promote/component_env_subset_test.go @@ -0,0 +1,163 @@ +package promote + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/require" +) + +// componentEnvSubsetManifest declares a global three-env ladder [dev, staging, +// prod] and two components: "api" narrows its ladder to the strict subset [dev, +// staging] while "web" inherits the full global ladder. Each component seeds its +// own dev deployment under state.components..dev so a component-scoped +// promotion has a source to advance. +const componentEnvSubsetManifest = `ci: + config: + trunk_branch: main + environments: [dev, staging, prod] + components: + api: + path: services/api + tag_prefix: api- + environments: [dev, staging] + web: + path: services/web + tag_prefix: web- + state: + components: + api: + dev: + sha: apidevsha + version: api-1.0.0-rc.0 + web: + dev: + sha: webdevsha + version: web-1.0.0-rc.0 +` + +func writeSubsetManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(componentEnvSubsetManifest), 0o644)) + return path +} + +// TestNewPromoter_ComponentSubset_CascadeToProdRejected proves the promotion +// runtime respects a component's narrowed environment ladder: "api" declares +// [dev, staging], so a cascade targeting the global-only "prod" env must be +// rejected because prod is not in api's resolved ladder. Before the runtime honored +// the subset it read the global [dev, staging, prod] ladder and would happily +// promote api into prod, an env api never targets. +func TestNewPromoter_ComponentSubset_CascadeToProdRejected(t *testing.T) { + path := writeSubsetManifest(t) + + p, err := NewPromoter(PromoterOptions{ + ConfigPath: path, + DryRun: true, + Actor: "test-actor", + Component: "api", + }) + require.NoError(t, err) + + result, err := p.Promote(ModeCascade, "dev-to-prod") + require.NoError(t, err) + require.False(t, result.Success, "cascade into prod must fail: prod is outside api's ladder [dev, staging]") + require.Contains(t, result.Error, "prod") +} + +// TestNewPromoter_ComponentSubset_CascadeStaysInLadder proves a cascade within +// api's subset succeeds and never reaches beyond its last env. dev-to-staging is +// valid; staging is api's final env. +func TestNewPromoter_ComponentSubset_CascadeStaysInLadder(t *testing.T) { + path := writeSubsetManifest(t) + + p, err := NewPromoter(PromoterOptions{ + ConfigPath: path, + DryRun: true, + Actor: "test-actor", + Component: "api", + }) + require.NoError(t, err) + + result, err := p.Promote(ModeCascade, "dev-to-staging") + require.NoError(t, err) + require.True(t, result.Success, "dev-to-staging is inside api's ladder; error: %s", result.Error) + for _, promo := range result.Promotions { + require.NotEqual(t, "prod", promo.Environment, "api must never target prod") + } + require.Equal(t, "apidevsha", result.Promotions[len(result.Promotions)-1].SHA) +} + +// TestNewPromoter_ComponentSubset_DefaultTreatsLastSubsetEnvAsFinal proves default +// (sequential) mode advances api only through its own ladder and treats staging, +// the last env of api's subset, as the final environment. No promotion targets the +// global-only prod env. +func TestNewPromoter_ComponentSubset_DefaultTreatsLastSubsetEnvAsFinal(t *testing.T) { + path := writeSubsetManifest(t) + + p, err := NewPromoter(PromoterOptions{ + ConfigPath: path, + DryRun: true, + Actor: "test-actor", + Component: "api", + }) + require.NoError(t, err) + + result, err := p.Promote(ModeDefault, "") + require.NoError(t, err) + require.True(t, result.Success, "default promotion must advance api within its ladder; error: %s", result.Error) + require.NotEmpty(t, result.Promotions) + for _, promo := range result.Promotions { + require.NotEqual(t, "prod", promo.Environment, "api must never target the global-only prod env") + } +} + +// TestNewPromoter_ComponentSubset_SiblingKeepsFullLadder proves the narrowing is +// scoped to the addressed component: "web" inherits the full global ladder, so a +// cascade to prod succeeds and lands on prod. +func TestNewPromoter_ComponentSubset_SiblingKeepsFullLadder(t *testing.T) { + path := writeSubsetManifest(t) + + p, err := NewPromoter(PromoterOptions{ + ConfigPath: path, + DryRun: true, + Actor: "test-actor", + Component: "web", + }) + require.NoError(t, err) + + result, err := p.Promote(ModeCascade, "dev-to-prod") + require.NoError(t, err) + require.True(t, result.Success, "web keeps the full ladder; dev-to-prod must succeed; error: %s", result.Error) + require.Equal(t, "prod", result.Promotions[len(result.Promotions)-1].Environment) + require.Equal(t, "webdevsha", result.Promotions[len(result.Promotions)-1].SHA) +} + +// TestApplyComponentLadder_EmptyComponentUnchanged proves the single-component +// (empty component) path leaves the global ladder byte-identical: applyComponentLadder +// is a no-op and the working config still carries the full global ladder. +func TestApplyComponentLadder_EmptyComponentUnchanged(t *testing.T) { + path := writeSubsetManifest(t) + cicdFile, err := config.ParseManifestFile(path, config.DefaultManifestKey) + require.NoError(t, err) + + before := append([]string(nil), cicdFile.Config.Environments...) + require.NoError(t, applyComponentLadder(cicdFile, "")) + require.Equal(t, before, cicdFile.Config.Environments, "empty component must not narrow the ladder") + require.Equal(t, []string{"dev", "staging", "prod"}, cicdFile.Config.Environments) +} + +// TestApplyComponentLadder_NarrowsToComponentSubset proves the helper narrows the +// working config's ladder to the addressed component's resolved subset. +func TestApplyComponentLadder_NarrowsToComponentSubset(t *testing.T) { + path := writeSubsetManifest(t) + cicdFile, err := config.ParseManifestFile(path, config.DefaultManifestKey) + require.NoError(t, err) + + require.NoError(t, applyComponentLadder(cicdFile, "api")) + require.Equal(t, []string{"dev", "staging"}, cicdFile.Config.Environments) +} diff --git a/internal/promote/preflight_component_subset_test.go b/internal/promote/preflight_component_subset_test.go new file mode 100644 index 00000000..92f8427a --- /dev/null +++ b/internal/promote/preflight_component_subset_test.go @@ -0,0 +1,56 @@ +package promote + +import ( + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/require" +) + +// TestPreflighter_ComponentSubset_TreatsLastSubsetEnvAsFinal proves the preflight +// planner honors a component's narrowed ladder end to end: for "api" (ladder [dev, +// staging]) a default-mode preflight advances dev->staging and marks staging as the +// final environment, never planning an advance into the global-only prod env. +func TestPreflighter_ComponentSubset_TreatsLastSubsetEnvAsFinal(t *testing.T) { + path := writeSubsetManifest(t) + cicdFile, err := config.ParseManifestFile(path, config.DefaultManifestKey) + require.NoError(t, err) + require.NoError(t, overlayComponentState(cicdFile, path, "api")) + require.NoError(t, applyComponentLadder(cicdFile, "api")) + + pf := NewPreflighter(PreflighterOptions{ + Config: cicdFile, + Mode: ModeDefault, + }) + + result, err := pf.Run() + require.NoError(t, err) + for _, env := range result.EnvsToUpdate { + require.NotEqual(t, "prod", env, "api preflight must not plan an advance into prod") + } + // staging is api's last env, so crossing into it is the terminal publish + // boundary: the plan advances to the "release" marker and marks the crossing + // final, rather than trying to advance to the global-only prod env. + require.True(t, result.IsFinalEnv, "api's last-env crossing must be treated as final") + require.Equal(t, "release", result.TargetEnv, "the terminal crossing lands on the release marker, never prod") +} + +// TestPreflighter_ComponentSubset_SiblingReachesProd proves the sibling "web", +// inheriting the full ladder, still plans a cascade all the way to prod. +func TestPreflighter_ComponentSubset_SiblingReachesProd(t *testing.T) { + path := writeSubsetManifest(t) + cicdFile, err := config.ParseManifestFile(path, config.DefaultManifestKey) + require.NoError(t, err) + require.NoError(t, overlayComponentState(cicdFile, path, "web")) + require.NoError(t, applyComponentLadder(cicdFile, "web")) + + pf := NewPreflighter(PreflighterOptions{ + Config: cicdFile, + Mode: ModeCascade, + Target: "dev-to-prod", + }) + + result, err := pf.Run() + require.NoError(t, err) + require.Equal(t, "prod", result.TargetEnv, "web reaches prod on the full ladder") +} diff --git a/internal/promote/promote.go b/internal/promote/promote.go index 38255f4f..90ca2fe0 100644 --- a/internal/promote/promote.go +++ b/internal/promote/promote.go @@ -102,6 +102,14 @@ func NewPromoter(opts PromoterOptions, options ...Option) (*Promoter, error) { return nil, err } + // Narrow the working ladder to the component's resolved environment subset. A + // component may declare a shorter `environments` list than the repo-global + // ladder; the promotion runtime must advance and gate along that subset, not + // the global ladder. A no-op for the single-component (empty) path. + if err := applyComponentLadder(cicdFile, opts.Component); err != nil { + return nil, err + } + actor := opts.Actor if actor == "" { actor = "github-actions[bot]" @@ -149,6 +157,35 @@ func overlayComponentState(cicdFile *config.CICDFile, configPath, component stri return nil } +// applyComponentLadder narrows the working config's environment ladder to the +// named component's resolved subset, read via config.TrunkConfig.ResolveComponent. +// A component may override the repo-global `environments` list with a shorter +// subset; the promotion runtime derives the next-env, is-last-env, and gating +// decisions from cicdFile.Config.Environments, so narrowing it here makes every +// such derivation honor the component's ladder without teaching each call site +// about components. It is a no-op when component is empty (the single-component +// path is byte-identical) or when the manifest carries no config. This mirrors +// how internal/rollback.New resolves the component's ladder for its guards. +func applyComponentLadder(cicdFile *config.CICDFile, component string) error { + if component == "" || cicdFile.Config == nil { + return nil + } + // A component may be recorded only under state.components (per-component state + // seeding) without a config.components declaration. Such a component carries no + // `environments` override, so the repo-global ladder already applies; there is + // nothing to narrow and resolving would fail on the undeclared name. Narrow the + // ladder only for a component the config actually declares. + if _, declared := cicdFile.Config.Components[component]; !declared { + return nil + } + resolved, err := cicdFile.Config.ResolveComponent(component) + if err != nil { + return fmt.Errorf("resolving component %q: %w", component, err) + } + cicdFile.Config.Environments = resolved.Config.Environments + return nil +} + // Promote executes a promotion and returns the result // mode: "default" for sequential single-step, "cascade" for atomic multi-step // target: for cascade mode, the target (e.g., "dev-to-prod")