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
93 changes: 93 additions & 0 deletions e2e/scenarios/52-component-orchestrate-isolation.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: "Per-Component Orchestrate Seed Isolation"
description: |
Proves two components seed the same first environment independently, each
recording its version under only its own state.components.<name>.<dev> subtree,
so neither overwrites the other's seed row. The manifest declares two components
(api, web), each owning a path subtree with its own strict tag namespace, sharing
the dev to prod ladder on its own version line. Generation fans the orchestrate
lane out to one orchestrate-<name>.yaml per component.

api and web are seeded, then each is orchestrated to dev in turn with NO
interleaving promotion between them. Before, an orchestrate recorded its built
version into the shared flat state.dev row, so web's orchestrate would overwrite
api's seed and the two could not both be observed at dev at once; a scenario had
to promote api out of dev before touching web to avoid the clobber. Here both
orchestrate to dev back to back, and the proof is that after web's orchestrate,
api's dev seed still reads its own api-0.1.0-rc.0 line under state.components.api,
coexisting with web's web-0.1.0-rc.0 under state.components.web: the seed write is
scoped per component, not shared and flat.

config:
trunk_branch: main
environments: [dev, 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-
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. The seed lands under
# state.components.api.dev, not the shared flat state.dev.
- name: "Orchestrate api to seed its dev prerelease"
action: orchestrate
orchestrate:
component: api
expect:
state:
api-dev:
component: api
env: dev
version: "api-0.1.0-rc.0"

# Cut web's dev prerelease WITHOUT first promoting api out of dev. web writes
# only state.components.web.dev; api's seed row must survive untouched. Before the
# per-component seed write, web's orchestrate would have overwritten the shared
# flat state.dev and api's seed would be gone.
- name: "Orchestrate web to seed its dev prerelease"
action: orchestrate
orchestrate:
component: web
expect:
state:
web-dev:
component: web
env: dev
version: "web-0.1.0-rc.0"
api-dev:
component: api
env: dev
version: "api-0.1.0-rc.0"
unchanged: true
81 changes: 81 additions & 0 deletions internal/config/readcomponentstate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package config

import (
"testing"

"github.com/stretchr/testify/require"
)

const twoComponentStateManifest = `ci:
config:
environments: [dev, prod]
state:
components:
api:
dev:
sha: apidevsha
version: api-0.1.0-rc.0
prod:
sha: apiprodsha
version: api-0.1.0
web:
dev:
sha: webdevsha
version: web-0.1.0-rc.0
`

// TestReadComponentState_ReadsOnlyNamedComponent proves the reader returns every
// env row a single component owns and ignores its siblings, so a flat-map
// consumer can overlay one component's seed without pulling in another's.
func TestReadComponentState_ReadsOnlyNamedComponent(t *testing.T) {
got, err := ReadComponentState([]byte(twoComponentStateManifest), "ci", "api")
require.NoError(t, err)
require.Len(t, got, 2, "api owns dev and prod rows")
require.Equal(t, "apidevsha", got["dev"].SHA)
require.Equal(t, "api-0.1.0-rc.0", got["dev"].Version)
require.Equal(t, "apiprodsha", got["prod"].SHA)
require.Equal(t, "api-0.1.0", got["prod"].Version)
_, hasWebLeak := got["web"]
require.False(t, hasWebLeak, "reader must not leak a sibling component's rows")
}

// TestReadComponentState_MissingComponentOrSubtree proves absent inputs yield a
// nil map and no error, so an overlay is a clean no-op when a component has no
// recorded state yet or the manifest declares no components at all.
func TestReadComponentState_MissingComponentOrSubtree(t *testing.T) {
got, err := ReadComponentState([]byte(twoComponentStateManifest), "ci", "billing")
require.NoError(t, err)
require.Nil(t, got, "an undeclared component yields no rows")

const flat = `ci:
config:
environments: [dev]
state:
dev:
sha: devsha
`
got, err = ReadComponentState([]byte(flat), "ci", "api")
require.NoError(t, err)
require.Nil(t, got, "a manifest with no components subtree yields no rows")
}

// TestReadComponentState_RoundTripsWriteScopedState proves the reader observes
// exactly what the component-scoped writer records: writing a component's env row
// then reading it back yields the same SHA and version, so overlay-then-write is
// a faithful round trip.
func TestReadComponentState_RoundTripsWriteScopedState(t *testing.T) {
const base = `ci:
config:
environments: [dev, prod]
`
out, err := WriteScopedState([]byte(base), "ci",
StateWrite{Component: "api", Env: "dev", State: &EnvState{SHA: "s1", Version: "api-0.1.0-rc.0"}},
)
require.NoError(t, err)

got, err := ReadComponentState(out, "ci", "api")
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, "s1", got["dev"].SHA)
require.Equal(t, "api-0.1.0-rc.0", got["dev"].Version)
}
31 changes: 31 additions & 0 deletions internal/config/statemerge.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,37 @@ func deleteMappingKey(m *yaml.Node, key string) {
}
}

