-
Notifications
You must be signed in to change notification settings - Fork 1
feat(iac): extract iac/specparse + add iac/specgen.SpecToYAML (infra-admin P2 PR2/12) #841
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
a59d583
refactor(iac): extract []any resource-spec converter to core iac/spec…
intel352 22d2ff7
feat(iac): add iac/specgen.SpecToYAML authored-spec serializer (round…
intel352 f3af164
docs(iac): fix stale infra.admin comment in state_test + supersede re…
intel352 99e1875
fix(iac): round-trip DependsOn + Hints in specparse/specgen (review)
intel352 1667ada
docs(iac): fix secret:// casing + nil-slice comment (Copilot review)
intel352 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| 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) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.