diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index 884bd478..03ad6542 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -396,7 +396,25 @@ "items": { "type": "integer" }, "description": "Percent waves (for example [10, 50, 100])." }, - "analysis": { "type": "string", "description": "Workflow path that gates each wave." } + "analysis": { "type": "string", "description": "Workflow path that gates each wave." }, + "percent": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Single initial canary weight (1..100). RESERVED - not yet wired to generation." + }, + "bake_time": { + "type": "string", + "description": "Soak duration after traffic shift before promotion (Go duration string, for example \"30m\"). RESERVED - not yet wired to generation." + }, + "promote_callback": { + "type": "string", + "description": "Local callback workflow path invoked on promotion. RESERVED - not yet wired to generation." + }, + "rollback_callback": { + "type": "string", + "description": "Local callback workflow path invoked on rollback. RESERVED - not yet wired to generation." + } } }, "blueGreenConfig": { diff --git a/docs/src/content/docs/versioning.md b/docs/src/content/docs/versioning.md index b2acf6ac..72d010ea 100644 --- a/docs/src/content/docs/versioning.md +++ b/docs/src/content/docs/versioning.md @@ -73,6 +73,25 @@ These slots parse and pass structural validation today, but carry no generator, That later release attaches behavior additively, so it does not bump `schema_version`: a manifest written against the reserved shape stays valid, and the schema-version-to-CLI matrix above is unchanged. +## Reserved shape: progressive rollout + +The manifest reserves the shape for progressive rollout on a deploy callback. A deploy may declare a `rollout:` block with a `type` of `default`, `rolling`, `canary`, or `blue_green`, and an optional sub-block matching that type. + +The `canary:` sub-block reserves four fields: + +- `percent`, the initial canary weight, an integer from 1 to 100. +- `bake_time`, the soak duration before promotion, written as a Go duration string (for example `30m`). +- `promote_callback`, a local workflow path that performs the promotion. +- `rollback_callback`, a local workflow path that performs the rollback. + +The `blue_green:` sub-block reserves one field: + +- `switch`, the workflow path that performs the cutover. + +These fields parse and pass structural validation today, but carry no generator behavior. A manifest declaring them produces byte-identical generated workflows, so the reserved shape is safe to adopt now. Attaching behavior to these fields later is additive and does not bump `schema_version`. + +`matrix:` and `rollout:` are separate canonical concerns: `matrix:` describes the fan-out a callback runs across, and `rollout:` describes how a release advances through a callback. There is no shared `strategy:` block that combines them. + ## Migrations Each `schema_version` bump is recorded with a `Migration` section in [CHANGELOG.md](https://github.com/stablekernel/cascade/blob/main/CHANGELOG.md) describing exactly what changed and the steps to update a manifest from the previous version. There are no migrations yet: the current schema version is the first. diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go index e5138ece..8cc2a181 100644 --- a/e2e/harness/scenario.go +++ b/e2e/harness/scenario.go @@ -120,6 +120,11 @@ type DeployConfig struct { // manifest untouched. See BuildConfig.Secrets for the accepted forms and the // rationale for the generic value type. Secrets any `yaml:"secrets,omitempty"` + // Rollout carries the rollout sub-block (type, canary, blue_green) through to + // the generated manifest untouched. A generic map keeps the harness decoupled + // from the generator's RolloutConfig shape, so a scenario can declare any + // reserved rollout field without the harness needing to know its structure. + Rollout map[string]any `yaml:"rollout,omitempty"` } // ConcurrencySpec defines the per-callback concurrency block written to trunk-config.yaml. diff --git a/e2e/scenarios/23-canary-bluegreen-reserved.yaml b/e2e/scenarios/23-canary-bluegreen-reserved.yaml new file mode 100644 index 00000000..0116e6b7 --- /dev/null +++ b/e2e/scenarios/23-canary-bluegreen-reserved.yaml @@ -0,0 +1,50 @@ +name: "Canary and Blue/Green Reserved Shape" +description: | + Exercises the canary and blue_green rollout sub-blocks on deploy callbacks. + These blocks are reserved and shape-only today: they parse and pass structural + validation, but carry no generator behavior. The scenario declares both shapes + across two deploys, generates the workflows, then regenerates and proves the + output is byte-identical with no drift. + +config: + trunk_branch: main + environments: [dev, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: app-canary + workflow: deploy-canary.yaml + triggers: ["src/**"] + rollout: + type: canary + canary: + percent: 10 + bake_time: 5m + promote_callback: .github/workflows/promote.yaml + rollback_callback: .github/workflows/rollback.yaml + - name: app-bg + workflow: deploy-bg.yaml + triggers: ["src/**"] + rollout: + type: blue_green + blue_green: + switch: .github/workflows/switch.yaml + +steps: + - name: "Seed a minimal source tree" + action: commit + commit: + message: "seed source" + files: + src/main.go: | + package main + + func main() {} + + - name: "Regenerate and confirm no drift" + action: verify + verify: + regenerate: true + expect_exit: 0 diff --git a/internal/config/schema_v1.go b/internal/config/schema_v1.go index 8984f768..08f316d8 100644 --- a/internal/config/schema_v1.go +++ b/internal/config/schema_v1.go @@ -170,10 +170,18 @@ const ( // CanaryConfig is the reserved canary rollout sub-block. type CanaryConfig struct { - // Steps are the percent waves (e.g. [10, 50, 100]). + // Steps are the percent waves (for example [10, 50, 100]). Steps []int `yaml:"steps,omitempty" json:"steps,omitempty"` // Analysis is a workflow path that gates each wave. Analysis string `yaml:"analysis,omitempty" json:"analysis,omitempty"` + // Percent is the single initial canary weight (1..100). RESERVED - not yet wired to generation. + Percent int `yaml:"percent,omitempty" json:"percent,omitempty"` + // BakeTime is the soak duration after traffic shift before promotion (Go duration string, e.g. "30m"). RESERVED - not yet wired to generation. + BakeTime string `yaml:"bake_time,omitempty" json:"bake_time,omitempty"` + // PromoteCallback is the local callback workflow path invoked on promotion. RESERVED - not yet wired to generation. + PromoteCallback string `yaml:"promote_callback,omitempty" json:"promote_callback,omitempty"` + // RollbackCallback is the local callback workflow path invoked on rollback. RESERVED - not yet wired to generation. + RollbackCallback string `yaml:"rollback_callback,omitempty" json:"rollback_callback,omitempty"` } // BlueGreenConfig is the reserved blue/green rollout sub-block. diff --git a/internal/config/validate_canary_test.go b/internal/config/validate_canary_test.go new file mode 100644 index 00000000..cb51284d --- /dev/null +++ b/internal/config/validate_canary_test.go @@ -0,0 +1,158 @@ +package config + +import ( + "strings" + "testing" +) + +// TestValidateCanaryFields exercises the reserved canary sub-block validation +// rules added alongside the CanaryConfig field expansion. +func TestValidateCanaryFields(t *testing.T) { + t.Parallel() + + t.Run("new canary fields validate at CurrentSchemaVersion", func(t *testing.T) { + t.Parallel() + cfg := parseInline(t, ` +environments: [dev, prod] +deploys: + - name: app + workflow: .github/workflows/deploy.yaml + rollout: + type: canary + canary: + percent: 10 + bake_time: 30m + promote_callback: .github/workflows/promote.yaml + rollback_callback: .github/workflows/rollback.yaml +`) + if errs := Validate(cfg); len(errs) != 0 { + t.Fatalf("expected no errors, got %v", errs) + } + }) + + t.Run("percent valid range", func(t *testing.T) { + t.Parallel() + for _, pct := range []int{1, 50, 100} { + pct := pct + t.Run("", func(t *testing.T) { + t.Parallel() + c := &CanaryConfig{Percent: pct} + if errs := validateCanaryConfig("deploys[0]", c); len(errs) != 0 { + t.Errorf("percent %d: expected no errors, got %v", pct, errs) + } + }) + } + }) + + t.Run("percent zero is unset and valid", func(t *testing.T) { + t.Parallel() + c := &CanaryConfig{Percent: 0} + if errs := validateCanaryConfig("deploys[0]", c); len(errs) != 0 { + t.Fatalf("percent 0 (unset): expected no errors, got %v", errs) + } + }) + + t.Run("percent 101 rejected", func(t *testing.T) { + t.Parallel() + c := &CanaryConfig{Percent: 101} + errs := validateCanaryConfig("deploys[0]", c) + if !hasErrContaining(errs, "rollout.canary.percent must be between 1 and 100") { + t.Fatalf("expected percent rejection, got %v", errs) + } + }) + + t.Run("percent -1 rejected", func(t *testing.T) { + t.Parallel() + c := &CanaryConfig{Percent: -1} + errs := validateCanaryConfig("deploys[0]", c) + if !hasErrContaining(errs, "rollout.canary.percent must be between 1 and 100") { + t.Fatalf("expected percent rejection, got %v", errs) + } + }) + + t.Run("bake_time valid durations", func(t *testing.T) { + t.Parallel() + for _, d := range []string{"30s", "5m"} { + d := d + t.Run(d, func(t *testing.T) { + t.Parallel() + c := &CanaryConfig{BakeTime: d} + if errs := validateCanaryConfig("deploys[0]", c); len(errs) != 0 { + t.Errorf("bake_time %q: expected no errors, got %v", d, errs) + } + }) + } + }) + + t.Run("bake_time invalid rejected", func(t *testing.T) { + t.Parallel() + c := &CanaryConfig{BakeTime: "notaduration"} + errs := validateCanaryConfig("deploys[0]", c) + if !hasErrContaining(errs, "rollout.canary.bake_time must be a valid Go duration") { + t.Fatalf("expected bake_time rejection, got %v", errs) + } + }) + + t.Run("promote_callback valid paths", func(t *testing.T) { + t.Parallel() + for _, path := range []string{".github/workflows/x.yml", "x.yml"} { + path := path + t.Run(path, func(t *testing.T) { + t.Parallel() + c := &CanaryConfig{PromoteCallback: path} + if errs := validateCanaryConfig("deploys[0]", c); len(errs) != 0 { + t.Errorf("promote_callback %q: expected no errors, got %v", path, errs) + } + }) + } + }) + + t.Run("promote_callback invalid paths rejected", func(t *testing.T) { + t.Parallel() + for _, path := range []string{"../evil.yml", "foo/bar.yml"} { + path := path + t.Run(path, func(t *testing.T) { + t.Parallel() + c := &CanaryConfig{PromoteCallback: path} + errs := validateCanaryConfig("deploys[0]", c) + if !hasErrContaining(errs, "promote_callback") { + t.Errorf("promote_callback %q: expected rejection, got %v", path, errs) + } + for _, e := range errs { + if strings.Contains(e, "promote_callback") && !strings.Contains(e, "local callback workflow must be") { + t.Errorf("promote_callback %q: error has wrong message: %v", path, errs) + } + } + }) + } + }) + + t.Run("rollback_callback valid paths", func(t *testing.T) { + t.Parallel() + for _, path := range []string{".github/workflows/x.yml", "x.yml"} { + path := path + t.Run(path, func(t *testing.T) { + t.Parallel() + c := &CanaryConfig{RollbackCallback: path} + if errs := validateCanaryConfig("deploys[0]", c); len(errs) != 0 { + t.Errorf("rollback_callback %q: expected no errors, got %v", path, errs) + } + }) + } + }) + + t.Run("rollback_callback invalid paths rejected", func(t *testing.T) { + t.Parallel() + for _, path := range []string{"../evil.yml", "foo/bar.yml"} { + path := path + t.Run(path, func(t *testing.T) { + t.Parallel() + c := &CanaryConfig{RollbackCallback: path} + errs := validateCanaryConfig("deploys[0]", c) + if !hasErrContaining(errs, "rollback_callback") { + t.Errorf("rollback_callback %q: expected rejection, got %v", path, errs) + } + }) + } + }) +} diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index 994056b0..9a878bc4 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -5,6 +5,7 @@ import ( "regexp" "sort" "strings" + "time" ) // Structural validation for the v1 reserved-shape fields. These rules are the @@ -219,6 +220,25 @@ func validateRollout(prefix string, r *RolloutConfig, environments []string) []s if (t == RolloutTypeCanary || t == RolloutTypeBlueGreen) && len(environments) == 0 { errs = append(errs, fmt.Sprintf("%s.rollout.type %q requires environments to be configured", prefix, t)) } + if r.Canary != nil { + errs = append(errs, validateCanaryConfig(prefix, r.Canary)...) + } + return errs +} + +// validateCanaryConfig checks the optional reserved fields on a CanaryConfig. +func validateCanaryConfig(prefix string, c *CanaryConfig) []string { + var errs []string + if c.Percent != 0 && (c.Percent < 1 || c.Percent > 100) { + errs = append(errs, fmt.Sprintf("%s.rollout.canary.percent must be between 1 and 100", prefix)) + } + if c.BakeTime != "" { + if _, err := time.ParseDuration(c.BakeTime); err != nil { + errs = append(errs, fmt.Sprintf("%s.rollout.canary.bake_time must be a valid Go duration: %v", prefix, err)) + } + } + errs = append(errs, validateLocalCallbackWorkflowPath(prefix+".rollout.canary.promote_callback", c.PromoteCallback)...) + errs = append(errs, validateLocalCallbackWorkflowPath(prefix+".rollout.canary.rollback_callback", c.RollbackCallback)...) return errs } diff --git a/internal/generate/rollout_reserved_test.go b/internal/generate/rollout_reserved_test.go new file mode 100644 index 00000000..af3ac4ef --- /dev/null +++ b/internal/generate/rollout_reserved_test.go @@ -0,0 +1,79 @@ +package generate + +import ( + "bytes" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/require" +) + +// TestRolloutReservedFieldsAreByteIdentical asserts that populating the +// reserved canary sub-block fields (percent, bake_time, promote_callback, +// rollback_callback) produces byte-identical output to a config that leaves +// them empty. These fields are reserved shape but not yet wired to generation. +func TestRolloutReservedFieldsAreByteIdentical(t *testing.T) { + t.Parallel() + + // Case A: reserved fields populated. + rolloutWithReserved := &config.RolloutConfig{ + Type: "canary", + Canary: &config.CanaryConfig{ + Steps: []int{10, 50, 100}, + Analysis: ".github/workflows/canary-analysis.yaml", + Percent: 10, + BakeTime: "30m", + PromoteCallback: ".github/workflows/promote.yaml", + RollbackCallback: ".github/workflows/rollback.yaml", + }, + } + + // Case B: same type, same Steps/Analysis, reserved fields absent. + rolloutWithoutReserved := &config.RolloutConfig{ + Type: "canary", + Canary: &config.CanaryConfig{ + Steps: []int{10, 50, 100}, + Analysis: ".github/workflows/canary-analysis.yaml", + }, + } + + cfgA := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Deploys: []config.DeployConfig{ + { + Name: "app", + Workflow: ".github/workflows/deploy-app.yaml", + Rollout: rolloutWithReserved, + }, + }, + } + + cfgB := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Deploys: []config.DeployConfig{ + { + Name: "app", + Workflow: ".github/workflows/deploy-app.yaml", + Rollout: rolloutWithoutReserved, + }, + }, + } + + gA := NewPromoteGenerator(cfgA, t.TempDir()) + outA, err := gA.Generate() + require.NoError(t, err) + + gB := NewPromoteGenerator(cfgB, t.TempDir()) + outB, err := gB.Generate() + require.NoError(t, err) + + // Guard against a vacuous comparison of two empty strings: the generator + // must have produced a substantial workflow for the equality to be meaningful. + require.NotEmpty(t, outA, "generated output must be non-empty") + require.Greater(t, len(outA), 1024, "generated workflow should be substantial") + + require.True(t, bytes.Equal([]byte(outA), []byte(outB)), + "reserved canary fields must not affect generated output:\nwith fields:\n%s\nwithout fields:\n%s", outA, outB) +} diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index 884bd478..03ad6542 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -396,7 +396,25 @@ "items": { "type": "integer" }, "description": "Percent waves (for example [10, 50, 100])." }, - "analysis": { "type": "string", "description": "Workflow path that gates each wave." } + "analysis": { "type": "string", "description": "Workflow path that gates each wave." }, + "percent": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Single initial canary weight (1..100). RESERVED - not yet wired to generation." + }, + "bake_time": { + "type": "string", + "description": "Soak duration after traffic shift before promotion (Go duration string, for example \"30m\"). RESERVED - not yet wired to generation." + }, + "promote_callback": { + "type": "string", + "description": "Local callback workflow path invoked on promotion. RESERVED - not yet wired to generation." + }, + "rollback_callback": { + "type": "string", + "description": "Local callback workflow path invoked on rollback. RESERVED - not yet wired to generation." + } } }, "blueGreenConfig": { diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 884bd478..03ad6542 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -396,7 +396,25 @@ "items": { "type": "integer" }, "description": "Percent waves (for example [10, 50, 100])." }, - "analysis": { "type": "string", "description": "Workflow path that gates each wave." } + "analysis": { "type": "string", "description": "Workflow path that gates each wave." }, + "percent": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Single initial canary weight (1..100). RESERVED - not yet wired to generation." + }, + "bake_time": { + "type": "string", + "description": "Soak duration after traffic shift before promotion (Go duration string, for example \"30m\"). RESERVED - not yet wired to generation." + }, + "promote_callback": { + "type": "string", + "description": "Local callback workflow path invoked on promotion. RESERVED - not yet wired to generation." + }, + "rollback_callback": { + "type": "string", + "description": "Local callback workflow path invoked on rollback. RESERVED - not yet wired to generation." + } } }, "blueGreenConfig": {