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
7 changes: 7 additions & 0 deletions cmd/wfctl/infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ func runInfraPlan(args []string) error {
// Embed a hash of the desired-state inputs so wfctl infra apply --plan
// can detect stale plans when the config changes after plan generation.
plan.DesiredHash = desiredStateHash(desired)
plan.Include = sortedIncludeNames(planIncludeSet)
// Stamp generator metadata (wfctl version + IaC plugin versions) so
// operators can inspect what toolchain version produced this plan.
meta := buildGeneratorMetadata()
Expand Down Expand Up @@ -1433,6 +1434,7 @@ func runInfraApply(args []string) error {
if err != nil {
return fmt.Errorf("parse infra resource specs: %w", err)
}
planIncludeSet := includeSetFromNames(plan.Include)
if plan.DesiredHash == "" {
return fmt.Errorf("plan file has no hash — regenerate with: wfctl infra plan -o plan.json")
}
Expand Down Expand Up @@ -1464,6 +1466,11 @@ func runInfraApply(args []string) error {
if stateErr != nil {
return fmt.Errorf("load state for stale-check: %w", stateErr)
}
if err := validateIncludeSet(planIncludeSet, desired, currentState); err != nil {
return fmt.Errorf("validate plan include scope: %w", err)
}
desired = filterSpecsByInclude(desired, planIncludeSet)
currentState = filterStatesByInclude(currentState, planIncludeSet)
planApplyCfg, cfgErr := config.LoadFromFile(cfgFile)
if cfgErr != nil {
return fmt.Errorf("load config for stale-check: %w", cfgErr)
Expand Down
29 changes: 29 additions & 0 deletions cmd/wfctl/infra_apply_include.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,35 @@ func parseIncludeFlag(raw string) map[string]struct{} {
return out
}

func sortedIncludeNames(include map[string]struct{}) []string {
if len(include) == 0 {
return nil
}
names := make([]string, 0, len(include))
for name := range include {
names = append(names, name)
}
sort.Strings(names)
return names
}

func includeSetFromNames(names []string) map[string]struct{} {
if len(names) == 0 {
return nil
}
out := make(map[string]struct{}, len(names))
for _, name := range names {
if strings.TrimSpace(name) == "" {
continue
}
out[name] = struct{}{}
}
if len(out) == 0 {
return nil
}
return out
}

// validateIncludeSet returns an error if any name in the include set is not
// declared in either specs or states. Resources can be in either side
// (state-only resource is eligible for delete; spec-only resource is
Expand Down
46 changes: 46 additions & 0 deletions cmd/wfctl/infra_apply_include_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,52 @@ func TestParseIncludeFlag_AllWhitespace_ReturnsNil(t *testing.T) {
}
}

// ── include scope serialisation helpers ───────────────────────────────────────

func TestSortedIncludeNames_NilInclude_ReturnsNil(t *testing.T) {
if got := sortedIncludeNames(nil); got != nil {
t.Errorf("nil include should yield nil names; got %v", got)
}
}

func TestSortedIncludeNames_ReturnsSortedNames(t *testing.T) {
got := sortedIncludeNames(map[string]struct{}{
"res-C": {},
"res-A": {},
"res-B": {},
})
want := []string{"res-A", "res-B", "res-C"}
if len(got) != len(want) {
t.Fatalf("expected %d names; got %v", len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sorted names = %v, want %v", got, want)
}
}
}

func TestIncludeSetFromNames_EmptyInput_ReturnsNil(t *testing.T) {
if got := includeSetFromNames(nil); got != nil {
t.Errorf("nil names should yield nil include set; got %v", got)
}
if got := includeSetFromNames([]string{"", " "}); got != nil {
t.Errorf("blank names should yield nil include set; got %v", got)
}
}

func TestIncludeSetFromNames_TrimsBlankNames(t *testing.T) {
got := includeSetFromNames([]string{"res-A", "", " ", "res-B"})
if len(got) != 2 {
t.Fatalf("expected 2 names; got %v", got)
}
for _, name := range []string{"res-A", "res-B"} {
if _, ok := got[name]; !ok {
t.Errorf("expected %q in set; got %v", name, got)
}
}
}

// ── validateIncludeSet ────────────────────────────────────────────────────────

func TestValidateIncludeSet_NilInclude_NoError(t *testing.T) {
Expand Down
57 changes: 57 additions & 0 deletions cmd/wfctl/infra_apply_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,63 @@ modules:
}
}

// TestInfraApplyConsumesScopedPlan verifies that a persisted plan produced with
// --include is hash-checked against the same scoped desired set at apply time.
func TestInfraApplyConsumesScopedPlan(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "infra.yaml")
if err := os.WriteFile(cfgPath, []byte(`
modules:
- name: test-provider
type: iac.provider
config:
provider: fake-cloud
token: "test-token"

- name: my-db
type: infra.database
config:
provider: test-provider
engine: postgres
size: s

- name: other-db
type: infra.database
config:
provider: test-provider
engine: postgres
size: s
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
planPath := filepath.Join(dir, "plan.json")

fake := &applyCapture{}
origResolve := resolveIaCProvider
resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) {
return fake, nil, nil
}
t.Cleanup(func() { resolveIaCProvider = origResolve })
fake.installAsV2Dispatch(t)

if err := runInfraPlan([]string{"--config", cfgPath, "--include=my-db", "--output", planPath}); err != nil {
t.Fatalf("runInfraPlan: %v", err)
}

if err := runInfraApply([]string{"--auto-approve", "--config", cfgPath, "--plan", planPath}); err != nil {
t.Fatalf("runInfraApply scoped plan: %v", err)
}
if !fake.applyCalled {
t.Fatal("scoped plan was not applied")
}
if fake.appliedPlan == nil || len(fake.appliedPlan.Actions) != 1 {
t.Fatalf("applied plan actions = %+v, want exactly one action", fake.appliedPlan)
}
if got := fake.appliedPlan.Actions[0].Resource.Name; got != "my-db" {
t.Fatalf("applied resource = %q, want my-db", got)
}
}

func TestInfraApplyPlanSkipBootstrap(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "infra.yaml")
Expand Down
6 changes: 6 additions & 0 deletions interfaces/iac_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ type IaCPlan struct {
// --plan compares this against the current config to detect stale plans.
DesiredHash string `json:"plan_hash,omitempty"`

// Include records the sorted resource names passed through
// `wfctl infra plan --include`. Empty means the plan covered the full
// desired set. wfctl infra apply --plan uses this to recompute DesiredHash
// against the same scope without requiring --include at apply time.
Include []string `json:"include,omitempty"`

// SchemaVersion is bumped when on-disk plan format changes (W-5 sets to 2 when JIT is required).
SchemaVersion int `json:"schema_version,omitempty"`

Expand Down
15 changes: 15 additions & 0 deletions interfaces/iac_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ func TestIaCPlan_InputSnapshotField(t *testing.T) {
}
}

func TestIaCPlan_IncludeField(t *testing.T) {
p := IaCPlan{Include: []string{"bmw-dns"}}
data, err := json.Marshal(p)
if err != nil {
t.Fatal(err)
}
var got IaCPlan
if err := json.Unmarshal(data, &got); err != nil {
t.Fatal(err)
}
if len(got.Include) != 1 || got.Include[0] != "bmw-dns" {
t.Errorf("Include roundtrip failed: %v", got.Include)
}
}

func TestPlanAction_ResolvedConfigHashField(t *testing.T) {
// platform.ConfigHash returns a lower-case hex sha256 digest with no
// "sha256:" prefix; use a realistic 64-hex value so the test's expected
Expand Down
Loading