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
2 changes: 2 additions & 0 deletions docs/plans/2026-06-01-iac-provider-region-lister.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
52 changes: 52 additions & 0 deletions iac/specgen/specgen.go
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
intel352 marked this conversation as resolved.
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)
}
87 changes: 87 additions & 0 deletions iac/specgen/specgen_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
70 changes: 70 additions & 0 deletions iac/specparse/specparse.go
Original file line number Diff line number Diff line change
@@ -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
}
132 changes: 132 additions & 0 deletions iac/specparse/specparse_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 5 additions & 6 deletions iac/wfctlhelpers/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading