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/specgen/specgen.go b/iac/specgen/specgen.go new file mode 100644 index 000000000..48e984d66 --- /dev/null +++ b/iac/specgen/specgen.go @@ -0,0 +1,52 @@ +// 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", "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 ( + "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), 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 { + 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 + } + 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 new file mode 100644 index 000000000..3207c04b8 --- /dev/null +++ b/iac/specgen/specgen_test.go @@ -0,0 +1,87 @@ +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", + }, + }, + Hints: &interfaces.ResourceHints{CPU: "2", Memory: "4Gi", Storage: "10Gi"}, + DependsOn: []string{"vpc", "network"}, + }, + { + 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) + } +} diff --git a/iac/specparse/specparse.go b/iac/specparse/specparse.go new file mode 100644 index 000000000..0a465b781 --- /dev/null +++ b/iac/specparse/specparse.go @@ -0,0 +1,70 @@ +// 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) + } + 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 new file mode 100644 index 000000000..2c20b701c --- /dev/null +++ b/iac/specparse/specparse_test.go @@ -0,0 +1,132 @@ +package specparse_test + +import ( + "reflect" + "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") + } +} + +// 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) + } +} 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") diff --git a/module/pipeline_step_iac_provider_plan.go b/module/pipeline_step_iac_provider_plan.go index f510f9b73..df9f100af 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" ) @@ -52,38 +53,12 @@ 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) { - 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.