From a59d58388437be44907407dc72d40966fd4b4957 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 17:02:54 -0400 Subject: [PATCH 1/5] refactor(iac): extract []any resource-spec converter to core iac/specparse Co-Authored-By: Claude Sonnet 4.6 --- iac/specparse/specparse.go | 48 ++++++++++++ iac/specparse/specparse_test.go | 90 +++++++++++++++++++++++ module/pipeline_step_iac_provider_plan.go | 33 +-------- 3 files changed, 142 insertions(+), 29 deletions(-) create mode 100644 iac/specparse/specparse.go create mode 100644 iac/specparse/specparse_test.go diff --git a/iac/specparse/specparse.go b/iac/specparse/specparse.go new file mode 100644 index 000000000..ba0dd4b13 --- /dev/null +++ b/iac/specparse/specparse.go @@ -0,0 +1,48 @@ +// Package specparse converts an already-decoded []any of spec maps (as +// produced by YAML/JSON config loaders) into []interfaces.ResourceSpec. +// +// This is the in-memory parser — it does NOT read files or expand secret:// +// references. Secret refs pass through verbatim so that downstream JIT +// substitution (iac/jitsubst) can expand them at apply time. +package specparse + +import ( + "fmt" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// ParseResourceSpecs converts a raw config value ([]any of map[string]any) +// into []interfaces.ResourceSpec. A nil raw value is allowed and returns a +// nil slice. Secret:// refs in config values are preserved verbatim. +func ParseResourceSpecs(raw any) ([]interfaces.ResourceSpec, error) { + if raw == nil { + return nil, nil + } + list, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("specs must be a list, got %T", raw) + } + specs := make([]interfaces.ResourceSpec, 0, len(list)) + for i, item := range list { + m, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("specs[%d] must be a map, got %T", i, item) + } + spec := interfaces.ResourceSpec{} + if n, ok := m["name"].(string); ok { + spec.Name = n + } + if t, ok := m["type"].(string); ok { + spec.Type = t + } + if c, ok := m["config"].(map[string]any); ok { + spec.Config = c + } + if sz, ok := m["size"].(string); ok { + spec.Size = interfaces.Size(sz) + } + specs = append(specs, spec) + } + return specs, nil +} diff --git a/iac/specparse/specparse_test.go b/iac/specparse/specparse_test.go new file mode 100644 index 000000000..de5f82320 --- /dev/null +++ b/iac/specparse/specparse_test.go @@ -0,0 +1,90 @@ +package specparse_test + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/iac/specparse" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestParseResourceSpecs_RoundTripShape verifies that a representative []any +// of spec maps parses to the expected []interfaces.ResourceSpec. Critically, +// secret:// refs inside a resource's config map must survive verbatim — no +// expansion is performed. +func TestParseResourceSpecs_RoundTripShape(t *testing.T) { + raw := []any{ + map[string]any{ + "name": "web-server", + "type": "droplet", + "size": "s-1vcpu-1gb", + "config": map[string]any{ + "region": "nyc3", + "password": "secret://vault/db-password", + }, + }, + map[string]any{ + "name": "db", + "type": "database", + // no size, no config + }, + } + + specs, err := specparse.ParseResourceSpecs(raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(specs) != 2 { + t.Fatalf("expected 2 specs, got %d", len(specs)) + } + + // First spec + s0 := specs[0] + if s0.Name != "web-server" { + t.Errorf("specs[0].Name = %q, want %q", s0.Name, "web-server") + } + if s0.Type != "droplet" { + t.Errorf("specs[0].Type = %q, want %q", s0.Type, "droplet") + } + if s0.Size != interfaces.Size("s-1vcpu-1gb") { + t.Errorf("specs[0].Size = %q, want %q", s0.Size, "s-1vcpu-1gb") + } + if s0.Config == nil { + t.Fatal("specs[0].Config is nil") + } + // secret:// ref must be preserved verbatim + got, ok := s0.Config["password"].(string) + if !ok { + t.Fatalf("specs[0].Config[\"password\"] is not a string") + } + const wantRef = "secret://vault/db-password" + if got != wantRef { + t.Errorf("secret ref not preserved: got %q, want %q", got, wantRef) + } + + // Second spec + s1 := specs[1] + if s1.Name != "db" { + t.Errorf("specs[1].Name = %q, want %q", s1.Name, "db") + } + if s1.Type != "database" { + t.Errorf("specs[1].Type = %q, want %q", s1.Type, "database") + } + if s1.Config != nil { + t.Errorf("specs[1].Config should be nil, got %v", s1.Config) + } + + // nil raw must return nil, nil (no error) + empty, err := specparse.ParseResourceSpecs(nil) + if err != nil { + t.Fatalf("nil raw: unexpected error: %v", err) + } + if empty != nil { + t.Errorf("nil raw: expected nil slice, got %v", empty) + } + + // non-list must error + _, err = specparse.ParseResourceSpecs("notalist") + if err == nil { + t.Error("non-list raw: expected error, got nil") + } +} diff --git a/module/pipeline_step_iac_provider_plan.go b/module/pipeline_step_iac_provider_plan.go index f510f9b73..89b0af281 100644 --- a/module/pipeline_step_iac_provider_plan.go +++ b/module/pipeline_step_iac_provider_plan.go @@ -9,6 +9,7 @@ import ( "github.com/GoCodeAlone/modular" "github.com/GoCodeAlone/workflow/iac/jitsubst" + "github.com/GoCodeAlone/workflow/iac/specparse" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -54,36 +55,10 @@ func NewIaCProviderPlanStepFactory() StepFactory { // parseResourceSpecs converts a raw config value ([]any of map[string]any) into // []interfaces.ResourceSpec. A nil or missing "specs" key is allowed (returns empty // slice) for providers that derive specs internally. +// Thin wrapper around specparse.ParseResourceSpecs; kept private so call sites +// in iac_provider_plan and iac_provider_apply are unchanged. func parseResourceSpecs(raw any) ([]interfaces.ResourceSpec, error) { - if raw == nil { - return nil, nil - } - list, ok := raw.([]any) - if !ok { - return nil, fmt.Errorf("specs must be a list, got %T", raw) - } - specs := make([]interfaces.ResourceSpec, 0, len(list)) - for i, item := range list { - m, ok := item.(map[string]any) - if !ok { - return nil, fmt.Errorf("specs[%d] must be a map, got %T", i, item) - } - spec := interfaces.ResourceSpec{} - if n, ok := m["name"].(string); ok { - spec.Name = n - } - if t, ok := m["type"].(string); ok { - spec.Type = t - } - if c, ok := m["config"].(map[string]any); ok { - spec.Config = c - } - if sz, ok := m["size"].(string); ok { - spec.Size = interfaces.Size(sz) - } - specs = append(specs, spec) - } - return specs, nil + return specparse.ParseResourceSpecs(raw) } // parseResourceRefs converts a raw config value to []interfaces.ResourceRef. From 22d2ff74b5d25a0be365d2f2fb155803425caa9c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 17:03:33 -0400 Subject: [PATCH 2/5] feat(iac): add iac/specgen.SpecToYAML authored-spec serializer (round-trips iac/specparse) Co-Authored-By: Claude Sonnet 4.6 --- iac/specgen/specgen.go | 34 +++++++++++++++ iac/specgen/specgen_test.go | 85 +++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 iac/specgen/specgen.go create mode 100644 iac/specgen/specgen_test.go diff --git a/iac/specgen/specgen.go b/iac/specgen/specgen.go new file mode 100644 index 000000000..c3e7a355d --- /dev/null +++ b/iac/specgen/specgen.go @@ -0,0 +1,34 @@ +// Package specgen serialises []interfaces.ResourceSpec to YAML in the +// resource-spec schema shape consumed by iac/specparse.ParseResourceSpecs. +// +// SpecToYAML is the inverse of specparse.ParseResourceSpecs: it emits the +// same field names ("name", "type", "config", "size") so that a re-parse +// round-trips without loss. Secret:// references in Config values are emitted +// verbatim — no expansion is performed. +package specgen + +import ( + "github.com/GoCodeAlone/workflow/interfaces" + "gopkg.in/yaml.v3" +) + +// SpecToYAML marshals specs to YAML in the resource-spec schema. +// Each spec becomes a mapping with fields name, type, size (omitted when +// empty), and config (omitted when nil). Secret:// refs survive verbatim. +func SpecToYAML(specs []interfaces.ResourceSpec) ([]byte, error) { + items := make([]map[string]any, 0, len(specs)) + for _, s := range specs { + m := map[string]any{ + "name": s.Name, + "type": s.Type, + } + if s.Size != "" { + m["size"] = string(s.Size) + } + if s.Config != nil { + m["config"] = s.Config + } + items = append(items, m) + } + return yaml.Marshal(items) +} diff --git a/iac/specgen/specgen_test.go b/iac/specgen/specgen_test.go new file mode 100644 index 000000000..edb3a3aad --- /dev/null +++ b/iac/specgen/specgen_test.go @@ -0,0 +1,85 @@ +package specgen_test + +import ( + "bytes" + "reflect" + "testing" + + "gopkg.in/yaml.v3" + + "github.com/GoCodeAlone/workflow/iac/specgen" + "github.com/GoCodeAlone/workflow/iac/specparse" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestSpecToYAML_RoundTrip asserts that serialising a []interfaces.ResourceSpec +// with SpecToYAML and then re-parsing the YAML bytes (decode to []any → +// specparse.ParseResourceSpecs) produces a slice that deep-equals the input. +func TestSpecToYAML_RoundTrip(t *testing.T) { + input := []interfaces.ResourceSpec{ + { + Name: "web-server", + Type: "droplet", + Size: interfaces.Size("s-1vcpu-1gb"), + Config: map[string]any{ + "region": "nyc3", + "password": "secret://vault/db-password", + "tags": []any{ + "env:prod", + "team:backend", + }, + }, + }, + { + Name: "db", + Type: "database", + }, + } + + data, err := specgen.SpecToYAML(input) + if err != nil { + t.Fatalf("SpecToYAML error: %v", err) + } + if len(data) == 0 { + t.Fatal("SpecToYAML returned empty bytes") + } + + // Decode YAML bytes → []any, then ParseResourceSpecs. + var raw []any + if err := yaml.Unmarshal(data, &raw); err != nil { + t.Fatalf("yaml.Unmarshal error: %v\nYAML:\n%s", err, data) + } + + got, err := specparse.ParseResourceSpecs(raw) + if err != nil { + t.Fatalf("ParseResourceSpecs error: %v", err) + } + + if !reflect.DeepEqual(got, input) { + t.Errorf("round-trip mismatch.\ngot: %+v\nwant: %+v", got, input) + } +} + +// TestSpecToYAML_PreservesSecretRefs asserts that secret:// references are +// emitted verbatim in the serialised YAML (not expanded or redacted). +func TestSpecToYAML_PreservesSecretRefs(t *testing.T) { + specs := []interfaces.ResourceSpec{ + { + Name: "web", + Type: "droplet", + Config: map[string]any{ + "password": "secret://vault/db-password", + }, + }, + } + + data, err := specgen.SpecToYAML(specs) + if err != nil { + t.Fatalf("SpecToYAML error: %v", err) + } + + const wantRef = "secret://vault/db-password" + if !bytes.Contains(data, []byte(wantRef)) { + t.Errorf("YAML output does not contain %q\nYAML:\n%s", wantRef, data) + } +} From f3af16400114543e972db7a750a38c0379192317 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 17:04:08 -0400 Subject: [PATCH 3/5] docs(iac): fix stale infra.admin comment in state_test + supersede region-lister plan Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-06-01-iac-provider-region-lister.md | 2 ++ iac/wfctlhelpers/state_test.go | 11 +++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-06-01-iac-provider-region-lister.md b/docs/plans/2026-06-01-iac-provider-region-lister.md index 387ba1f42..b74913c2d 100644 --- a/docs/plans/2026-06-01-iac-provider-region-lister.md +++ b/docs/plans/2026-06-01-iac-provider-region-lister.md @@ -1,3 +1,5 @@ +> **SUPERSEDED 2026-06-02** — the RegionLister shipped via the infra-admin migration (workflow v0.70.0, providerclient accessors). This plan references deleted iac/admin files. Retained for history. + # IaC Provider Region Lister Implementation Plan > **For the implementing agent:** REQUIRED SUB-SKILL: Use autodev:executing-plans to implement this plan task-by-task. diff --git a/iac/wfctlhelpers/state_test.go b/iac/wfctlhelpers/state_test.go index ee644b954..b86f182c5 100644 --- a/iac/wfctlhelpers/state_test.go +++ b/iac/wfctlhelpers/state_test.go @@ -10,12 +10,11 @@ import ( "github.com/GoCodeAlone/workflow/interfaces" ) -// TestResolveStateStore_MemoryBackend verifies the lifted ResolveStateStore -// resolves an iac.state module with backend: memory to a usable -// interfaces.IaCStateStore that returns an empty resource list on a fresh -// open. This is the entry-point assertion for the host-side infra.admin -// module's state binding per docs/plans/2026-05-27-infra-admin-dynamic.md -// Task 1. +// TestResolveStateStore_MemoryBackend verifies that wfctlhelpers.ResolveStateStore +// resolves an iac.state module configured with backend: memory to a usable +// interfaces.IaCStateStore. It asserts an empty fresh store, then exercises a +// full round-trip (SaveResource / ListResources / GetResource / DeleteResource) +// to confirm the store contract is satisfied end-to-end. func TestResolveStateStore_MemoryBackend(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "test.yaml") From 99e1875cc45b578face2936d56c1c4e3bf2f2218 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 17:13:56 -0400 Subject: [PATCH 4/5] fix(iac): round-trip DependsOn + Hints in specparse/specgen (review) ParseResourceSpecs and SpecToYAML handled only Name/Type/Config/Size and silently dropped the remaining two ResourceSpec fields (Hints, DependsOn). The typed adapter dispatches Hints+DependsOn to provider plugins, so the serialize path stripped them. Tests now use a fully-populated fixture and prove symmetry via reflect.DeepEqual over the full SpecToYAML->Unmarshal-> ParseResourceSpecs round-trip. Co-Authored-By: Claude Sonnet 4.6 --- iac/specgen/specgen.go | 26 ++++++++++++++++---- iac/specgen/specgen_test.go | 2 ++ iac/specparse/specparse.go | 22 +++++++++++++++++ iac/specparse/specparse_test.go | 42 +++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/iac/specgen/specgen.go b/iac/specgen/specgen.go index c3e7a355d..3a0942126 100644 --- a/iac/specgen/specgen.go +++ b/iac/specgen/specgen.go @@ -2,9 +2,9 @@ // resource-spec schema shape consumed by iac/specparse.ParseResourceSpecs. // // SpecToYAML is the inverse of specparse.ParseResourceSpecs: it emits the -// same field names ("name", "type", "config", "size") so that a re-parse -// round-trips without loss. Secret:// references in Config values are emitted -// verbatim — no expansion is performed. +// same field names ("name", "type", "config", "size", "depends_on", "hints") +// so that a re-parse round-trips without loss. Secret:// references in Config +// values are emitted verbatim — no expansion is performed. package specgen import ( @@ -14,7 +14,9 @@ import ( // SpecToYAML marshals specs to YAML in the resource-spec schema. // Each spec becomes a mapping with fields name, type, size (omitted when -// empty), and config (omitted when nil). Secret:// refs survive verbatim. +// empty), config (omitted when nil), depends_on (omitted when empty), and +// hints (omitted when nil, with empty subfields omitted). Secret:// refs +// survive verbatim. func SpecToYAML(specs []interfaces.ResourceSpec) ([]byte, error) { items := make([]map[string]any, 0, len(specs)) for _, s := range specs { @@ -28,6 +30,22 @@ func SpecToYAML(specs []interfaces.ResourceSpec) ([]byte, error) { if s.Config != nil { m["config"] = s.Config } + if len(s.DependsOn) > 0 { + m["depends_on"] = s.DependsOn + } + if s.Hints != nil { + hints := map[string]any{} + if s.Hints.CPU != "" { + hints["cpu"] = s.Hints.CPU + } + if s.Hints.Memory != "" { + hints["memory"] = s.Hints.Memory + } + if s.Hints.Storage != "" { + hints["storage"] = s.Hints.Storage + } + m["hints"] = hints + } items = append(items, m) } return yaml.Marshal(items) diff --git a/iac/specgen/specgen_test.go b/iac/specgen/specgen_test.go index edb3a3aad..3207c04b8 100644 --- a/iac/specgen/specgen_test.go +++ b/iac/specgen/specgen_test.go @@ -29,6 +29,8 @@ func TestSpecToYAML_RoundTrip(t *testing.T) { "team:backend", }, }, + Hints: &interfaces.ResourceHints{CPU: "2", Memory: "4Gi", Storage: "10Gi"}, + DependsOn: []string{"vpc", "network"}, }, { Name: "db", diff --git a/iac/specparse/specparse.go b/iac/specparse/specparse.go index ba0dd4b13..9c2f29bd3 100644 --- a/iac/specparse/specparse.go +++ b/iac/specparse/specparse.go @@ -42,6 +42,28 @@ func ParseResourceSpecs(raw any) ([]interfaces.ResourceSpec, error) { if sz, ok := m["size"].(string); ok { spec.Size = interfaces.Size(sz) } + if dl, ok := m["depends_on"].([]any); ok { + deps := make([]string, 0, len(dl)) + for _, d := range dl { + if ds, ok := d.(string); ok { + deps = append(deps, ds) + } + } + spec.DependsOn = deps + } + if h, ok := m["hints"].(map[string]any); ok { + hints := &interfaces.ResourceHints{} + if v, ok := h["cpu"].(string); ok { + hints.CPU = v + } + if v, ok := h["memory"].(string); ok { + hints.Memory = v + } + if v, ok := h["storage"].(string); ok { + hints.Storage = v + } + spec.Hints = hints + } specs = append(specs, spec) } return specs, nil diff --git a/iac/specparse/specparse_test.go b/iac/specparse/specparse_test.go index de5f82320..2c20b701c 100644 --- a/iac/specparse/specparse_test.go +++ b/iac/specparse/specparse_test.go @@ -1,6 +1,7 @@ package specparse_test import ( + "reflect" "testing" "github.com/GoCodeAlone/workflow/iac/specparse" @@ -88,3 +89,44 @@ func TestParseResourceSpecs_RoundTripShape(t *testing.T) { t.Error("non-list raw: expected error, got nil") } } + +// TestParseResourceSpecs_DependsOnAndHints verifies that raw []any spec maps +// carrying depends_on and hints keys parse into the corresponding struct +// fields. The typed adapter dispatches these to provider plugins, so dropping +// them silently is a correctness bug on the dynamic-apply input path. +func TestParseResourceSpecs_DependsOnAndHints(t *testing.T) { + raw := []any{ + map[string]any{ + "name": "web-server", + "type": "droplet", + "depends_on": []any{"vpc", "network"}, + "hints": map[string]any{ + "cpu": "2", + "memory": "4Gi", + "storage": "10Gi", + }, + }, + } + + specs, err := specparse.ParseResourceSpecs(raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(specs) != 1 { + t.Fatalf("expected 1 spec, got %d", len(specs)) + } + s := specs[0] + + wantDeps := []string{"vpc", "network"} + if !reflect.DeepEqual(s.DependsOn, wantDeps) { + t.Errorf("DependsOn = %v, want %v", s.DependsOn, wantDeps) + } + + if s.Hints == nil { + t.Fatal("Hints is nil, want populated *ResourceHints") + } + wantHints := &interfaces.ResourceHints{CPU: "2", Memory: "4Gi", Storage: "10Gi"} + if !reflect.DeepEqual(s.Hints, wantHints) { + t.Errorf("Hints = %+v, want %+v", s.Hints, wantHints) + } +} From 1667ada542b7a7a6b560a4cfc66628a643ad2d83 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 17:28:48 -0400 Subject: [PATCH 5/5] docs(iac): fix secret:// casing + nil-slice comment (Copilot review) Co-Authored-By: Claude Opus 4.8 (1M context) --- iac/specgen/specgen.go | 4 ++-- iac/specparse/specparse.go | 2 +- module/pipeline_step_iac_provider_plan.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/iac/specgen/specgen.go b/iac/specgen/specgen.go index 3a0942126..48e984d66 100644 --- a/iac/specgen/specgen.go +++ b/iac/specgen/specgen.go @@ -3,7 +3,7 @@ // // SpecToYAML is the inverse of specparse.ParseResourceSpecs: it emits the // same field names ("name", "type", "config", "size", "depends_on", "hints") -// so that a re-parse round-trips without loss. Secret:// references in Config +// so that a re-parse round-trips without loss. secret:// references in Config // values are emitted verbatim — no expansion is performed. package specgen @@ -15,7 +15,7 @@ import ( // SpecToYAML marshals specs to YAML in the resource-spec schema. // Each spec becomes a mapping with fields name, type, size (omitted when // empty), config (omitted when nil), depends_on (omitted when empty), and -// hints (omitted when nil, with empty subfields omitted). Secret:// refs +// hints (omitted when nil, with empty subfields omitted). secret:// refs // survive verbatim. func SpecToYAML(specs []interfaces.ResourceSpec) ([]byte, error) { items := make([]map[string]any, 0, len(specs)) diff --git a/iac/specparse/specparse.go b/iac/specparse/specparse.go index 9c2f29bd3..0a465b781 100644 --- a/iac/specparse/specparse.go +++ b/iac/specparse/specparse.go @@ -14,7 +14,7 @@ import ( // ParseResourceSpecs converts a raw config value ([]any of map[string]any) // into []interfaces.ResourceSpec. A nil raw value is allowed and returns a -// nil slice. Secret:// refs in config values are preserved verbatim. +// nil slice. secret:// refs in config values are preserved verbatim. func ParseResourceSpecs(raw any) ([]interfaces.ResourceSpec, error) { if raw == nil { return nil, nil diff --git a/module/pipeline_step_iac_provider_plan.go b/module/pipeline_step_iac_provider_plan.go index 89b0af281..df9f100af 100644 --- a/module/pipeline_step_iac_provider_plan.go +++ b/module/pipeline_step_iac_provider_plan.go @@ -53,8 +53,8 @@ func NewIaCProviderPlanStepFactory() StepFactory { } // parseResourceSpecs converts a raw config value ([]any of map[string]any) into -// []interfaces.ResourceSpec. A nil or missing "specs" key is allowed (returns empty -// slice) for providers that derive specs internally. +// []interfaces.ResourceSpec. A nil or missing "specs" key is allowed (returns a +// nil slice) for providers that derive specs internally. // Thin wrapper around specparse.ParseResourceSpecs; kept private so call sites // in iac_provider_plan and iac_provider_apply are unchanged. func parseResourceSpecs(raw any) ([]interfaces.ResourceSpec, error) {