// ReadComponentState reads the recorded per-env state a single component owns
// under state.components.<component>.<env>, returning an env-keyed map of the
// same EnvState shape the flat state.<env> rows use. It is the read counterpart
// to the component-scoped WriteScopedState: a consumer that works against the
// flat state map (promotion, orchestration) can overlay a component's seed into
// its working map so every existing state.<env> lookup transparently sees that
// component's rows, without teaching each lookup about the components subtree.
//
// A manifest with no components subtree, or no rows for the named component,
// yields a nil map and no error. manifestKey defaults to DefaultManifestKey when
// empty. YAML parse failures are wrapped with %w. Sibling components are ignored;
// only the named component's rows are returned.
func ReadComponentState(current []byte, manifestKey, component string) (map[string]*EnvState, error) {
if manifestKey == "" {
manifestKey = DefaultManifestKey
}
var doc map[string]struct {
State struct {
Components map[string]map[string]*EnvState `yaml:"components"`
} `yaml:"state"`
}
if err := yaml.Unmarshal(current, &doc); err != nil {
return nil, fmt.Errorf("parsing component state: %w", err)
}
section, ok := doc[manifestKey]
if !ok {
return nil, nil
}
return section.State.Components[component], nil
}

// valueNode marshals v through YAML and returns the resulting value node, so a
// typed value can be spliced into the document tree.
func valueNode(v any) (*yaml.Node, error) {
Expand Down
65 changes: 65 additions & 0 deletions internal/generate/component_workflows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,71 @@ func TestOrchestrateTargets_Components_FanOut(t *testing.T) {
}
}

// updateManifestStep extracts the Update Manifest step body from a generated
// orchestrate workflow so state-write assertions stay focused on the yq edits.
func updateManifestStep(t *testing.T, workflow string) string {
t.Helper()
idx := strings.Index(workflow, "- name: Update Manifest")
require.GreaterOrEqual(t, idx, 0, "Update Manifest step missing")
return workflow[idx:]
}

// TestOrchestrate_Components_ScopeSeedStateWrite proves each per-component
// orchestrate workflow records its seeded env under
// state.components.<name>.<env> in the Update Manifest step, and never writes the
// shared flat state.<env> row. This is the seed-state isolation: two components
// orchestrating the same env write disjoint subtrees, so neither overwrites the
// other's row (the coupling that previously forced scenarios to interleave).
func TestOrchestrate_Components_ScopeSeedStateWrite(t *testing.T) {
cfg := twoComponentConfig()

targets, err := orchestrateTargets(cfg, "", ".github/workflows/orchestrate.yaml", nil, false)
require.NoError(t, err)

byPath := map[string]string{}
for _, tg := range targets {
content, gerr := tg.Gen.Generate()
require.NoError(t, gerr)
byPath[tg.Path] = content
}

api := updateManifestStep(t, byPath[".github/workflows/orchestrate-api.yaml"])
web := updateManifestStep(t, byPath[".github/workflows/orchestrate-web.yaml"])

// api seeds only its own subtree.
require.Contains(t, api, "state.components.api.$ENVIRONMENT.sha",
"api orchestrate must record its seed under state.components.api")
require.Contains(t, api, "state.components.api.$ENVIRONMENT.version")
require.NotContains(t, api, "state.components.web",
"api orchestrate must not touch web's subtree (isolation)")

// The flat env row must not be written by a component orchestrate: a bare
// state.$ENVIRONMENT would let two components clobber the same row.
require.NotContains(t, api, "state.$ENVIRONMENT.sha",
"component orchestrate must not write the shared flat state.<env> row")

// web seeds only its own subtree.
require.Contains(t, web, "state.components.web.$ENVIRONMENT.sha",
"web orchestrate must record its seed under state.components.web")
require.NotContains(t, web, "state.components.api",
"web orchestrate must not touch api's subtree (isolation)")
}

// TestOrchestrate_SingleComponent_SeedStateWriteStaysFlat proves a manifest with
// no components: block keeps writing the flat state.<env> row, byte-identical to
// the historical behavior (no state.components. path leaks in).
func TestOrchestrate_SingleComponent_SeedStateWriteStaysFlat(t *testing.T) {
cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}}
workflow, err := NewGenerator(cfg, "").Generate()
require.NoError(t, err)
step := updateManifestStep(t, workflow)

require.Contains(t, step, "state.$ENVIRONMENT.sha",
"single-component orchestrate must keep the flat state.<env> write")
require.NotContains(t, step, "state.components.",
"single-component orchestrate must not emit any component-scoped state path")
}

// TestGenerator_SingleComponent_NoComponentFlag proves the single-component
// orchestrate workflow emits no --component flag, keeping its setup invocation
// byte-identical to the pre-component generator.
Expand Down
40 changes: 27 additions & 13 deletions internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -1684,19 +1684,32 @@ func (g *Generator) writeManifestUpdateStep(sb *strings.Builder, sorted []string
sb.WriteString(" apply_state_edits() {\n")
sb.WriteString(" TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)\n")

// A per-component orchestrate scopes its seed under state.components.<name>.<env>
// so two components seeding the same environment write disjoint subtrees and
// never overwrite each other's row. The single-component workflow keeps the
// flat state.<env> path (empty segment), byte-identical to the historical
// output. The name is a generation-time literal validated by the component
// grammar, matching how deploy/build names are already interpolated into these
// yq paths.
stateComp := ""
if g.componentName != "" {
stateComp = "components." + g.componentName + "."
}

if len(g.config.Environments) > 0 {
envSel := ".$MANIFEST_KEY.state." + stateComp + "$ENVIRONMENT"
sb.WriteString(" # Update environment-level state (committed, not deployed)\n")
sb.WriteString(" yq eval -i \".$MANIFEST_KEY.state.$ENVIRONMENT.sha = \\\"$HEAD_SHA\\\"\" \"$MANIFEST_FILE\"\n")
sb.WriteString(" yq eval -i \".$MANIFEST_KEY.state.$ENVIRONMENT.version = \\\"$VERSION\\\"\" \"$MANIFEST_FILE\"\n")
sb.WriteString(" yq eval -i \".$MANIFEST_KEY.state.$ENVIRONMENT.committed_at = \\\"$TIMESTAMP\\\"\" \"$MANIFEST_FILE\"\n")
sb.WriteString(" yq eval -i \".$MANIFEST_KEY.state.$ENVIRONMENT.committed_by = \\\"${{ github.actor }}\\\"\" \"$MANIFEST_FILE\"\n")
fmt.Fprintf(sb, " yq eval -i \"%s.sha = \\\"$HEAD_SHA\\\"\" \"$MANIFEST_FILE\"\n", envSel)
fmt.Fprintf(sb, " yq eval -i \"%s.version = \\\"$VERSION\\\"\" \"$MANIFEST_FILE\"\n", envSel)
fmt.Fprintf(sb, " yq eval -i \"%s.committed_at = \\\"$TIMESTAMP\\\"\" \"$MANIFEST_FILE\"\n", envSel)
fmt.Fprintf(sb, " yq eval -i \"%s.committed_by = \\\"${{ github.actor }}\\\"\" \"$MANIFEST_FILE\"\n", envSel)

for _, d := range g.config.Deploys {
envName := strings.ToUpper(strings.ReplaceAll(d.Name, "-", "_"))
fmt.Fprintf(sb, " if [[ \"$%s_RESULT\" == \"success\" ]]; then\n", envName)
fmt.Fprintf(sb, " yq eval -i \".$MANIFEST_KEY.state.$ENVIRONMENT.deploys.%s.sha = \\\"$HEAD_SHA\\\"\" \"$MANIFEST_FILE\"\n", d.Name)
fmt.Fprintf(sb, " yq eval -i \".$MANIFEST_KEY.state.$ENVIRONMENT.deploys.%s.deployed_at = \\\"$TIMESTAMP\\\"\" \"$MANIFEST_FILE\"\n", d.Name)
fmt.Fprintf(sb, " yq eval -i \".$MANIFEST_KEY.state.$ENVIRONMENT.deploys.%s.deployed_by = \\\"${{ github.actor }}\\\"\" \"$MANIFEST_FILE\"\n", d.Name)
fmt.Fprintf(sb, " yq eval -i \"%s.deploys.%s.sha = \\\"$HEAD_SHA\\\"\" \"$MANIFEST_FILE\"\n", envSel, d.Name)
fmt.Fprintf(sb, " yq eval -i \"%s.deploys.%s.deployed_at = \\\"$TIMESTAMP\\\"\" \"$MANIFEST_FILE\"\n", envSel, d.Name)
fmt.Fprintf(sb, " yq eval -i \"%s.deploys.%s.deployed_by = \\\"${{ github.actor }}\\\"\" \"$MANIFEST_FILE\"\n", envSel, d.Name)
sb.WriteString(" fi\n")
}

Expand All @@ -1706,20 +1719,21 @@ func (g *Generator) writeManifestUpdateStep(sb *strings.Builder, sorted []string
if out == "artifact_id" {
envName := strings.ToUpper(strings.ReplaceAll(b.Name, "-", "_"))
fmt.Fprintf(sb, " if [[ -n \"$BUILD_ARTIFACT_%s\" ]]; then\n", envName)
fmt.Fprintf(sb, " yq eval -i \".$MANIFEST_KEY.state.$ENVIRONMENT.builds.%s.sha = \\\"$HEAD_SHA\\\"\" \"$MANIFEST_FILE\"\n", b.Name)
fmt.Fprintf(sb, " yq eval -i \".$MANIFEST_KEY.state.$ENVIRONMENT.builds.%s.artifact_id = \\\"$BUILD_ARTIFACT_%s\\\"\" \"$MANIFEST_FILE\"\n", b.Name, envName)
fmt.Fprintf(sb, " yq eval -i \"%s.builds.%s.sha = \\\"$HEAD_SHA\\\"\" \"$MANIFEST_FILE\"\n", envSel, b.Name)
fmt.Fprintf(sb, " yq eval -i \"%s.builds.%s.artifact_id = \\\"$BUILD_ARTIFACT_%s\\\"\" \"$MANIFEST_FILE\"\n", envSel, b.Name, envName)
sb.WriteString(" fi\n")
break
}
}
}
} else {
// No environments - update state under 'prerelease' key (consistent with orchestrator.DefaultStateKey)
preSel := ".$MANIFEST_KEY.state." + stateComp + "prerelease"
sb.WriteString(" # Update state under prerelease key (no environments)\n")
sb.WriteString(" yq eval -i \".$MANIFEST_KEY.state.prerelease.sha = \\\"$HEAD_SHA\\\"\" \"$MANIFEST_FILE\"\n")
sb.WriteString(" yq eval -i \".$MANIFEST_KEY.state.prerelease.version = \\\"$VERSION\\\"\" \"$MANIFEST_FILE\"\n")
sb.WriteString(" yq eval -i \".$MANIFEST_KEY.state.prerelease.committed_at = \\\"$TIMESTAMP\\\"\" \"$MANIFEST_FILE\"\n")
sb.WriteString(" yq eval -i \".$MANIFEST_KEY.state.prerelease.committed_by = \\\"${{ github.actor }}\\\"\" \"$MANIFEST_FILE\"\n")
fmt.Fprintf(sb, " yq eval -i \"%s.sha = \\\"$HEAD_SHA\\\"\" \"$MANIFEST_FILE\"\n", preSel)
fmt.Fprintf(sb, " yq eval -i \"%s.version = \\\"$VERSION\\\"\" \"$MANIFEST_FILE\"\n", preSel)
fmt.Fprintf(sb, " yq eval -i \"%s.committed_at = \\\"$TIMESTAMP\\\"\" \"$MANIFEST_FILE\"\n", preSel)
fmt.Fprintf(sb, " yq eval -i \"%s.committed_by = \\\"${{ github.actor }}\\\"\" \"$MANIFEST_FILE\"\n", preSel)
}

sb.WriteString(" }\n")
Expand Down
8 changes: 6 additions & 2 deletions internal/orchestrate/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,12 @@ func runFinalize(version, deployResults, buildResults string) error {
log.Info("%sRunning in dry-run mode", log.DryRunPrefix())
}

// Create orchestrator
orch, err := NewOrchestrator(configPath, manifestKey, environment)
// Create orchestrator. WithComponent("") is a no-op, so the single-component
// path is unchanged; a per-component generated workflow passes --component so
// the seeded state is recorded under state.components.<component>.<env> rather
// than the shared flat state.<env>, keeping two components from overwriting
// each other's seed row.
orch, err := NewOrchestrator(configPath, manifestKey, environment, WithComponent(component))
if err != nil {
return fmt.Errorf("initializing orchestrator: %w", err)
}
Expand Down
Loading