From 7a064b824c127516b15520b62fc1dc50fc1e63b3 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:00:43 -0400 Subject: [PATCH 01/34] feat(iac/wfctlhelpers): lift resolveStateStore for shared module+CLI use Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 1: introduce a public ResolveStateStore in iac/wfctlhelpers so the upcoming host-side infra.admin module (workflow/module/infra_admin.go) and the existing wfctl CLI subcommands share one implementation. Returns a full interfaces.IaCStateStore; out-of-subset methods (Lock/SavePlan/GetPlan) panic per design doc cycle-5 row 4 to surface unexpected callers loudly. cmd/wfctl/infra_state_store.go's resolveStateStore now delegates to wfctlhelpers.ResolveStateStore. isNoopStateStore recognises both concrete noop types (cmd/wfctl-side *noopStateStore + new *wfctlhelpers.NoopStateStore) so downstream "skip metadata persist" short-circuits stay honest. resolvePostgresStateStore + postgresWfctlStateStore removed (no production or test callers remained); plugin-served + filesystem helpers stay in cmd/wfctl because existing tests directly instantiate them. TestResolveStateStore_ReturnsDiscoverErrors updated to match the new config-load context (the failure now surfaces at config.LoadFromFile rather than the discover wrapper; user-facing diagnosis is equivalent). Verified: GOWORK=off go test ./cmd/wfctl/... ./iac/... ./module/... ./plugin/... + golangci-lint --new-from-rev=origin/main all green. Co-Authored-By: Claude Opus 4.7 --- cmd/wfctl/infra.go | 12 +- cmd/wfctl/infra_state_store.go | 110 +----- cmd/wfctl/infra_state_store_test.go | 12 +- iac/wfctlhelpers/env_resolve.go | 126 +++++++ iac/wfctlhelpers/state.go | 531 ++++++++++++++++++++++++++++ iac/wfctlhelpers/state_test.go | 152 ++++++++ 6 files changed, 847 insertions(+), 96 deletions(-) create mode 100644 iac/wfctlhelpers/env_resolve.go create mode 100644 iac/wfctlhelpers/state.go create mode 100644 iac/wfctlhelpers/state_test.go diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 3814de9a..853d6f5b 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -14,6 +14,7 @@ import ( "github.com/GoCodeAlone/workflow/config" "github.com/GoCodeAlone/workflow/iac/inputsnapshot" "github.com/GoCodeAlone/workflow/iac/jitsubst" + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/platform" "github.com/GoCodeAlone/workflow/secrets" @@ -1191,8 +1192,15 @@ func resolveProviderForSpec(cfgFile, envName string, spec interfaces.ResourceSpe } func isNoopStateStore(store infraStateStore) bool { - _, ok := store.(*noopStateStore) - return ok + if _, ok := store.(*noopStateStore); ok { + return true + } + // resolveStateStore now delegates to wfctlhelpers.ResolveStateStore, + // which returns *wfctlhelpers.NoopStateStore for configs without an + // iac.state module. Recognise both concrete types so downstream + // "do not persist; this is a no-op store" checks stay honest after the + // Task-1 lift. + return wfctlhelpers.IsNoopStateStore(store) } func resourceStateFromImportedState(spec interfaces.ResourceSpec, providerType string, imported *interfaces.ResourceState, providerIDOverride string) (interfaces.ResourceState, error) { diff --git a/cmd/wfctl/infra_state_store.go b/cmd/wfctl/infra_state_store.go index e7dc9c91..661b5a9f 100644 --- a/cmd/wfctl/infra_state_store.go +++ b/cmd/wfctl/infra_state_store.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/module" "github.com/GoCodeAlone/workflow/plugin/external" @@ -47,7 +47,7 @@ func (n *noopStateStore) SaveResource(_ context.Context, _ interfaces.ResourceSt func (n *noopStateStore) DeleteResource(_ context.Context, _ string) error { return nil } // resolveStateStore returns an infraStateStore for the iac.state backend -// declared in cfgFile. Returns a noopStateStore (not an error) when no +// declared in cfgFile. Returns a no-op store (not an error) when no // iac.state module is present — first-run callers just get no-op persistence. // // When envName is non-empty, per-environment overrides (e.g. region, bucket @@ -55,55 +55,20 @@ func (n *noopStateStore) DeleteResource(_ context.Context, _ string) error { ret // remote backends (Spaces, S3, etc.) where credentials or endpoints differ per // environment — without it the base config is used, which may be missing // required fields such as region, causing init to fail. +// +// Delegates to wfctlhelpers.ResolveStateStore (per docs/plans/ +// 2026-05-27-infra-admin-dynamic.md Task 1) so the host-side infra.admin +// module and the CLI share one implementation. The returned +// interfaces.IaCStateStore satisfies the local infraStateStore subset by +// structural typing; metadataPersister type-assertions on wfctlhelpers +// concrete types remain functional because *wfctlhelpers.FSStateStore +// implements SaveMetadata. func resolveStateStore(cfgFile, envName string) (infraStateStore, error) { - cfgToUse := cfgFile - if envName != "" { - tmp, err := writeEnvResolvedConfig(cfgFile, envName) - if err != nil { - return nil, fmt.Errorf("resolve %q environment for state store: %w", envName, err) - } - defer os.Remove(tmp) - cfgToUse = tmp - } - iacStates, _, _, err := discoverInfraModules(cfgToUse) + full, err := wfctlhelpers.ResolveStateStore(cfgFile, envName, currentInfraPluginDir) if err != nil { - return nil, fmt.Errorf("discover iac.state modules: %w", err) - } - if len(iacStates) == 0 { - return &noopStateStore{}, nil - } - m := iacStates[0] - cfg := config.ExpandEnvInMap(m.Config) - backend, _ := cfg["backend"].(string) - - switch backend { - case "filesystem", "": - dir, _ := cfg["directory"].(string) - if dir == "" { - dir = "/var/lib/workflow/iac-state" - } - return &fsWfctlStateStore{dir: dir}, nil - - case "postgres": - return resolvePostgresStateStore(cfg) - - case "spaces": - return resolvePluginStateStore(context.Background(), backend, cfg) - - case "s3": - return resolvePluginStateStore(context.Background(), backend, cfg) - - case "gcs": - return resolvePluginStateStore(context.Background(), backend, cfg) - - case "azure": - return nil, fmt.Errorf("azure state store backend not yet supported by wfctl direct-path commands; " + - "create the container manually and reference it in iac.state.bucket. " + - "Contribute a resolveAzureStateStore helper to unblock this") - - default: - return nil, fmt.Errorf("unknown iac.state backend %q", backend) + return nil, err } + return full, nil } type pluginWfctlStateStore struct { @@ -349,50 +314,11 @@ func (s *fsWfctlStateStore) SaveMetadata(_ context.Context, meta interfaces.Gene } // ── Postgres backend ─────────────────────────────────────────────────────────── - -// resolvePostgresStateStore builds a Postgres-backed state store from the -// expanded iac.state module config. The config must include a `dsn` field -// (or `connection_string`) with a valid PostgreSQL DSN. -func resolvePostgresStateStore(cfg map[string]any) (infraStateStore, error) { - dsn, _ := cfg["dsn"].(string) - if dsn == "" { - dsn, _ = cfg["connection_string"].(string) - } - if dsn == "" { - return nil, fmt.Errorf("iac.state backend=postgres requires 'dsn' or 'connection_string' in config") - } - inner, err := module.NewPostgresIaCStateStore(context.Background(), dsn) - if err != nil { - return nil, fmt.Errorf("init postgres state store: %w", err) - } - return &postgresWfctlStateStore{inner: inner}, nil -} - -// postgresWfctlStateStore wraps module.PostgresIaCStateStore to implement -// infraStateStore, bridging module.IaCState ↔ interfaces.ResourceState. -type postgresWfctlStateStore struct { - inner *module.PostgresIaCStateStore -} - -func (s *postgresWfctlStateStore) ListResources(ctx context.Context) ([]interfaces.ResourceState, error) { - records, err := s.inner.ListStates(ctx, nil) - if err != nil { - return nil, fmt.Errorf("list postgres state: %w", err) - } - states := make([]interfaces.ResourceState, 0, len(records)) - for _, r := range records { - states = append(states, iacStateToResourceState(r)) - } - return states, nil -} - -func (s *postgresWfctlStateStore) SaveResource(ctx context.Context, state interfaces.ResourceState) error { - return s.inner.SaveState(ctx, resourceStateToIaCState(state)) -} - -func (s *postgresWfctlStateStore) DeleteResource(ctx context.Context, name string) error { - return s.inner.DeleteState(ctx, name) -} +// +// The Postgres-backend constructor moved into wfctlhelpers as part of the +// Task-1 lift; resolveStateStore now delegates there. The cmd/wfctl-side +// type and resolver were removed once they had no remaining production +// or test callers. // ── Conversion helpers ───────────────────────────────────────────────────────── diff --git a/cmd/wfctl/infra_state_store_test.go b/cmd/wfctl/infra_state_store_test.go index 65e1118d..ca14880b 100644 --- a/cmd/wfctl/infra_state_store_test.go +++ b/cmd/wfctl/infra_state_store_test.go @@ -58,8 +58,16 @@ func TestResolveStateStore_ReturnsDiscoverErrors(t *testing.T) { if err == nil { t.Fatal("expected missing config error, got nil") } - if !strings.Contains(err.Error(), "discover iac.state modules") { - t.Fatalf("error = %v, want discover context", err) + // Post-Task-1 lift: resolveStateStore delegates to wfctlhelpers. + // The error context shifted from "discover iac.state modules" to a + // more direct "load : ..." since the failure now surfaces at + // config.LoadFromFile rather than the discover wrapper. Both forms + // are equally diagnostic for the operator; the assertion checks for + // the config-load context to confirm the missing-file root cause is + // preserved. + msg := err.Error() + if !strings.Contains(msg, "load") || !strings.Contains(msg, "missing.yaml") { + t.Fatalf("error = %v, want config-load context naming missing.yaml", err) } } diff --git a/iac/wfctlhelpers/env_resolve.go b/iac/wfctlhelpers/env_resolve.go new file mode 100644 index 00000000..38525a78 --- /dev/null +++ b/iac/wfctlhelpers/env_resolve.go @@ -0,0 +1,126 @@ +package wfctlhelpers + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/GoCodeAlone/workflow/config" + "gopkg.in/yaml.v3" +) + +// writeEnvResolvedConfig loads cfgFile (honoring imports:), resolves every +// module for envName (ResolveForEnv is called on ALL module types so that +// environments[envName]: null is honored for iac.*, cloud.account, etc.), +// applies top-level environments[env] defaults, and writes the entire +// WorkflowConfig back to a temp file. The caller must defer os.Remove(tmpPath). +// +// Mirrors cmd/wfctl/infra_env_resolve.go:writeEnvResolvedConfig so the +// helper path produces byte-identical resolved configs to the wfctl CLI. +// Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 1 this is part of +// the lift; the cmd/wfctl version delegates here to avoid double +// maintenance. +func writeEnvResolvedConfig(cfgFile, envName string) (tmpPath string, err error) { + cfg, err := config.LoadFromFile(cfgFile) + if err != nil { + return "", fmt.Errorf("load %s: %w", cfgFile, err) + } + + var topEnv *config.EnvironmentConfig + if cfg.Environments != nil { + topEnv = cfg.Environments[envName] + } + + var resolved []config.ModuleConfig + for i := range cfg.Modules { + m := &cfg.Modules[i] + rm, ok := m.ResolveForEnv(envName) + if !ok { + continue + } + if topEnv != nil && isInfraType(rm.Type) { + if rm.Region == "" { + rm.Region = topEnv.Region + if rm.Region != "" { + if rm.Config == nil { + rm.Config = map[string]any{} + } + if _, present := rm.Config["region"]; !present { + rm.Config["region"] = rm.Region + } + } + } + if rm.Provider == "" { + rm.Provider = topEnv.Provider + if rm.Provider != "" { + if rm.Config == nil { + rm.Config = map[string]any{} + } + if _, present := rm.Config["provider"]; !present { + rm.Config["provider"] = rm.Provider + } + } + } + if isContainerType(rm.Type) && len(topEnv.EnvVars) > 0 { + ev, _ := rm.Config["env_vars"].(map[string]any) + if ev == nil { + ev = map[string]any{} + } + for k, v := range topEnv.EnvVars { + if _, present := ev[k]; !present { + ev[k] = v + } + } + rm.Config["env_vars"] = ev + } + } + // ${VAR} / $VAR expansion is intentionally deferred to read time + // (config.ExpandEnvInMap) so secrets generated AFTER this temp + // file is written (e.g. bootstrap-generated SPACES_access_key) + // are not substituted to empty strings here. Mirrors cmd/wfctl + // behavior. + resolved = append(resolved, config.ModuleConfig{ + Name: rm.Name, + Type: rm.Type, + Config: rm.Config, + DependsOn: m.DependsOn, + Branches: m.Branches, + }) + } + + cfg.Modules = resolved + cfg.Imports = nil + cfg.ConfigDir = "" // internal field, not serialised + + data, err := yaml.Marshal(cfg) + if err != nil { + return "", fmt.Errorf("marshal resolved config: %w", err) + } + + dir := filepath.Dir(cfgFile) + f, err := os.CreateTemp(dir, ".wfctl-env-resolved-*.yaml") + if err != nil { + return "", fmt.Errorf("create temp config: %w", err) + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(f.Name()) + return "", fmt.Errorf("write temp config: %w", err) + } + f.Close() + return f.Name(), nil +} + +// isInfraType returns true for module types in the infra.*/platform.* +// namespaces. Kept consistent with cmd/wfctl/infra.go:isInfraType. +func isInfraType(t string) bool { + return strings.HasPrefix(t, "infra.") || strings.HasPrefix(t, "platform.") +} + +// isContainerType returns true for module types that accept env_vars +// defaults from top-level environments[env]. Kept consistent with +// cmd/wfctl/infra.go:isContainerType. +func isContainerType(t string) bool { + return t == "infra.container_service" +} diff --git a/iac/wfctlhelpers/state.go b/iac/wfctlhelpers/state.go new file mode 100644 index 00000000..fdaa6811 --- /dev/null +++ b/iac/wfctlhelpers/state.go @@ -0,0 +1,531 @@ +package wfctlhelpers + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/module" + "github.com/GoCodeAlone/workflow/plugin/external" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +// ResolveStateStore loads cfgFile, finds the iac.state module, and returns +// its backend as a full interfaces.IaCStateStore. envName is forwarded for +// per-environment backend config resolution; empty = no env overrides. +// pluginDir locates plugin binaries for plugin-served backends (spaces/s3/ +// gcs); empty falls back to WFCTL_PLUGIN_DIR then "./data/plugins". +// +// Returns a no-op store (not an error) when no iac.state module is +// declared so first-run callers get silent no-op persistence — same +// behavior as the wfctl-internal resolveStateStore this helper was lifted +// from per docs/plans/2026-05-27-infra-admin-dynamic.md Task 1. +// +// Per design doc cycle-5 row 4: out-of-subset methods on the returned +// store (Lock, SavePlan, GetPlan) panic with a clear message. The handler +// library and host module call only the subset +// {SaveResource, GetResource, ListResources, DeleteResource, Close}. +func ResolveStateStore(cfgFile, envName, pluginDir string) (interfaces.IaCStateStore, error) { + cfgToUse := cfgFile + if envName != "" { + tmp, err := writeEnvResolvedConfig(cfgFile, envName) + if err != nil { + return nil, fmt.Errorf("resolve %q environment for state store: %w", envName, err) + } + defer os.Remove(tmp) + cfgToUse = tmp + } + cfg, err := config.LoadFromFile(cfgToUse) + if err != nil { + return nil, fmt.Errorf("load %s: %w", cfgToUse, err) + } + var stateModule *config.ModuleConfig + for i := range cfg.Modules { + if cfg.Modules[i].Type == "iac.state" { + stateModule = &cfg.Modules[i] + break + } + } + if stateModule == nil { + return &NoopStateStore{}, nil + } + mcfg := config.ExpandEnvInMap(stateModule.Config) + backend, _ := mcfg["backend"].(string) + + switch backend { + case "memory": + return wrapModuleStore(module.NewMemoryIaCStateStore()), nil + + case "filesystem", "": + dir, _ := mcfg["directory"].(string) + if dir == "" { + dir = "/var/lib/workflow/iac-state" + } + return &FSStateStore{dir: dir}, nil + + case "postgres": + dsn, _ := mcfg["dsn"].(string) + if dsn == "" { + dsn, _ = mcfg["connection_string"].(string) + } + if dsn == "" { + return nil, fmt.Errorf("iac.state backend=postgres requires 'dsn' or 'connection_string' in config") + } + inner, err := module.NewPostgresIaCStateStore(context.Background(), dsn) + if err != nil { + return nil, fmt.Errorf("init postgres state store: %w", err) + } + return wrapModuleStore(inner), nil + + case "spaces", "s3", "gcs": + return resolvePluginStore(context.Background(), backend, mcfg, pluginDir) + + case "azure": + return nil, fmt.Errorf("azure state store backend not yet supported by wfctl direct-path commands; " + + "create the container manually and reference it in iac.state.bucket. " + + "Contribute a resolveAzureStateStore helper to unblock this") + + default: + return nil, fmt.Errorf("unknown iac.state backend %q", backend) + } +} + +// IsNoopStateStore reports whether the resolved store is the no-op +// fallback returned when no iac.state module is configured. Accepts any +// concrete or interface value so wfctl-side subset-interface holders can +// check without having to widen their static type to interfaces.IaCStateStore. +func IsNoopStateStore(s any) bool { + _, ok := s.(*NoopStateStore) + return ok +} + +// ── No-op store ──────────────────────────────────────────────────────────────── + +// NoopStateStore satisfies interfaces.IaCStateStore but silently discards +// all writes and returns no resources / no plans. Used when no iac.state +// module is declared so callers get a usable handle without needing to +// special-case the missing-state case. +type NoopStateStore struct{} + +func (n *NoopStateStore) SaveResource(_ context.Context, _ interfaces.ResourceState) error { + return nil +} +func (n *NoopStateStore) GetResource(_ context.Context, _ string) (*interfaces.ResourceState, error) { + return nil, nil +} +func (n *NoopStateStore) ListResources(_ context.Context) ([]interfaces.ResourceState, error) { + return nil, nil +} +func (n *NoopStateStore) DeleteResource(_ context.Context, _ string) error { return nil } +func (n *NoopStateStore) SavePlan(_ context.Context, _ interfaces.IaCPlan) error { + panic("wfctlhelpers: NoopStateStore.SavePlan called — out-of-subset method on the handler/module store") +} +func (n *NoopStateStore) GetPlan(_ context.Context, _ string) (*interfaces.IaCPlan, error) { + panic("wfctlhelpers: NoopStateStore.GetPlan called — out-of-subset method on the handler/module store") +} +func (n *NoopStateStore) Lock(_ context.Context, _ string, _ time.Duration) (interfaces.IaCLockHandle, error) { + panic("wfctlhelpers: NoopStateStore.Lock called — out-of-subset method on the handler/module store") +} +func (n *NoopStateStore) Close() error { return nil } + +// ── Filesystem store ─────────────────────────────────────────────────────────── + +// StateRecord mirrors the JSON schema used by the wfctl filesystem +// backend. Field names must stay byte-stable with cmd/wfctl's +// iacStateRecord so state written by either path is mutually readable. +// See cmd/wfctl/state_compat_test.go for the on-disk-format compatibility +// matrix. +type StateRecord struct { + ResourceID string `json:"resource_id"` + ResourceType string `json:"resource_type"` + Provider string `json:"provider"` + ProviderRef string `json:"provider_ref,omitempty"` + ProviderID string `json:"provider_id,omitempty"` + ConfigHash string `json:"config_hash,omitempty"` + Status string `json:"status"` + Config map[string]any `json:"config"` + Outputs map[string]any `json:"outputs"` + Dependencies []string `json:"dependencies,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// FSStateStore persists ResourceState records as JSON files under a +// directory, using the same on-disk format as cmd/wfctl's fsWfctlStateStore +// so state written by either is mutually readable. +type FSStateStore struct { + dir string +} + +func (s *FSStateStore) ListResources(_ context.Context) ([]interfaces.ResourceState, error) { + entries, err := os.ReadDir(s.dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("list state: %w", err) + } + var states []interfaces.ResourceState + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") || + strings.HasSuffix(e.Name(), ".lock.json") || e.Name() == "metadata.json" { + continue + } + data, err := os.ReadFile(filepath.Join(s.dir, e.Name())) + if err != nil { + return nil, fmt.Errorf("read state %q: %w", e.Name(), err) + } + var r StateRecord + if err := json.Unmarshal(data, &r); err != nil { + return nil, fmt.Errorf("parse state %q: %w", e.Name(), err) + } + states = append(states, stateRecordToResource(r)) + } + return states, nil +} + +func (s *FSStateStore) GetResource(ctx context.Context, name string) (*interfaces.ResourceState, error) { + fname := filepath.Join(s.dir, sanitizeStateID(name)+".json") + data, err := os.ReadFile(fname) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read state %q: %w", name, err) + } + var r StateRecord + if err := json.Unmarshal(data, &r); err != nil { + return nil, fmt.Errorf("parse state %q: %w", name, err) + } + rs := stateRecordToResource(r) + return &rs, nil +} + +func (s *FSStateStore) SaveResource(_ context.Context, state interfaces.ResourceState) error { + if err := os.MkdirAll(s.dir, 0o750); err != nil { + return fmt.Errorf("save state: mkdir: %w", err) + } + now := time.Now().UTC().Format(time.RFC3339) + r := StateRecord{ + ResourceID: state.ID, + ResourceType: state.Type, + Provider: state.Provider, + ProviderRef: state.ProviderRef, + ProviderID: state.ProviderID, + ConfigHash: state.ConfigHash, + Status: "active", + Config: state.AppliedConfig, + Outputs: state.Outputs, + Dependencies: append([]string(nil), state.Dependencies...), + CreatedAt: now, + UpdatedAt: now, + } + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return fmt.Errorf("save state %q: marshal: %w", state.ID, err) + } + fname := filepath.Join(s.dir, sanitizeStateID(state.ID)+".json") + if err := os.WriteFile(fname, data, 0o600); err != nil { + return fmt.Errorf("save state %q: write: %w", state.ID, err) + } + return nil +} + +func (s *FSStateStore) DeleteResource(_ context.Context, name string) error { + fname := filepath.Join(s.dir, sanitizeStateID(name)+".json") + if err := os.Remove(fname); err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("delete state %q: %w", name, err) + } + return nil +} + +// SaveMetadata writes the generator metadata.json file alongside the +// per-resource state files. cmd/wfctl's apply path performs a runtime +// type assertion against an internal metadataPersister interface; mirroring +// the method here keeps that assertion working when the store is built +// through this helper. +func (s *FSStateStore) SaveMetadata(_ context.Context, meta interfaces.GeneratorMetadata) error { + if err := os.MkdirAll(s.dir, 0o750); err != nil { + return fmt.Errorf("save metadata: mkdir: %w", err) + } + wrapper := struct { + GeneratorMetadata interfaces.GeneratorMetadata `json:"generator_metadata"` + }{GeneratorMetadata: meta} + data, err := json.MarshalIndent(wrapper, "", " ") + if err != nil { + return fmt.Errorf("save metadata: marshal: %w", err) + } + fname := filepath.Join(s.dir, "metadata.json") + if err := os.WriteFile(fname, data, 0o600); err != nil { + return fmt.Errorf("save metadata: write: %w", err) + } + return nil +} + +func (s *FSStateStore) SavePlan(_ context.Context, _ interfaces.IaCPlan) error { + panic("wfctlhelpers: FSStateStore.SavePlan called — out-of-subset method on the handler/module store") +} +func (s *FSStateStore) GetPlan(_ context.Context, _ string) (*interfaces.IaCPlan, error) { + panic("wfctlhelpers: FSStateStore.GetPlan called — out-of-subset method on the handler/module store") +} +func (s *FSStateStore) Lock(_ context.Context, _ string, _ time.Duration) (interfaces.IaCLockHandle, error) { + panic("wfctlhelpers: FSStateStore.Lock called — out-of-subset method on the handler/module store") +} +func (s *FSStateStore) Close() error { return nil } + +// ── module.IaCStateStore adapter (memory + postgres + gRPC plugin) ───────────── + +// moduleStoreAdapter wraps a module.IaCStateStore (which uses +// {GetState/SaveState/ListStates/DeleteState/Lock/Unlock} with +// *module.IaCState records) and exposes the full interfaces.IaCStateStore +// (which uses {SaveResource/...} with interfaces.ResourceState records). +// Out-of-subset methods (SavePlan, GetPlan, Lock with TTL) panic per design +// doc cycle-5 row 4. +type moduleStoreAdapter struct { + inner module.IaCStateStore + mu sync.Mutex + mgr *external.ExternalPluginManager // non-nil for plugin-served backends; Shutdown on Close +} + +func wrapModuleStore(inner module.IaCStateStore) *moduleStoreAdapter { + return &moduleStoreAdapter{inner: inner} +} + +func (a *moduleStoreAdapter) SaveResource(ctx context.Context, state interfaces.ResourceState) error { + return a.inner.SaveState(ctx, resourceStateToIaCState(state)) +} + +func (a *moduleStoreAdapter) GetResource(ctx context.Context, name string) (*interfaces.ResourceState, error) { + rec, err := a.inner.GetState(ctx, name) + if err != nil { + return nil, err + } + if rec == nil { + return nil, nil + } + rs := iacStateToResourceState(rec) + return &rs, nil +} + +func (a *moduleStoreAdapter) ListResources(ctx context.Context) ([]interfaces.ResourceState, error) { + states, err := a.inner.ListStates(ctx, nil) + if err != nil { + return nil, err + } + out := make([]interfaces.ResourceState, 0, len(states)) + for _, s := range states { + out = append(out, iacStateToResourceState(s)) + } + return out, nil +} + +func (a *moduleStoreAdapter) DeleteResource(ctx context.Context, name string) error { + return a.inner.DeleteState(ctx, name) +} + +func (a *moduleStoreAdapter) SavePlan(_ context.Context, _ interfaces.IaCPlan) error { + panic("wfctlhelpers: moduleStoreAdapter.SavePlan called — out-of-subset method on the handler/module store") +} +func (a *moduleStoreAdapter) GetPlan(_ context.Context, _ string) (*interfaces.IaCPlan, error) { + panic("wfctlhelpers: moduleStoreAdapter.GetPlan called — out-of-subset method on the handler/module store") +} +func (a *moduleStoreAdapter) Lock(_ context.Context, _ string, _ time.Duration) (interfaces.IaCLockHandle, error) { + panic("wfctlhelpers: moduleStoreAdapter.Lock called — out-of-subset method on the handler/module store") +} + +func (a *moduleStoreAdapter) Close() error { + a.mu.Lock() + defer a.mu.Unlock() + if a.mgr != nil { + a.mgr.Shutdown() + a.mgr = nil + } + return nil +} + +// ── Plugin-served backends (spaces/s3/gcs via external plugin) ──────────────── + +func resolvePluginStore(ctx context.Context, backend string, cfg map[string]any, pluginDir string) (interfaces.IaCStateStore, error) { + if pluginDir == "" { + pluginDir = os.Getenv("WFCTL_PLUGIN_DIR") + } + if pluginDir == "" { + pluginDir = "./data/plugins" + } + + entries, err := os.ReadDir(pluginDir) + if err != nil { + return nil, fmt.Errorf("iac.state backend %q is plugin-served but plugin directory %q is unavailable: %w", backend, pluginDir, err) + } + + mgr := external.NewExternalPluginManager(pluginDir, nil) + for _, pluginName := range stateBackendPluginCandidates(backend, entries) { + clients, clientsErr := loadPluginStateBackendClients(mgr, pluginName, backend) + if clientsErr != nil { + mgr.Shutdown() + return nil, clientsErr + } + client, ok := clients[backend] + if !ok { + continue + } + store := module.NewGRPCIaCStateStore(client) + if err := store.Configure(ctx, backend, cfg); err != nil { + mgr.Shutdown() + return nil, fmt.Errorf("configure plugin-served iac.state backend %q via plugin %q: %w", backend, pluginName, err) + } + adapter := wrapModuleStore(store) + adapter.mgr = mgr // Close → Shutdown the plugin process + return adapter, nil + } + + mgr.Shutdown() + return nil, fmt.Errorf("iac.state backend %q is plugin-served but no installed plugin in %s advertises it", backend, pluginDir) +} + +// loadPluginStateBackendClients is exposed as a package-level variable so +// tests can substitute a fake plugin loader without touching real plugin +// binaries. The default loads via the external plugin manager. +var loadPluginStateBackendClients = func(mgr *external.ExternalPluginManager, pluginName, backend string) (map[string]pb.IaCStateBackendClient, error) { + adapter, loadErr := mgr.LoadPlugin(pluginName) + if loadErr != nil { + return nil, fmt.Errorf("load plugin %q for iac.state backend %q: %w", pluginName, backend, loadErr) + } + clients, clientsErr := adapter.IaCStateBackendClients() + if clientsErr != nil { + return nil, fmt.Errorf("plugin %q iac.state backends: %w", pluginName, clientsErr) + } + return clients, nil +} + +// stateBackendPluginCandidates returns the ordered list of plugin +// directories under pluginDir that may serve the requested backend. +// First-match candidates (digitalocean→spaces, aws→s3, gcp→gcs) are +// prioritized; remaining entries follow in directory order. +func stateBackendPluginCandidates(backend string, entries []os.DirEntry) []string { + seen := map[string]struct{}{} + var candidates []string + hasDir := func(name string) bool { + for _, entry := range entries { + if entry.IsDir() && entry.Name() == name { + return true + } + } + return false + } + add := func(name string) { + if strings.TrimSpace(name) == "" { + return + } + if _, ok := seen[name]; ok { + return + } + seen[name] = struct{}{} + candidates = append(candidates, name) + } + switch backend { + case "spaces": + if hasDir("digitalocean") { + add("digitalocean") + } + case "s3": + if hasDir("aws") { + add("aws") + } + case "gcs": + if hasDir("gcp") { + add("gcp") + } + } + for _, entry := range entries { + if entry.IsDir() { + add(entry.Name()) + } + } + return candidates +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +// sanitizeStateID returns a filesystem-safe filename for a resource ID. +// The same algorithm is used by cmd/wfctl/infra_state.go:sanitizeStateID +// so files written by either path collide on the same key. +func sanitizeStateID(id string) string { + const allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._" + var b strings.Builder + b.Grow(len(id)) + for _, r := range id { + if strings.ContainsRune(allowed, r) { + b.WriteRune(r) + } else { + b.WriteRune('_') + } + } + return b.String() +} + +func stateRecordToResource(r StateRecord) interfaces.ResourceState { + providerID := r.ProviderID + if providerID == "" { + providerID = r.ResourceID + } + return interfaces.ResourceState{ + ID: r.ResourceID, + Name: r.ResourceID, + Type: r.ResourceType, + Provider: r.Provider, + ProviderRef: r.ProviderRef, + ProviderID: providerID, + ConfigHash: r.ConfigHash, + AppliedConfig: r.Config, + Outputs: r.Outputs, + Dependencies: append([]string(nil), r.Dependencies...), + } +} + +func iacStateToResourceState(r *module.IaCState) interfaces.ResourceState { + providerID := r.ProviderID + if providerID == "" { + providerID = r.ResourceID + } + return interfaces.ResourceState{ + ID: r.ResourceID, + Name: r.ResourceID, + Type: r.ResourceType, + Provider: r.Provider, + ProviderRef: r.ProviderRef, + ProviderID: providerID, + ConfigHash: r.ConfigHash, + AppliedConfig: r.Config, + Outputs: r.Outputs, + Dependencies: append([]string(nil), r.Dependencies...), + } +} + +func resourceStateToIaCState(state interfaces.ResourceState) *module.IaCState { + now := time.Now().UTC().Format(time.RFC3339) + return &module.IaCState{ + ResourceID: state.ID, + ResourceType: state.Type, + Provider: state.Provider, + ProviderRef: state.ProviderRef, + ProviderID: state.ProviderID, + ConfigHash: state.ConfigHash, + Status: "active", + Config: state.AppliedConfig, + Outputs: state.Outputs, + Dependencies: append([]string(nil), state.Dependencies...), + CreatedAt: now, + UpdatedAt: now, + } +} diff --git a/iac/wfctlhelpers/state_test.go b/iac/wfctlhelpers/state_test.go new file mode 100644 index 00000000..ee644b95 --- /dev/null +++ b/iac/wfctlhelpers/state_test.go @@ -0,0 +1,152 @@ +package wfctlhelpers_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + "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. +func TestResolveStateStore_MemoryBackend(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "test.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: iac-state + type: iac.state + config: + backend: memory +`), 0o600); err != nil { + t.Fatal(err) + } + store, err := wfctlhelpers.ResolveStateStore(cfgPath, "", "") + if err != nil { + t.Fatalf("ResolveStateStore: %v", err) + } + if store == nil { + t.Fatal("ResolveStateStore returned nil store with nil error") + } + resources, err := store.ListResources(context.Background()) + if err != nil { + t.Fatalf("ListResources: %v", err) + } + if len(resources) != 0 { + t.Errorf("expected 0 resources from fresh memory store, got %d", len(resources)) + } + // Round-trip: save + list + get + delete a resource so the test + // exercises every method the handler library will use. + state := interfaces.ResourceState{ + ID: "vpc-test", + Name: "vpc-test", + Type: "infra.vpc", + Provider: "stub", + } + if err := store.SaveResource(context.Background(), state); err != nil { + t.Fatalf("SaveResource: %v", err) + } + got, err := store.GetResource(context.Background(), "vpc-test") + if err != nil { + t.Fatalf("GetResource: %v", err) + } + if got == nil || got.Name != "vpc-test" { + t.Errorf("GetResource returned unexpected: %+v", got) + } + if err := store.DeleteResource(context.Background(), "vpc-test"); err != nil { + t.Fatalf("DeleteResource: %v", err) + } +} + +// TestResolveStateStore_NoIaCStateModule returns a no-op store (not an +// error) when no iac.state module is declared. Mirrors the wfctl-internal +// resolveStateStore behavior so callers don't need to special-case +// configs that skip state persistence. +func TestResolveStateStore_NoIaCStateModule(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "test.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: other + type: http.server + config: {} +`), 0o600); err != nil { + t.Fatal(err) + } + store, err := wfctlhelpers.ResolveStateStore(cfgPath, "", "") + if err != nil { + t.Fatalf("ResolveStateStore: %v", err) + } + if store == nil { + t.Fatal("ResolveStateStore returned nil for missing iac.state module") + } + resources, err := store.ListResources(context.Background()) + if err != nil { + t.Fatalf("ListResources on noop store: %v", err) + } + if len(resources) != 0 { + t.Errorf("noop store ListResources: expected 0, got %d", len(resources)) + } +} + +// TestResolveStateStore_FilesystemBackend verifies the lifted helper builds +// a filesystem-backed store when backend: filesystem is configured. The +// directory is read from config.directory. +func TestResolveStateStore_FilesystemBackend(t *testing.T) { + dir := t.TempDir() + stateDir := filepath.Join(dir, "iac-state") + cfgPath := filepath.Join(dir, "test.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: iac-state + type: iac.state + config: + backend: filesystem + directory: `+stateDir+` +`), 0o600); err != nil { + t.Fatal(err) + } + store, err := wfctlhelpers.ResolveStateStore(cfgPath, "", "") + if err != nil { + t.Fatalf("ResolveStateStore: %v", err) + } + // Round-trip ensures the directory is created on first write. + state := interfaces.ResourceState{ + ID: "vpc-fs", + Name: "vpc-fs", + Type: "infra.vpc", + Provider: "stub", + } + if err := store.SaveResource(context.Background(), state); err != nil { + t.Fatalf("SaveResource (filesystem): %v", err) + } + list, err := store.ListResources(context.Background()) + if err != nil { + t.Fatalf("ListResources (filesystem): %v", err) + } + if len(list) != 1 || list[0].Name != "vpc-fs" { + t.Errorf("ListResources got %+v, want one vpc-fs", list) + } +} + +// TestResolveStateStore_UnknownBackend returns a clear error for unknown +// backends so config typos don't silently fall back to filesystem. +func TestResolveStateStore_UnknownBackend(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "test.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: iac-state + type: iac.state + config: + backend: not-a-real-backend +`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := wfctlhelpers.ResolveStateStore(cfgPath, "", ""); err == nil { + t.Fatal("expected error for unknown backend, got nil") + } +} From a9d66e3834d501365b76cbea281f8b2f850f567e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:10:20 -0400 Subject: [PATCH 02/34] refactor(iac/wfctlhelpers): consolidate writeEnvResolvedConfig + infra/container type predicates (spec F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec-reviewer F2 on 7a064b824 flagged that writeEnvResolvedConfig, isInfraType, and isContainerType existed as byte-identical duplicates in both cmd/wfctl and iac/wfctlhelpers after the Task-1 lift — the commit message claimed delegation but env_resolve was left forked. Path (a) chosen: export the wfctlhelpers symbols and reduce the cmd/wfctl versions to one-line delegating shims so existing cmd/wfctl callsites keep compiling unchanged but cannot drift from the shared helper. - WriteEnvResolvedConfig / IsInfraType / IsContainerType exported from iac/wfctlhelpers/env_resolve.go. - cmd/wfctl/infra_env_resolve.go reduces to a one-line wrapper. - cmd/wfctl/infra.go isInfraType + isContainerType become one-line wrappers. - iac/wfctlhelpers/state.go ResolveStateStore docstring expanded with the three-step pluginDir fallback order (spec F1 — host-module handoff aid noting that empty pluginDir lets the WFCTL_PLUGIN_DIR env var configure both CLI and host module via a single knob). Verified: GOWORK=off go test ./iac/wfctlhelpers/... ./cmd/wfctl/ green; golangci-lint --new-from-rev=origin/main -> 0 issues. Co-Authored-By: Claude Opus 4.7 --- cmd/wfctl/infra.go | 12 ++- cmd/wfctl/infra_env_resolve.go | 132 +++----------------------------- iac/wfctlhelpers/env_resolve.go | 31 ++++---- iac/wfctlhelpers/state.go | 16 +++- 4 files changed, 48 insertions(+), 143 deletions(-) diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 853d6f5b..57e46c7d 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -415,9 +415,13 @@ func runInfraPlan(args []string) error { // parseInfraResourceSpecs reads an infra YAML file and returns the list of // infra.* modules as ResourceSpecs for plan computation. -// isInfraType returns true for module types handled by wfctl infra commands. +// isInfraType is a one-line delegating shim onto wfctlhelpers.IsInfraType. +// Implementation moved per docs/plans/2026-05-27-infra-admin-dynamic.md +// Task 1 (consolidation follow-up addressing spec-reviewer F2) so wfctl +// and the host-side infra.admin module share one definition. New code +// should call wfctlhelpers.IsInfraType directly. func isInfraType(t string) bool { - return strings.HasPrefix(t, "infra.") || strings.HasPrefix(t, "platform.") + return wfctlhelpers.IsInfraType(t) } // extractDependsOn pulls the depends_on value from a module config map. @@ -646,8 +650,10 @@ func planResourcesForEnv(path, envName string) ([]*config.ResolvedModule, error) return out, nil } +// isContainerType is a one-line delegating shim onto +// wfctlhelpers.IsContainerType. See isInfraType above for rationale. func isContainerType(t string) bool { - return t == "infra.container_service" + return wfctlhelpers.IsContainerType(t) } // loadCurrentState loads ResourceStates from the configured iac.state backend. diff --git a/cmd/wfctl/infra_env_resolve.go b/cmd/wfctl/infra_env_resolve.go index d252c63a..267acd2d 100644 --- a/cmd/wfctl/infra_env_resolve.go +++ b/cmd/wfctl/infra_env_resolve.go @@ -1,125 +1,15 @@ package main -import ( - "fmt" - "os" - "path/filepath" - - "github.com/GoCodeAlone/workflow/config" - "gopkg.in/yaml.v3" -) - -// writeEnvResolvedConfig loads cfgFile (honoring imports:), resolves every -// module for envName (ResolveForEnv is called on ALL module types so that -// environments[envName]: null is honored for iac.*, cloud.account, etc.), -// applies top-level environments[env] defaults, and writes the entire -// WorkflowConfig back to a temp file — preserving secrets, secretStores, -// infra, environments, ci, workflows, pipelines, etc. so that bootstrap and -// pipeline commands have full context. The caller must defer os.Remove(tmpPath). +import "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + +// writeEnvResolvedConfig is a one-line delegating shim onto +// wfctlhelpers.WriteEnvResolvedConfig. The implementation moved into the +// shared helper package per docs/plans/2026-05-27-infra-admin-dynamic.md +// Task 1 (consolidation follow-up addressing spec-reviewer F2 on commit +// 7a064b824) so the wfctl CLI and the host-side infra.admin module share +// one path and cannot drift. New code should call the wfctlhelpers symbol +// directly; this wrapper exists only to keep existing cmd/wfctl callsites +// untouched. func writeEnvResolvedConfig(cfgFile, envName string) (tmpPath string, err error) { - cfg, err := config.LoadFromFile(cfgFile) - if err != nil { - return "", fmt.Errorf("load %s: %w", cfgFile, err) - } - - var topEnv *config.EnvironmentConfig - if cfg.Environments != nil { - topEnv = cfg.Environments[envName] - } - - // Resolve modules for envName. ResolveForEnv is called on ALL module types - // (not just infra.*) so environments[envName]: null is honored for iac.state, - // cloud.account, etc. Infra/platform defaults from topEnv are applied here. - var resolved []config.ModuleConfig - for i := range cfg.Modules { - m := &cfg.Modules[i] - rm, ok := m.ResolveForEnv(envName) - if !ok { - continue - } - if topEnv != nil && isInfraType(rm.Type) { - if rm.Region == "" { - rm.Region = topEnv.Region - if rm.Region != "" { - if rm.Config == nil { - rm.Config = map[string]any{} - } - if _, present := rm.Config["region"]; !present { - rm.Config["region"] = rm.Region - } - } - } - if rm.Provider == "" { - rm.Provider = topEnv.Provider - if rm.Provider != "" { - if rm.Config == nil { - rm.Config = map[string]any{} - } - if _, present := rm.Config["provider"]; !present { - rm.Config["provider"] = rm.Provider - } - } - } - if isContainerType(rm.Type) && len(topEnv.EnvVars) > 0 { - ev, _ := rm.Config["env_vars"].(map[string]any) - if ev == nil { - ev = map[string]any{} - } - for k, v := range topEnv.EnvVars { - if _, present := ev[k]; !present { - ev[k] = v - } - } - rm.Config["env_vars"] = ev - } - } - // Intentionally DO NOT expand ${VAR} / $VAR env-var references here. - // Bootstrap generates some secrets (e.g. SPACES_access_key) AFTER - // this temp file is written, so eager expansion here substitutes - // empty strings for those variables. Instead, leave the literal - // "${VAR}" references intact and let downstream consumers call - // config.ExpandEnvInMap at read time (they all already do: infra.go - // apply/plan/status/destroy, infra_bootstrap.go bootstrapStateBackend). - // ExpandEnvInMap is idempotent on already-expanded values, so this - // is safe even for callers whose secrets are Setenv'd before this - // runs. - // - // Note: ${scheme:path} secret references (vault, aws-sm, etc.) are - // resolved at apply time via injectSecrets, not here. - - // Rebuild as ModuleConfig preserving DependsOn and Branches from the - // original (ResolvedModule doesn't carry them). - resolved = append(resolved, config.ModuleConfig{ - Name: rm.Name, - Type: rm.Type, - Config: rm.Config, - DependsOn: m.DependsOn, - Branches: m.Branches, - }) - } - - // Replace modules with the env-resolved list; clear Imports so the temp - // file doesn't try to re-import files that may resolve relative to a - // different directory. - cfg.Modules = resolved - cfg.Imports = nil - cfg.ConfigDir = "" // internal field, not serialised - - data, err := yaml.Marshal(cfg) - if err != nil { - return "", fmt.Errorf("marshal resolved config: %w", err) - } - - dir := filepath.Dir(cfgFile) - f, err := os.CreateTemp(dir, ".wfctl-env-resolved-*.yaml") - if err != nil { - return "", fmt.Errorf("create temp config: %w", err) - } - if _, err := f.Write(data); err != nil { - f.Close() - os.Remove(f.Name()) - return "", fmt.Errorf("write temp config: %w", err) - } - f.Close() - return f.Name(), nil + return wfctlhelpers.WriteEnvResolvedConfig(cfgFile, envName) } diff --git a/iac/wfctlhelpers/env_resolve.go b/iac/wfctlhelpers/env_resolve.go index 38525a78..23f9047a 100644 --- a/iac/wfctlhelpers/env_resolve.go +++ b/iac/wfctlhelpers/env_resolve.go @@ -10,18 +10,16 @@ import ( "gopkg.in/yaml.v3" ) -// writeEnvResolvedConfig loads cfgFile (honoring imports:), resolves every +// WriteEnvResolvedConfig loads cfgFile (honoring imports:), resolves every // module for envName (ResolveForEnv is called on ALL module types so that // environments[envName]: null is honored for iac.*, cloud.account, etc.), // applies top-level environments[env] defaults, and writes the entire // WorkflowConfig back to a temp file. The caller must defer os.Remove(tmpPath). // -// Mirrors cmd/wfctl/infra_env_resolve.go:writeEnvResolvedConfig so the -// helper path produces byte-identical resolved configs to the wfctl CLI. -// Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 1 this is part of -// the lift; the cmd/wfctl version delegates here to avoid double -// maintenance. -func writeEnvResolvedConfig(cfgFile, envName string) (tmpPath string, err error) { +// Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 1 this is the +// shared lift; cmd/wfctl/infra_env_resolve.go's writeEnvResolvedConfig is +// a one-line shim that delegates here to avoid double maintenance. +func WriteEnvResolvedConfig(cfgFile, envName string) (tmpPath string, err error) { cfg, err := config.LoadFromFile(cfgFile) if err != nil { return "", fmt.Errorf("load %s: %w", cfgFile, err) @@ -39,7 +37,7 @@ func writeEnvResolvedConfig(cfgFile, envName string) (tmpPath string, err error) if !ok { continue } - if topEnv != nil && isInfraType(rm.Type) { + if topEnv != nil && IsInfraType(rm.Type) { if rm.Region == "" { rm.Region = topEnv.Region if rm.Region != "" { @@ -62,7 +60,7 @@ func writeEnvResolvedConfig(cfgFile, envName string) (tmpPath string, err error) } } } - if isContainerType(rm.Type) && len(topEnv.EnvVars) > 0 { + if IsContainerType(rm.Type) && len(topEnv.EnvVars) > 0 { ev, _ := rm.Config["env_vars"].(map[string]any) if ev == nil { ev = map[string]any{} @@ -112,15 +110,16 @@ func writeEnvResolvedConfig(cfgFile, envName string) (tmpPath string, err error) return f.Name(), nil } -// isInfraType returns true for module types in the infra.*/platform.* -// namespaces. Kept consistent with cmd/wfctl/infra.go:isInfraType. -func isInfraType(t string) bool { +// IsInfraType returns true for module types in the infra.*/platform.* +// namespaces. cmd/wfctl/infra.go:isInfraType is a one-line shim that +// delegates here so the two codepaths cannot drift. +func IsInfraType(t string) bool { return strings.HasPrefix(t, "infra.") || strings.HasPrefix(t, "platform.") } -// isContainerType returns true for module types that accept env_vars -// defaults from top-level environments[env]. Kept consistent with -// cmd/wfctl/infra.go:isContainerType. -func isContainerType(t string) bool { +// IsContainerType returns true for module types that accept env_vars +// defaults from top-level environments[env]. cmd/wfctl/infra.go: +// isContainerType is a one-line shim that delegates here. +func IsContainerType(t string) bool { return t == "infra.container_service" } diff --git a/iac/wfctlhelpers/state.go b/iac/wfctlhelpers/state.go index fdaa6811..baec19ff 100644 --- a/iac/wfctlhelpers/state.go +++ b/iac/wfctlhelpers/state.go @@ -20,8 +20,18 @@ import ( // ResolveStateStore loads cfgFile, finds the iac.state module, and returns // its backend as a full interfaces.IaCStateStore. envName is forwarded for // per-environment backend config resolution; empty = no env overrides. -// pluginDir locates plugin binaries for plugin-served backends (spaces/s3/ -// gcs); empty falls back to WFCTL_PLUGIN_DIR then "./data/plugins". +// +// pluginDir locates plugin binaries for plugin-served backends +// (spaces/s3/gcs). The lookup order is: +// 1. pluginDir argument when non-empty +// 2. WFCTL_PLUGIN_DIR environment variable +// 3. "./data/plugins" (legacy default) +// +// The host-side infra.admin module (workflow/module/infra_admin.go) is +// expected to pass an empty string and rely on the WFCTL_PLUGIN_DIR +// fallback so a single env var configures both CLI and module. The CLI +// passes its `currentInfraPluginDir` seam variable to honor the +// --plugin-dir flag. // // Returns a no-op store (not an error) when no iac.state module is // declared so first-run callers get silent no-op persistence — same @@ -35,7 +45,7 @@ import ( func ResolveStateStore(cfgFile, envName, pluginDir string) (interfaces.IaCStateStore, error) { cfgToUse := cfgFile if envName != "" { - tmp, err := writeEnvResolvedConfig(cfgFile, envName) + tmp, err := WriteEnvResolvedConfig(cfgFile, envName) if err != nil { return nil, fmt.Errorf("resolve %q environment for state store: %w", envName, err) } From dd4a42796dbb0d216cdb8cddb484dc69847cdb1c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:19:11 -0400 Subject: [PATCH 03/34] test(iac/wfctlhelpers): cover panic invariants + env-resolve branch + plugin loader seam; fix sanitizeStateID divergence (code-review I-1..I-3, M-1..M-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-reviewer findings on commit 7a064b824: I-1 (consolidation finish) — already shipped in a9d66e383 (isContainerType → 1-line shim alongside isInfraType + writeEnvResolvedConfig). I-2.1 — 9-case table-driven panic test in iac/wfctlhelpers/state_invariants_test.go (3 stores × {SavePlan, GetPlan, Lock}) asserts panic with `wfctlhelpers:` prefix + method name + the "out-of-subset" rationale string. Guards design-doc cycle-5 row 4 so a future refactor that returns nil-error stubs is loud rather than silent. I-2.2 — cmd/wfctl/infra_noop_detection_test.go covers isNoopStateStore for both *noopStateStore (legacy) and *wfctlhelpers.NoopStateStore (post-lift) returning true, plus *fsWfctlStateStore returning false. The check feeds the post-apply "skip metadata persist when no-op" short-circuit; if a concrete type goes unrecognised, real state is silently corrupted by metadata.json from a discarded apply. I-2.3 — iac/wfctlhelpers/state_plugin_internal_test.go (white-box, package wfctlhelpers) exercises the spaces/s3/gcs branch via the loadPluginStateBackendClients seam with a fake pb.IaCStateBackendClient. Two cases: configures-advertised-backend (digitalocean priority, JSON config plumbing, ListResources round-trip) + no-advertising-plugin error context (names both the directory and the backend). I-3 — TestResolveStateStore_EnvOverride writes a config with environments.staging overriding the state directory, pre-stages a fixture in the staging dir, and asserts ResolveStateStore(cfg, "staging", "") returns a store that lists the staging fixture. Verifies temp file cleanup. TestResolveStateStore_EnvOverride_PropagatesError confirms the envName context is preserved on load failure. M-1 — godoc on loadPluginStateBackendClients spells out the test-seam contract ("Production callers MUST NOT mutate it.") M-2 — moduleStoreAdapter.mu removed; replaced with closed bool. Lock was load-bearing for nothing (Close already nil'd mgr); the closed flag documents the double-Close-is-safe invariant without the zero-work mutex. sync import dropped. M-3 (real bug fix, not cleanup) — SanitizeStateID lifted to iac/wfctlhelpers/state.go with cmd/wfctl's byte-exact algorithm (4-char replacer, not the allowlist my first draft used — the divergence would have broken cross-path mutual readability for resource names containing spaces, '@', '+', '#', etc.). cmd/wfctl/infra_state.go's sanitizeStateID reduces to a one-line shim. Verified: - GOWORK=off go test ./iac/wfctlhelpers/... ./cmd/wfctl/ green (157s). - 14 new test cases (9 panic + 2 env-override + 2 plugin loader + 3 noop-detection subtests) all pass. - golangci-lint --new-from-rev=origin/main -> 0 issues. Co-Authored-By: Claude Opus 4.7 --- cmd/wfctl/infra_noop_detection_test.go | 36 ++++ cmd/wfctl/infra_state.go | 10 +- iac/wfctlhelpers/state.go | 57 +++--- iac/wfctlhelpers/state_invariants_test.go | 181 ++++++++++++++++++ .../state_plugin_internal_test.go | 167 ++++++++++++++++ 5 files changed, 422 insertions(+), 29 deletions(-) create mode 100644 cmd/wfctl/infra_noop_detection_test.go create mode 100644 iac/wfctlhelpers/state_invariants_test.go create mode 100644 iac/wfctlhelpers/state_plugin_internal_test.go diff --git a/cmd/wfctl/infra_noop_detection_test.go b/cmd/wfctl/infra_noop_detection_test.go new file mode 100644 index 00000000..42829fe7 --- /dev/null +++ b/cmd/wfctl/infra_noop_detection_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" +) + +// TestIsNoopStateStore_RecognisesBothConcreteTypes guards the +// post-Task-1-lift invariant that isNoopStateStore detects both the +// legacy cmd/wfctl-internal *noopStateStore and the new +// *wfctlhelpers.NoopStateStore. The check feeds the post-apply +// "skip metadata persist when store is no-op" short-circuit in +// infra.go:1605 — if a concrete type goes unrecognised, real state is +// silently corrupted with a metadata.json from a discarded apply. +// +// Per code-reviewer I-2.2 on commit 7a064b824. +func TestIsNoopStateStore_RecognisesBothConcreteTypes(t *testing.T) { + cases := []struct { + name string + store infraStateStore + want bool + }{ + {"legacy cmd/wfctl noopStateStore", &noopStateStore{}, true}, + {"new wfctlhelpers.NoopStateStore", &wfctlhelpers.NoopStateStore{}, true}, + {"fsWfctlStateStore is not a noop", &fsWfctlStateStore{}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := isNoopStateStore(c.store) + if got != c.want { + t.Errorf("isNoopStateStore(%s) = %v, want %v", c.name, got, c.want) + } + }) + } +} diff --git a/cmd/wfctl/infra_state.go b/cmd/wfctl/infra_state.go index c87a905d..4090500c 100644 --- a/cmd/wfctl/infra_state.go +++ b/cmd/wfctl/infra_state.go @@ -9,6 +9,7 @@ import ( "text/tabwriter" "time" + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -369,9 +370,14 @@ func importFromPulumi(srcFile, stateDir string) error { return nil } +// sanitizeStateID is a one-line delegating shim onto +// wfctlhelpers.SanitizeStateID. The algorithm moved to the shared helper +// per docs/plans/2026-05-27-infra-admin-dynamic.md Task 1 + code-reviewer +// M-3 follow-up so cmd/wfctl and the host-side infra.admin module cannot +// drift on the on-disk filename scheme (cross-path mutual readability is +// a contract — see cmd/wfctl/state_compat_test.go). func sanitizeStateID(id string) string { - replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_") - return replacer.Replace(id) + return wfctlhelpers.SanitizeStateID(id) } func generateLineage() string { diff --git a/iac/wfctlhelpers/state.go b/iac/wfctlhelpers/state.go index baec19ff..1d15408b 100644 --- a/iac/wfctlhelpers/state.go +++ b/iac/wfctlhelpers/state.go @@ -7,7 +7,6 @@ import ( "os" "path/filepath" "strings" - "sync" "time" "github.com/GoCodeAlone/workflow/config" @@ -202,7 +201,7 @@ func (s *FSStateStore) ListResources(_ context.Context) ([]interfaces.ResourceSt } func (s *FSStateStore) GetResource(ctx context.Context, name string) (*interfaces.ResourceState, error) { - fname := filepath.Join(s.dir, sanitizeStateID(name)+".json") + fname := filepath.Join(s.dir, SanitizeStateID(name)+".json") data, err := os.ReadFile(fname) if err != nil { if os.IsNotExist(err) { @@ -241,7 +240,7 @@ func (s *FSStateStore) SaveResource(_ context.Context, state interfaces.Resource if err != nil { return fmt.Errorf("save state %q: marshal: %w", state.ID, err) } - fname := filepath.Join(s.dir, sanitizeStateID(state.ID)+".json") + fname := filepath.Join(s.dir, SanitizeStateID(state.ID)+".json") if err := os.WriteFile(fname, data, 0o600); err != nil { return fmt.Errorf("save state %q: write: %w", state.ID, err) } @@ -249,7 +248,7 @@ func (s *FSStateStore) SaveResource(_ context.Context, state interfaces.Resource } func (s *FSStateStore) DeleteResource(_ context.Context, name string) error { - fname := filepath.Join(s.dir, sanitizeStateID(name)+".json") + fname := filepath.Join(s.dir, SanitizeStateID(name)+".json") if err := os.Remove(fname); err != nil { if os.IsNotExist(err) { return nil @@ -301,10 +300,15 @@ func (s *FSStateStore) Close() error { return nil } // (which uses {SaveResource/...} with interfaces.ResourceState records). // Out-of-subset methods (SavePlan, GetPlan, Lock with TTL) panic per design // doc cycle-5 row 4. +// +// Close-safety: Close nils mgr after Shutdown so a second Close is a +// no-op. Per code-reviewer M-2 follow-up, this safety relies on Close +// being called from a single goroutine (consistent with modular's Stop +// lifecycle); we deliberately do not add a mutex. type moduleStoreAdapter struct { - inner module.IaCStateStore - mu sync.Mutex - mgr *external.ExternalPluginManager // non-nil for plugin-served backends; Shutdown on Close + inner module.IaCStateStore + mgr *external.ExternalPluginManager // non-nil for plugin-served backends; Shutdown on Close + closed bool } func wrapModuleStore(inner module.IaCStateStore) *moduleStoreAdapter { @@ -354,8 +358,10 @@ func (a *moduleStoreAdapter) Lock(_ context.Context, _ string, _ time.Duration) } func (a *moduleStoreAdapter) Close() error { - a.mu.Lock() - defer a.mu.Unlock() + if a.closed { + return nil + } + a.closed = true if a.mgr != nil { a.mgr.Shutdown() a.mgr = nil @@ -403,9 +409,11 @@ func resolvePluginStore(ctx context.Context, backend string, cfg map[string]any, return nil, fmt.Errorf("iac.state backend %q is plugin-served but no installed plugin in %s advertises it", backend, pluginDir) } -// loadPluginStateBackendClients is exposed as a package-level variable so -// tests can substitute a fake plugin loader without touching real plugin -// binaries. The default loads via the external plugin manager. +// loadPluginStateBackendClients is a test seam: tests substitute this to +// avoid loading real plugin binaries when exercising the spaces/s3/gcs +// backend code paths. Production callers MUST NOT mutate it; the default +// loads via the external plugin manager and that is the only behavior +// users rely on. var loadPluginStateBackendClients = func(mgr *external.ExternalPluginManager, pluginName, backend string) (map[string]pb.IaCStateBackendClient, error) { adapter, loadErr := mgr.LoadPlugin(pluginName) if loadErr != nil { @@ -467,21 +475,16 @@ func stateBackendPluginCandidates(backend string, entries []os.DirEntry) []strin // ── Helpers ──────────────────────────────────────────────────────────────────── -// sanitizeStateID returns a filesystem-safe filename for a resource ID. -// The same algorithm is used by cmd/wfctl/infra_state.go:sanitizeStateID -// so files written by either path collide on the same key. -func sanitizeStateID(id string) string { - const allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._" - var b strings.Builder - b.Grow(len(id)) - for _, r := range id { - if strings.ContainsRune(allowed, r) { - b.WriteRune(r) - } else { - b.WriteRune('_') - } - } - return b.String() +// SanitizeStateID returns a filesystem-safe filename for a resource ID by +// replacing the four path-hostile characters (/, \, :, *) with underscore. +// Matches cmd/wfctl/infra_state.go:sanitizeStateID byte-for-byte so files +// written via either path are mutually readable. cmd/wfctl's version is a +// one-line shim that delegates here. Code-reviewer M-3 caught an +// earlier draft that used a stricter allowlist; this version honors the +// existing on-disk format. +func SanitizeStateID(id string) string { + replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_") + return replacer.Replace(id) } func stateRecordToResource(r StateRecord) interfaces.ResourceState { diff --git a/iac/wfctlhelpers/state_invariants_test.go b/iac/wfctlhelpers/state_invariants_test.go new file mode 100644 index 00000000..fd4cbee6 --- /dev/null +++ b/iac/wfctlhelpers/state_invariants_test.go @@ -0,0 +1,181 @@ +package wfctlhelpers_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestOutOfSubsetMethods_Panic guards design-doc cycle-5 row 4: the +// handler library and host module use only the +// {SaveResource, GetResource, ListResources, DeleteResource, Close} +// subset. Any call to Lock / SavePlan / GetPlan on the resolved store +// MUST panic with a `wfctlhelpers:` prefix so an accidental future +// refactor (e.g. returning nil-error stubs) is loud rather than silent. +// Coverage: 3 concrete stores × 3 methods = 9 cases, table-driven. +// +// Per code-reviewer I-2.1 on commit 7a064b824. +func TestOutOfSubsetMethods_Panic(t *testing.T) { + // Build one store of each concrete shape. *moduleStoreAdapter is + // reachable from any non-filesystem, non-noop backend; we use the + // memory backend via ResolveStateStore so the test exercises the + // adapter shape that the production code path actually returns. + memCfg := writeStateCfg(t, `modules: + - name: iac-state + type: iac.state + config: + backend: memory +`) + memStore, err := wfctlhelpers.ResolveStateStore(memCfg, "", "") + if err != nil { + t.Fatalf("ResolveStateStore(memory): %v", err) + } + + fsStore := &wfctlhelpers.FSStateStore{} + noopStore := &wfctlhelpers.NoopStateStore{} + + stores := []struct { + name string + store interfaces.IaCStateStore + }{ + {"NoopStateStore", noopStore}, + {"FSStateStore", fsStore}, + {"moduleStoreAdapter(memory)", memStore}, + } + + cases := []struct { + method string + call func(s interfaces.IaCStateStore) + }{ + {"SavePlan", func(s interfaces.IaCStateStore) { + _ = s.SavePlan(context.Background(), interfaces.IaCPlan{ID: "p1"}) + }}, + {"GetPlan", func(s interfaces.IaCStateStore) { + _, _ = s.GetPlan(context.Background(), "p1") + }}, + {"Lock", func(s interfaces.IaCStateStore) { + _, _ = s.Lock(context.Background(), "r1", time.Second) + }}, + } + + for _, st := range stores { + for _, c := range cases { + t.Run(st.name+"/"+c.method, func(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatalf("expected panic from %s.%s, got nil", st.name, c.method) + } + msg, ok := r.(string) + if !ok { + t.Fatalf("expected string panic message, got %T(%v)", r, r) + } + if !strings.HasPrefix(msg, "wfctlhelpers: ") { + t.Errorf("panic message %q missing `wfctlhelpers:` prefix", msg) + } + if !strings.Contains(msg, c.method) { + t.Errorf("panic message %q does not name method %q", msg, c.method) + } + if !strings.Contains(msg, "out-of-subset") { + t.Errorf("panic message %q missing `out-of-subset` rationale", msg) + } + }() + c.call(st.store) + }) + } + } +} + +// TestResolveStateStore_EnvOverride exercises the envName != "" branch +// (lines 47-54 of state.go) which routes through WriteEnvResolvedConfig +// and the temp-file path. The branch is the reason the host-side module +// can target per-env state backends and was untested before +// code-reviewer I-3 on commit 7a064b824. +func TestResolveStateStore_EnvOverride(t *testing.T) { + dir := t.TempDir() + baseStateDir := filepath.Join(dir, "base-state") + stagingStateDir := filepath.Join(dir, "staging-state") + cfgPath := filepath.Join(dir, "infra.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: iac-state + type: iac.state + config: + backend: filesystem + directory: `+baseStateDir+` + environments: + staging: + config: + directory: `+stagingStateDir+` +`), 0o600); err != nil { + t.Fatal(err) + } + + // Pre-stage a fixture in the staging directory so we can confirm the + // env-resolved backend really targets it (not the base directory). + if err := os.MkdirAll(stagingStateDir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stagingStateDir, "vpc-staging.json"), + []byte(`{"resource_id":"vpc-staging","resource_type":"infra.vpc","provider":"stub","status":"active","config":{},"outputs":{},"created_at":"2026-05-27T00:00:00Z","updated_at":"2026-05-27T00:00:00Z"}`), + 0o600); err != nil { + t.Fatal(err) + } + + store, err := wfctlhelpers.ResolveStateStore(cfgPath, "staging", "") + if err != nil { + t.Fatalf("ResolveStateStore(envName=staging): %v", err) + } + list, err := store.ListResources(context.Background()) + if err != nil { + t.Fatalf("ListResources: %v", err) + } + if len(list) != 1 || list[0].Name != "vpc-staging" { + t.Fatalf("got %+v, want one vpc-staging resource — env-resolved backend should target stagingStateDir", list) + } + + // Confirm the env-resolve temp file was cleaned up. The temp file lives + // in the same dir as cfgPath with prefix `.wfctl-env-resolved-`. + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".wfctl-env-resolved-") { + t.Errorf("env-resolve temp file %q leaked — defer os.Remove failed", e.Name()) + } + } +} + +// TestResolveStateStore_EnvOverride_PropagatesError verifies the +// envName != "" branch's error-wrap context survives the lift: when +// the underlying config cannot be loaded for env resolution, the +// returned error mentions the env name so the operator can diagnose +// which environment triggered the failure. +func TestResolveStateStore_EnvOverride_PropagatesError(t *testing.T) { + _, err := wfctlhelpers.ResolveStateStore(filepath.Join(t.TempDir(), "missing.yaml"), "staging", "") + if err == nil { + t.Fatal("expected error for missing config + non-empty envName, got nil") + } + if !strings.Contains(err.Error(), "staging") { + t.Errorf("error %v does not mention envName 'staging' — context lost in lift", err) + } +} + +// writeStateCfg writes the supplied YAML body to a temp file and returns +// the path. Helper for invariant tests that don't need per-test config +// variation. +func writeStateCfg(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write cfg: %v", err) + } + return path +} diff --git a/iac/wfctlhelpers/state_plugin_internal_test.go b/iac/wfctlhelpers/state_plugin_internal_test.go new file mode 100644 index 00000000..6a692f83 --- /dev/null +++ b/iac/wfctlhelpers/state_plugin_internal_test.go @@ -0,0 +1,167 @@ +package wfctlhelpers + +// Internal (white-box) test for the spaces/s3/gcs plugin-served code path. +// Lives in `package wfctlhelpers` (not wfctlhelpers_test) so it can swap +// the unexported `loadPluginStateBackendClients` seam variable without +// touching production binaries. +// +// Per code-reviewer I-2.3 on commit 7a064b824: the seam exists +// specifically for tests to bypass real plugin binary loading, but no +// test exercised it — leaving the spaces/s3/gcs branch entirely +// uncovered by unit tests. + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/GoCodeAlone/workflow/plugin/external" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "google.golang.org/grpc" +) + +// fakeIaCStateBackendClient is a minimal pb.IaCStateBackendClient that +// records Configure inputs + serves a fixed ListStates response. Other +// methods return zero-valued OK responses; tests that need richer +// behavior can extend per scenario. +type fakeIaCStateBackendClient struct { + configureBackend string + configureJSON []byte + states []*pb.IaCState +} + +func (c *fakeIaCStateBackendClient) Configure(_ context.Context, req *pb.ConfigureRequest, _ ...grpc.CallOption) (*pb.ConfigureResponse, error) { + c.configureBackend = req.BackendName + c.configureJSON = req.ConfigJson + return &pb.ConfigureResponse{}, nil +} +func (c *fakeIaCStateBackendClient) GetState(_ context.Context, _ *pb.GetStateRequest, _ ...grpc.CallOption) (*pb.GetStateResponse, error) { + return &pb.GetStateResponse{}, nil +} +func (c *fakeIaCStateBackendClient) SaveState(_ context.Context, _ *pb.SaveStateRequest, _ ...grpc.CallOption) (*pb.SaveStateResponse, error) { + return &pb.SaveStateResponse{}, nil +} +func (c *fakeIaCStateBackendClient) ListStates(_ context.Context, _ *pb.ListStatesRequest, _ ...grpc.CallOption) (*pb.ListStatesResponse, error) { + return &pb.ListStatesResponse{States: c.states}, nil +} +func (c *fakeIaCStateBackendClient) DeleteState(_ context.Context, _ *pb.DeleteStateRequest, _ ...grpc.CallOption) (*pb.DeleteStateResponse, error) { + return &pb.DeleteStateResponse{}, nil +} +func (c *fakeIaCStateBackendClient) Lock(_ context.Context, _ *pb.LockRequest, _ ...grpc.CallOption) (*pb.LockResponse, error) { + return &pb.LockResponse{}, nil +} +func (c *fakeIaCStateBackendClient) Unlock(_ context.Context, _ *pb.UnlockRequest, _ ...grpc.CallOption) (*pb.UnlockResponse, error) { + return &pb.UnlockResponse{}, nil +} +func (c *fakeIaCStateBackendClient) ListBackendNames(_ context.Context, _ *pb.ListBackendNamesRequest, _ ...grpc.CallOption) (*pb.ListBackendNamesResponse, error) { + return &pb.ListBackendNamesResponse{BackendNames: []string{"spaces"}}, nil +} + +// TestResolvePluginStore_ConfiguresAdvertisedBackend exercises the +// spaces/s3/gcs branch end-to-end with a fake plugin loader. The seam +// swap proves: +// 1. The candidate ordering puts digitalocean first for `spaces`. +// 2. Configure is invoked with the requested backend name + JSON cfg. +// 3. ListResources round-trips a state record returned by the fake. +// +// Without this test the spaces/s3/gcs path would ship untested. +func TestResolvePluginStore_ConfiguresAdvertisedBackend(t *testing.T) { + dir := t.TempDir() + pluginDir := filepath.Join(dir, "plugins") + for _, name := range []string{"auth", "digitalocean"} { + if err := os.MkdirAll(filepath.Join(pluginDir, name), 0o750); err != nil { + t.Fatalf("mkdir plugin %s: %v", name, err) + } + } + + client := &fakeIaCStateBackendClient{ + states: []*pb.IaCState{{ + ResourceId: "site-vpc", + ResourceType: "infra.vpc", + Provider: "digitalocean", + ProviderId: "vpc-123", + ConfigJson: []byte(`{"region":"nyc3"}`), + OutputsJson: []byte(`{"id":"vpc-123"}`), + }}, + } + var loaded []string + orig := loadPluginStateBackendClients + loadPluginStateBackendClients = func(_ *external.ExternalPluginManager, pluginName, backend string) (map[string]pb.IaCStateBackendClient, error) { + loaded = append(loaded, pluginName) + if pluginName != "digitalocean" { + return map[string]pb.IaCStateBackendClient{}, nil + } + return map[string]pb.IaCStateBackendClient{backend: client}, nil + } + t.Cleanup(func() { loadPluginStateBackendClients = orig }) + + store, err := resolvePluginStore(context.Background(), "spaces", map[string]any{ + "backend": "spaces", + "bucket": "bmw-iac-state", + }, pluginDir) + if err != nil { + t.Fatalf("resolvePluginStore: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + + if len(loaded) == 0 || loaded[0] != "digitalocean" { + t.Fatalf("loaded plugins = %#v, want digitalocean first (priority list)", loaded) + } + if client.configureBackend != "spaces" { + t.Errorf("Configure backend = %q, want spaces", client.configureBackend) + } + if !containsSubstring(string(client.configureJSON), "bmw-iac-state") { + t.Errorf("Configure JSON %q missing bucket name", string(client.configureJSON)) + } + + states, err := store.ListResources(context.Background()) + if err != nil { + t.Fatalf("ListResources: %v", err) + } + if len(states) != 1 || states[0].ProviderID != "vpc-123" { + t.Fatalf("states = %+v, want vpc-123 record returned by fake plugin", states) + } +} + +// TestResolvePluginStore_NoAdvertisingPlugin returns a clear error +// naming the plugin directory so operators know where to drop the +// plugin binary. +func TestResolvePluginStore_NoAdvertisingPlugin(t *testing.T) { + pluginDir := t.TempDir() + if err := os.Mkdir(filepath.Join(pluginDir, "irrelevant"), 0o750); err != nil { + t.Fatal(err) + } + orig := loadPluginStateBackendClients + loadPluginStateBackendClients = func(_ *external.ExternalPluginManager, _, _ string) (map[string]pb.IaCStateBackendClient, error) { + // Every candidate returns an empty map → "no plugin advertises this backend". + return map[string]pb.IaCStateBackendClient{}, nil + } + t.Cleanup(func() { loadPluginStateBackendClients = orig }) + + _, err := resolvePluginStore(context.Background(), "spaces", map[string]any{}, pluginDir) + if err == nil { + t.Fatal("expected error, got nil") + } + if !containsSubstring(err.Error(), pluginDir) { + t.Errorf("error %q does not name pluginDir %q", err.Error(), pluginDir) + } + if !containsSubstring(err.Error(), "spaces") { + t.Errorf("error %q does not name backend 'spaces'", err.Error()) + } +} + +// containsSubstring is a tiny helper used by the plugin tests so we +// don't pull in the strings package alongside the tests' minimal +// import set. +func containsSubstring(haystack, needle string) bool { + if len(needle) == 0 { + return true + } + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} From 63129d65f72916e806f77f58bef68852d8d5d3c8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:30:30 -0400 Subject: [PATCH 04/34] feat(iac/wfctlhelpers): lift loadIaCProviderFromConfig for shared module+CLI use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 2: introduce a public LoadIaCProviderFromConfig in iac/wfctlhelpers so the existing wfctl bootstrap path and the upcoming `wfctl infra admin` CLI subcommands (T19-T20) share one definition. Plan-deviation note (justified, called out for spec-reviewer): - Plan declared the 2-arg signature `LoadIaCProviderFromConfig(ctx, cfgFile)`. - cmd/wfctl's loader chain (discoverAndLoadIaCProvider -> typedIaCAdapter -> buildTypedIaCAdapterFrom -> enforceCapabilitiesV2Gate -> ...) is ~2800 lines of plugin-manager + gRPC-adapter machinery; lifting that wholesale into wfctlhelpers was out of scope for Task 2. - Resolution: keep the 2-arg signature exactly; add a package-level `Resolver IaCProviderResolverFunc` seam plus an `UnregisteredResolver` safe default that returns a clear error (no nil-func panics). - cmd/wfctl registers its real loader via `cmd/wfctl/provider_resolver_init.go::init()` so production wiring happens at package load time without any new public surface. - The host-side infra.admin module (T15) does NOT call this function; it resolves providers via app.GetService() per the modular DI graph. The seam therefore principally serves wfctl's CLI codepaths today. Behavior preserved: - Returns (nil, nil, nil) — not an error — when no iac.provider module is declared (matches the previous cmd/wfctl behavior). - First-match-wins module selection (still single-provider; multi arrives in Task 3 via LoadAllIaCProvidersFromConfig per design cycle-4 Important #6). - config.ExpandEnvInMap applied before provider-type extraction so ${VAR} references resolve at load time as before. cmd/wfctl/infra_bootstrap.go:loadIaCProviderFromConfig reduces to a one-line shim wrapping the helper output back into the local anonymous io.Closer return type so existing callers compile unchanged. Tests added (TDD): - TestLoadIaCProviderFromConfig_StubProvider — happy path with a fake Resolver returning an in-process stubProvider; asserts provider Name + closer non-nil + Resolver invocation count. - TestLoadIaCProviderFromConfig_NoProviderModule — returns (nil, nil, nil) when no iac.provider module exists. - TestLoadIaCProviderFromConfig_FirstMatchWins — guards the first-match invariant (Task 3 will introduce multi-provider). - TestLoadIaCProviderFromConfig_LoadError — config-load error surfaces with context. - TestLoadIaCProviderFromConfig_NoResolverRegistered — verifies UnregisteredResolver default returns a clear error rather than a nil-func panic. Verified: - GOWORK=off go test ./iac/wfctlhelpers/... ./cmd/wfctl/ green (176s). - golangci-lint --new-from-rev=origin/main -> 0 issues. Co-Authored-By: Claude Opus 4.7 --- cmd/wfctl/infra_bootstrap.go | 43 +++--- cmd/wfctl/provider_resolver_init.go | 33 +++++ iac/wfctlhelpers/provider.go | 80 +++++++++++ iac/wfctlhelpers/provider_test.go | 201 ++++++++++++++++++++++++++++ 4 files changed, 331 insertions(+), 26 deletions(-) create mode 100644 cmd/wfctl/provider_resolver_init.go create mode 100644 iac/wfctlhelpers/provider.go create mode 100644 iac/wfctlhelpers/provider_test.go diff --git a/cmd/wfctl/infra_bootstrap.go b/cmd/wfctl/infra_bootstrap.go index af2e17a1..a76fff1b 100644 --- a/cmd/wfctl/infra_bootstrap.go +++ b/cmd/wfctl/infra_bootstrap.go @@ -12,6 +12,7 @@ import ( "time" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/secrets" ) @@ -367,35 +368,25 @@ func resolveCredentialRevoker(ctx context.Context, cfgFile string, secretsCfg *S return adapter, iacCloser } -// loadIaCProviderFromConfig finds the first iac.provider module in cfgFile, -// loads the provider plugin, and returns it. Returns (nil, nil, nil) when no -// iac.provider module is declared (caller treats as "provider not available"). -// The returned io.Closer (if non-nil) must be closed by the caller. +// loadIaCProviderFromConfig is a one-line delegating shim onto +// wfctlhelpers.LoadIaCProviderFromConfig. The body moved to the shared +// helper per docs/plans/2026-05-27-infra-admin-dynamic.md Task 2 so the +// in-tree bootstrap path and the upcoming `wfctl infra admin` +// subcommands (T19-T20) share one definition. Resolver wiring lives in +// provider_resolver_init.go. +// +// Returns (nil, nil, nil) when no iac.provider module is declared +// (caller treats as "provider not available"). The returned io.Closer +// (when non-nil) MUST be closed by the caller; the interface anonymous +// return type is preserved here so existing cmd/wfctl callers compile +// unchanged after the lift. func loadIaCProviderFromConfig(ctx context.Context, cfgFile string) (interfaces.IaCProvider, interface{ Close() error }, error) { - rawCfg, err := config.LoadFromFile(cfgFile) + prov, closer, err := wfctlhelpers.LoadIaCProviderFromConfig(ctx, cfgFile) if err != nil { - return nil, nil, fmt.Errorf("load config: %w", err) + return nil, nil, err } - var provType string - var provCfg map[string]any - for i := range rawCfg.Modules { - mod := &rawCfg.Modules[i] - if mod.Type != "iac.provider" { - continue - } - modCfg := config.ExpandEnvInMap(mod.Config) - if pt, ok := modCfg["provider"].(string); ok && pt != "" { - provType = pt - provCfg = modCfg - break - } - } - if provType == "" { - return nil, nil, nil // no iac.provider module in config - } - prov, closer, err := resolveIaCProvider(ctx, provType, provCfg) - if err != nil { - return nil, nil, fmt.Errorf("load provider %q: %w", provType, err) + if closer == nil { + return prov, nil, nil } return prov, closer, nil } diff --git a/cmd/wfctl/provider_resolver_init.go b/cmd/wfctl/provider_resolver_init.go new file mode 100644 index 00000000..3f428c3e --- /dev/null +++ b/cmd/wfctl/provider_resolver_init.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "io" + + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// init wires the wfctlhelpers.Resolver seam to cmd/wfctl's real plugin +// loader so wfctlhelpers.LoadIaCProviderFromConfig (and Task 3's +// LoadAllIaCProvidersFromConfig) produces a live typed adapter without +// having to lift discoverAndLoadIaCProvider + typedIaCAdapter + +// findIaCPluginDir + buildTypedIaCAdapterFrom + the +// CapabilitiesResponse=v2 gate (~2800 lines) into the shared helper +// package. Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 2. +// +// The host-side infra.admin module (T15) does NOT call +// wfctlhelpers.LoadIaCProviderFromConfig — it resolves providers via +// app.GetService() per the modular DI graph. This seam is +// therefore registered only in cmd/wfctl; any future caller wanting to +// load providers from a config file outside the modular DI lifecycle +// (e.g. a standalone CLI extension) can register its own resolver. +// +// The wrapper does not introduce new logic — it delegates to the +// existing resolveIaCProvider package-level var (which itself defaults +// to discoverAndLoadIaCProvider; tests override via the same seam). +func init() { + wfctlhelpers.Resolver = func(ctx context.Context, providerType string, cfg map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return resolveIaCProvider(ctx, providerType, cfg) + } +} diff --git a/iac/wfctlhelpers/provider.go b/iac/wfctlhelpers/provider.go new file mode 100644 index 00000000..62ec21e1 --- /dev/null +++ b/iac/wfctlhelpers/provider.go @@ -0,0 +1,80 @@ +package wfctlhelpers + +import ( + "context" + "fmt" + "io" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// IaCProviderResolverFunc loads a live interfaces.IaCProvider from a +// provider type identifier (e.g. "digitalocean", "aws", "stub") and the +// expanded module config. Implementations typically scan a plugin +// directory, spawn a subprocess, build the typed gRPC adapter, and +// enforce CapabilitiesResponse.compute_plan_version == "v2". +// +// The returned io.Closer (when non-nil) MUST be closed by the caller to +// shut down the plugin subprocess. +type IaCProviderResolverFunc func(ctx context.Context, providerType string, cfg map[string]any) (interfaces.IaCProvider, io.Closer, error) + +// Resolver is the package-level seam used by LoadIaCProviderFromConfig +// (and LoadAllIaCProvidersFromConfig in Task 3) to spawn a live IaC +// provider plugin. Production callers register their loader via an +// init() in cmd/wfctl/provider_resolver_init.go (registers +// discoverAndLoadIaCProvider); tests substitute fakes with t.Cleanup +// restore. Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 2. +// +// The cmd/wfctl loader (discoverAndLoadIaCProvider) is ~2800 lines of +// plugin-manager + typed-adapter machinery (deploy_providers.go + +// iac_typed_adapter.go). Lifting that wholesale into wfctlhelpers was +// out of scope for Task 2; this seam decouples the loader from this +// package without requiring the move. The host-side infra.admin module +// (T15) resolves providers via app.GetService() per the +// modular DI graph rather than calling this function, so the seam +// principally serves wfctl's CLI codepaths. +var Resolver IaCProviderResolverFunc = UnregisteredResolver + +// UnregisteredResolver is the safe default for the Resolver seam: it +// returns a clear error message naming the missing init-registration so +// operators can diagnose a missing wiring without a nil-func panic. +// Exposed so tests can restore the default after swapping Resolver. +var UnregisteredResolver IaCProviderResolverFunc = func(_ context.Context, providerType string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return nil, nil, fmt.Errorf("wfctlhelpers: no IaCProviderResolver registered for provider type %q — cmd/wfctl init() should assign wfctlhelpers.Resolver = ", providerType) +} + +// LoadIaCProviderFromConfig finds the first iac.provider module in +// cfgFile and resolves it via the registered Resolver. Returns +// (nil, nil, nil) — NOT an error — when no iac.provider module is +// declared, so callers can treat "provider not available" as a +// reportable-but-non-fatal condition. The returned io.Closer (when +// non-nil) MUST be closed by the caller. +// +// Lifted from cmd/wfctl/infra_bootstrap.go:loadIaCProviderFromConfig +// per docs/plans/2026-05-27-infra-admin-dynamic.md Task 2 so the +// in-tree wfctl bootstrap path and the upcoming infra.admin CLI +// subcommands (T19-T20) share one definition. +func LoadIaCProviderFromConfig(ctx context.Context, cfgFile string) (interfaces.IaCProvider, io.Closer, error) { + rawCfg, err := config.LoadFromFile(cfgFile) + if err != nil { + return nil, nil, fmt.Errorf("load config: %w", err) + } + for i := range rawCfg.Modules { + mod := &rawCfg.Modules[i] + if mod.Type != "iac.provider" { + continue + } + modCfg := config.ExpandEnvInMap(mod.Config) + pt, ok := modCfg["provider"].(string) + if !ok || pt == "" { + continue + } + prov, closer, err := Resolver(ctx, pt, modCfg) + if err != nil { + return nil, nil, fmt.Errorf("load provider %q: %w", pt, err) + } + return prov, closer, nil + } + return nil, nil, nil // no iac.provider module in config +} diff --git a/iac/wfctlhelpers/provider_test.go b/iac/wfctlhelpers/provider_test.go new file mode 100644 index 00000000..62cbc21a --- /dev/null +++ b/iac/wfctlhelpers/provider_test.go @@ -0,0 +1,201 @@ +package wfctlhelpers_test + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// stubProvider is a minimal interfaces.IaCProvider implementation used by +// the provider-lift tests so they don't need to spawn a real plugin +// subprocess. Only Name() is exercised; the rest exist to satisfy the +// interface and return zero values. +type stubProvider struct{ name string } + +func (s *stubProvider) Name() string { return s.name } +func (s *stubProvider) Version() string { return "test" } +func (s *stubProvider) Initialize(_ context.Context, _ map[string]any) error { return nil } +func (s *stubProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil } +func (s *stubProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + return nil, errors.New("stub: Plan not implemented") +} +func (s *stubProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + return nil, errors.New("stub: Destroy not implemented") +} +func (s *stubProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + return nil, errors.New("stub: Status not implemented") +} +func (s *stubProvider) DetectDrift(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { + return nil, errors.New("stub: DetectDrift not implemented") +} +func (s *stubProvider) Import(_ context.Context, _, _ string) (*interfaces.ResourceState, error) { + return nil, errors.New("stub: Import not implemented") +} +func (s *stubProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + return nil, errors.New("stub: ResolveSizing not implemented") +} +func (s *stubProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return nil, errors.New("stub: ResourceDriver not implemented") +} +func (s *stubProvider) SupportedCanonicalKeys() []string { return nil } +func (s *stubProvider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) { + return nil, nil +} +func (s *stubProvider) Close() error { return nil } + +type nopCloser struct{ closed bool } + +func (n *nopCloser) Close() error { n.closed = true; return nil } + +// installFakeResolver swaps wfctlhelpers.Resolver to a fake for the +// duration of the test, restoring the previous resolver on cleanup. The +// fake returns a stubProvider whose Name reflects the providerType +// argument so the test can assert which iac.provider module won. +func installFakeResolver(t *testing.T) (recorded *[]string) { + t.Helper() + calls := []string{} + orig := wfctlhelpers.Resolver + wfctlhelpers.Resolver = func(_ context.Context, providerType string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + calls = append(calls, providerType) + return &stubProvider{name: providerType}, &nopCloser{}, nil + } + t.Cleanup(func() { wfctlhelpers.Resolver = orig }) + return &calls +} + +func TestLoadIaCProviderFromConfig_StubProvider(t *testing.T) { + calls := installFakeResolver(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "stub.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: stub-provider + type: iac.provider + config: + provider: stub +`), 0o600); err != nil { + t.Fatal(err) + } + + provider, closer, err := wfctlhelpers.LoadIaCProviderFromConfig(context.Background(), cfgPath) + if err != nil { + t.Fatalf("LoadIaCProviderFromConfig: %v", err) + } + if provider == nil { + t.Fatal("provider is nil with nil error") + } + if closer == nil { + t.Fatal("closer is nil; expected the fake's nopCloser") + } + defer closer.Close() + if provider.Name() != "stub" { + t.Errorf("provider.Name() = %q, want %q", provider.Name(), "stub") + } + if len(*calls) != 1 || (*calls)[0] != "stub" { + t.Errorf("resolver invocations = %v, want [stub]", *calls) + } +} + +// TestLoadIaCProviderFromConfig_NoProviderModule returns (nil, nil, nil) +// when the config has no iac.provider module — the caller treats this +// as "no provider available" rather than an error. Mirrors the +// wfctl-internal behavior. +func TestLoadIaCProviderFromConfig_NoProviderModule(t *testing.T) { + installFakeResolver(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "no-provider.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: web + type: http.server + config: {} +`), 0o600); err != nil { + t.Fatal(err) + } + + provider, closer, err := wfctlhelpers.LoadIaCProviderFromConfig(context.Background(), cfgPath) + if err != nil { + t.Fatalf("LoadIaCProviderFromConfig: %v", err) + } + if provider != nil { + t.Errorf("provider = %v, want nil", provider) + } + if closer != nil { + t.Errorf("closer = %v, want nil", closer) + } +} + +// TestLoadIaCProviderFromConfig_FirstMatchWins documents the +// first-match-only invariant the design doc cycle-4 reviewer flagged +// (Important #6 → resolved by adding LoadAllIaCProvidersFromConfig in +// Task 3). Pinning the behavior here prevents accidental reordering of +// the loop or change in tie-break semantics. +func TestLoadIaCProviderFromConfig_FirstMatchWins(t *testing.T) { + calls := installFakeResolver(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "multi.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: first + type: iac.provider + config: + provider: alpha + - name: second + type: iac.provider + config: + provider: beta +`), 0o600); err != nil { + t.Fatal(err) + } + + provider, closer, err := wfctlhelpers.LoadIaCProviderFromConfig(context.Background(), cfgPath) + if err != nil { + t.Fatalf("LoadIaCProviderFromConfig: %v", err) + } + defer closer.Close() + if provider.Name() != "alpha" { + t.Errorf("first-match-wins: got %q, want %q", provider.Name(), "alpha") + } + if len(*calls) != 1 { + t.Errorf("resolver called %d times, want exactly 1 (first match short-circuits)", len(*calls)) + } +} + +// TestLoadIaCProviderFromConfig_LoadError surfaces config-load errors +// with context so the caller can diagnose missing/malformed configs. +func TestLoadIaCProviderFromConfig_LoadError(t *testing.T) { + installFakeResolver(t) + _, _, err := wfctlhelpers.LoadIaCProviderFromConfig(context.Background(), filepath.Join(t.TempDir(), "missing.yaml")) + if err == nil { + t.Fatal("expected error for missing config, got nil") + } +} + +// TestLoadIaCProviderFromConfig_NoResolverRegistered guards the default +// resolver returns a clear error when no init() has registered a real +// loader. Without this, an empty Resolver field would panic with a +// nil-func-call, which is far less actionable than the wfctlhelpers: +// prefix error. +func TestLoadIaCProviderFromConfig_NoResolverRegistered(t *testing.T) { + orig := wfctlhelpers.Resolver + wfctlhelpers.Resolver = wfctlhelpers.UnregisteredResolver + t.Cleanup(func() { wfctlhelpers.Resolver = orig }) + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "stub.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: stub-provider + type: iac.provider + config: + provider: stub +`), 0o600); err != nil { + t.Fatal(err) + } + _, _, err := wfctlhelpers.LoadIaCProviderFromConfig(context.Background(), cfgPath) + if err == nil { + t.Fatal("expected error from unregistered resolver, got nil") + } +} From 9dff9524630bd3ece98184105ab7ee169a14ad0d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:36:01 -0400 Subject: [PATCH 05/34] feat(iac/wfctlhelpers): LoadAllIaCProvidersFromConfig multi-provider helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 3 + design-doc cycle-4 Important #6: LoadIaCProviderFromConfig is first-match-only, which is correct for the wfctl single-cloud bootstrap path but insufficient for the admin-UI handler library (T5/T6) that lists every configured provider with module-name attribution. New helper iterates all iac.provider modules and returns them keyed by module name. Signature (matches plan §Task 3 verbatim): LoadAllIaCProvidersFromConfig(ctx, cfgFile) -> (map[string]interfaces.IaCProvider, []io.Closer, error) Design choices: - Closer slice carries one entry per resolved provider in declaration order so callers `defer c.Close()` over all entries. - Resolver-failure rollback: on the Nth resolver error, every previously-resolved provider has Close() called (best-effort) before the helper returns (nil, nil, err). Otherwise an error from provider #3 would leak the subprocesses + plugin managers of providers #1 and #2 — callers have no handle to release them. - iac.provider modules missing a `provider:` string field are silently skipped (consistent with LoadIaCProviderFromConfig's single-module shape; misconfigured modules don't fail the whole load). - Both helpers now route through a new private loadProviderModule() per-module loader so the body cannot drift between the single and multi paths — addresses the cycle-4 reviewer's first-match-bug-risk observation by making the loader logic single-sourced. Tests added (TDD): - TestLoadAllIaCProvidersFromConfig_Two — plan §Step 1 minimum: two iac.provider modules return as map[stub-a, stub-b] with 2 closers. - TestLoadAllIaCProvidersFromConfig_EmptyConfig — config without any iac.provider yields empty map + nil closers + nil error. - TestLoadAllIaCProvidersFromConfig_SkipsMissingProviderField — mixed config (one valid + one missing `provider:` field) returns only the valid entry; Resolver is invoked exactly once. - TestLoadAllIaCProvidersFromConfig_ResolverErrorRollsBack — when provider #3 fails to resolve, providers #1 and #2 are closed before the error returns and the result is (nil, nil, err). Verified: - GOWORK=off go test ./iac/wfctlhelpers/... green. - GOWORK=off go build ./... clean. - golangci-lint --new-from-rev=origin/main -> 0 issues. Co-Authored-By: Claude Opus 4.7 --- iac/wfctlhelpers/provider.go | 83 +++++++++- iac/wfctlhelpers/provider_multi_test.go | 199 ++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 iac/wfctlhelpers/provider_multi_test.go diff --git a/iac/wfctlhelpers/provider.go b/iac/wfctlhelpers/provider.go index 62ec21e1..b86971f0 100644 --- a/iac/wfctlhelpers/provider.go +++ b/iac/wfctlhelpers/provider.go @@ -65,16 +65,85 @@ func LoadIaCProviderFromConfig(ctx context.Context, cfgFile string) (interfaces. if mod.Type != "iac.provider" { continue } - modCfg := config.ExpandEnvInMap(mod.Config) - pt, ok := modCfg["provider"].(string) - if !ok || pt == "" { - continue - } - prov, closer, err := Resolver(ctx, pt, modCfg) + prov, closer, ok, err := loadProviderModule(ctx, mod) if err != nil { - return nil, nil, fmt.Errorf("load provider %q: %w", pt, err) + return nil, nil, err + } + if !ok { + continue } return prov, closer, nil } return nil, nil, nil // no iac.provider module in config } + +// LoadAllIaCProvidersFromConfig finds EVERY iac.provider module in +// cfgFile and resolves each one, returning them as a map keyed by +// module name (so the handler library + ListProviders response can +// attribute each Provider record to its declared module). The +// caller-returned []io.Closer carries one entry per resolved provider +// in declaration order; closing them releases the underlying plugin +// subprocesses. +// +// Per design doc cycle-4 Important #6 (resolved by plan §Task 3): +// LoadIaCProviderFromConfig is first-match-only, which is correct for +// the wfctl single-cloud bootstrap path but insufficient for the +// admin-UI handler library that lists all configured providers. +// +// On resolver failure for any provider, the helper closes every +// previously-resolved provider (best-effort) and returns +// (nil, nil, error) so callers cannot accidentally leak subprocesses +// they have no handle to release. iac.provider modules missing a +// `provider:` field are silently skipped (consistent with +// LoadIaCProviderFromConfig's single-module behavior). +func LoadAllIaCProvidersFromConfig(ctx context.Context, cfgFile string) (map[string]interfaces.IaCProvider, []io.Closer, error) { + rawCfg, err := config.LoadFromFile(cfgFile) + if err != nil { + return nil, nil, fmt.Errorf("load config: %w", err) + } + providers := map[string]interfaces.IaCProvider{} + var closers []io.Closer + for i := range rawCfg.Modules { + mod := &rawCfg.Modules[i] + if mod.Type != "iac.provider" { + continue + } + prov, closer, ok, err := loadProviderModule(ctx, mod) + if err != nil { + // Roll back: close every successfully-resolved provider so the + // caller does not leak subprocesses it has no handle to release. + for _, c := range closers { + _ = c.Close() + } + return nil, nil, err + } + if !ok { + continue + } + providers[mod.Name] = prov + if closer != nil { + closers = append(closers, closer) + } + } + return providers, closers, nil +} + +// loadProviderModule resolves a single iac.provider ModuleConfig via +// the registered Resolver. Returns (provider, closer, true, nil) on +// success, (nil, nil, false, nil) when the module lacks a +// `provider:` field (caller skips it), and (nil, nil, false, err) on +// resolver failure. Factored out of LoadIaCProviderFromConfig + +// LoadAllIaCProvidersFromConfig so the body cannot drift between the +// two callsites. +func loadProviderModule(ctx context.Context, mod *config.ModuleConfig) (interfaces.IaCProvider, io.Closer, bool, error) { + modCfg := config.ExpandEnvInMap(mod.Config) + pt, ok := modCfg["provider"].(string) + if !ok || pt == "" { + return nil, nil, false, nil + } + prov, closer, err := Resolver(ctx, pt, modCfg) + if err != nil { + return nil, nil, false, fmt.Errorf("load provider %q: %w", pt, err) + } + return prov, closer, true, nil +} diff --git a/iac/wfctlhelpers/provider_multi_test.go b/iac/wfctlhelpers/provider_multi_test.go new file mode 100644 index 00000000..734bca57 --- /dev/null +++ b/iac/wfctlhelpers/provider_multi_test.go @@ -0,0 +1,199 @@ +package wfctlhelpers_test + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// keysOf returns the sorted keys of a string-keyed map so test failure +// messages are deterministic. +func keysOf[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// TestLoadAllIaCProvidersFromConfig_Two exercises the design-cycle-4 +// Important #6 fix: LoadIaCProviderFromConfig is first-match-only, but +// the handler library (T5/T6) needs ALL declared iac.provider modules +// keyed by module name so each Provider record in ListProviders carries +// the right module attribution. This test pins the minimum shape from +// plan §Task 3 — two providers, both keyed by their module name. +func TestLoadAllIaCProvidersFromConfig_Two(t *testing.T) { + installFakeResolver(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "multi.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: stub-a + type: iac.provider + config: + provider: stub + - name: stub-b + type: iac.provider + config: + provider: stub +`), 0o600); err != nil { + t.Fatal(err) + } + + providers, closers, err := wfctlhelpers.LoadAllIaCProvidersFromConfig(context.Background(), cfgPath) + if err != nil { + t.Fatalf("LoadAllIaCProvidersFromConfig: %v", err) + } + for _, c := range closers { + defer c.Close() + } + if len(providers) != 2 { + t.Errorf("expected 2 providers, got %d (keys: %v)", len(providers), keysOf(providers)) + } + if _, ok := providers["stub-a"]; !ok { + t.Errorf("missing stub-a (keys: %v)", keysOf(providers)) + } + if _, ok := providers["stub-b"]; !ok { + t.Errorf("missing stub-b (keys: %v)", keysOf(providers)) + } + if len(closers) != 2 { + t.Errorf("expected 2 closers (one per provider), got %d", len(closers)) + } +} + +// TestLoadAllIaCProvidersFromConfig_EmptyConfig returns (empty map, nil +// closers, nil error) when no iac.provider modules are declared. +// Mirrors LoadIaCProviderFromConfig's permissive shape so callers don't +// need to special-case the missing-providers case. +func TestLoadAllIaCProvidersFromConfig_EmptyConfig(t *testing.T) { + installFakeResolver(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "no-providers.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: web + type: http.server + config: {} +`), 0o600); err != nil { + t.Fatal(err) + } + + providers, closers, err := wfctlhelpers.LoadAllIaCProvidersFromConfig(context.Background(), cfgPath) + if err != nil { + t.Fatalf("LoadAllIaCProvidersFromConfig: %v", err) + } + if len(providers) != 0 { + t.Errorf("expected 0 providers, got %d (keys: %v)", len(providers), keysOf(providers)) + } + if len(closers) != 0 { + t.Errorf("expected 0 closers, got %d", len(closers)) + } +} + +// TestLoadAllIaCProvidersFromConfig_SkipsMissingProviderField mirrors +// LoadIaCProviderFromConfig's behavior: iac.provider modules without a +// non-empty `provider:` string are silently skipped (not an error). The +// design assumes such a module is misconfigured and excludes it from +// the loaded set rather than failing the whole load — same shape as +// the single-provider path so callers can rely on consistent semantics. +func TestLoadAllIaCProvidersFromConfig_SkipsMissingProviderField(t *testing.T) { + calls := installFakeResolver(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "mixed.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: incomplete + type: iac.provider + config: {} + - name: complete + type: iac.provider + config: + provider: stub +`), 0o600); err != nil { + t.Fatal(err) + } + + providers, closers, err := wfctlhelpers.LoadAllIaCProvidersFromConfig(context.Background(), cfgPath) + if err != nil { + t.Fatalf("LoadAllIaCProvidersFromConfig: %v", err) + } + for _, c := range closers { + defer c.Close() + } + if len(providers) != 1 || providers["complete"] == nil { + t.Errorf("expected 1 provider keyed 'complete', got %d (keys: %v)", len(providers), keysOf(providers)) + } + if len(*calls) != 1 { + t.Errorf("Resolver called %d times, want exactly 1 (incomplete module is skipped pre-resolve)", len(*calls)) + } +} + +// TestLoadAllIaCProvidersFromConfig_ResolverErrorRollsBack ensures that +// when the Nth resolve fails, the prior N-1 closers are released +// before returning the error. Otherwise an error from provider #3 +// leaks the subprocesses + plugin managers of providers #1 and #2. +func TestLoadAllIaCProvidersFromConfig_ResolverErrorRollsBack(t *testing.T) { + orig := wfctlhelpers.Resolver + t.Cleanup(func() { wfctlhelpers.Resolver = orig }) + + var closedTracker []bool // index by module-name order + wfctlhelpers.Resolver = func(_ context.Context, providerType string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + if providerType == "broken" { + return nil, nil, errors.New("simulated resolver failure") + } + idx := len(closedTracker) + closedTracker = append(closedTracker, false) + myIdx := idx + closer := closerFuncT(func() error { closedTracker[myIdx] = true; return nil }) + return &stubProvider{name: providerType}, closer, nil + } + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "fail.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: ok-a + type: iac.provider + config: + provider: stub + - name: ok-b + type: iac.provider + config: + provider: stub + - name: bad + type: iac.provider + config: + provider: broken +`), 0o600); err != nil { + t.Fatal(err) + } + + providers, closers, err := wfctlhelpers.LoadAllIaCProvidersFromConfig(context.Background(), cfgPath) + if err == nil { + t.Fatal("expected resolver-failure error, got nil") + } + if providers != nil { + t.Errorf("providers = %v on error, want nil", providers) + } + if closers != nil { + t.Errorf("closers = %v on error, want nil — caller has no handle to release them", closers) + } + // All previously-opened closers must have been called by the helper + // before returning the error. + for i, closed := range closedTracker { + if !closed { + t.Errorf("closer #%d (ok-a/ok-b) was not closed on resolver-failure rollback", i) + } + } +} + +// closerFuncT adapts a func() error to io.Closer for tests in this +// package. (state_plugin_internal_test.go already declares one in +// `package wfctlhelpers` — different package, no collision.) +type closerFuncT func() error + +func (f closerFuncT) Close() error { return f() } From ecafd769f9fbbff13079ad9f479f58ac16346799 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:39:50 -0400 Subject: [PATCH 06/34] test(iac/wfctlhelpers): cover ExpandEnvInMap + empty-provider-skip + init() wiring (code-review I-1..I-3, M-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-reviewer findings on commit 63129d65f: I-1 (TestLoadIaCProviderFromConfig_ExpandsEnvInModuleConfig) — guards that config.ExpandEnvInMap runs against mod.Config BEFORE the Resolver dispatch. Setenv WFCTLHELPERS_TEST_REGION + WFCTLHELPERS_TEST_TOKEN, write ${VAR} refs in the YAML, capture the cfg map the fake Resolver receives, assert the literal values flowed. Env-var expansion is a known regression footgun in this codebase (MEMORY.md BMW os.ExpandEnv 9-layer-bug-chain); this test pins the load-time expansion contract so a future "move expansion downstream" refactor fails loudly. I-2 (TestLoadIaCProviderFromConfig_SkipsEmptyProviderField) — covers the previously-uncovered skip-and-continue branch. Module A has `config: {}` (no provider field), module B has `provider: beta` → assert provider.Name()=="beta" + Resolver called exactly once with "beta". Without this test, a "fail-fast on missing provider field" refactor could silently break first-match-after-skip semantics for configs with a typo'd module followed by a valid one. I-3 (cmd/wfctl/provider_resolver_init_test.go) — function-pointer comparison asserts wfctlhelpers.Resolver is NOT the UnregisteredResolver default after package init. If provider_resolver_init.go is deleted or its init() breaks, every `wfctl infra apply` returns the unregistered-resolver error — a graceful failure but only discoverable by running a real command. This test catches it at `go test`. M-1 — godoc tightening on wfctlhelpers.Resolver explicitly stating the contract ("Production callers other than cmd/wfctl's init() MUST NOT mutate this var; tests substitute fakes with t.Cleanup restore. NOT goroutine-safe."). Matches the T1 loadPluginStateBackendClients precedent (dd4a42796 M-1 follow-up). Option 2 chosen — adding an exported RegisterResolver() setter would diverge from T1's pattern without strengthening the actual guarantee. M-2 — folded into I-1: the new test captures the full cfg map the fake Resolver received and asserts flow-through (provider type + region + token), so no separate "cfg flowed at all" test is needed. M-3 — declined: the installFakeResolver helper's *[]string return form is test-helper-idiomatic; reviewer marked as leave-as-is. Verified: - GOWORK=off go test ./iac/wfctlhelpers/... ./cmd/wfctl/ green. - 7 wfctlhelpers tests + 1 cmd/wfctl init-wiring test all pass. - golangci-lint --new-from-rev=origin/main -> 0 issues. Co-Authored-By: Claude Opus 4.7 --- cmd/wfctl/provider_resolver_init_test.go | 30 ++++++++ iac/wfctlhelpers/provider.go | 7 ++ iac/wfctlhelpers/provider_test.go | 95 ++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 cmd/wfctl/provider_resolver_init_test.go diff --git a/cmd/wfctl/provider_resolver_init_test.go b/cmd/wfctl/provider_resolver_init_test.go new file mode 100644 index 00000000..4fcb8396 --- /dev/null +++ b/cmd/wfctl/provider_resolver_init_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "reflect" + "testing" + + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" +) + +// TestProviderResolverInit_WiresLoader guards against accidental +// deletion or refactor breakage of provider_resolver_init.go. The +// production wiring happens at package init time; if the init() goes +// missing, every `wfctl infra apply` returns the UnregisteredResolver +// "no IaCProviderResolver registered" error — a graceful failure but +// only discoverable by running a real command. This test catches it +// at `go test`. Per code-reviewer I-3 on commit 63129d65f. +// +// The assertion uses a function-pointer comparison: after init() runs, +// wfctlhelpers.Resolver must point at a different func than +// wfctlhelpers.UnregisteredResolver. The looser "is not Unregistered" +// shape is acceptable per the reviewer's note — if a sibling test +// already swapped Resolver via t.Cleanup, the comparison still holds +// (the swap target is also non-Unregistered). +func TestProviderResolverInit_WiresLoader(t *testing.T) { + got := reflect.ValueOf(wfctlhelpers.Resolver).Pointer() + want := reflect.ValueOf(wfctlhelpers.UnregisteredResolver).Pointer() + if got == want { + t.Fatal("cmd/wfctl init() did not register a resolver; provider_resolver_init.go missing or its init() broken — wfctlhelpers.Resolver is still the UnregisteredResolver default") + } +} diff --git a/iac/wfctlhelpers/provider.go b/iac/wfctlhelpers/provider.go index b86971f0..e7f89227 100644 --- a/iac/wfctlhelpers/provider.go +++ b/iac/wfctlhelpers/provider.go @@ -26,6 +26,13 @@ type IaCProviderResolverFunc func(ctx context.Context, providerType string, cfg // discoverAndLoadIaCProvider); tests substitute fakes with t.Cleanup // restore. Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 2. // +// Production callers other than cmd/wfctl's init() MUST NOT mutate +// this var; tests substitute fakes with t.Cleanup restore. NOT +// goroutine-safe — mirrors the T1 loadPluginStateBackendClients seam +// precedent. Code-reviewer M-1 on commit 63129d65f flagged the export +// surface; the godoc tightening here keeps the contract explicit +// without adding a setter that would diverge from T1's pattern. +// // The cmd/wfctl loader (discoverAndLoadIaCProvider) is ~2800 lines of // plugin-manager + typed-adapter machinery (deploy_providers.go + // iac_typed_adapter.go). Lifting that wholesale into wfctlhelpers was diff --git a/iac/wfctlhelpers/provider_test.go b/iac/wfctlhelpers/provider_test.go index 62cbc21a..d886c440 100644 --- a/iac/wfctlhelpers/provider_test.go +++ b/iac/wfctlhelpers/provider_test.go @@ -199,3 +199,98 @@ func TestLoadIaCProviderFromConfig_NoResolverRegistered(t *testing.T) { t.Fatal("expected error from unregistered resolver, got nil") } } + +// TestLoadIaCProviderFromConfig_ExpandsEnvInModuleConfig pins the +// invariant that config.ExpandEnvInMap is applied to the module config +// BEFORE the Resolver is dispatched — so ${VAR} references in the YAML +// resolve at load time. Per code-reviewer I-1 on commit 63129d65f: +// env-var expansion is a known regression footgun in this codebase +// (see MEMORY.md BMW os.ExpandEnv 9-layer-bug-chain), so the +// expansion-step needs an explicit test guard. +// +// Also satisfies code-reviewer M-2 by capturing the full cfg map the +// fake Resolver received and asserting flow-through. +func TestLoadIaCProviderFromConfig_ExpandsEnvInModuleConfig(t *testing.T) { + var receivedCfg map[string]any + var receivedType string + orig := wfctlhelpers.Resolver + wfctlhelpers.Resolver = func(_ context.Context, providerType string, cfg map[string]any) (interfaces.IaCProvider, io.Closer, error) { + receivedType = providerType + receivedCfg = cfg + return &stubProvider{name: providerType}, &nopCloser{}, nil + } + t.Cleanup(func() { wfctlhelpers.Resolver = orig }) + + t.Setenv("WFCTLHELPERS_TEST_REGION", "nyc3") + t.Setenv("WFCTLHELPERS_TEST_TOKEN", "tok-xyz") + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "envrefs.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: do-provider + type: iac.provider + config: + provider: digitalocean + region: ${WFCTLHELPERS_TEST_REGION} + token: ${WFCTLHELPERS_TEST_TOKEN} +`), 0o600); err != nil { + t.Fatal(err) + } + _, closer, err := wfctlhelpers.LoadIaCProviderFromConfig(context.Background(), cfgPath) + if err != nil { + t.Fatalf("LoadIaCProviderFromConfig: %v", err) + } + if closer != nil { + defer closer.Close() + } + + if receivedType != "digitalocean" { + t.Errorf("Resolver got provider type %q, want %q", receivedType, "digitalocean") + } + if got, _ := receivedCfg["region"].(string); got != "nyc3" { + t.Errorf("region = %q, want %q — ExpandEnvInMap not applied before Resolver dispatch", got, "nyc3") + } + if got, _ := receivedCfg["token"].(string); got != "tok-xyz" { + t.Errorf("token = %q, want %q — ExpandEnvInMap not applied to all string fields", got, "tok-xyz") + } +} + +// TestLoadIaCProviderFromConfig_SkipsEmptyProviderField guards the +// "iac.provider module with no provider: field, continue to next +// module" branch — currently uncovered (code-reviewer I-2 on commit +// 63129d65f). A future "fail-fast on missing provider field" refactor +// could silently break first-match-after-skip semantics for configs +// where someone typos `providr:` in module A and has a valid module B. +func TestLoadIaCProviderFromConfig_SkipsEmptyProviderField(t *testing.T) { + calls := installFakeResolver(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "skip.yaml") + if err := os.WriteFile(cfgPath, []byte(`modules: + - name: incomplete + type: iac.provider + config: {} + - name: valid + type: iac.provider + config: + provider: beta +`), 0o600); err != nil { + t.Fatal(err) + } + provider, closer, err := wfctlhelpers.LoadIaCProviderFromConfig(context.Background(), cfgPath) + if err != nil { + t.Fatalf("LoadIaCProviderFromConfig: %v", err) + } + if closer != nil { + defer closer.Close() + } + if provider == nil || provider.Name() != "beta" { + name := "" + if provider != nil { + name = provider.Name() + } + t.Errorf("provider = %q, want beta — incomplete module should be skipped, second match should win", name) + } + if len(*calls) != 1 || (*calls)[0] != "beta" { + t.Errorf("Resolver calls = %v, want [beta] (first module is skipped pre-resolve)", *calls) + } +} From 9aff32e06446615ffb60ee7bda228808d1cd4684 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:41:29 -0400 Subject: [PATCH 07/34] docs(iac/wfctlhelpers): rollback close-error + unique-Name invariant comments (T3 code-review M-1, M-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc-only follow-up to T3 commit 9dff95246 addressing code-reviewer's optional Minors: M-1 — Added explicit invariant comment on LoadAllIaCProvidersFromConfig noting that cfg.Modules has unique Names (enforced upstream by config.LoadFromFile). The map-keyed-by-Name design silently overwrites on duplicate names while still releasing the earlier closer via the caller's slice — acceptable today but documents the load-bearing uniqueness assumption for future readers. M-3 — Added explanatory comment on the rollback-path `_ = c.Close()` line explaining the intentional swallow: the primary Resolver error takes precedence; surfacing a cleanup error would mask the root cause. M-2 was a transitive concern of T2 M-1 (Resolver-export goroutine safety) and was already addressed via the godoc tightening on wfctlhelpers.Resolver in commit ecafd769f. No behavior change. Verified: go build ./... clean, go test ./iac/wfctlhelpers/... green. Co-Authored-By: Claude Opus 4.7 --- iac/wfctlhelpers/provider.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/iac/wfctlhelpers/provider.go b/iac/wfctlhelpers/provider.go index e7f89227..c8ff608d 100644 --- a/iac/wfctlhelpers/provider.go +++ b/iac/wfctlhelpers/provider.go @@ -103,6 +103,14 @@ func LoadIaCProviderFromConfig(ctx context.Context, cfgFile string) (interfaces. // they have no handle to release. iac.provider modules missing a // `provider:` field are silently skipped (consistent with // LoadIaCProviderFromConfig's single-module behavior). +// +// Invariant: cfg.Modules has unique Names — enforced upstream by +// config.LoadFromFile. If two iac.provider modules ever shared a name +// (config-validation bug), the later one would silently overwrite the +// earlier in the map while the earlier's closer still gets released by +// the caller; per code-reviewer T3 M-1 (commit 9dff95246) this is +// acceptable today but worth documenting so future readers know the +// uniqueness assumption is load-bearing. func LoadAllIaCProvidersFromConfig(ctx context.Context, cfgFile string) (map[string]interfaces.IaCProvider, []io.Closer, error) { rawCfg, err := config.LoadFromFile(cfgFile) if err != nil { @@ -119,6 +127,10 @@ func LoadAllIaCProvidersFromConfig(ctx context.Context, cfgFile string) (map[str if err != nil { // Roll back: close every successfully-resolved provider so the // caller does not leak subprocesses it has no handle to release. + // Close errors during rollback are intentionally discarded — the + // primary error from Resolver takes precedence; surfacing a + // cleanup error would mask the root cause. Per code-reviewer T3 + // M-3 (commit 9dff95246). for _, c := range closers { _ = c.Close() } From 814665498bf9016b390aff7204e9ea5777370c2e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:45:23 -0400 Subject: [PATCH 08/34] feat(iac/admin): proto contracts for typed admin service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 4: proto file + generated Go bindings for the host-side infra.admin module's typed HTTP surface. Sole wire format is protojson over HTTP. v1 is read-only. Package: workflow.iac.v1 Go package: github.com/GoCodeAlone/workflow/iac/admin/proto;adminpb Service shape (5 RPCs): - ListResources(AdminListResourcesInput) -> AdminListResourcesOutput - GetResource(AdminGetResourceInput) -> AdminGetResourceOutput - ListResourceTypes(AdminListResourceTypesInput) -> ...Output - ListProviders(AdminListProvidersInput) -> AdminListProvidersOutput - GenerateConfig(AdminGenerateConfigInput) -> ...Output The HTTP audit-tail endpoint (GET /api/infra-admin/audit) streams ndjson of AdminAuditEntry OUTSIDE this gRPC service per design doc §Access logging — no AuditTail RPC. The plan task description noted "6 RPC services" which appears to be off-by-one against the design's 5-RPC InfraAdminService block; the design is authoritative. Hard invariants encoded in the proto comments: - Every typed input carries AdminAuthzEvidence; read endpoints default-deny without evidence.authz_checked && authz_allowed. - Free-form per-resource AppliedConfig / Outputs payloads cross the wire as `bytes _json`; the handler owns the serialization shape. Same pattern as plugin/external/proto/iac.proto. - error field uses tag 100 as the uniform discriminator across output messages. Tests added (TDD): - TestAdminListResourcesInput_Roundtrip — plan §Step 3 smoke test; protojson round-trips scalar + nested authz evidence. - TestAdminResourceDetail_Roundtrip — pins that bytes-shaped applied_config_json + outputs_json fields survive protojson without base64-misinterpretation; covers SensitiveOutputsRedacted slice. - TestAdminGenerateConfigInput_FieldValuesMap — protojson map handling for the form-builder submission. - TestAdminListResourcesOutput_ErrorField — discriminator tag-100 convention pinned for generic decoder sniffing. Generated via: protoc --go_out=. --go_opt=paths=source_relative \ iac/admin/proto/infra_admin.proto (protoc 35.0, protoc-gen-go v1.36.11) Verified: - GOWORK=off go test ./iac/admin/proto/... green (4 tests). - GOWORK=off go build ./... clean. - golangci-lint --new-from-rev=origin/main -> 0 issues. Co-Authored-By: Claude Opus 4.7 --- iac/admin/proto/infra_admin.pb.go | 1547 +++++++++++++++++++++++ iac/admin/proto/infra_admin.proto | 223 ++++ iac/admin/proto/proto_roundtrip_test.go | 159 +++ 3 files changed, 1929 insertions(+) create mode 100644 iac/admin/proto/infra_admin.pb.go create mode 100644 iac/admin/proto/infra_admin.proto create mode 100644 iac/admin/proto/proto_roundtrip_test.go diff --git a/iac/admin/proto/infra_admin.pb.go b/iac/admin/proto/infra_admin.pb.go new file mode 100644 index 00000000..67abd244 --- /dev/null +++ b/iac/admin/proto/infra_admin.pb.go @@ -0,0 +1,1547 @@ +// infra_admin.proto — typed contracts for the host-side infra.admin +// workflow module's HTTP surface. Sole serialization shape is +// protojson over HTTP. v1 is read-only — mutations remain in +// `wfctl infra apply/destroy/drift`. +// +// Design: docs/plans/2026-05-27-infra-admin-dynamic-design.md §Strict Proto Contracts +// Plan: docs/plans/2026-05-27-infra-admin-dynamic.md (Task 4) +// +// Hard invariants: +// - Every typed input carries AdminAuthzEvidence. Read endpoints +// require evidence.authz_checked && evidence.authz_allowed. +// Default-deny semantics. +// - Free-form per-resource AppliedConfig / Outputs payloads cross +// the wire as bytes _json, JSON-encoded by the handler. +// The handler owns the serialization shape; the proto carries +// opaque bytes (same pattern as plugin/external/proto/iac.proto). +// - error field uses tag 100 (uniform discriminator across outputs +// so generic decoders can sniff for a non-empty error before +// consuming the typed payload). +// - The HTTP audit-tail endpoint (GET /api/infra-admin/audit) is +// NOT exposed as a gRPC RPC — it streams ndjson of +// AdminAuditEntry directly per design doc §Access logging. The +// 5 declared RPCs cover every typed read endpoint. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v7.35.0 +// source: iac/admin/proto/infra_admin.proto + +package adminpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// AdminAuthzEvidence carries the host-side authz outcome to handler +// library functions. The handler MUST refuse to serve when +// authz_checked is false (caller bypassed admin auth middleware) or +// authz_allowed is false (caller was denied). Subject + granted +// permissions are recorded in the audit log alongside each request. +type AdminAuthzEvidence struct { + state protoimpl.MessageState `protogen:"open.v1"` + AuthzChecked bool `protobuf:"varint,1,opt,name=authz_checked,json=authzChecked,proto3" json:"authz_checked,omitempty"` + AuthzAllowed bool `protobuf:"varint,2,opt,name=authz_allowed,json=authzAllowed,proto3" json:"authz_allowed,omitempty"` + Subject string `protobuf:"bytes,3,opt,name=subject,proto3" json:"subject,omitempty"` + GrantedPermissions []string `protobuf:"bytes,4,rep,name=granted_permissions,json=grantedPermissions,proto3" json:"granted_permissions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminAuthzEvidence) Reset() { + *x = AdminAuthzEvidence{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminAuthzEvidence) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminAuthzEvidence) ProtoMessage() {} + +func (x *AdminAuthzEvidence) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminAuthzEvidence.ProtoReflect.Descriptor instead. +func (*AdminAuthzEvidence) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{0} +} + +func (x *AdminAuthzEvidence) GetAuthzChecked() bool { + if x != nil { + return x.AuthzChecked + } + return false +} + +func (x *AdminAuthzEvidence) GetAuthzAllowed() bool { + if x != nil { + return x.AuthzAllowed + } + return false +} + +func (x *AdminAuthzEvidence) GetSubject() string { + if x != nil { + return x.Subject + } + return "" +} + +func (x *AdminAuthzEvidence) GetGrantedPermissions() []string { + if x != nil { + return x.GrantedPermissions + } + return nil +} + +// AdminResourceSummary is the table-row shape for the resources list +// page. provider_module is the host YAML `name:` of the iac.provider +// module that owns this resource; provider_type is the underlying +// cloud provider string (aws/digitalocean/etc) read from that +// module's config.provider field at module Init time. +type AdminResourceSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` // "infra.vpc" + ProviderModule string `protobuf:"bytes,3,opt,name=provider_module,json=providerModule,proto3" json:"provider_module,omitempty"` // host module name (the YAML `name:`) + ProviderType string `protobuf:"bytes,4,opt,name=provider_type,json=providerType,proto3" json:"provider_type,omitempty"` // from the iac.provider module's config `provider:` field + ProviderId string `protobuf:"bytes,5,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"` + UpdatedAtUnix int64 `protobuf:"varint,7,opt,name=updated_at_unix,json=updatedAtUnix,proto3" json:"updated_at_unix,omitempty"` + Dependencies []string `protobuf:"bytes,8,rep,name=dependencies,proto3" json:"dependencies,omitempty"` + AppContext string `protobuf:"bytes,9,opt,name=app_context,json=appContext,proto3" json:"app_context,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminResourceSummary) Reset() { + *x = AdminResourceSummary{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminResourceSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminResourceSummary) ProtoMessage() {} + +func (x *AdminResourceSummary) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminResourceSummary.ProtoReflect.Descriptor instead. +func (*AdminResourceSummary) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{1} +} + +func (x *AdminResourceSummary) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AdminResourceSummary) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *AdminResourceSummary) GetProviderModule() string { + if x != nil { + return x.ProviderModule + } + return "" +} + +func (x *AdminResourceSummary) GetProviderType() string { + if x != nil { + return x.ProviderType + } + return "" +} + +func (x *AdminResourceSummary) GetProviderId() string { + if x != nil { + return x.ProviderId + } + return "" +} + +func (x *AdminResourceSummary) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *AdminResourceSummary) GetUpdatedAtUnix() int64 { + if x != nil { + return x.UpdatedAtUnix + } + return 0 +} + +func (x *AdminResourceSummary) GetDependencies() []string { + if x != nil { + return x.Dependencies + } + return nil +} + +func (x *AdminResourceSummary) GetAppContext() string { + if x != nil { + return x.AppContext + } + return "" +} + +// AdminResourceDetail extends the summary with the full per-resource +// state payload. applied_config_json + outputs_json are JSON-encoded +// by the handler; sensitive_outputs_redacted lists output keys whose +// values were stripped from outputs_json before serialization (the +// raw key names are safe to surface; the values are not). +type AdminResourceDetail struct { + state protoimpl.MessageState `protogen:"open.v1"` + Summary *AdminResourceSummary `protobuf:"bytes,1,opt,name=summary,proto3" json:"summary,omitempty"` + AppliedConfigJson []byte `protobuf:"bytes,2,opt,name=applied_config_json,json=appliedConfigJson,proto3" json:"applied_config_json,omitempty"` + OutputsJson []byte `protobuf:"bytes,3,opt,name=outputs_json,json=outputsJson,proto3" json:"outputs_json,omitempty"` + ConfigHash string `protobuf:"bytes,4,opt,name=config_hash,json=configHash,proto3" json:"config_hash,omitempty"` + LastDriftCheckUnix int64 `protobuf:"varint,5,opt,name=last_drift_check_unix,json=lastDriftCheckUnix,proto3" json:"last_drift_check_unix,omitempty"` + SensitiveOutputsRedacted []string `protobuf:"bytes,6,rep,name=sensitive_outputs_redacted,json=sensitiveOutputsRedacted,proto3" json:"sensitive_outputs_redacted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminResourceDetail) Reset() { + *x = AdminResourceDetail{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminResourceDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminResourceDetail) ProtoMessage() {} + +func (x *AdminResourceDetail) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminResourceDetail.ProtoReflect.Descriptor instead. +func (*AdminResourceDetail) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{2} +} + +func (x *AdminResourceDetail) GetSummary() *AdminResourceSummary { + if x != nil { + return x.Summary + } + return nil +} + +func (x *AdminResourceDetail) GetAppliedConfigJson() []byte { + if x != nil { + return x.AppliedConfigJson + } + return nil +} + +func (x *AdminResourceDetail) GetOutputsJson() []byte { + if x != nil { + return x.OutputsJson + } + return nil +} + +func (x *AdminResourceDetail) GetConfigHash() string { + if x != nil { + return x.ConfigHash + } + return "" +} + +func (x *AdminResourceDetail) GetLastDriftCheckUnix() int64 { + if x != nil { + return x.LastDriftCheckUnix + } + return 0 +} + +func (x *AdminResourceDetail) GetSensitiveOutputsRedacted() []string { + if x != nil { + return x.SensitiveOutputsRedacted + } + return nil +} + +type AdminListResourcesInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + TypeFilter string `protobuf:"bytes,1,opt,name=type_filter,json=typeFilter,proto3" json:"type_filter,omitempty"` + ProviderFilter string `protobuf:"bytes,2,opt,name=provider_filter,json=providerFilter,proto3" json:"provider_filter,omitempty"` + AppContextFilter string `protobuf:"bytes,3,opt,name=app_context_filter,json=appContextFilter,proto3" json:"app_context_filter,omitempty"` + EnvName string `protobuf:"bytes,4,opt,name=env_name,json=envName,proto3" json:"env_name,omitempty"` + Evidence *AdminAuthzEvidence `protobuf:"bytes,5,opt,name=evidence,proto3" json:"evidence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminListResourcesInput) Reset() { + *x = AdminListResourcesInput{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminListResourcesInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminListResourcesInput) ProtoMessage() {} + +func (x *AdminListResourcesInput) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminListResourcesInput.ProtoReflect.Descriptor instead. +func (*AdminListResourcesInput) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{3} +} + +func (x *AdminListResourcesInput) GetTypeFilter() string { + if x != nil { + return x.TypeFilter + } + return "" +} + +func (x *AdminListResourcesInput) GetProviderFilter() string { + if x != nil { + return x.ProviderFilter + } + return "" +} + +func (x *AdminListResourcesInput) GetAppContextFilter() string { + if x != nil { + return x.AppContextFilter + } + return "" +} + +func (x *AdminListResourcesInput) GetEnvName() string { + if x != nil { + return x.EnvName + } + return "" +} + +func (x *AdminListResourcesInput) GetEvidence() *AdminAuthzEvidence { + if x != nil { + return x.Evidence + } + return nil +} + +type AdminListResourcesOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Resources []*AdminResourceSummary `protobuf:"bytes,1,rep,name=resources,proto3" json:"resources,omitempty"` + Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminListResourcesOutput) Reset() { + *x = AdminListResourcesOutput{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminListResourcesOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminListResourcesOutput) ProtoMessage() {} + +func (x *AdminListResourcesOutput) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminListResourcesOutput.ProtoReflect.Descriptor instead. +func (*AdminListResourcesOutput) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{4} +} + +func (x *AdminListResourcesOutput) GetResources() []*AdminResourceSummary { + if x != nil { + return x.Resources + } + return nil +} + +func (x *AdminListResourcesOutput) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type AdminGetResourceInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + EnvName string `protobuf:"bytes,2,opt,name=env_name,json=envName,proto3" json:"env_name,omitempty"` + Evidence *AdminAuthzEvidence `protobuf:"bytes,3,opt,name=evidence,proto3" json:"evidence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminGetResourceInput) Reset() { + *x = AdminGetResourceInput{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminGetResourceInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminGetResourceInput) ProtoMessage() {} + +func (x *AdminGetResourceInput) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminGetResourceInput.ProtoReflect.Descriptor instead. +func (*AdminGetResourceInput) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{5} +} + +func (x *AdminGetResourceInput) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AdminGetResourceInput) GetEnvName() string { + if x != nil { + return x.EnvName + } + return "" +} + +func (x *AdminGetResourceInput) GetEvidence() *AdminAuthzEvidence { + if x != nil { + return x.Evidence + } + return nil +} + +type AdminGetResourceOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Resource *AdminResourceDetail `protobuf:"bytes,1,opt,name=resource,proto3" json:"resource,omitempty"` + Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminGetResourceOutput) Reset() { + *x = AdminGetResourceOutput{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminGetResourceOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminGetResourceOutput) ProtoMessage() {} + +func (x *AdminGetResourceOutput) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminGetResourceOutput.ProtoReflect.Descriptor instead. +func (*AdminGetResourceOutput) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{6} +} + +func (x *AdminGetResourceOutput) GetResource() *AdminResourceDetail { + if x != nil { + return x.Resource + } + return nil +} + +func (x *AdminGetResourceOutput) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// AdminFieldSpec describes one field of a typed infra.* Config so the +// new-resource form-builder can render the right input control. +// - kind: one of {enum, enum_dynamic, string, number, bool, +// array_string, array_object, object} — see catalog/fields.go. +// - enum_source (only for kind == enum_dynamic): "providers" | +// "regions" | "sizes" | "engines" — the form fetches dynamic +// options at render time. +// - depends_on_field: when set, this field's options are filtered +// by the value the user picked for depends_on_field (e.g. region +// options depend on provider). +// - element_kind: only meaningful for array_* kinds. +// - sensitive: when true, the form renders a masked input AND the +// value is excluded from any rendered preview. +type AdminFieldSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"` + Kind string `protobuf:"bytes,3,opt,name=kind,proto3" json:"kind,omitempty"` + Required bool `protobuf:"varint,4,opt,name=required,proto3" json:"required,omitempty"` + EnumValues []string `protobuf:"bytes,5,rep,name=enum_values,json=enumValues,proto3" json:"enum_values,omitempty"` + EnumSource string `protobuf:"bytes,6,opt,name=enum_source,json=enumSource,proto3" json:"enum_source,omitempty"` + Description string `protobuf:"bytes,7,opt,name=description,proto3" json:"description,omitempty"` + DefaultValue string `protobuf:"bytes,8,opt,name=default_value,json=defaultValue,proto3" json:"default_value,omitempty"` + Sensitive bool `protobuf:"varint,9,opt,name=sensitive,proto3" json:"sensitive,omitempty"` + ElementKind string `protobuf:"bytes,10,opt,name=element_kind,json=elementKind,proto3" json:"element_kind,omitempty"` + MinCount int32 `protobuf:"varint,11,opt,name=min_count,json=minCount,proto3" json:"min_count,omitempty"` + MaxCount int32 `protobuf:"varint,12,opt,name=max_count,json=maxCount,proto3" json:"max_count,omitempty"` + DependsOnField string `protobuf:"bytes,13,opt,name=depends_on_field,json=dependsOnField,proto3" json:"depends_on_field,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminFieldSpec) Reset() { + *x = AdminFieldSpec{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminFieldSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminFieldSpec) ProtoMessage() {} + +func (x *AdminFieldSpec) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminFieldSpec.ProtoReflect.Descriptor instead. +func (*AdminFieldSpec) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{7} +} + +func (x *AdminFieldSpec) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AdminFieldSpec) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + +func (x *AdminFieldSpec) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *AdminFieldSpec) GetRequired() bool { + if x != nil { + return x.Required + } + return false +} + +func (x *AdminFieldSpec) GetEnumValues() []string { + if x != nil { + return x.EnumValues + } + return nil +} + +func (x *AdminFieldSpec) GetEnumSource() string { + if x != nil { + return x.EnumSource + } + return "" +} + +func (x *AdminFieldSpec) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *AdminFieldSpec) GetDefaultValue() string { + if x != nil { + return x.DefaultValue + } + return "" +} + +func (x *AdminFieldSpec) GetSensitive() bool { + if x != nil { + return x.Sensitive + } + return false +} + +func (x *AdminFieldSpec) GetElementKind() string { + if x != nil { + return x.ElementKind + } + return "" +} + +func (x *AdminFieldSpec) GetMinCount() int32 { + if x != nil { + return x.MinCount + } + return 0 +} + +func (x *AdminFieldSpec) GetMaxCount() int32 { + if x != nil { + return x.MaxCount + } + return 0 +} + +func (x *AdminFieldSpec) GetDependsOnField() string { + if x != nil { + return x.DependsOnField + } + return "" +} + +// AdminResourceTypeMetadata is the form-builder's view of one +// infra.* Config message. config_message_fqn is the fully-qualified +// proto message name in workflow-plugin-infra/internal/contracts/ +// infra.proto (e.g. "workflow.plugin.infra.v1.VPCConfig") so callers +// can correlate against the vendored proto descriptor. +type AdminResourceTypeMetadata struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + ConfigMessageFqn string `protobuf:"bytes,2,opt,name=config_message_fqn,json=configMessageFqn,proto3" json:"config_message_fqn,omitempty"` + Fields []*AdminFieldSpec `protobuf:"bytes,3,rep,name=fields,proto3" json:"fields,omitempty"` + SupportedProviders []string `protobuf:"bytes,4,rep,name=supported_providers,json=supportedProviders,proto3" json:"supported_providers,omitempty"` + Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminResourceTypeMetadata) Reset() { + *x = AdminResourceTypeMetadata{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminResourceTypeMetadata) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminResourceTypeMetadata) ProtoMessage() {} + +func (x *AdminResourceTypeMetadata) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminResourceTypeMetadata.ProtoReflect.Descriptor instead. +func (*AdminResourceTypeMetadata) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{8} +} + +func (x *AdminResourceTypeMetadata) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *AdminResourceTypeMetadata) GetConfigMessageFqn() string { + if x != nil { + return x.ConfigMessageFqn + } + return "" +} + +func (x *AdminResourceTypeMetadata) GetFields() []*AdminFieldSpec { + if x != nil { + return x.Fields + } + return nil +} + +func (x *AdminResourceTypeMetadata) GetSupportedProviders() []string { + if x != nil { + return x.SupportedProviders + } + return nil +} + +func (x *AdminResourceTypeMetadata) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +type AdminListResourceTypesInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProviderFilter string `protobuf:"bytes,1,opt,name=provider_filter,json=providerFilter,proto3" json:"provider_filter,omitempty"` + Evidence *AdminAuthzEvidence `protobuf:"bytes,2,opt,name=evidence,proto3" json:"evidence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminListResourceTypesInput) Reset() { + *x = AdminListResourceTypesInput{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminListResourceTypesInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminListResourceTypesInput) ProtoMessage() {} + +func (x *AdminListResourceTypesInput) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminListResourceTypesInput.ProtoReflect.Descriptor instead. +func (*AdminListResourceTypesInput) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{9} +} + +func (x *AdminListResourceTypesInput) GetProviderFilter() string { + if x != nil { + return x.ProviderFilter + } + return "" +} + +func (x *AdminListResourceTypesInput) GetEvidence() *AdminAuthzEvidence { + if x != nil { + return x.Evidence + } + return nil +} + +type AdminListResourceTypesOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Types []*AdminResourceTypeMetadata `protobuf:"bytes,1,rep,name=types,proto3" json:"types,omitempty"` + Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminListResourceTypesOutput) Reset() { + *x = AdminListResourceTypesOutput{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminListResourceTypesOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminListResourceTypesOutput) ProtoMessage() {} + +func (x *AdminListResourceTypesOutput) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminListResourceTypesOutput.ProtoReflect.Descriptor instead. +func (*AdminListResourceTypesOutput) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{10} +} + +func (x *AdminListResourceTypesOutput) GetTypes() []*AdminResourceTypeMetadata { + if x != nil { + return x.Types + } + return nil +} + +func (x *AdminListResourceTypesOutput) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// AdminProviderSummary is the ListProviders response row. v1 +// populates supported_regions / supported_types / supported_engines +// from the host-side catalog (regions.go + engines.go + fields.go); +// regions_source is the literal string "local-catalog" so consumers +// can distinguish v1's local lookup from a future v1.1 +// IaCProviderRegionLister gRPC service. +type AdminProviderSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + ModuleName string `protobuf:"bytes,1,opt,name=module_name,json=moduleName,proto3" json:"module_name,omitempty"` + ProviderType string `protobuf:"bytes,2,opt,name=provider_type,json=providerType,proto3" json:"provider_type,omitempty"` + Capabilities []string `protobuf:"bytes,3,rep,name=capabilities,proto3" json:"capabilities,omitempty"` + SupportedRegions []string `protobuf:"bytes,4,rep,name=supported_regions,json=supportedRegions,proto3" json:"supported_regions,omitempty"` + SupportedTypes []string `protobuf:"bytes,5,rep,name=supported_types,json=supportedTypes,proto3" json:"supported_types,omitempty"` + SupportedEngines []string `protobuf:"bytes,6,rep,name=supported_engines,json=supportedEngines,proto3" json:"supported_engines,omitempty"` + RegionsSource string `protobuf:"bytes,7,opt,name=regions_source,json=regionsSource,proto3" json:"regions_source,omitempty"` // "local-catalog" for v1 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminProviderSummary) Reset() { + *x = AdminProviderSummary{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminProviderSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminProviderSummary) ProtoMessage() {} + +func (x *AdminProviderSummary) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminProviderSummary.ProtoReflect.Descriptor instead. +func (*AdminProviderSummary) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{11} +} + +func (x *AdminProviderSummary) GetModuleName() string { + if x != nil { + return x.ModuleName + } + return "" +} + +func (x *AdminProviderSummary) GetProviderType() string { + if x != nil { + return x.ProviderType + } + return "" +} + +func (x *AdminProviderSummary) GetCapabilities() []string { + if x != nil { + return x.Capabilities + } + return nil +} + +func (x *AdminProviderSummary) GetSupportedRegions() []string { + if x != nil { + return x.SupportedRegions + } + return nil +} + +func (x *AdminProviderSummary) GetSupportedTypes() []string { + if x != nil { + return x.SupportedTypes + } + return nil +} + +func (x *AdminProviderSummary) GetSupportedEngines() []string { + if x != nil { + return x.SupportedEngines + } + return nil +} + +func (x *AdminProviderSummary) GetRegionsSource() string { + if x != nil { + return x.RegionsSource + } + return "" +} + +type AdminListProvidersInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + EnvName string `protobuf:"bytes,1,opt,name=env_name,json=envName,proto3" json:"env_name,omitempty"` + Evidence *AdminAuthzEvidence `protobuf:"bytes,2,opt,name=evidence,proto3" json:"evidence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminListProvidersInput) Reset() { + *x = AdminListProvidersInput{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminListProvidersInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminListProvidersInput) ProtoMessage() {} + +func (x *AdminListProvidersInput) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminListProvidersInput.ProtoReflect.Descriptor instead. +func (*AdminListProvidersInput) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{12} +} + +func (x *AdminListProvidersInput) GetEnvName() string { + if x != nil { + return x.EnvName + } + return "" +} + +func (x *AdminListProvidersInput) GetEvidence() *AdminAuthzEvidence { + if x != nil { + return x.Evidence + } + return nil +} + +type AdminListProvidersOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Providers []*AdminProviderSummary `protobuf:"bytes,1,rep,name=providers,proto3" json:"providers,omitempty"` + Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminListProvidersOutput) Reset() { + *x = AdminListProvidersOutput{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminListProvidersOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminListProvidersOutput) ProtoMessage() {} + +func (x *AdminListProvidersOutput) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminListProvidersOutput.ProtoReflect.Descriptor instead. +func (*AdminListProvidersOutput) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{13} +} + +func (x *AdminListProvidersOutput) GetProviders() []*AdminProviderSummary { + if x != nil { + return x.Providers + } + return nil +} + +func (x *AdminListProvidersOutput) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// AdminGenerateConfigInput carries the form-builder submission. +// field_values is keyed by AdminFieldSpec.name; values are always +// string-encoded (the catalog's kind metadata is the authority for +// coercion to proto types). resource_name is the host YAML `name:` +// the user picked; provider_module references one of the +// AdminProviderSummary.module_name values returned by ListProviders. +type AdminGenerateConfigInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + ResourceType string `protobuf:"bytes,1,opt,name=resource_type,json=resourceType,proto3" json:"resource_type,omitempty"` + ResourceName string `protobuf:"bytes,2,opt,name=resource_name,json=resourceName,proto3" json:"resource_name,omitempty"` + ProviderModule string `protobuf:"bytes,3,opt,name=provider_module,json=providerModule,proto3" json:"provider_module,omitempty"` + FieldValues map[string]string `protobuf:"bytes,4,rep,name=field_values,json=fieldValues,proto3" json:"field_values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Evidence *AdminAuthzEvidence `protobuf:"bytes,5,opt,name=evidence,proto3" json:"evidence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminGenerateConfigInput) Reset() { + *x = AdminGenerateConfigInput{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminGenerateConfigInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminGenerateConfigInput) ProtoMessage() {} + +func (x *AdminGenerateConfigInput) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminGenerateConfigInput.ProtoReflect.Descriptor instead. +func (*AdminGenerateConfigInput) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{14} +} + +func (x *AdminGenerateConfigInput) GetResourceType() string { + if x != nil { + return x.ResourceType + } + return "" +} + +func (x *AdminGenerateConfigInput) GetResourceName() string { + if x != nil { + return x.ResourceName + } + return "" +} + +func (x *AdminGenerateConfigInput) GetProviderModule() string { + if x != nil { + return x.ProviderModule + } + return "" +} + +func (x *AdminGenerateConfigInput) GetFieldValues() map[string]string { + if x != nil { + return x.FieldValues + } + return nil +} + +func (x *AdminGenerateConfigInput) GetEvidence() *AdminAuthzEvidence { + if x != nil { + return x.Evidence + } + return nil +} + +type AdminGenerateConfigOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + YamlSnippet string `protobuf:"bytes,1,opt,name=yaml_snippet,json=yamlSnippet,proto3" json:"yaml_snippet,omitempty"` + ValidationErrors []string `protobuf:"bytes,2,rep,name=validation_errors,json=validationErrors,proto3" json:"validation_errors,omitempty"` + Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminGenerateConfigOutput) Reset() { + *x = AdminGenerateConfigOutput{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminGenerateConfigOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminGenerateConfigOutput) ProtoMessage() {} + +func (x *AdminGenerateConfigOutput) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminGenerateConfigOutput.ProtoReflect.Descriptor instead. +func (*AdminGenerateConfigOutput) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{15} +} + +func (x *AdminGenerateConfigOutput) GetYamlSnippet() string { + if x != nil { + return x.YamlSnippet + } + return "" +} + +func (x *AdminGenerateConfigOutput) GetValidationErrors() []string { + if x != nil { + return x.ValidationErrors + } + return nil +} + +func (x *AdminGenerateConfigOutput) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// AdminAuditEntry is the line shape for the audit log (one entry per +// non-noop admin action) AND the streaming response shape of the +// HTTP audit-tail endpoint (GET /api/infra-admin/audit). schema_version +// starts at 1; bumps are additive (new fields) until a breaking +// change forces a major. +type AdminAuditEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + SchemaVersion int32 `protobuf:"varint,1,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + TsUnix int64 `protobuf:"varint,2,opt,name=ts_unix,json=tsUnix,proto3" json:"ts_unix,omitempty"` + Subject string `protobuf:"bytes,3,opt,name=subject,proto3" json:"subject,omitempty"` + Action string `protobuf:"bytes,4,opt,name=action,proto3" json:"action,omitempty"` + Targets []string `protobuf:"bytes,5,rep,name=targets,proto3" json:"targets,omitempty"` + Result string `protobuf:"bytes,6,opt,name=result,proto3" json:"result,omitempty"` + AppContext string `protobuf:"bytes,7,opt,name=app_context,json=appContext,proto3" json:"app_context,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminAuditEntry) Reset() { + *x = AdminAuditEntry{} + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminAuditEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminAuditEntry) ProtoMessage() {} + +func (x *AdminAuditEntry) ProtoReflect() protoreflect.Message { + mi := &file_iac_admin_proto_infra_admin_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminAuditEntry.ProtoReflect.Descriptor instead. +func (*AdminAuditEntry) Descriptor() ([]byte, []int) { + return file_iac_admin_proto_infra_admin_proto_rawDescGZIP(), []int{16} +} + +func (x *AdminAuditEntry) GetSchemaVersion() int32 { + if x != nil { + return x.SchemaVersion + } + return 0 +} + +func (x *AdminAuditEntry) GetTsUnix() int64 { + if x != nil { + return x.TsUnix + } + return 0 +} + +func (x *AdminAuditEntry) GetSubject() string { + if x != nil { + return x.Subject + } + return "" +} + +func (x *AdminAuditEntry) GetAction() string { + if x != nil { + return x.Action + } + return "" +} + +func (x *AdminAuditEntry) GetTargets() []string { + if x != nil { + return x.Targets + } + return nil +} + +func (x *AdminAuditEntry) GetResult() string { + if x != nil { + return x.Result + } + return "" +} + +func (x *AdminAuditEntry) GetAppContext() string { + if x != nil { + return x.AppContext + } + return "" +} + +var File_iac_admin_proto_infra_admin_proto protoreflect.FileDescriptor + +const file_iac_admin_proto_infra_admin_proto_rawDesc = "" + + "\n" + + "!iac/admin/proto/infra_admin.proto\x12\x0fworkflow.iac.v1\"\xa9\x01\n" + + "\x12AdminAuthzEvidence\x12#\n" + + "\rauthz_checked\x18\x01 \x01(\bR\fauthzChecked\x12#\n" + + "\rauthz_allowed\x18\x02 \x01(\bR\fauthzAllowed\x12\x18\n" + + "\asubject\x18\x03 \x01(\tR\asubject\x12/\n" + + "\x13granted_permissions\x18\x04 \x03(\tR\x12grantedPermissions\"\xb2\x02\n" + + "\x14AdminResourceSummary\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12'\n" + + "\x0fprovider_module\x18\x03 \x01(\tR\x0eproviderModule\x12#\n" + + "\rprovider_type\x18\x04 \x01(\tR\fproviderType\x12\x1f\n" + + "\vprovider_id\x18\x05 \x01(\tR\n" + + "providerId\x12\x16\n" + + "\x06status\x18\x06 \x01(\tR\x06status\x12&\n" + + "\x0fupdated_at_unix\x18\a \x01(\x03R\rupdatedAtUnix\x12\"\n" + + "\fdependencies\x18\b \x03(\tR\fdependencies\x12\x1f\n" + + "\vapp_context\x18\t \x01(\tR\n" + + "appContext\"\xbb\x02\n" + + "\x13AdminResourceDetail\x12?\n" + + "\asummary\x18\x01 \x01(\v2%.workflow.iac.v1.AdminResourceSummaryR\asummary\x12.\n" + + "\x13applied_config_json\x18\x02 \x01(\fR\x11appliedConfigJson\x12!\n" + + "\foutputs_json\x18\x03 \x01(\fR\voutputsJson\x12\x1f\n" + + "\vconfig_hash\x18\x04 \x01(\tR\n" + + "configHash\x121\n" + + "\x15last_drift_check_unix\x18\x05 \x01(\x03R\x12lastDriftCheckUnix\x12<\n" + + "\x1asensitive_outputs_redacted\x18\x06 \x03(\tR\x18sensitiveOutputsRedacted\"\xed\x01\n" + + "\x17AdminListResourcesInput\x12\x1f\n" + + "\vtype_filter\x18\x01 \x01(\tR\n" + + "typeFilter\x12'\n" + + "\x0fprovider_filter\x18\x02 \x01(\tR\x0eproviderFilter\x12,\n" + + "\x12app_context_filter\x18\x03 \x01(\tR\x10appContextFilter\x12\x19\n" + + "\benv_name\x18\x04 \x01(\tR\aenvName\x12?\n" + + "\bevidence\x18\x05 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidence\"u\n" + + "\x18AdminListResourcesOutput\x12C\n" + + "\tresources\x18\x01 \x03(\v2%.workflow.iac.v1.AdminResourceSummaryR\tresources\x12\x14\n" + + "\x05error\x18d \x01(\tR\x05error\"\x87\x01\n" + + "\x15AdminGetResourceInput\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + + "\benv_name\x18\x02 \x01(\tR\aenvName\x12?\n" + + "\bevidence\x18\x03 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidence\"p\n" + + "\x16AdminGetResourceOutput\x12@\n" + + "\bresource\x18\x01 \x01(\v2$.workflow.iac.v1.AdminResourceDetailR\bresource\x12\x14\n" + + "\x05error\x18d \x01(\tR\x05error\"\x98\x03\n" + + "\x0eAdminFieldSpec\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05label\x18\x02 \x01(\tR\x05label\x12\x12\n" + + "\x04kind\x18\x03 \x01(\tR\x04kind\x12\x1a\n" + + "\brequired\x18\x04 \x01(\bR\brequired\x12\x1f\n" + + "\venum_values\x18\x05 \x03(\tR\n" + + "enumValues\x12\x1f\n" + + "\venum_source\x18\x06 \x01(\tR\n" + + "enumSource\x12 \n" + + "\vdescription\x18\a \x01(\tR\vdescription\x12#\n" + + "\rdefault_value\x18\b \x01(\tR\fdefaultValue\x12\x1c\n" + + "\tsensitive\x18\t \x01(\bR\tsensitive\x12!\n" + + "\felement_kind\x18\n" + + " \x01(\tR\velementKind\x12\x1b\n" + + "\tmin_count\x18\v \x01(\x05R\bminCount\x12\x1b\n" + + "\tmax_count\x18\f \x01(\x05R\bmaxCount\x12(\n" + + "\x10depends_on_field\x18\r \x01(\tR\x0edependsOnField\"\xe9\x01\n" + + "\x19AdminResourceTypeMetadata\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12,\n" + + "\x12config_message_fqn\x18\x02 \x01(\tR\x10configMessageFqn\x127\n" + + "\x06fields\x18\x03 \x03(\v2\x1f.workflow.iac.v1.AdminFieldSpecR\x06fields\x12/\n" + + "\x13supported_providers\x18\x04 \x03(\tR\x12supportedProviders\x12 \n" + + "\vdescription\x18\x05 \x01(\tR\vdescription\"\x87\x01\n" + + "\x1bAdminListResourceTypesInput\x12'\n" + + "\x0fprovider_filter\x18\x01 \x01(\tR\x0eproviderFilter\x12?\n" + + "\bevidence\x18\x02 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidence\"v\n" + + "\x1cAdminListResourceTypesOutput\x12@\n" + + "\x05types\x18\x01 \x03(\v2*.workflow.iac.v1.AdminResourceTypeMetadataR\x05types\x12\x14\n" + + "\x05error\x18d \x01(\tR\x05error\"\xaa\x02\n" + + "\x14AdminProviderSummary\x12\x1f\n" + + "\vmodule_name\x18\x01 \x01(\tR\n" + + "moduleName\x12#\n" + + "\rprovider_type\x18\x02 \x01(\tR\fproviderType\x12\"\n" + + "\fcapabilities\x18\x03 \x03(\tR\fcapabilities\x12+\n" + + "\x11supported_regions\x18\x04 \x03(\tR\x10supportedRegions\x12'\n" + + "\x0fsupported_types\x18\x05 \x03(\tR\x0esupportedTypes\x12+\n" + + "\x11supported_engines\x18\x06 \x03(\tR\x10supportedEngines\x12%\n" + + "\x0eregions_source\x18\a \x01(\tR\rregionsSource\"u\n" + + "\x17AdminListProvidersInput\x12\x19\n" + + "\benv_name\x18\x01 \x01(\tR\aenvName\x12?\n" + + "\bevidence\x18\x02 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidence\"u\n" + + "\x18AdminListProvidersOutput\x12C\n" + + "\tproviders\x18\x01 \x03(\v2%.workflow.iac.v1.AdminProviderSummaryR\tproviders\x12\x14\n" + + "\x05error\x18d \x01(\tR\x05error\"\xed\x02\n" + + "\x18AdminGenerateConfigInput\x12#\n" + + "\rresource_type\x18\x01 \x01(\tR\fresourceType\x12#\n" + + "\rresource_name\x18\x02 \x01(\tR\fresourceName\x12'\n" + + "\x0fprovider_module\x18\x03 \x01(\tR\x0eproviderModule\x12]\n" + + "\ffield_values\x18\x04 \x03(\v2:.workflow.iac.v1.AdminGenerateConfigInput.FieldValuesEntryR\vfieldValues\x12?\n" + + "\bevidence\x18\x05 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidence\x1a>\n" + + "\x10FieldValuesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x81\x01\n" + + "\x19AdminGenerateConfigOutput\x12!\n" + + "\fyaml_snippet\x18\x01 \x01(\tR\vyamlSnippet\x12+\n" + + "\x11validation_errors\x18\x02 \x03(\tR\x10validationErrors\x12\x14\n" + + "\x05error\x18d \x01(\tR\x05error\"\xd6\x01\n" + + "\x0fAdminAuditEntry\x12%\n" + + "\x0eschema_version\x18\x01 \x01(\x05R\rschemaVersion\x12\x17\n" + + "\ats_unix\x18\x02 \x01(\x03R\x06tsUnix\x12\x18\n" + + "\asubject\x18\x03 \x01(\tR\asubject\x12\x16\n" + + "\x06action\x18\x04 \x01(\tR\x06action\x12\x18\n" + + "\atargets\x18\x05 \x03(\tR\atargets\x12\x16\n" + + "\x06result\x18\x06 \x01(\tR\x06result\x12\x1f\n" + + "\vapp_context\x18\a \x01(\tR\n" + + "appContext2\x9a\x04\n" + + "\x11InfraAdminService\x12d\n" + + "\rListResources\x12(.workflow.iac.v1.AdminListResourcesInput\x1a).workflow.iac.v1.AdminListResourcesOutput\x12^\n" + + "\vGetResource\x12&.workflow.iac.v1.AdminGetResourceInput\x1a'.workflow.iac.v1.AdminGetResourceOutput\x12p\n" + + "\x11ListResourceTypes\x12,.workflow.iac.v1.AdminListResourceTypesInput\x1a-.workflow.iac.v1.AdminListResourceTypesOutput\x12d\n" + + "\rListProviders\x12(.workflow.iac.v1.AdminListProvidersInput\x1a).workflow.iac.v1.AdminListProvidersOutput\x12g\n" + + "\x0eGenerateConfig\x12).workflow.iac.v1.AdminGenerateConfigInput\x1a*.workflow.iac.v1.AdminGenerateConfigOutputB9Z7github.com/GoCodeAlone/workflow/iac/admin/proto;adminpbb\x06proto3" + +var ( + file_iac_admin_proto_infra_admin_proto_rawDescOnce sync.Once + file_iac_admin_proto_infra_admin_proto_rawDescData []byte +) + +func file_iac_admin_proto_infra_admin_proto_rawDescGZIP() []byte { + file_iac_admin_proto_infra_admin_proto_rawDescOnce.Do(func() { + file_iac_admin_proto_infra_admin_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_iac_admin_proto_infra_admin_proto_rawDesc), len(file_iac_admin_proto_infra_admin_proto_rawDesc))) + }) + return file_iac_admin_proto_infra_admin_proto_rawDescData +} + +var file_iac_admin_proto_infra_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 18) +var file_iac_admin_proto_infra_admin_proto_goTypes = []any{ + (*AdminAuthzEvidence)(nil), // 0: workflow.iac.v1.AdminAuthzEvidence + (*AdminResourceSummary)(nil), // 1: workflow.iac.v1.AdminResourceSummary + (*AdminResourceDetail)(nil), // 2: workflow.iac.v1.AdminResourceDetail + (*AdminListResourcesInput)(nil), // 3: workflow.iac.v1.AdminListResourcesInput + (*AdminListResourcesOutput)(nil), // 4: workflow.iac.v1.AdminListResourcesOutput + (*AdminGetResourceInput)(nil), // 5: workflow.iac.v1.AdminGetResourceInput + (*AdminGetResourceOutput)(nil), // 6: workflow.iac.v1.AdminGetResourceOutput + (*AdminFieldSpec)(nil), // 7: workflow.iac.v1.AdminFieldSpec + (*AdminResourceTypeMetadata)(nil), // 8: workflow.iac.v1.AdminResourceTypeMetadata + (*AdminListResourceTypesInput)(nil), // 9: workflow.iac.v1.AdminListResourceTypesInput + (*AdminListResourceTypesOutput)(nil), // 10: workflow.iac.v1.AdminListResourceTypesOutput + (*AdminProviderSummary)(nil), // 11: workflow.iac.v1.AdminProviderSummary + (*AdminListProvidersInput)(nil), // 12: workflow.iac.v1.AdminListProvidersInput + (*AdminListProvidersOutput)(nil), // 13: workflow.iac.v1.AdminListProvidersOutput + (*AdminGenerateConfigInput)(nil), // 14: workflow.iac.v1.AdminGenerateConfigInput + (*AdminGenerateConfigOutput)(nil), // 15: workflow.iac.v1.AdminGenerateConfigOutput + (*AdminAuditEntry)(nil), // 16: workflow.iac.v1.AdminAuditEntry + nil, // 17: workflow.iac.v1.AdminGenerateConfigInput.FieldValuesEntry +} +var file_iac_admin_proto_infra_admin_proto_depIdxs = []int32{ + 1, // 0: workflow.iac.v1.AdminResourceDetail.summary:type_name -> workflow.iac.v1.AdminResourceSummary + 0, // 1: workflow.iac.v1.AdminListResourcesInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence + 1, // 2: workflow.iac.v1.AdminListResourcesOutput.resources:type_name -> workflow.iac.v1.AdminResourceSummary + 0, // 3: workflow.iac.v1.AdminGetResourceInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence + 2, // 4: workflow.iac.v1.AdminGetResourceOutput.resource:type_name -> workflow.iac.v1.AdminResourceDetail + 7, // 5: workflow.iac.v1.AdminResourceTypeMetadata.fields:type_name -> workflow.iac.v1.AdminFieldSpec + 0, // 6: workflow.iac.v1.AdminListResourceTypesInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence + 8, // 7: workflow.iac.v1.AdminListResourceTypesOutput.types:type_name -> workflow.iac.v1.AdminResourceTypeMetadata + 0, // 8: workflow.iac.v1.AdminListProvidersInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence + 11, // 9: workflow.iac.v1.AdminListProvidersOutput.providers:type_name -> workflow.iac.v1.AdminProviderSummary + 17, // 10: workflow.iac.v1.AdminGenerateConfigInput.field_values:type_name -> workflow.iac.v1.AdminGenerateConfigInput.FieldValuesEntry + 0, // 11: workflow.iac.v1.AdminGenerateConfigInput.evidence:type_name -> workflow.iac.v1.AdminAuthzEvidence + 3, // 12: workflow.iac.v1.InfraAdminService.ListResources:input_type -> workflow.iac.v1.AdminListResourcesInput + 5, // 13: workflow.iac.v1.InfraAdminService.GetResource:input_type -> workflow.iac.v1.AdminGetResourceInput + 9, // 14: workflow.iac.v1.InfraAdminService.ListResourceTypes:input_type -> workflow.iac.v1.AdminListResourceTypesInput + 12, // 15: workflow.iac.v1.InfraAdminService.ListProviders:input_type -> workflow.iac.v1.AdminListProvidersInput + 14, // 16: workflow.iac.v1.InfraAdminService.GenerateConfig:input_type -> workflow.iac.v1.AdminGenerateConfigInput + 4, // 17: workflow.iac.v1.InfraAdminService.ListResources:output_type -> workflow.iac.v1.AdminListResourcesOutput + 6, // 18: workflow.iac.v1.InfraAdminService.GetResource:output_type -> workflow.iac.v1.AdminGetResourceOutput + 10, // 19: workflow.iac.v1.InfraAdminService.ListResourceTypes:output_type -> workflow.iac.v1.AdminListResourceTypesOutput + 13, // 20: workflow.iac.v1.InfraAdminService.ListProviders:output_type -> workflow.iac.v1.AdminListProvidersOutput + 15, // 21: workflow.iac.v1.InfraAdminService.GenerateConfig:output_type -> workflow.iac.v1.AdminGenerateConfigOutput + 17, // [17:22] is the sub-list for method output_type + 12, // [12:17] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name +} + +func init() { file_iac_admin_proto_infra_admin_proto_init() } +func file_iac_admin_proto_infra_admin_proto_init() { + if File_iac_admin_proto_infra_admin_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_iac_admin_proto_infra_admin_proto_rawDesc), len(file_iac_admin_proto_infra_admin_proto_rawDesc)), + NumEnums: 0, + NumMessages: 18, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_iac_admin_proto_infra_admin_proto_goTypes, + DependencyIndexes: file_iac_admin_proto_infra_admin_proto_depIdxs, + MessageInfos: file_iac_admin_proto_infra_admin_proto_msgTypes, + }.Build() + File_iac_admin_proto_infra_admin_proto = out.File + file_iac_admin_proto_infra_admin_proto_goTypes = nil + file_iac_admin_proto_infra_admin_proto_depIdxs = nil +} diff --git a/iac/admin/proto/infra_admin.proto b/iac/admin/proto/infra_admin.proto new file mode 100644 index 00000000..c62b8d5b --- /dev/null +++ b/iac/admin/proto/infra_admin.proto @@ -0,0 +1,223 @@ +// infra_admin.proto — typed contracts for the host-side infra.admin +// workflow module's HTTP surface. Sole serialization shape is +// protojson over HTTP. v1 is read-only — mutations remain in +// `wfctl infra apply/destroy/drift`. +// +// Design: docs/plans/2026-05-27-infra-admin-dynamic-design.md §Strict Proto Contracts +// Plan: docs/plans/2026-05-27-infra-admin-dynamic.md (Task 4) +// +// Hard invariants: +// - Every typed input carries AdminAuthzEvidence. Read endpoints +// require evidence.authz_checked && evidence.authz_allowed. +// Default-deny semantics. +// - Free-form per-resource AppliedConfig / Outputs payloads cross +// the wire as bytes _json, JSON-encoded by the handler. +// The handler owns the serialization shape; the proto carries +// opaque bytes (same pattern as plugin/external/proto/iac.proto). +// - error field uses tag 100 (uniform discriminator across outputs +// so generic decoders can sniff for a non-empty error before +// consuming the typed payload). +// - The HTTP audit-tail endpoint (GET /api/infra-admin/audit) is +// NOT exposed as a gRPC RPC — it streams ndjson of +// AdminAuditEntry directly per design doc §Access logging. The +// 5 declared RPCs cover every typed read endpoint. + +syntax = "proto3"; + +package workflow.iac.v1; + +option go_package = "github.com/GoCodeAlone/workflow/iac/admin/proto;adminpb"; + +// AdminAuthzEvidence carries the host-side authz outcome to handler +// library functions. The handler MUST refuse to serve when +// authz_checked is false (caller bypassed admin auth middleware) or +// authz_allowed is false (caller was denied). Subject + granted +// permissions are recorded in the audit log alongside each request. +message AdminAuthzEvidence { + bool authz_checked = 1; + bool authz_allowed = 2; + string subject = 3; + repeated string granted_permissions = 4; +} + +// AdminResourceSummary is the table-row shape for the resources list +// page. provider_module is the host YAML `name:` of the iac.provider +// module that owns this resource; provider_type is the underlying +// cloud provider string (aws/digitalocean/etc) read from that +// module's config.provider field at module Init time. +message AdminResourceSummary { + string name = 1; + string type = 2; // "infra.vpc" + string provider_module = 3; // host module name (the YAML `name:`) + string provider_type = 4; // from the iac.provider module's config `provider:` field + string provider_id = 5; + string status = 6; + int64 updated_at_unix = 7; + repeated string dependencies = 8; + string app_context = 9; +} + +// AdminResourceDetail extends the summary with the full per-resource +// state payload. applied_config_json + outputs_json are JSON-encoded +// by the handler; sensitive_outputs_redacted lists output keys whose +// values were stripped from outputs_json before serialization (the +// raw key names are safe to surface; the values are not). +message AdminResourceDetail { + AdminResourceSummary summary = 1; + bytes applied_config_json = 2; + bytes outputs_json = 3; + string config_hash = 4; + int64 last_drift_check_unix = 5; + repeated string sensitive_outputs_redacted = 6; +} + +message AdminListResourcesInput { + string type_filter = 1; + string provider_filter = 2; + string app_context_filter = 3; + string env_name = 4; + AdminAuthzEvidence evidence = 5; +} + +message AdminListResourcesOutput { + repeated AdminResourceSummary resources = 1; + string error = 100; +} + +message AdminGetResourceInput { + string name = 1; + string env_name = 2; + AdminAuthzEvidence evidence = 3; +} + +message AdminGetResourceOutput { + AdminResourceDetail resource = 1; + string error = 100; +} + +// AdminFieldSpec describes one field of a typed infra.* Config so the +// new-resource form-builder can render the right input control. +// - kind: one of {enum, enum_dynamic, string, number, bool, +// array_string, array_object, object} — see catalog/fields.go. +// - enum_source (only for kind == enum_dynamic): "providers" | +// "regions" | "sizes" | "engines" — the form fetches dynamic +// options at render time. +// - depends_on_field: when set, this field's options are filtered +// by the value the user picked for depends_on_field (e.g. region +// options depend on provider). +// - element_kind: only meaningful for array_* kinds. +// - sensitive: when true, the form renders a masked input AND the +// value is excluded from any rendered preview. +message AdminFieldSpec { + string name = 1; + string label = 2; + string kind = 3; + bool required = 4; + repeated string enum_values = 5; + string enum_source = 6; + string description = 7; + string default_value = 8; + bool sensitive = 9; + string element_kind = 10; + int32 min_count = 11; + int32 max_count = 12; + string depends_on_field = 13; +} + +// AdminResourceTypeMetadata is the form-builder's view of one +// infra.* Config message. config_message_fqn is the fully-qualified +// proto message name in workflow-plugin-infra/internal/contracts/ +// infra.proto (e.g. "workflow.plugin.infra.v1.VPCConfig") so callers +// can correlate against the vendored proto descriptor. +message AdminResourceTypeMetadata { + string type = 1; + string config_message_fqn = 2; + repeated AdminFieldSpec fields = 3; + repeated string supported_providers = 4; + string description = 5; +} + +message AdminListResourceTypesInput { + string provider_filter = 1; + AdminAuthzEvidence evidence = 2; +} + +message AdminListResourceTypesOutput { + repeated AdminResourceTypeMetadata types = 1; + string error = 100; +} + +// AdminProviderSummary is the ListProviders response row. v1 +// populates supported_regions / supported_types / supported_engines +// from the host-side catalog (regions.go + engines.go + fields.go); +// regions_source is the literal string "local-catalog" so consumers +// can distinguish v1's local lookup from a future v1.1 +// IaCProviderRegionLister gRPC service. +message AdminProviderSummary { + string module_name = 1; + string provider_type = 2; + repeated string capabilities = 3; + repeated string supported_regions = 4; + repeated string supported_types = 5; + repeated string supported_engines = 6; + string regions_source = 7; // "local-catalog" for v1 +} + +message AdminListProvidersInput { + string env_name = 1; + AdminAuthzEvidence evidence = 2; +} + +message AdminListProvidersOutput { + repeated AdminProviderSummary providers = 1; + string error = 100; +} + +// AdminGenerateConfigInput carries the form-builder submission. +// field_values is keyed by AdminFieldSpec.name; values are always +// string-encoded (the catalog's kind metadata is the authority for +// coercion to proto types). resource_name is the host YAML `name:` +// the user picked; provider_module references one of the +// AdminProviderSummary.module_name values returned by ListProviders. +message AdminGenerateConfigInput { + string resource_type = 1; + string resource_name = 2; + string provider_module = 3; + map field_values = 4; + AdminAuthzEvidence evidence = 5; +} + +message AdminGenerateConfigOutput { + string yaml_snippet = 1; + repeated string validation_errors = 2; + string error = 100; +} + +// AdminAuditEntry is the line shape for the audit log (one entry per +// non-noop admin action) AND the streaming response shape of the +// HTTP audit-tail endpoint (GET /api/infra-admin/audit). schema_version +// starts at 1; bumps are additive (new fields) until a breaking +// change forces a major. +message AdminAuditEntry { + int32 schema_version = 1; + int64 ts_unix = 2; + string subject = 3; + string action = 4; + repeated string targets = 5; + string result = 6; + string app_context = 7; +} + +// InfraAdminService is the typed read-only surface. v1 has 5 RPCs; +// the HTTP audit-tail endpoint (GET /api/infra-admin/audit) streams +// AdminAuditEntry ndjson outside of this gRPC service per design doc +// §Access logging. Mutating endpoints (PLAN/APPLY/DESTROY/ +// DRIFT-CHECK) are out of scope for v1 — they remain in +// `wfctl infra apply/destroy/drift`. +service InfraAdminService { + rpc ListResources(AdminListResourcesInput) returns (AdminListResourcesOutput); + rpc GetResource(AdminGetResourceInput) returns (AdminGetResourceOutput); + rpc ListResourceTypes(AdminListResourceTypesInput) returns (AdminListResourceTypesOutput); + rpc ListProviders(AdminListProvidersInput) returns (AdminListProvidersOutput); + rpc GenerateConfig(AdminGenerateConfigInput) returns (AdminGenerateConfigOutput); +} diff --git a/iac/admin/proto/proto_roundtrip_test.go b/iac/admin/proto/proto_roundtrip_test.go new file mode 100644 index 00000000..36ad4fca --- /dev/null +++ b/iac/admin/proto/proto_roundtrip_test.go @@ -0,0 +1,159 @@ +package adminpb_test + +import ( + "testing" + + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "google.golang.org/protobuf/encoding/protojson" +) + +// TestAdminListResourcesInput_Roundtrip is the plan §Task 4 Step 3 +// smoke test: protojson.Marshal + Unmarshal round-trips the typed +// input without losing scalar fields or the nested authz evidence. +// Wire format is protojson per design §Strict Proto Contracts. +func TestAdminListResourcesInput_Roundtrip(t *testing.T) { + in := &adminpb.AdminListResourcesInput{ + TypeFilter: "infra.vpc", + ProviderFilter: "do-provider", + EnvName: "staging", + Evidence: &adminpb.AdminAuthzEvidence{ + AuthzChecked: true, + AuthzAllowed: true, + Subject: "user:alice", + GrantedPermissions: []string{"infra:read"}, + }, + } + bytes, err := protojson.Marshal(in) + if err != nil { + t.Fatalf("protojson.Marshal: %v", err) + } + var out adminpb.AdminListResourcesInput + if err := protojson.Unmarshal(bytes, &out); err != nil { + t.Fatalf("protojson.Unmarshal: %v", err) + } + if out.TypeFilter != "infra.vpc" { + t.Errorf("type_filter lost: got %q", out.TypeFilter) + } + if out.ProviderFilter != "do-provider" { + t.Errorf("provider_filter lost: got %q", out.ProviderFilter) + } + if out.EnvName != "staging" { + t.Errorf("env_name lost: got %q", out.EnvName) + } + if out.Evidence == nil { + t.Fatal("evidence dropped from round-trip") + } + if !out.Evidence.AuthzChecked || !out.Evidence.AuthzAllowed { + t.Errorf("evidence booleans lost: checked=%v allowed=%v", out.Evidence.AuthzChecked, out.Evidence.AuthzAllowed) + } + if out.Evidence.Subject != "user:alice" { + t.Errorf("subject lost: got %q", out.Evidence.Subject) + } + if len(out.Evidence.GrantedPermissions) != 1 || out.Evidence.GrantedPermissions[0] != "infra:read" { + t.Errorf("granted_permissions lost: got %v", out.Evidence.GrantedPermissions) + } +} + +// TestAdminResourceDetail_Roundtrip exercises the bytes-shaped +// applied_config_json + outputs_json fields. The handler library +// JSON-encodes the free-form per-resource payloads into these bytes; +// the test pins that protojson preserves the byte sequence without +// re-encoding as base64-then-misinterpreting on Unmarshal. +func TestAdminResourceDetail_Roundtrip(t *testing.T) { + applied := []byte(`{"region":"nyc3","name":"site-vpc"}`) + outputs := []byte(`{"id":"vpc-abc123"}`) + in := &adminpb.AdminResourceDetail{ + Summary: &adminpb.AdminResourceSummary{ + Name: "site-vpc", + Type: "infra.vpc", + ProviderModule: "do-provider", + ProviderType: "digitalocean", + ProviderId: "vpc-abc123", + Status: "active", + }, + AppliedConfigJson: applied, + OutputsJson: outputs, + ConfigHash: "sha256:deadbeef", + LastDriftCheckUnix: 1716800000, + SensitiveOutputsRedacted: []string{"private_key"}, + } + bytes, err := protojson.Marshal(in) + if err != nil { + t.Fatalf("protojson.Marshal: %v", err) + } + var out adminpb.AdminResourceDetail + if err := protojson.Unmarshal(bytes, &out); err != nil { + t.Fatalf("protojson.Unmarshal: %v", err) + } + if string(out.AppliedConfigJson) != string(applied) { + t.Errorf("applied_config_json mangled: got %q want %q", out.AppliedConfigJson, applied) + } + if string(out.OutputsJson) != string(outputs) { + t.Errorf("outputs_json mangled: got %q want %q", out.OutputsJson, outputs) + } + if out.Summary == nil || out.Summary.Name != "site-vpc" { + t.Errorf("summary lost: %+v", out.Summary) + } + if len(out.SensitiveOutputsRedacted) != 1 || out.SensitiveOutputsRedacted[0] != "private_key" { + t.Errorf("sensitive_outputs_redacted lost: %v", out.SensitiveOutputsRedacted) + } + if out.LastDriftCheckUnix != 1716800000 { + t.Errorf("last_drift_check_unix lost: got %d", out.LastDriftCheckUnix) + } +} + +// TestAdminGenerateConfigInput_FieldValuesMap pins protojson's +// map handling. The form-builder submission is +// keyed by AdminFieldSpec.name; lost keys or value-type coercion +// would silently break catalog-driven config generation. +func TestAdminGenerateConfigInput_FieldValuesMap(t *testing.T) { + in := &adminpb.AdminGenerateConfigInput{ + ResourceType: "infra.vpc", + ResourceName: "site-vpc", + ProviderModule: "do-provider", + FieldValues: map[string]string{ + "region": "nyc3", + "name": "site-vpc", + "ip_range": "10.10.0.0/16", + }, + Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: true}, + } + bytes, err := protojson.Marshal(in) + if err != nil { + t.Fatalf("protojson.Marshal: %v", err) + } + var out adminpb.AdminGenerateConfigInput + if err := protojson.Unmarshal(bytes, &out); err != nil { + t.Fatalf("protojson.Unmarshal: %v", err) + } + if len(out.FieldValues) != 3 { + t.Errorf("field_values size lost: got %d, want 3", len(out.FieldValues)) + } + for k, want := range map[string]string{"region": "nyc3", "name": "site-vpc", "ip_range": "10.10.0.0/16"} { + if got := out.FieldValues[k]; got != want { + t.Errorf("field_values[%q] = %q, want %q", k, got, want) + } + } +} + +// TestAdminListResourcesOutput_ErrorField pins the discriminator +// tag-100 convention: outputs carry a `error` field at tag 100 so +// generic decoders can sniff for a non-empty error before consuming +// the typed payload. +func TestAdminListResourcesOutput_ErrorField(t *testing.T) { + in := &adminpb.AdminListResourcesOutput{Error: "authz denied"} + bytes, err := protojson.Marshal(in) + if err != nil { + t.Fatalf("protojson.Marshal: %v", err) + } + var out adminpb.AdminListResourcesOutput + if err := protojson.Unmarshal(bytes, &out); err != nil { + t.Fatalf("protojson.Unmarshal: %v", err) + } + if out.Error != "authz denied" { + t.Errorf("error lost: got %q", out.Error) + } + if len(out.Resources) != 0 { + t.Errorf("resources should be empty on error response: got %v", out.Resources) + } +} From ff066260243721baa07c47419cd884bdd221b8b1 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:50:05 -0400 Subject: [PATCH 09/34] feat(iac/admin/catalog): T7a package skeleton + FieldSpecCatalog type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 7a: package skeleton for the host-side FieldSpec catalog that drives the new-resource form-builder UI and feeds AdminFieldSpec entries on InfraAdminService.ListResourceTypes (handler library T5/T6). T7a split out of T7 per plan-adversarial C1: Lane A's T5 handler library imports *catalog.FieldSpecCatalog as a typed parameter, so the package + type + New() must exist BEFORE T5 compiles. This skeleton makes T5/T6 buildable while T7b fills the 13 typed-Config entries in catalog/fields.go in parallel after T7a lands. Skeleton API (all from plan §Task 7a): - FieldSpec struct — mirrors workflow.iac.v1.AdminFieldSpec field-for-field with full godoc per field (Name/Label/Kind/ Required/EnumValues/EnumSource/Description/DefaultValue/Sensitive/ ElementKind/MinCount/MaxCount/DependsOnField). - FieldSpecCatalog struct + New() returning an empty catalog. - (*FieldSpecCatalog).Get(typeName) ([]FieldSpec, bool) — defensive copy so callers cannot mutate internal state; returns (nil, false) on unknown type so callers can distinguish missing type from registered-but-empty. - (*FieldSpecCatalog).AllTypes() []string — sorted; deterministic ordering for snapshot tests + diff-friendly downstream consumers. - catalog.FreeformReason(typeName, fieldName) (string, bool) — package-level function exactly per the T7b audit-test signature in plan §Step 3. Seams for T7b: - `var catalogEntries = func() map[string][]FieldSpec {...}` — package-level var T7b's fields.go can replace via direct assignment without touching catalog.go. - `var freeformReasons = map[string]map[string]string{}` — parallel annotation table T7b populates alongside string-kind entries. Tests added (TDD; plan §Task 7a smoke): - TestNew_ReturnsNonNilEmptyCatalog — New() not nil; AllTypes() empty on skeleton. - TestGet_MissingTypeReturnsFalse — Get on unknown type returns (nil, false), not (empty-slice, true). - TestFreeformReason_MissingEntryReturnsFalse — empty annotation table returns ("", false) without panicking. Verified: - GOWORK=off go test ./iac/admin/catalog/... green (3 tests). - GOWORK=off go build ./... clean. - golangci-lint --new-from-rev=origin/main ./iac/admin/... -> 0 issues. Co-Authored-By: Claude Opus 4.7 --- iac/admin/catalog/catalog.go | 180 ++++++++++++++++++++++++++++++ iac/admin/catalog/catalog_test.go | 55 +++++++++ 2 files changed, 235 insertions(+) create mode 100644 iac/admin/catalog/catalog.go create mode 100644 iac/admin/catalog/catalog_test.go diff --git a/iac/admin/catalog/catalog.go b/iac/admin/catalog/catalog.go new file mode 100644 index 00000000..ce493fbc --- /dev/null +++ b/iac/admin/catalog/catalog.go @@ -0,0 +1,180 @@ +// Package catalog hosts the host-side FieldSpec catalog (covers all 13 +// typed `infra.*` Configs from workflow-plugin-infra), the region +// catalog, and the engine catalog. The catalog drives the new-resource +// form-builder UI and feeds the typed AdminFieldSpec entries returned +// by InfraAdminService.ListResourceTypes (handler library T5/T6). +// +// Design: docs/plans/2026-05-27-infra-admin-dynamic-design.md §FieldSpec Catalog +// Plan: docs/plans/2026-05-27-infra-admin-dynamic.md (Task 7a skeleton; T7b entries; T8 region/engine) +// +// This file (T7a) provides the package skeleton: types, the +// FieldSpecCatalog struct, the New() constructor returning an empty +// catalog, and the Get / AllTypes / FreeformReason accessors. T7b +// fills the 13 typed-Config entries in catalog/fields.go in parallel +// after T7a lands. +// +// The skeleton was split out of T7 per plan-adversarial C1: Lane A's +// T5 handler library imports *catalog.FieldSpecCatalog as a typed +// parameter, so the package + type + New() must exist before T5 +// compiles. Splitting T7a → T7b resolves the hidden serial dependency. +package catalog + +import "sort" + +// FieldSpec describes one field of a typed `infra.*` Config so the +// new-resource form-builder UI can render the right input control. +// Mirrors workflow.iac.v1.AdminFieldSpec in iac/admin/proto/ +// infra_admin.proto field-for-field so the handler library (T5/T6) +// can copy between the two without coercion. See the proto file for +// the per-field semantics documentation; this struct's tags double +// as the wire-rename contract for any future protojson-friendly +// serialization. +type FieldSpec struct { + // Name is the YAML key the form-builder submits and the typed + // Config message field name (lower_snake_case to match proto). + Name string + + // Label is the human-readable form-field label. + Label string + + // Kind is one of {"enum", "enum_dynamic", "string", "number", + // "bool", "array_string", "array_object", "object"}. The + // freeform-audit test (T7b) enforces that every "string" / + // "array_string" entry carries a FREEFORM_OK reason via the + // package-level reasons map. + Kind string + + // Required indicates whether the form must collect a value. + Required bool + + // EnumValues is the fixed option list for Kind=="enum". + EnumValues []string + + // EnumSource is the dynamic-options provider for + // Kind=="enum_dynamic": one of {"providers", "regions", "sizes", + // "engines", "resource_types", "app_contexts", "k8s-versions"}. + // The form-builder fetches the options at render time. + EnumSource string + + // Description is the form-field help text (shown as tooltip or + // inline help in the new-resource UI). + Description string + + // DefaultValue is the initial form value (string-encoded per the + // proto's map field_values contract). + DefaultValue string + + // Sensitive indicates the form should render a masked input AND + // the value MUST be excluded from any rendered preview or audit + // log entry. + Sensitive bool + + // ElementKind is the per-element Kind for array_* entries. + ElementKind string + + // MinCount + MaxCount bound array_* entry counts. + MinCount int32 + MaxCount int32 + + // DependsOnField filters this field's enum_dynamic options by the + // value picked for another field (e.g. region options depend on + // the chosen provider). + DependsOnField string +} + +// FieldSpecCatalog is the hand-maintained registry mapping resource +// type names (e.g. "infra.vpc") to their per-field specs. The +// handler library (T5/T6) holds one instance for the lifetime of the +// host-side infra.admin module; the catalog is read-only after +// New() returns. +// +// T7a ships the catalog skeleton with an empty entries map; T7b +// populates the map alongside the parallel freeformReasons table. +type FieldSpecCatalog struct { + entries map[string][]FieldSpec +} + +// New returns an empty FieldSpecCatalog. Per plan §Task 7a the +// skeleton catalog has zero entries so T5/T6 handler library can +// compile against the typed parameter while T7b fills the 13 +// typed-Config entries in parallel. +// +// The returned catalog is safe for concurrent reads (Get / AllTypes +// never mutate state). T7b's filled catalog will populate entries at +// package init time (or via New() initialization) so no caller-side +// locking is required. +func New() *FieldSpecCatalog { + return &FieldSpecCatalog{ + entries: catalogEntries(), + } +} + +// Get returns the field specs for typeName and ok=true when the type +// is registered. Returns (nil, false) for unknown types so callers +// can distinguish a missing-type from a registered-but-empty type +// (both return zero-length slices on the proto side; the boolean +// is the discriminator). +func (c *FieldSpecCatalog) Get(typeName string) ([]FieldSpec, bool) { + fields, ok := c.entries[typeName] + if !ok { + return nil, false + } + // Defensive copy so callers cannot mutate the catalog's internal + // state. Cost is minimal — entry slices average ~6 fields per + // type. + out := make([]FieldSpec, len(fields)) + copy(out, fields) + return out, true +} + +// AllTypes returns the sorted list of registered resource-type names. +// The sort is deterministic so callers (handler library + +// ListResourceTypes RPC response) emit a stable ordering for +// snapshot tests + diff-friendly downstream consumers. +func (c *FieldSpecCatalog) AllTypes() []string { + out := make([]string, 0, len(c.entries)) + for k := range c.entries { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// catalogEntries is the package-level seam T7b uses to populate the +// catalog. The skeleton ships with no entries; T7b's fields.go will +// override this variable with the hand-written table mapping +// {VPCConfig, ContainerServiceConfig, K8SClusterConfig, ...} to +// their per-field specs. +// +// Exposed as a package-level var rather than a function literal so +// T7b's fields.go can assign it directly via `var catalogEntries = +// func() map[string][]FieldSpec { ... }` without touching this file. +var catalogEntries = func() map[string][]FieldSpec { + return map[string][]FieldSpec{} +} + +// FreeformReason returns the FREEFORM_OK annotation reason for a +// catalog field whose Kind is "string" or "array_string". Returns +// ("", false) when no entry exists for {typeName, fieldName}. +// +// The plan §Task 7b audit test enumerates every catalog entry and +// asserts that every Kind ∈ {"string", "array_string"} field has a +// non-empty reason here. The reasons table is a parallel +// map[typeName]map[fieldName]string populated by hand alongside the +// catalog entries in T7b's fields.go. Skeleton ships with an empty +// map so the audit test runs (and trivially passes — there are no +// string-kind entries yet). +func FreeformReason(typeName, fieldName string) (string, bool) { + byField, ok := freeformReasons[typeName] + if !ok { + return "", false + } + reason, ok := byField[fieldName] + return reason, ok +} + +// freeformReasons is the parallel annotation table populated by T7b. +// Skeleton ships empty so the package compiles + the smoke test +// passes; T7b overrides this via init() or direct assignment from +// fields.go. +var freeformReasons = map[string]map[string]string{} diff --git a/iac/admin/catalog/catalog_test.go b/iac/admin/catalog/catalog_test.go new file mode 100644 index 00000000..bf0d3b26 --- /dev/null +++ b/iac/admin/catalog/catalog_test.go @@ -0,0 +1,55 @@ +package catalog_test + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" +) + +// TestNew_ReturnsNonNilEmptyCatalog pins the T7a skeleton contract: +// New() must return a usable catalog with zero entries. T7b fills the +// 13 typed Configs separately so this skeleton is callable from T5/T6 +// while the entry table is still being authored. Per plan §Task 7a +// Step 1. +func TestNew_ReturnsNonNilEmptyCatalog(t *testing.T) { + cat := catalog.New() + if cat == nil { + t.Fatal("catalog.New() returned nil") + } + types := cat.AllTypes() + if len(types) != 0 { + t.Errorf("expected empty AllTypes() on skeleton catalog, got %v", types) + } +} + +// TestGet_MissingTypeReturnsFalse pins the Get(missing) contract: the +// boolean must be false (not ok) when no entry exists for the given +// resource type. T5/T6 handler library code uses this signal to skip +// unknown types rather than crashing or returning empty results +// indistinguishable from a registered-but-empty type. +func TestGet_MissingTypeReturnsFalse(t *testing.T) { + cat := catalog.New() + fields, ok := cat.Get("infra.nonexistent") + if ok { + t.Errorf("Get(\"infra.nonexistent\") returned ok=true on empty catalog") + } + if fields != nil { + t.Errorf("Get on missing type returned %v, want nil slice", fields) + } +} + +// TestFreeformReason_MissingEntryReturnsFalse pins the +// package-level FreeformReason signature used by the T7b audit test: +// when no FREEFORM_OK annotation exists for the requested field, the +// function returns ("", false). The skeleton always returns false +// since the annotation map is empty; T7b populates the map alongside +// its FieldSpec entries. +func TestFreeformReason_MissingEntryReturnsFalse(t *testing.T) { + reason, ok := catalog.FreeformReason("infra.vpc", "cidr") + if ok { + t.Errorf("FreeformReason on empty catalog returned ok=true (reason=%q)", reason) + } + if reason != "" { + t.Errorf("FreeformReason returned reason=%q on empty catalog, want \"\"", reason) + } +} From b2efb577ab415657aee06529e6954dfe512d7f99 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:52:01 -0400 Subject: [PATCH 10/34] docs(iac/admin/proto): envelope doc comments + reserved tag ranges (T4 code-review M-1, M-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc + reserved-range only follow-up to T4 commit 814665498 addressing code-reviewer's optional Minors: M-1 — Added one-line doc comments to all 9 envelope I/O messages: AdminListResourcesInput/Output, AdminGetResourceInput/Output, AdminListResourceTypesInput/Output, AdminListProvidersInput/Output, AdminGenerateConfigOutput. (AdminGenerateConfigInput already had a detailed comment.) Each comment names the RPC and the field-set semantics so proto-file readers don't have to cross-reference the design doc. M-2 — Added `reserved` ranges on every I/O envelope: - Inputs: reserved to 99, 101 to 199; - Outputs: reserved to 99, 101 to 199; The 101-199 range future-proofs against accidental re-use of tag 100's neighborhood (tag 100 is the uniform error discriminator). The to 99 range guards against tag-number collisions when adding fields between current-max and 100. M-3 declined — informational only (15 vs 17 message count); no action required. The AdminGenerateConfigOutput.error semantics doc note clarifies the distinction between validation_errors (per-field, form remains submittable) and error (handler-level, e.g. authz denial). Regenerated infra_admin.pb.go via: protoc --go_out=. --go_opt=paths=source_relative \ iac/admin/proto/infra_admin.proto (protoc 35.0, protoc-gen-go v1.36.11) No behavior change. Verified: - GOWORK=off go test ./iac/admin/proto/... green (4 tests). - GOWORK=off go build ./... clean. - golangci-lint --new-from-rev=origin/main -> 0 issues. Co-Authored-By: Claude Opus 4.7 --- iac/admin/proto/infra_admin.pb.go | 55 +++++++++++++++++++++++-------- iac/admin/proto/infra_admin.proto | 44 +++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/iac/admin/proto/infra_admin.pb.go b/iac/admin/proto/infra_admin.pb.go index 67abd244..c1d71ae1 100644 --- a/iac/admin/proto/infra_admin.pb.go +++ b/iac/admin/proto/infra_admin.pb.go @@ -320,6 +320,9 @@ func (x *AdminResourceDetail) GetSensitiveOutputsRedacted() []string { return nil } +// AdminListResourcesInput is the request shape for the ListResources +// RPC. Filters narrow the returned set; evidence carries the host-side +// authz outcome (default-deny semantics). type AdminListResourcesInput struct { state protoimpl.MessageState `protogen:"open.v1"` TypeFilter string `protobuf:"bytes,1,opt,name=type_filter,json=typeFilter,proto3" json:"type_filter,omitempty"` @@ -396,6 +399,9 @@ func (x *AdminListResourcesInput) GetEvidence() *AdminAuthzEvidence { return nil } +// AdminListResourcesOutput is the response shape for the ListResources +// RPC. error is set when the handler refused (e.g. authz denied); when +// non-empty, consumers MUST ignore the typed payload. type AdminListResourcesOutput struct { state protoimpl.MessageState `protogen:"open.v1"` Resources []*AdminResourceSummary `protobuf:"bytes,1,rep,name=resources,proto3" json:"resources,omitempty"` @@ -448,6 +454,9 @@ func (x *AdminListResourcesOutput) GetError() string { return "" } +// AdminGetResourceInput is the request shape for the GetResource RPC. +// name is the resource's host-side identity (the YAML `name:`); the +// returned detail includes the full applied config + outputs. type AdminGetResourceInput struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -508,6 +517,9 @@ func (x *AdminGetResourceInput) GetEvidence() *AdminAuthzEvidence { return nil } +// AdminGetResourceOutput is the response shape for the GetResource RPC. +// Carries AdminResourceDetail when found; error when the resource is +// missing or authz denied. type AdminGetResourceOutput struct { state protoimpl.MessageState `protogen:"open.v1"` Resource *AdminResourceDetail `protobuf:"bytes,1,opt,name=resource,proto3" json:"resource,omitempty"` @@ -794,6 +806,9 @@ func (x *AdminResourceTypeMetadata) GetDescription() string { return "" } +// AdminListResourceTypesInput is the request shape for the +// ListResourceTypes RPC. provider_filter narrows the returned types to +// those a given provider supports. type AdminListResourceTypesInput struct { state protoimpl.MessageState `protogen:"open.v1"` ProviderFilter string `protobuf:"bytes,1,opt,name=provider_filter,json=providerFilter,proto3" json:"provider_filter,omitempty"` @@ -846,6 +861,9 @@ func (x *AdminListResourceTypesInput) GetEvidence() *AdminAuthzEvidence { return nil } +// AdminListResourceTypesOutput is the response shape for the +// ListResourceTypes RPC. types is the form-builder's view of every +// registered infra.* Config (one entry per FieldSpecCatalog type). type AdminListResourceTypesOutput struct { state protoimpl.MessageState `protogen:"open.v1"` Types []*AdminResourceTypeMetadata `protobuf:"bytes,1,rep,name=types,proto3" json:"types,omitempty"` @@ -996,6 +1014,8 @@ func (x *AdminProviderSummary) GetRegionsSource() string { return "" } +// AdminListProvidersInput is the request shape for the ListProviders +// RPC. env_name selects the per-environment overlay. type AdminListProvidersInput struct { state protoimpl.MessageState `protogen:"open.v1"` EnvName string `protobuf:"bytes,1,opt,name=env_name,json=envName,proto3" json:"env_name,omitempty"` @@ -1048,6 +1068,9 @@ func (x *AdminListProvidersInput) GetEvidence() *AdminAuthzEvidence { return nil } +// AdminListProvidersOutput is the response shape for the ListProviders +// RPC. One AdminProviderSummary per iac.provider module declared in the +// host's WorkflowConfig. type AdminListProvidersOutput struct { state protoimpl.MessageState `protogen:"open.v1"` Providers []*AdminProviderSummary `protobuf:"bytes,1,rep,name=providers,proto3" json:"providers,omitempty"` @@ -1182,6 +1205,12 @@ func (x *AdminGenerateConfigInput) GetEvidence() *AdminAuthzEvidence { return nil } +// AdminGenerateConfigOutput is the response shape for the +// GenerateConfig RPC. yaml_snippet is the typed-coerced YAML the form +// produced; validation_errors carries any per-field validation +// failures the catalog reported (form remains submittable on +// validation_errors — `error` is reserved for handler-level failures +// like authz denial). type AdminGenerateConfigOutput struct { state protoimpl.MessageState `protogen:"open.v1"` YamlSnippet string `protobuf:"bytes,1,opt,name=yaml_snippet,json=yamlSnippet,proto3" json:"yaml_snippet,omitempty"` @@ -1368,24 +1397,24 @@ const file_iac_admin_proto_infra_admin_proto_rawDesc = "" + "\vconfig_hash\x18\x04 \x01(\tR\n" + "configHash\x121\n" + "\x15last_drift_check_unix\x18\x05 \x01(\x03R\x12lastDriftCheckUnix\x12<\n" + - "\x1asensitive_outputs_redacted\x18\x06 \x03(\tR\x18sensitiveOutputsRedacted\"\xed\x01\n" + + "\x1asensitive_outputs_redacted\x18\x06 \x03(\tR\x18sensitiveOutputsRedacted\"\xfa\x01\n" + "\x17AdminListResourcesInput\x12\x1f\n" + "\vtype_filter\x18\x01 \x01(\tR\n" + "typeFilter\x12'\n" + "\x0fprovider_filter\x18\x02 \x01(\tR\x0eproviderFilter\x12,\n" + "\x12app_context_filter\x18\x03 \x01(\tR\x10appContextFilter\x12\x19\n" + "\benv_name\x18\x04 \x01(\tR\aenvName\x12?\n" + - "\bevidence\x18\x05 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidence\"u\n" + + "\bevidence\x18\x05 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidenceJ\x04\b\x06\x10dJ\x05\be\x10\xc8\x01\"\x82\x01\n" + "\x18AdminListResourcesOutput\x12C\n" + "\tresources\x18\x01 \x03(\v2%.workflow.iac.v1.AdminResourceSummaryR\tresources\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05error\"\x87\x01\n" + + "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x02\x10dJ\x05\be\x10\xc8\x01\"\x94\x01\n" + "\x15AdminGetResourceInput\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + "\benv_name\x18\x02 \x01(\tR\aenvName\x12?\n" + - "\bevidence\x18\x03 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidence\"p\n" + + "\bevidence\x18\x03 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidenceJ\x04\b\x04\x10dJ\x05\be\x10\xc8\x01\"}\n" + "\x16AdminGetResourceOutput\x12@\n" + "\bresource\x18\x01 \x01(\v2$.workflow.iac.v1.AdminResourceDetailR\bresource\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05error\"\x98\x03\n" + + "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x02\x10dJ\x05\be\x10\xc8\x01\"\x98\x03\n" + "\x0eAdminFieldSpec\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + "\x05label\x18\x02 \x01(\tR\x05label\x12\x12\n" + @@ -1408,13 +1437,13 @@ const file_iac_admin_proto_infra_admin_proto_rawDesc = "" + "\x12config_message_fqn\x18\x02 \x01(\tR\x10configMessageFqn\x127\n" + "\x06fields\x18\x03 \x03(\v2\x1f.workflow.iac.v1.AdminFieldSpecR\x06fields\x12/\n" + "\x13supported_providers\x18\x04 \x03(\tR\x12supportedProviders\x12 \n" + - "\vdescription\x18\x05 \x01(\tR\vdescription\"\x87\x01\n" + + "\vdescription\x18\x05 \x01(\tR\vdescription\"\x94\x01\n" + "\x1bAdminListResourceTypesInput\x12'\n" + "\x0fprovider_filter\x18\x01 \x01(\tR\x0eproviderFilter\x12?\n" + - "\bevidence\x18\x02 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidence\"v\n" + + "\bevidence\x18\x02 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidenceJ\x04\b\x03\x10dJ\x05\be\x10\xc8\x01\"\x83\x01\n" + "\x1cAdminListResourceTypesOutput\x12@\n" + "\x05types\x18\x01 \x03(\v2*.workflow.iac.v1.AdminResourceTypeMetadataR\x05types\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05error\"\xaa\x02\n" + + "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x02\x10dJ\x05\be\x10\xc8\x01\"\xaa\x02\n" + "\x14AdminProviderSummary\x12\x1f\n" + "\vmodule_name\x18\x01 \x01(\tR\n" + "moduleName\x12#\n" + @@ -1423,13 +1452,13 @@ const file_iac_admin_proto_infra_admin_proto_rawDesc = "" + "\x11supported_regions\x18\x04 \x03(\tR\x10supportedRegions\x12'\n" + "\x0fsupported_types\x18\x05 \x03(\tR\x0esupportedTypes\x12+\n" + "\x11supported_engines\x18\x06 \x03(\tR\x10supportedEngines\x12%\n" + - "\x0eregions_source\x18\a \x01(\tR\rregionsSource\"u\n" + + "\x0eregions_source\x18\a \x01(\tR\rregionsSource\"\x82\x01\n" + "\x17AdminListProvidersInput\x12\x19\n" + "\benv_name\x18\x01 \x01(\tR\aenvName\x12?\n" + - "\bevidence\x18\x02 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidence\"u\n" + + "\bevidence\x18\x02 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidenceJ\x04\b\x03\x10dJ\x05\be\x10\xc8\x01\"\x82\x01\n" + "\x18AdminListProvidersOutput\x12C\n" + "\tproviders\x18\x01 \x03(\v2%.workflow.iac.v1.AdminProviderSummaryR\tproviders\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05error\"\xed\x02\n" + + "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x02\x10dJ\x05\be\x10\xc8\x01\"\xfa\x02\n" + "\x18AdminGenerateConfigInput\x12#\n" + "\rresource_type\x18\x01 \x01(\tR\fresourceType\x12#\n" + "\rresource_name\x18\x02 \x01(\tR\fresourceName\x12'\n" + @@ -1438,11 +1467,11 @@ const file_iac_admin_proto_infra_admin_proto_rawDesc = "" + "\bevidence\x18\x05 \x01(\v2#.workflow.iac.v1.AdminAuthzEvidenceR\bevidence\x1a>\n" + "\x10FieldValuesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x81\x01\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x06\x10dJ\x05\be\x10\xc8\x01\"\x8e\x01\n" + "\x19AdminGenerateConfigOutput\x12!\n" + "\fyaml_snippet\x18\x01 \x01(\tR\vyamlSnippet\x12+\n" + "\x11validation_errors\x18\x02 \x03(\tR\x10validationErrors\x12\x14\n" + - "\x05error\x18d \x01(\tR\x05error\"\xd6\x01\n" + + "\x05error\x18d \x01(\tR\x05errorJ\x04\b\x03\x10dJ\x05\be\x10\xc8\x01\"\xd6\x01\n" + "\x0fAdminAuditEntry\x12%\n" + "\x0eschema_version\x18\x01 \x01(\x05R\rschemaVersion\x12\x17\n" + "\ats_unix\x18\x02 \x01(\x03R\x06tsUnix\x12\x18\n" + diff --git a/iac/admin/proto/infra_admin.proto b/iac/admin/proto/infra_admin.proto index c62b8d5b..5d957369 100644 --- a/iac/admin/proto/infra_admin.proto +++ b/iac/admin/proto/infra_admin.proto @@ -71,28 +71,49 @@ message AdminResourceDetail { repeated string sensitive_outputs_redacted = 6; } +// AdminListResourcesInput is the request shape for the ListResources +// RPC. Filters narrow the returned set; evidence carries the host-side +// authz outcome (default-deny semantics). message AdminListResourcesInput { string type_filter = 1; string provider_filter = 2; string app_context_filter = 3; string env_name = 4; AdminAuthzEvidence evidence = 5; + // Tag 6-99 reserved for future filter additions; tag 100 is the + // uniform error discriminator on Output messages (Input messages + // don't carry error but reserve the tag for symmetry). + reserved 6 to 99, 101 to 199; } +// AdminListResourcesOutput is the response shape for the ListResources +// RPC. error is set when the handler refused (e.g. authz denied); when +// non-empty, consumers MUST ignore the typed payload. message AdminListResourcesOutput { repeated AdminResourceSummary resources = 1; string error = 100; + // Tag 2-99 reserved for future Output-payload fields; tag 101-199 + // reserved for future Output-side error/diagnostic metadata. + reserved 2 to 99, 101 to 199; } +// AdminGetResourceInput is the request shape for the GetResource RPC. +// name is the resource's host-side identity (the YAML `name:`); the +// returned detail includes the full applied config + outputs. message AdminGetResourceInput { string name = 1; string env_name = 2; AdminAuthzEvidence evidence = 3; + reserved 4 to 99, 101 to 199; } +// AdminGetResourceOutput is the response shape for the GetResource RPC. +// Carries AdminResourceDetail when found; error when the resource is +// missing or authz denied. message AdminGetResourceOutput { AdminResourceDetail resource = 1; string error = 100; + reserved 2 to 99, 101 to 199; } // AdminFieldSpec describes one field of a typed infra.* Config so the @@ -137,14 +158,22 @@ message AdminResourceTypeMetadata { string description = 5; } +// AdminListResourceTypesInput is the request shape for the +// ListResourceTypes RPC. provider_filter narrows the returned types to +// those a given provider supports. message AdminListResourceTypesInput { string provider_filter = 1; AdminAuthzEvidence evidence = 2; + reserved 3 to 99, 101 to 199; } +// AdminListResourceTypesOutput is the response shape for the +// ListResourceTypes RPC. types is the form-builder's view of every +// registered infra.* Config (one entry per FieldSpecCatalog type). message AdminListResourceTypesOutput { repeated AdminResourceTypeMetadata types = 1; string error = 100; + reserved 2 to 99, 101 to 199; } // AdminProviderSummary is the ListProviders response row. v1 @@ -163,14 +192,21 @@ message AdminProviderSummary { string regions_source = 7; // "local-catalog" for v1 } +// AdminListProvidersInput is the request shape for the ListProviders +// RPC. env_name selects the per-environment overlay. message AdminListProvidersInput { string env_name = 1; AdminAuthzEvidence evidence = 2; + reserved 3 to 99, 101 to 199; } +// AdminListProvidersOutput is the response shape for the ListProviders +// RPC. One AdminProviderSummary per iac.provider module declared in the +// host's WorkflowConfig. message AdminListProvidersOutput { repeated AdminProviderSummary providers = 1; string error = 100; + reserved 2 to 99, 101 to 199; } // AdminGenerateConfigInput carries the form-builder submission. @@ -185,12 +221,20 @@ message AdminGenerateConfigInput { string provider_module = 3; map field_values = 4; AdminAuthzEvidence evidence = 5; + reserved 6 to 99, 101 to 199; } +// AdminGenerateConfigOutput is the response shape for the +// GenerateConfig RPC. yaml_snippet is the typed-coerced YAML the form +// produced; validation_errors carries any per-field validation +// failures the catalog reported (form remains submittable on +// validation_errors — `error` is reserved for handler-level failures +// like authz denial). message AdminGenerateConfigOutput { string yaml_snippet = 1; repeated string validation_errors = 2; string error = 100; + reserved 3 to 99, 101 to 199; } // AdminAuditEntry is the line shape for the audit log (one entry per From fefdf3ccf35f460e8712734fec42f9711842f498 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:53:14 -0400 Subject: [PATCH 11/34] docs(iac/admin/catalog): fix T7b reassignment mechanism in seam comments (T7a spec-review nit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec-reviewer's T7a comment-nit (commit ff0662602) caught that the catalogEntries + freeformReasons seam comments described an implementation-incorrect pattern. The comments claimed T7b's fields.go could "assign directly via `var catalogEntries = func() ... { ... }` without touching this file" — but Go forbids re-declaring a package-level var of the same name across files in the same package. T7b MUST take the form: package catalog func init() { catalogEntries = func() map[string][]FieldSpec { return map[string][]FieldSpec{ "infra.vpc": { ... }, // ... 13 typed Configs ... } } } Updated both seam comments (catalogEntries + freeformReasons) to specify the init()-reassignment pattern explicitly so T7b's implementer doesn't follow the wrong incantation. The API surface + behavior of the skeleton are unchanged; this is comment-only. Verified: GOWORK=off go test ./iac/admin/catalog/... green; GOWORK=off go build ./... clean. Co-Authored-By: Claude Opus 4.7 --- iac/admin/catalog/catalog.go | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/iac/admin/catalog/catalog.go b/iac/admin/catalog/catalog.go index ce493fbc..0948c12f 100644 --- a/iac/admin/catalog/catalog.go +++ b/iac/admin/catalog/catalog.go @@ -146,9 +146,23 @@ func (c *FieldSpecCatalog) AllTypes() []string { // {VPCConfig, ContainerServiceConfig, K8SClusterConfig, ...} to // their per-field specs. // -// Exposed as a package-level var rather than a function literal so -// T7b's fields.go can assign it directly via `var catalogEntries = -// func() map[string][]FieldSpec { ... }` without touching this file. +// Exposed as a package-level var (not a const, not a function +// literal embedded in New) so T7b's fields.go can reassign it from +// an init() function in the same package — Go forbids redeclaring +// a package-level var of the same name across files, so T7b's +// fields.go MUST take this shape: +// +// package catalog +// func init() { +// catalogEntries = func() map[string][]FieldSpec { +// return map[string][]FieldSpec{ +// "infra.vpc": { ... }, +// // ... 13 typed Configs ... +// } +// } +// } +// +// Per spec-reviewer T7a comment-nit (commit ff0662602). var catalogEntries = func() map[string][]FieldSpec { return map[string][]FieldSpec{} } @@ -175,6 +189,8 @@ func FreeformReason(typeName, fieldName string) (string, bool) { // freeformReasons is the parallel annotation table populated by T7b. // Skeleton ships empty so the package compiles + the smoke test -// passes; T7b overrides this via init() or direct assignment from -// fields.go. +// passes; T7b reassigns this from a same-package init() in fields.go +// (same mechanism + Go-redeclaration constraint as catalogEntries +// above — assign via init(), don't re-`var`-declare). Per +// spec-reviewer T7a comment-nit (commit ff0662602). var freeformReasons = map[string]map[string]string{} From fad5f722057cb5be438a55d5119bc00e9e284ced Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:54:12 -0400 Subject: [PATCH 12/34] feat(iac/admin/ui_dist): asset pages resources/resource/new (T10-T12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three HTML pages + companion JS for the host-side infra-admin module's admin-shell iframes: - resources.html + .js: tabular list with type / provider / app_context filters; refresh button; links to detail view. - resource.html + .js: typed summary table + applied_config + outputs JSON view; redaction note for sensitive output keys. - new.html + .js: form-builder centerpiece — type dropdown populated from /api/infra-admin/types, per-FieldSpec widgets (string/number/bool/enum/enum_dynamic/array_*) with dependent dropdowns (provider → region/engines via enum_dynamic depends_on_field). Submits to /api/infra-admin/generate-config and renders the returned yaml_snippet with copy-to-clipboard. CSP: external .js files only (no inline scripts or handlers); styles via styles.css only (no inline style attrs). Compatible with the host's `default-src 'self'; script-src 'self'; style-src 'self'` policy. Wire format: protojson snake_case (handler library must set MarshalOptions{UseProtoNames: true} for response field names to match). Embed + serve come in T13 (Lane A). Playwright regression spec in PR-2 T24. --- iac/admin/ui_dist/new.html | 42 +++ iac/admin/ui_dist/new.js | 430 +++++++++++++++++++++++++++++++ iac/admin/ui_dist/resource.html | 32 +++ iac/admin/ui_dist/resource.js | 123 +++++++++ iac/admin/ui_dist/resources.html | 26 ++ iac/admin/ui_dist/resources.js | 110 ++++++++ iac/admin/ui_dist/styles.css | 28 ++ 7 files changed, 791 insertions(+) create mode 100644 iac/admin/ui_dist/new.html create mode 100644 iac/admin/ui_dist/new.js create mode 100644 iac/admin/ui_dist/resource.html create mode 100644 iac/admin/ui_dist/resource.js create mode 100644 iac/admin/ui_dist/resources.html create mode 100644 iac/admin/ui_dist/resources.js create mode 100644 iac/admin/ui_dist/styles.css diff --git a/iac/admin/ui_dist/new.html b/iac/admin/ui_dist/new.html new file mode 100644 index 00000000..fff1d215 --- /dev/null +++ b/iac/admin/ui_dist/new.html @@ -0,0 +1,42 @@ + + + + + + Draft New Infra Resource + + + +

Draft New Resource

+ +
+
+ + + + +
+ +
+ +
+ + +
+
+ +
+

Generated YAML

+

+    
+ +
+
    +
    + + + + + diff --git a/iac/admin/ui_dist/new.js b/iac/admin/ui_dist/new.js new file mode 100644 index 00000000..1c6a902b --- /dev/null +++ b/iac/admin/ui_dist/new.js @@ -0,0 +1,430 @@ +// new.js — form-builder for /admin/infra-admin/new.html. +// CSP-compliant: external file only. +// +// Endpoints: +// POST /api/infra-admin/types → AdminListResourceTypesOutput +// POST /api/infra-admin/providers → AdminListProvidersOutput +// POST /api/infra-admin/generate-config → AdminGenerateConfigOutput +// +// Wire format: protojson with UseProtoNames=true (snake_case fields). +// +// FieldSpec.kind values handled: +// - "string" → text input (must carry FREEFORM_OK reason on the +// server side; client renders tooltip from .description) +// - "number" → number input with min_count/max_count as min/max +// - "bool" → checkbox +// - "enum" → select populated from .enum_values +// - "enum_dynamic" → select populated from .enum_source resolution: +// "providers" → /providers +// "regions" → ProviderSummary.supported_regions +// (depends_on=provider) +// "engines" → ProviderSummary.supported_engines +// (depends_on=provider) +// "sizes" → fixed [xs, s, m, l, xl] +// - "array_string" → repeatable text inputs (add/remove) +// - "array_number" → repeatable number inputs +// - "array_enum" → repeatable enum selects + +const API = '/api/infra-admin'; +const SIZE_OPTIONS = ['xs', 's', 'm', 'l', 'xl']; + +// In-memory caches populated at load time. +const STATE = { + types: [], // AdminResourceTypeMetadata[] + providers: [], // AdminProviderSummary[] + selectedType: null, // AdminResourceTypeMetadata +}; + +function esc(s) { + return String(s == null ? '' : s).replace(/[<>&"']/g, c => ({ + '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', + }[c])); +} + +function showError(err) { + document.getElementById('error').textContent = err ? String(err) : ''; +} + +async function postJSON(path, body) { + const resp = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!resp.ok) throw new Error(`${path}: HTTP ${resp.status}`); + const data = await resp.json(); + if (data && data.error) throw new Error(data.error); + return data; +} + +function providerByModule(name) { + return STATE.providers.find(p => p.module_name === name); +} + +// --- field rendering ------------------------------------------------------- + +function makeLabel(spec) { + const lbl = document.createElement('label'); + lbl.className = 'field-label'; + lbl.setAttribute('for', `field-${spec.name}`); + lbl.textContent = spec.label || spec.name; + if (spec.required) lbl.textContent += ' *'; + return lbl; +} + +function makeHelp(spec) { + if (!spec.description) return null; + const help = document.createElement('div'); + help.className = 'field-help'; + help.textContent = spec.description; + return help; +} + +function makeStringInput(spec) { + const inp = document.createElement('input'); + inp.type = 'text'; + inp.id = `field-${spec.name}`; + inp.name = spec.name; + if (spec.required) inp.required = true; + if (spec.default_value) inp.value = spec.default_value; + if (spec.sensitive) inp.type = 'password'; + if (spec.description) inp.title = spec.description; + return inp; +} + +function makeNumberInput(spec) { + const inp = document.createElement('input'); + inp.type = 'number'; + inp.id = `field-${spec.name}`; + inp.name = spec.name; + if (spec.required) inp.required = true; + if (spec.min_count != null && spec.min_count !== 0) inp.min = spec.min_count; + if (spec.max_count != null && spec.max_count !== 0) inp.max = spec.max_count; + if (spec.default_value) inp.value = spec.default_value; + return inp; +} + +function makeBoolInput(spec) { + const inp = document.createElement('input'); + inp.type = 'checkbox'; + inp.id = `field-${spec.name}`; + inp.name = spec.name; + if (spec.default_value === 'true') inp.checked = true; + return inp; +} + +function makeSelect(spec, options) { + const sel = document.createElement('select'); + sel.id = `field-${spec.name}`; + sel.name = spec.name; + if (spec.required) sel.required = true; + const blank = document.createElement('option'); + blank.value = ''; + blank.textContent = spec.required ? '— select —' : '(any)'; + sel.appendChild(blank); + for (const v of options) { + const opt = document.createElement('option'); + opt.value = String(v); + opt.textContent = String(v); + if (spec.default_value === String(v)) opt.selected = true; + sel.appendChild(opt); + } + return sel; +} + +function resolveDynamicEnum(spec, formState) { + switch (spec.enum_source) { + case 'providers': + return STATE.providers.map(p => p.module_name); + case 'sizes': + return SIZE_OPTIONS.slice(); + case 'regions': { + const providerMod = formState[spec.depends_on_field || 'provider']; + const p = providerByModule(providerMod); + return p ? (p.supported_regions || []).slice() : []; + } + case 'engines': { + const providerMod = formState[spec.depends_on_field || 'provider']; + const p = providerByModule(providerMod); + return p ? (p.supported_engines || []).slice() : []; + } + case 'resource_types': + return STATE.types.map(t => t.type); + default: + return []; + } +} + +function makeArrayField(spec, elementKind) { + const wrap = document.createElement('div'); + wrap.className = 'array-field'; + wrap.dataset.name = spec.name; + wrap.dataset.kind = elementKind; + + const rows = document.createElement('div'); + rows.className = 'array-rows'; + wrap.appendChild(rows); + + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.textContent = `+ Add ${spec.label || spec.name}`; + addBtn.addEventListener('click', () => addArrayRow(spec, rows, elementKind)); + wrap.appendChild(addBtn); + + // Seed with min_count rows (or 1 if min_count > 0 / required). + const seed = Math.max(spec.min_count || 0, spec.required ? 1 : 0); + for (let i = 0; i < seed; i++) addArrayRow(spec, rows, elementKind); + return wrap; +} + +function addArrayRow(spec, rows, elementKind) { + const row = document.createElement('div'); + row.className = 'array-row'; + let input; + switch (elementKind) { + case 'number': + input = document.createElement('input'); + input.type = 'number'; + if (spec.min_count != null && spec.min_count !== 0) input.min = spec.min_count; + if (spec.max_count != null && spec.max_count !== 0) input.max = spec.max_count; + break; + case 'enum': + input = makeSelect( + { name: `${spec.name}_item`, required: false, default_value: '' }, + spec.enum_values || [], + ); + break; + case 'enum_dynamic': + input = makeSelect( + { name: `${spec.name}_item`, required: false, default_value: '' }, + resolveDynamicEnum(spec, snapshotFormState()), + ); + break; + default: + input = document.createElement('input'); + input.type = 'text'; + } + input.dataset.arrayItem = '1'; + input.name = `${spec.name}[]`; + const rm = document.createElement('button'); + rm.type = 'button'; + rm.textContent = '–'; + rm.addEventListener('click', () => row.remove()); + row.appendChild(input); + row.appendChild(rm); + rows.appendChild(row); +} + +function renderField(spec) { + const row = document.createElement('div'); + row.className = 'field-row'; + row.appendChild(makeLabel(spec)); + + const right = document.createElement('div'); + let widget; + switch (spec.kind) { + case 'string': + widget = makeStringInput(spec); + break; + case 'number': + widget = makeNumberInput(spec); + break; + case 'bool': + widget = makeBoolInput(spec); + break; + case 'enum': + widget = makeSelect(spec, spec.enum_values || []); + break; + case 'enum_dynamic': + widget = makeSelect(spec, resolveDynamicEnum(spec, snapshotFormState())); + widget.dataset.enumDynamic = spec.enum_source || ''; + if (spec.depends_on_field) widget.dataset.dependsOn = spec.depends_on_field; + break; + case 'array_string': + widget = makeArrayField(spec, 'string'); + break; + case 'array_number': + widget = makeArrayField(spec, 'number'); + break; + case 'array_enum': + widget = makeArrayField(spec, 'enum'); + break; + case 'array_enum_dynamic': + widget = makeArrayField(spec, 'enum_dynamic'); + break; + default: + // Unknown kind — degrade to text input with a warning tooltip. + widget = makeStringInput(spec); + widget.title = `unknown kind ${spec.kind}; rendered as text`; + } + right.appendChild(widget); + const help = makeHelp(spec); + if (help) right.appendChild(help); + row.appendChild(right); + return row; +} + +// --- form lifecycle -------------------------------------------------------- + +function snapshotFormState() { + const out = {}; + const fields = document.getElementById('fields'); + if (!fields) return out; + for (const el of fields.querySelectorAll('input, select')) { + if (el.dataset.arrayItem === '1') continue; + if (el.type === 'checkbox') { + out[el.name] = el.checked ? 'true' : 'false'; + } else if (el.name) { + out[el.name] = el.value; + } + } + return out; +} + +function refreshDependentDynamics() { + // Recompute enum_dynamic selects whose depends_on field changed. + const state = snapshotFormState(); + document.querySelectorAll('select[data-enum-dynamic]').forEach(sel => { + const src = sel.dataset.enumDynamic; + const deps = sel.dataset.dependsOn; + if (!deps) return; // independent dynamic; populated at render + const spec = { + name: sel.name, + enum_source: src, + depends_on_field: deps, + required: sel.required, + }; + const options = resolveDynamicEnum(spec, state); + const prev = sel.value; + // Repopulate while preserving placeholder. + while (sel.options.length > 1) sel.remove(1); + for (const v of options) { + const opt = document.createElement('option'); + opt.value = String(v); + opt.textContent = String(v); + sel.appendChild(opt); + } + if (options.includes(prev)) sel.value = prev; + }); +} + +function renderType(typeMeta) { + STATE.selectedType = typeMeta; + const wrap = document.getElementById('fields'); + wrap.innerHTML = ''; + if (!typeMeta) return; + for (const f of (typeMeta.fields || [])) { + wrap.appendChild(renderField(f)); + } + // Wire up dependency refresh on every input change. + wrap.addEventListener('change', refreshDependentDynamics); +} + +function readSubmittedFieldValues() { + const out = {}; + const fields = document.getElementById('fields'); + for (const el of fields.querySelectorAll('input, select')) { + if (!el.name) continue; + if (el.type === 'checkbox') { + out[el.name] = el.checked ? 'true' : 'false'; + continue; + } + if (el.name.endsWith('[]')) { + const key = el.name.slice(0, -2); + const cur = out[key]; + const val = el.value; + if (val === '' || val == null) continue; + out[key] = cur ? `${cur},${val}` : val; + continue; + } + if (el.value !== '' && el.value != null) out[el.name] = el.value; + } + return out; +} + +async function loadCatalog() { + showError(''); + try { + const [typesResp, provResp] = await Promise.all([ + postJSON(`${API}/types`, { + evidence: { authz_checked: true, authz_allowed: true }, + }), + postJSON(`${API}/providers`, { + evidence: { authz_checked: true, authz_allowed: true }, + }), + ]); + STATE.types = typesResp.types || []; + STATE.providers = provResp.providers || []; + + const sel = document.getElementById('type'); + while (sel.options.length > 1) sel.remove(1); + for (const t of STATE.types) { + const opt = document.createElement('option'); + opt.value = t.type; + opt.textContent = t.description ? `${t.type} — ${t.description}` : t.type; + sel.appendChild(opt); + } + } catch (err) { + showError(`load catalog: ${err.message}`); + } +} + +async function onSubmit(ev) { + ev.preventDefault(); + showError(''); + const errBox = document.getElementById('validation-errors'); + errBox.innerHTML = ''; + const out = document.getElementById('yaml-output'); + out.textContent = ''; + document.getElementById('copy').disabled = true; + + const typeName = document.getElementById('type').value; + const resourceName = document.getElementById('name').value.trim(); + if (!typeName || !resourceName) { + showError('type and name are required'); + return; + } + const fieldValues = readSubmittedFieldValues(); + // provider_module is taken from the `provider` field if present; + // catalog convention assigns enum_source=providers to a field named + // `provider`, whose value is the module name. + const providerModule = fieldValues.provider || ''; + + try { + const resp = await postJSON(`${API}/generate-config`, { + resource_type: typeName, + resource_name: resourceName, + provider_module: providerModule, + field_values: fieldValues, + evidence: { authz_checked: true, authz_allowed: true }, + }); + if (resp.validation_errors && resp.validation_errors.length > 0) { + for (const e of resp.validation_errors) { + const li = document.createElement('li'); + li.textContent = e; + errBox.appendChild(li); + } + } + out.textContent = resp.yaml_snippet || ''; + document.getElementById('copy').disabled = !resp.yaml_snippet; + } catch (err) { + showError(`generate-config: ${err.message}`); + } +} + +function onCopy() { + const out = document.getElementById('yaml-output'); + if (!out.textContent) return; + navigator.clipboard.writeText(out.textContent).catch(err => { + showError(`copy: ${err.message}`); + }); +} + +document.getElementById('type').addEventListener('change', ev => { + const tm = STATE.types.find(t => t.type === ev.target.value); + renderType(tm || null); +}); +document.getElementById('new-resource-form').addEventListener('submit', onSubmit); +document.getElementById('copy').addEventListener('click', onCopy); + +loadCatalog(); diff --git a/iac/admin/ui_dist/resource.html b/iac/admin/ui_dist/resource.html new file mode 100644 index 00000000..42a1ac2b --- /dev/null +++ b/iac/admin/ui_dist/resource.html @@ -0,0 +1,32 @@ + + + + + + Infra Resource Detail + + + +

    Resource Detail

    +

    « Back to resources

    + +
    +

    Summary

    +
    +
    + +
    +

    Applied Config

    +
    
    +  
    + +
    +

    Outputs

    +
    
    +    

    +
    + + + + + diff --git a/iac/admin/ui_dist/resource.js b/iac/admin/ui_dist/resource.js new file mode 100644 index 00000000..376faa30 --- /dev/null +++ b/iac/admin/ui_dist/resource.js @@ -0,0 +1,123 @@ +// resource.js — drives /admin/infra-admin/resource.html?name=. +// CSP-compliant: external file only. +// +// Endpoint: +// POST /api/infra-admin/resources/{name} → AdminGetResourceOutput +// +// Wire format: protojson with UseProtoNames=true (snake_case fields). +// applied_config_json / outputs_json arrive as base64-encoded `bytes` per +// protojson convention. Decoded to a JSON object for display. + +const API = '/api/infra-admin'; + +function esc(s) { + return String(s == null ? '' : s).replace(/[<>&"']/g, c => ({ + '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', + }[c])); +} + +function showError(err) { + document.getElementById('error').textContent = err ? String(err) : ''; +} + +function fmtTs(unix) { + if (!unix || unix === '0') return ''; + const n = typeof unix === 'string' ? parseInt(unix, 10) : unix; + if (!Number.isFinite(n) || n === 0) return ''; + return new Date(n * 1000).toISOString(); +} + +function decodeProtoBytes(b64) { + if (!b64) return null; + try { + const raw = atob(b64); + return JSON.parse(raw); + } catch (_) { + return b64; // fall back to raw if not JSON-shaped bytes + } +} + +async function postJSON(path, body) { + const resp = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!resp.ok) throw new Error(`${path}: HTTP ${resp.status}`); + const data = await resp.json(); + if (data && data.error) throw new Error(data.error); + return data; +} + +function renderSummary(s) { + const tbody = document.querySelector('#summary-table tbody'); + tbody.innerHTML = ''; + if (!s) return; + const rows = [ + ['Name', s.name], + ['Type', s.type], + ['Provider module', s.provider_module], + ['Provider type', s.provider_type], + ['Provider id', s.provider_id], + ['Status', s.status], + ['App context', s.app_context], + ['Updated', fmtTs(s.updated_at_unix)], + ['Dependencies', (s.dependencies || []).join(', ')], + ]; + for (const [k, v] of rows) { + const tr = document.createElement('tr'); + tr.innerHTML = `${esc(k)}${esc(v)}`; + tbody.appendChild(tr); + } +} + +function renderJSON(elId, obj) { + const el = document.getElementById(elId); + if (obj == null) { + el.textContent = '(empty)'; + return; + } + el.textContent = typeof obj === 'string' + ? obj + : JSON.stringify(obj, null, 2); +} + +function renderRedactionNote(redacted) { + const note = document.getElementById('redacted-note'); + if (!redacted || redacted.length === 0) { + note.textContent = ''; + return; + } + note.textContent = `Redacted output keys: ${redacted.join(', ')}`; + note.classList.add('redacted'); +} + +async function load() { + const params = new URLSearchParams(window.location.search); + const name = params.get('name'); + if (!name) { + showError('missing ?name= query parameter'); + return; + } + try { + // POST /api/infra-admin/resources/{name} — handler reads name from URL + // path; body carries env_name + evidence. Mirror that here. + const body = { + name: name, + evidence: { authz_checked: true, authz_allowed: true }, + }; + const data = await postJSON( + `${API}/resources/${encodeURIComponent(name)}`, + body, + ); + const r = data.resource || {}; + renderSummary(r.summary); + renderJSON('applied-config', decodeProtoBytes(r.applied_config_json)); + renderJSON('outputs-json', decodeProtoBytes(r.outputs_json)); + renderRedactionNote(r.sensitive_outputs_redacted || []); + } catch (err) { + showError(`get resource: ${err.message}`); + } +} + +load(); diff --git a/iac/admin/ui_dist/resources.html b/iac/admin/ui_dist/resources.html new file mode 100644 index 00000000..5e9e508f --- /dev/null +++ b/iac/admin/ui_dist/resources.html @@ -0,0 +1,26 @@ + + + + + + Infra Resources + + + +

    Infra Resources

    +
    + + + + +
    + + + + + +
    NameTypeProviderStatusUpdated
    + + + + diff --git a/iac/admin/ui_dist/resources.js b/iac/admin/ui_dist/resources.js new file mode 100644 index 00000000..ce75a94d --- /dev/null +++ b/iac/admin/ui_dist/resources.js @@ -0,0 +1,110 @@ +// resources.js — drives /admin/infra-admin/resources.html. +// CSP-compliant: external file only, no inline scripts/handlers. +// +// Wire format: protojson with UseProtoNames=true on the handler side. +// Field names match workflow/iac/admin/proto/infra_admin.proto snake_case. +// +// Endpoints: +// POST /api/infra-admin/resources → AdminListResourcesOutput +// POST /api/infra-admin/providers → AdminListProvidersOutput (populates filter dropdown) + +const API = '/api/infra-admin'; +const DETAIL_PATH = '/admin/infra-admin/resource.html'; + +function esc(s) { + return String(s == null ? '' : s).replace(/[<>&"']/g, c => ({ + '<': '<', '>': '>', '&': '&', '"': '"', "'": ''', + }[c])); +} + +function showError(err) { + document.getElementById('error').textContent = err ? String(err) : ''; +} + +function fmtTs(unix) { + if (!unix || unix === '0') return ''; + const n = typeof unix === 'string' ? parseInt(unix, 10) : unix; + if (!Number.isFinite(n) || n === 0) return ''; + return new Date(n * 1000).toISOString(); +} + +async function postJSON(path, body) { + const resp = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!resp.ok) throw new Error(`${path}: HTTP ${resp.status}`); + const data = await resp.json(); + if (data && data.error) throw new Error(data.error); + return data; +} + +async function loadProviders() { + try { + const data = await postJSON(`${API}/providers`, { + evidence: { authz_checked: true, authz_allowed: true }, + }); + const sel = document.getElementById('filter-provider'); + // Preserve "all providers" first option. + while (sel.options.length > 1) sel.remove(1); + for (const p of (data.providers || [])) { + const opt = document.createElement('option'); + opt.value = p.module_name; + opt.textContent = `${p.module_name} (${p.provider_type || '?'})`; + sel.appendChild(opt); + } + } catch (err) { + showError(`provider list: ${err.message}`); + } +} + +function renderTable(rows) { + const tbody = document.querySelector('#resources tbody'); + tbody.innerHTML = ''; + if (rows.length === 0) { + const tr = document.createElement('tr'); + tr.innerHTML = 'No resources match the current filters.'; + tbody.appendChild(tr); + return; + } + for (const r of rows) { + const tr = document.createElement('tr'); + const href = `${DETAIL_PATH}?name=${encodeURIComponent(r.name || '')}`; + tr.innerHTML = ` + ${esc(r.name)} + ${esc(r.type)} + ${esc(r.provider_module)}${r.provider_type ? ' / ' + esc(r.provider_type) : ''} + ${esc(r.status)} + ${esc(fmtTs(r.updated_at_unix))} + Detail`; + tbody.appendChild(tr); + } +} + +async function fetchResources() { + showError(''); + const body = { + type_filter: document.getElementById('filter-type').value.trim(), + provider_filter: document.getElementById('filter-provider').value, + app_context_filter: document.getElementById('filter-app-context').value.trim(), + evidence: { authz_checked: true, authz_allowed: true }, + }; + try { + const data = await postJSON(`${API}/resources`, body); + renderTable(data.resources || []); + } catch (err) { + showError(`list resources: ${err.message}`); + renderTable([]); + } +} + +document.getElementById('refresh').addEventListener('click', fetchResources); +document.getElementById('filter-type').addEventListener('change', fetchResources); +document.getElementById('filter-provider').addEventListener('change', fetchResources); +document.getElementById('filter-app-context').addEventListener('change', fetchResources); + +(async () => { + await loadProviders(); + await fetchResources(); +})(); diff --git a/iac/admin/ui_dist/styles.css b/iac/admin/ui_dist/styles.css new file mode 100644 index 00000000..9b667c94 --- /dev/null +++ b/iac/admin/ui_dist/styles.css @@ -0,0 +1,28 @@ +/* infra-admin: minimal dense operational styling. + Embedded into the host workflow binary via workflow/iac/admin/ui.go. + CSP: default-src 'self'; style-src 'self' — no inline styles, no @import. */ + +* { box-sizing: border-box; } +body { font: 14px/1.45 system-ui, sans-serif; margin: 1rem; color: #1a1a1a; background: #fafafa; } +h1 { font-size: 1.3rem; margin: 0 0 1rem; } +h2 { font-size: 1.1rem; margin: 1.2rem 0 0.5rem; } + +#filters, .form-controls { display: flex; gap: 0.5rem; margin-bottom: 0.8rem; align-items: center; flex-wrap: wrap; } +input, select, button { font: inherit; padding: 0.3rem 0.5rem; border: 1px solid #ccc; border-radius: 3px; background: #fff; } +button { cursor: pointer; background: #f0f0f0; } +button:hover { background: #e6e6e6; } +button[disabled] { cursor: not-allowed; opacity: 0.5; } + +table { border-collapse: collapse; width: 100%; background: #fff; } +th, td { padding: 0.4rem 0.6rem; border-bottom: 1px solid #eee; text-align: left; vertical-align: top; } +th { background: #f4f4f4; font-weight: 600; } +tr:hover td { background: #fafaff; } + +#error { color: #b00020; margin-top: 0.5rem; min-height: 1.2em; } +#yaml-output { background: #f7f7f7; border: 1px solid #ddd; padding: 0.7rem; overflow-x: auto; font: 13px/1.4 ui-monospace, monospace; } +.field-row { display: grid; grid-template-columns: 12rem 1fr; gap: 0.5rem; margin-bottom: 0.4rem; align-items: center; } +.field-label { font-weight: 500; } +.field-help { color: #666; font-size: 0.85em; } +.array-field { display: flex; flex-direction: column; gap: 0.25rem; } +.array-row { display: flex; gap: 0.3rem; } +.redacted { color: #888; font-style: italic; } From 16ea059e2a02546f76930ca981e861207c849741 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:58:05 -0400 Subject: [PATCH 13/34] feat(iac/admin/catalog): T7b fill 13 typed Configs + freeform audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Populates the T7a catalog skeleton via init() reassigning catalogEntries and freeformReasons (Go forbids cross-file var redeclaration, so init() is the documented seam — see catalog.go header comment from spec-reviewer T7a comment-nit). Coverage: all 13 typed `infra.*` Configs from workflow-plugin-infra/internal/contracts/infra.proto: vpc, container_service, k8s_cluster, database, cache, load_balancer, dns, registry, api_gateway, firewall, iam_role, storage, certificate. Each entry uses the (provider enum_dynamic, region enum_dynamic depends_on=provider) prefix via shared providerField()/regionField() helpers. Per-type fields follow the design's selectable-over-free-text contract: enum/enum_dynamic/bool/number-with-bounds wherever a finite domain exists; only deliberately opaque values (CIDR, image tag, version, domain, rule DSL, ARN) drop to Kind="string" / "array_string" — each annotated with a paired FREEFORM_OK reason in freeformReasons. Tests: - fields_audit_test.go: NoUnannotatedFreeText, AllExpectedTypesRegistered, EveryTypeHasProviderAndRegion, EnumDynamicHasSource. - catalog_test.go: updated T7a skeleton tests for post-T7b state (TestNew now asserts populated, FreeformReason missing-entry probe retargeted to a genuinely missing field). Design: docs/plans/2026-05-27-infra-admin-dynamic-design.md §FieldSpec Catalog (lines ~410-445). --- iac/admin/catalog/catalog_test.go | 39 ++- iac/admin/catalog/fields.go | 454 +++++++++++++++++++++++++ iac/admin/catalog/fields_audit_test.go | 120 +++++++ 3 files changed, 596 insertions(+), 17 deletions(-) create mode 100644 iac/admin/catalog/fields.go create mode 100644 iac/admin/catalog/fields_audit_test.go diff --git a/iac/admin/catalog/catalog_test.go b/iac/admin/catalog/catalog_test.go index bf0d3b26..2792a2e0 100644 --- a/iac/admin/catalog/catalog_test.go +++ b/iac/admin/catalog/catalog_test.go @@ -6,19 +6,19 @@ import ( "github.com/GoCodeAlone/workflow/iac/admin/catalog" ) -// TestNew_ReturnsNonNilEmptyCatalog pins the T7a skeleton contract: -// New() must return a usable catalog with zero entries. T7b fills the -// 13 typed Configs separately so this skeleton is callable from T5/T6 -// while the entry table is still being authored. Per plan §Task 7a -// Step 1. -func TestNew_ReturnsNonNilEmptyCatalog(t *testing.T) { +// TestNew_ReturnsPopulatedCatalog pins the post-T7b contract: New() +// returns a usable catalog populated by fields.go's init() with the +// 13 typed `infra.*` Configs. The original T7a skeleton expected an +// empty catalog; T7b's fields.go reassigns catalogEntries from init() +// so callers of New() see the full table. Per plan §Task 7b. +func TestNew_ReturnsPopulatedCatalog(t *testing.T) { cat := catalog.New() if cat == nil { t.Fatal("catalog.New() returned nil") } types := cat.AllTypes() - if len(types) != 0 { - t.Errorf("expected empty AllTypes() on skeleton catalog, got %v", types) + if len(types) == 0 { + t.Fatal("AllTypes() returned no entries; T7b should have populated 13 typed Configs") } } @@ -38,18 +38,23 @@ func TestGet_MissingTypeReturnsFalse(t *testing.T) { } } -// TestFreeformReason_MissingEntryReturnsFalse pins the -// package-level FreeformReason signature used by the T7b audit test: -// when no FREEFORM_OK annotation exists for the requested field, the -// function returns ("", false). The skeleton always returns false -// since the annotation map is empty; T7b populates the map alongside -// its FieldSpec entries. +// TestFreeformReason_MissingEntryReturnsFalse pins the package-level +// FreeformReason signature: when no FREEFORM_OK annotation exists for +// the requested {typeName, fieldName}, the function returns ("", false). +// +// Post-T7b inputs: use a genuinely missing pair (infra.vpc has no +// `nope` field) since T7b populated annotations for the previously +// empty (infra.vpc, cidr) pair used in the skeleton test. func TestFreeformReason_MissingEntryReturnsFalse(t *testing.T) { - reason, ok := catalog.FreeformReason("infra.vpc", "cidr") + reason, ok := catalog.FreeformReason("infra.vpc", "nope_missing_field") if ok { - t.Errorf("FreeformReason on empty catalog returned ok=true (reason=%q)", reason) + t.Errorf("FreeformReason for missing field returned ok=true (reason=%q)", reason) } if reason != "" { - t.Errorf("FreeformReason returned reason=%q on empty catalog, want \"\"", reason) + t.Errorf("FreeformReason returned reason=%q for missing field, want \"\"", reason) + } + // Cross-check: also asserts unknown typeName returns false. + if r, ok := catalog.FreeformReason("infra.unknown_type", "anything"); ok || r != "" { + t.Errorf("FreeformReason for unknown type returned ok=%v reason=%q, want false/\"\"", ok, r) } } diff --git a/iac/admin/catalog/fields.go b/iac/admin/catalog/fields.go new file mode 100644 index 00000000..052feda7 --- /dev/null +++ b/iac/admin/catalog/fields.go @@ -0,0 +1,454 @@ +// fields.go (T7b) populates the package-level catalogEntries and +// freeformReasons tables that T7a's New() / FreeformReason() expose. +// +// Per the T7a header comment (catalog.go), Go forbids redeclaring a +// package-level var across files in the same package, so this file +// reassigns the seams from init() rather than re-declaring them. +// +// Coverage maps the 13 typed `infra.*` Configs in +// workflow-plugin-infra/internal/contracts/infra.proto to their +// form-builder FieldSpecs. The catalog_proto_parity_test.go (T9) walks +// the vendored proto and asserts every non-allowlisted *Config message +// is covered here. +// +// Selectable-over-free-text contract: every Kind=="string" or +// Kind=="array_string" entry MUST carry a matching reason in +// freeformReasons[typeName][fieldName]. The audit test in +// fields_audit_test.go (T7b) enforces this. +// +// Design source: docs/plans/2026-05-27-infra-admin-dynamic-design.md +// §FieldSpec Catalog (lines ~410-445). + +package catalog + +func init() { + catalogEntries = func() map[string][]FieldSpec { + return map[string][]FieldSpec{ + // ---- infra.vpc → VPCConfig (proto §65) ---- + "infra.vpc": { + providerField(), + regionField(), + { + Name: "cidr", + Label: "CIDR block", + Kind: "string", // FREEFORM_OK: arbitrary RFC1918 / public-IP range + Required: true, + Description: "IPv4 CIDR (e.g. 10.0.0.0/16); per-provider validation runs on infra plan", + }, + { + Name: "availability_zones", + Label: "Availability zones", + Kind: "array_enum_dynamic", + EnumSource: "regions", + DependsOnField: "provider", + ElementKind: "enum_dynamic", + MinCount: 0, + MaxCount: 6, + Description: "AZ codes valid for the chosen provider+region", + }, + }, + + // ---- infra.container_service → ContainerServiceConfig (proto §17) ---- + "infra.container_service": { + providerField(), + regionField(), + { + Name: "image", + Label: "Container image", + Kind: "string", // FREEFORM_OK: registry tag (registry/path:tag) + Required: true, + Description: "Fully-qualified image tag, e.g. registry.example.com/svc:1.4.2", + }, + { + Name: "ports", + Label: "Container ports", + Kind: "array_number", + ElementKind: "number", + MinCount: 1, + MaxCount: 20, + Description: "TCP/UDP listen ports (1-65535)", + }, + { + Name: "replicas", + Label: "Replicas", + Kind: "number", + Required: true, + MinCount: 1, + MaxCount: 100, + DefaultValue: "1", + }, + }, + + // ---- infra.k8s_cluster → K8SClusterConfig (proto §29) ---- + "infra.k8s_cluster": { + providerField(), + regionField(), + { + Name: "version", + Label: "Kubernetes version", + Kind: "enum", + Required: true, + EnumValues: []string{"1.30", "1.29", "1.28"}, + Description: "Conservative shared version list. Switch to enum_dynamic + EnumSource=k8s-versions when per-provider variance matters.", + }, + { + Name: "node_count", + Label: "Node count", + Kind: "number", + Required: true, + MinCount: 1, + MaxCount: 1000, + DefaultValue: "3", + }, + { + Name: "node_size", + Label: "Node size", + Kind: "enum_dynamic", + EnumSource: "sizes", + Required: true, + DependsOnField: "", // sizes catalog is provider-independent in v1 + DefaultValue: "m", + }, + }, + + // ---- infra.database → DatabaseConfig (proto §40) ---- + "infra.database": { + providerField(), + regionField(), + { + Name: "engine", + Label: "Database engine", + Kind: "enum_dynamic", + EnumSource: "engines", + DependsOnField: "provider", + Required: true, + }, + { + Name: "version", + Label: "Engine version", + Kind: "string", // FREEFORM_OK: engine-specific (e.g. 15.5 for postgres) + Required: true, + Description: "Engine-specific version string (e.g. 15.5 for postgres)", + }, + { + Name: "size", + Label: "Instance size", + Kind: "enum", + Required: true, + EnumValues: []string{"xs", "s", "m", "l", "xl"}, + DefaultValue: "m", + }, + { + Name: "storage_gb", + Label: "Storage (GB)", + Kind: "number", + Required: true, + MinCount: 10, + MaxCount: 4096, + DefaultValue: "20", + }, + { + Name: "multi_az", + Label: "Multi-AZ", + Kind: "bool", + DefaultValue: "false", + }, + }, + + // ---- infra.cache → CacheConfig (proto §53) ---- + "infra.cache": { + providerField(), + regionField(), + { + Name: "engine", + Label: "Cache engine", + Kind: "enum", + Required: true, + EnumValues: []string{"redis", "memcached", "valkey"}, + DefaultValue: "redis", + }, + { + Name: "version", + Label: "Engine version", + Kind: "string", // FREEFORM_OK: engine-specific version + Required: true, + Description: "Engine-specific version string", + }, + { + Name: "size", + Label: "Instance size", + Kind: "enum", + Required: true, + EnumValues: []string{"xs", "s", "m", "l", "xl"}, + DefaultValue: "m", + }, + { + Name: "nodes", + Label: "Node count", + Kind: "number", + Required: true, + MinCount: 1, + MaxCount: 12, + DefaultValue: "1", + }, + }, + + // ---- infra.load_balancer → LoadBalancerConfig (proto §75) ---- + "infra.load_balancer": { + providerField(), + regionField(), + { + Name: "scheme", + Label: "Scheme", + Kind: "enum", + Required: true, + EnumValues: []string{"internet-facing", "internal"}, + DefaultValue: "internet-facing", + }, + { + Name: "ports", + Label: "Listener ports", + Kind: "array_number", + ElementKind: "number", + MinCount: 1, + MaxCount: 20, + Description: "TCP listener ports (1-65535)", + }, + }, + + // ---- infra.dns → DNSConfig (proto §85) ---- + "infra.dns": { + providerField(), + regionField(), + { + Name: "zone", + Label: "DNS zone", + Kind: "string", // FREEFORM_OK: domain name (apex) + Required: true, + Description: "Apex domain (e.g. example.com)", + }, + { + Name: "record", + Label: "Record name", + Kind: "string", // FREEFORM_OK: subdomain label + Required: true, + Description: "Subdomain label within the zone (e.g. api). rrtype is implicit per provider driver.", + }, + { + Name: "target", + Label: "Target", + Kind: "string", // FREEFORM_OK: IP address or domain + Required: true, + Description: "Record target — IPv4/IPv6 address or fully-qualified domain", + }, + }, + + // ---- infra.registry → RegistryConfig (proto §96) ---- + "infra.registry": { + providerField(), + regionField(), + { + Name: "name", + Label: "Registry name", + Kind: "string", // FREEFORM_OK: opaque provider-namespaced label + Required: true, + Description: "Provider-namespaced registry name; uniqueness scoped to the account", + }, + { + Name: "public", + Label: "Public registry", + Kind: "bool", + DefaultValue: "false", + }, + }, + + // ---- infra.api_gateway → APIGatewayConfig (proto §106) ---- + "infra.api_gateway": { + providerField(), + regionField(), + { + Name: "protocol", + Label: "Protocol", + Kind: "enum", + Required: true, + EnumValues: []string{"http", "https", "grpc", "websocket"}, + DefaultValue: "https", + }, + { + Name: "routes", + Label: "Routes", + Kind: "array_string", // FREEFORM_OK: opaque per-provider routing DSL + ElementKind: "string", + MinCount: 1, + MaxCount: 100, + Description: "Route specs in the provider's routing DSL (e.g. `/api/* → backend:8080`)", + }, + }, + + // ---- infra.firewall → FirewallConfig (proto §116) ---- + "infra.firewall": { + providerField(), + regionField(), + { + Name: "ingress", + Label: "Ingress rules", + Kind: "array_string", // FREEFORM_OK: per-provider rule DSL + ElementKind: "string", + MinCount: 0, + MaxCount: 100, + Description: "Ingress rule DSL (e.g. `tcp:443:0.0.0.0/0`)", + }, + { + Name: "egress", + Label: "Egress rules", + Kind: "array_string", // FREEFORM_OK: per-provider rule DSL + ElementKind: "string", + MinCount: 0, + MaxCount: 100, + Description: "Egress rule DSL (e.g. `tcp:0.0.0.0/0:443`)", + }, + }, + + // ---- infra.iam_role → IAMRoleConfig (proto §126) ---- + "infra.iam_role": { + providerField(), + regionField(), + { + Name: "name", + Label: "Role name", + Kind: "string", // FREEFORM_OK: provider-namespaced role label + Required: true, + Description: "Provider-namespaced role identifier", + }, + { + Name: "policies", + Label: "Attached policies", + Kind: "array_string", // FREEFORM_OK: opaque policy ARNs/IDs + ElementKind: "string", + MinCount: 0, + MaxCount: 50, + Description: "Policy ARNs (AWS) / role IDs (GCP) / built-in role names (Azure)", + }, + }, + + // ---- infra.storage → StorageConfig (proto §136) ---- + "infra.storage": { + providerField(), + regionField(), + { + Name: "name", + Label: "Bucket name", + Kind: "string", // FREEFORM_OK: globally-unique-per-provider bucket name + Required: true, + Description: "Provider-unique bucket / container name", + }, + { + Name: "class", + Label: "Storage class", + Kind: "enum", + Required: true, + EnumValues: []string{"standard", "cold", "archive", "nearline", "coldline"}, + DefaultValue: "standard", + }, + { + Name: "versioning", + Label: "Versioning enabled", + Kind: "bool", + DefaultValue: "false", + }, + }, + + // ---- infra.certificate → CertificateConfig (proto §147) ---- + "infra.certificate": { + providerField(), + regionField(), + { + Name: "domain", + Label: "Primary domain", + Kind: "string", // FREEFORM_OK: fully-qualified domain name + Required: true, + Description: "Primary FQDN (e.g. example.com)", + }, + { + Name: "subject_alt_names", + Label: "Subject alternative names", + Kind: "array_string", // FREEFORM_OK: SANs are arbitrary FQDNs + ElementKind: "string", + MinCount: 0, + MaxCount: 100, + Description: "Additional FQDNs covered by this certificate", + }, + }, + } + } + + freeformReasons = map[string]map[string]string{ + "infra.vpc": { + "cidr": "arbitrary RFC1918 / public-IP range; per-provider validation runs on infra plan", + }, + "infra.container_service": { + "image": "container registry tag — opaque registry/path:tag", + }, + "infra.database": { + "version": "engine-specific version string (postgres 15.5, mysql 8.0, etc.)", + }, + "infra.cache": { + "version": "engine-specific version string (redis 7.2, memcached 1.6.x, etc.)", + }, + "infra.dns": { + "zone": "apex domain — arbitrary FQDN", + "record": "subdomain label within the zone", + "target": "IPv4/IPv6 address or FQDN", + }, + "infra.registry": { + "name": "provider-namespaced registry name (no fixed enumeration)", + }, + "infra.api_gateway": { + "routes": "per-provider routing DSL — opaque rule strings", + }, + "infra.firewall": { + "ingress": "per-provider rule DSL (e.g. tcp:443:0.0.0.0/0)", + "egress": "per-provider rule DSL", + }, + "infra.iam_role": { + "name": "provider-namespaced role identifier", + "policies": "opaque policy identifiers (ARNs / role IDs)", + }, + "infra.storage": { + "name": "globally-unique-per-provider bucket / container name", + }, + "infra.certificate": { + "domain": "primary FQDN — arbitrary domain", + "subject_alt_names": "arbitrary FQDNs", + }, + } +} + +// providerField returns the common provider FieldSpec used by every +// typed `infra.*` Config. Defined as a helper so the catalog table +// stays compact and a single field shape (Name, EnumSource, etc.) +// applies consistently across all 13 entries. +func providerField() FieldSpec { + return FieldSpec{ + Name: "provider", + Label: "Provider module", + Kind: "enum_dynamic", + EnumSource: "providers", + Required: true, + Description: "Name of the iac.provider module (host YAML `name:` of the iac.provider entry)", + } +} + +// regionField is the common region FieldSpec — enum_dynamic with a +// per-provider region catalog, dependent on the `provider` field. +func regionField() FieldSpec { + return FieldSpec{ + Name: "region", + Label: "Region", + Kind: "enum_dynamic", + EnumSource: "regions", + DependsOnField: "provider", + Required: true, + Description: "Provider-specific region code (e.g. nyc1 for digitalocean, us-east-1 for aws)", + } +} diff --git a/iac/admin/catalog/fields_audit_test.go b/iac/admin/catalog/fields_audit_test.go new file mode 100644 index 00000000..9f29fdf1 --- /dev/null +++ b/iac/admin/catalog/fields_audit_test.go @@ -0,0 +1,120 @@ +package catalog_test + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" +) + +// TestCatalog_NoUnannotatedFreeText enforces the selectable-over-free-text +// contract from the design (§FieldSpec Catalog). Every catalog field with +// Kind "string" or "array_string" MUST carry a matching FREEFORM_OK reason +// in freeformReasons[typeName][fieldName]. +// +// New free-text fields added without a paired reason fail this test — +// the form-builder ergonomics depend on dropdowns being the default and +// text inputs being a deliberate exception with documented justification. +func TestCatalog_NoUnannotatedFreeText(t *testing.T) { + cat := catalog.New() + for _, typeName := range cat.AllTypes() { + fields, ok := cat.Get(typeName) + if !ok { + t.Fatalf("AllTypes returned %q but Get returned !ok", typeName) + } + for _, f := range fields { + if f.Kind != "string" && f.Kind != "array_string" { + continue + } + reason, hasReason := catalog.FreeformReason(typeName, f.Name) + if !hasReason || reason == "" { + t.Errorf("%s.%s is kind=%s but has no FREEFORM_OK reason in freeformReasons", + typeName, f.Name, f.Kind) + } + } + } +} + +// TestCatalog_AllExpectedTypesRegistered guards against accidental +// drop of one of the 13 typed Configs. The full proto-parity test +// (T9 catalog_proto_parity_test.go) walks the vendored proto and +// asserts the cross-product; this test is a fast unit-level +// canary that runs without filesystem dependencies. +func TestCatalog_AllExpectedTypesRegistered(t *testing.T) { + expected := []string{ + "infra.api_gateway", + "infra.cache", + "infra.certificate", + "infra.container_service", + "infra.database", + "infra.dns", + "infra.firewall", + "infra.iam_role", + "infra.k8s_cluster", + "infra.load_balancer", + "infra.registry", + "infra.storage", + "infra.vpc", + } + cat := catalog.New() + got := cat.AllTypes() + if len(got) != len(expected) { + t.Fatalf("AllTypes len=%d, want %d. got=%v", len(got), len(expected), got) + } + for i, name := range expected { + if got[i] != name { + t.Errorf("AllTypes[%d] = %q, want %q (full got=%v)", i, got[i], name, got) + } + } +} + +// TestCatalog_EveryTypeHasProviderAndRegion confirms the universal +// (provider, region) prefix is wired on every entry. The form-builder +// JS in new.js relies on this convention for dependent-dropdown +// wiring (region depends_on provider). +func TestCatalog_EveryTypeHasProviderAndRegion(t *testing.T) { + cat := catalog.New() + for _, typeName := range cat.AllTypes() { + fields, _ := cat.Get(typeName) + var hasProvider, hasRegion bool + for _, f := range fields { + if f.Name == "provider" { + hasProvider = true + if f.Kind != "enum_dynamic" || f.EnumSource != "providers" { + t.Errorf("%s.provider: kind=%s enum_source=%s, want enum_dynamic/providers", + typeName, f.Kind, f.EnumSource) + } + } + if f.Name == "region" { + hasRegion = true + if f.DependsOnField != "provider" { + t.Errorf("%s.region: depends_on_field=%q, want \"provider\"", + typeName, f.DependsOnField) + } + } + } + if !hasProvider { + t.Errorf("%s: missing required `provider` field", typeName) + } + if !hasRegion { + t.Errorf("%s: missing required `region` field", typeName) + } + } +} + +// TestCatalog_EnumDynamicHasSource asserts every enum_dynamic field +// declares a non-empty EnumSource so the form-builder JS knows which +// resolver to invoke. An empty EnumSource produces an empty dropdown +// at render time, which is a footgun. +func TestCatalog_EnumDynamicHasSource(t *testing.T) { + cat := catalog.New() + for _, typeName := range cat.AllTypes() { + fields, _ := cat.Get(typeName) + for _, f := range fields { + if f.Kind == "enum_dynamic" || f.Kind == "array_enum_dynamic" { + if f.EnumSource == "" { + t.Errorf("%s.%s: kind=%s but EnumSource is empty", typeName, f.Name, f.Kind) + } + } + } + } +} From b7e47ab7a97f9deaf57a087fbc416b6a13e3e1d2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 12:59:56 -0400 Subject: [PATCH 14/34] feat(iac/admin/catalog): T8 region + engine catalogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RegionCatalog and EngineCatalog populate the new-resource form-builder's enum_dynamic dropdowns whose EnumSource is "regions" / "engines" and DependsOnField is "provider" (set by T7b). Coverage per design §FieldSpec Catalog (lines ~445): - regions: digitalocean (10), aws (9), gcp (5), azure (4), stub (2). - engines: digitalocean (4: pg/mysql/mongo/redis), aws (6: + dynamodb/aurora), gcp (4: + spanner), azure (4: + cosmos), stub (1: pg only). API mirrors design's `For(providerType) []string` + `Providers() []string`. Both catalogs return defensive slice copies — caller-side mutation cannot corrupt the catalog. Nil-receiver safe for the populateProviderTypes degradation path (per plan-adversarial I3) where unknown provider types should fall through to free-text input rather than crash. v1 is local-only; IaCProviderRegionLister gRPC service extension filed as follow-up post-PR-1 merge (per scope manifest §Out of scope). Tests: NonEmptyPerProvider, DefensiveCopy, UncataloguedProviderReturnsNil, NilReceiver for both. RegionCatalog: DigitalOceanSet verbatim assert. EngineCatalog: AWSSuperset (postgres/mysql/mongo/redis/dynamodb/aurora), StubMinimal (postgres only). --- iac/admin/catalog/engines.go | 70 ++++++++++++++++++++++++ iac/admin/catalog/engines_test.go | 80 ++++++++++++++++++++++++++++ iac/admin/catalog/regions.go | 88 +++++++++++++++++++++++++++++++ iac/admin/catalog/regions_test.go | 83 +++++++++++++++++++++++++++++ 4 files changed, 321 insertions(+) create mode 100644 iac/admin/catalog/engines.go create mode 100644 iac/admin/catalog/engines_test.go create mode 100644 iac/admin/catalog/regions.go create mode 100644 iac/admin/catalog/regions_test.go diff --git a/iac/admin/catalog/engines.go b/iac/admin/catalog/engines.go new file mode 100644 index 00000000..d0dab277 --- /dev/null +++ b/iac/admin/catalog/engines.go @@ -0,0 +1,70 @@ +// engines.go (T8) hosts the per-provider database/cache engine +// catalog used by the new-resource form-builder for enum_dynamic + +// EnumSource="engines" fields (infra.database.engine, etc.) that +// depend on the chosen provider. +// +// Like the region catalog, v1 is local-only. Same refresh cadence — +// review on every minor provider-plugin release. +// +// Last reviewed: 2026-05-27. + +package catalog + +import "sort" + +// EngineCatalog maps provider-type strings to their supported +// database engines. Cache engines are handled separately via a fixed +// catalog entry (Kind="enum" with EnumValues=[redis, memcached, +// valkey]) — see fields.go infra.cache.engine — because the engine +// matrix there is uniform across providers in v1. +// +// The provider-type string convention matches RegionCatalog: comes +// from the iac.provider module's `provider:` config field +// ("digitalocean", "aws", "gcp", "azure", "stub"). +type EngineCatalog struct { + byProviderType map[string][]string +} + +// NewEngineCatalog returns the v1 local engine catalog. Coverage +// reflects the typed drivers shipped by each cloud provider plugin +// as of 2026-05-27. AWS adds dynamodb + aurora atop the common +// postgres/mysql/mongodb/redis set per the design table. +func NewEngineCatalog() *EngineCatalog { + return &EngineCatalog{byProviderType: map[string][]string{ + "digitalocean": {"postgres", "mysql", "mongodb", "redis"}, + "aws": {"postgres", "mysql", "mongodb", "redis", "dynamodb", "aurora"}, + "gcp": {"postgres", "mysql", "redis", "spanner"}, + "azure": {"postgres", "mysql", "redis", "cosmos"}, + "stub": {"postgres"}, + }} +} + +// For returns a defensive copy of catalogued engines for the given +// provider type, or nil when uncatalogued. Defensive copy parallels +// RegionCatalog.For semantics. +func (e *EngineCatalog) For(providerType string) []string { + if e == nil { + return nil + } + src, ok := e.byProviderType[providerType] + if !ok { + return nil + } + out := make([]string, len(src)) + copy(out, src) + return out +} + +// Providers returns the sorted list of catalogued provider-type +// keys. Symmetric with RegionCatalog.Providers. +func (e *EngineCatalog) Providers() []string { + if e == nil { + return nil + } + out := make([]string, 0, len(e.byProviderType)) + for k := range e.byProviderType { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/iac/admin/catalog/engines_test.go b/iac/admin/catalog/engines_test.go new file mode 100644 index 00000000..53b06c5a --- /dev/null +++ b/iac/admin/catalog/engines_test.go @@ -0,0 +1,80 @@ +package catalog_test + +import ( + "slices" + "testing" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" +) + +// TestEngineCatalog_NonEmptyPerProvider mirrors region catalog's +// non-empty invariant for database engines. +func TestEngineCatalog_NonEmptyPerProvider(t *testing.T) { + c := catalog.NewEngineCatalog() + for _, p := range c.Providers() { + engs := c.For(p) + if len(engs) == 0 { + t.Errorf("provider %q returned empty engine slice", p) + } + } +} + +// TestEngineCatalog_DefensiveCopy parallels RegionCatalog's slice- +// mutation guard. +func TestEngineCatalog_DefensiveCopy(t *testing.T) { + c := catalog.NewEngineCatalog() + a := c.For("aws") + if len(a) == 0 { + t.Fatal("aws returned no engines") + } + a[0] = "MUTATED" + b := c.For("aws") + if b[0] == "MUTATED" { + t.Errorf("catalog mutated via caller-side slice write: b[0]=%q", b[0]) + } +} + +// TestEngineCatalog_UncataloguedProviderReturnsNil mirrors region +// catalog's degradation contract. +func TestEngineCatalog_UncataloguedProviderReturnsNil(t *testing.T) { + c := catalog.NewEngineCatalog() + if got := c.For("nonexistent-cloud"); got != nil { + t.Errorf("For(unknown) = %v, want nil", got) + } +} + +// TestEngineCatalog_AWSSuperset asserts AWS is a strict superset of +// the common postgres/mysql/mongodb/redis set per the design's engine +// matrix (AWS additionally has dynamodb + aurora). +func TestEngineCatalog_AWSSuperset(t *testing.T) { + c := catalog.NewEngineCatalog() + aws := c.For("aws") + required := []string{"postgres", "mysql", "mongodb", "redis", "dynamodb", "aurora"} + for _, want := range required { + if !slices.Contains(aws, want) { + t.Errorf("aws missing required engine %q. got=%v", want, aws) + } + } +} + +// TestEngineCatalog_StubMinimal asserts the stub provider exposes +// the minimum engine surface for scenario tests — postgres only, +// per the design table. +func TestEngineCatalog_StubMinimal(t *testing.T) { + c := catalog.NewEngineCatalog() + stub := c.For("stub") + if len(stub) != 1 || stub[0] != "postgres" { + t.Errorf("stub engines = %v, want [postgres]", stub) + } +} + +// TestEngineCatalog_NilReceiver mirrors RegionCatalog nil-safety. +func TestEngineCatalog_NilReceiver(t *testing.T) { + var c *catalog.EngineCatalog + if got := c.For("aws"); got != nil { + t.Errorf("nil receiver For returned %v, want nil", got) + } + if got := c.Providers(); got != nil { + t.Errorf("nil receiver Providers returned %v, want nil", got) + } +} diff --git a/iac/admin/catalog/regions.go b/iac/admin/catalog/regions.go new file mode 100644 index 00000000..f53a77e2 --- /dev/null +++ b/iac/admin/catalog/regions.go @@ -0,0 +1,88 @@ +// regions.go (T8) hosts the per-provider region catalog used by the +// new-resource form-builder UI when populating the region dropdown for +// enum_dynamic+depends_on=provider fields. +// +// v1 is local-only — the design's IaCProviderRegionLister gRPC service +// extension is filed as a follow-up issue (per scope manifest §Out of +// scope). When a provider plugin lands runtime region listing, swap +// this constant table for the gRPC client. +// +// Refresh cadence: review on every minor upstream provider-plugin +// release. Add the new region codes here; the form-builder picks up +// the change after a host restart. +// +// Last reviewed: 2026-05-27. + +package catalog + +import "sort" + +// RegionCatalog maps provider-type strings (the YAML config +// `provider:` field on iac.provider modules) to their supported +// region codes. The provider-type string comes from +// workflow.plugins.infra.v1.InfraResourceConfig.provider — i.e. +// "digitalocean", "aws", "gcp", "azure", "stub" — NOT the host +// module name. +type RegionCatalog struct { + byProviderType map[string][]string +} + +// NewRegionCatalog returns the v1 local region catalog. Per design +// §FieldSpec Catalog the lists cover the regions surfaced by each +// provider plugin's typed driver as of 2026-05-27. Stub provider is +// included so unit + integration tests have deterministic options. +func NewRegionCatalog() *RegionCatalog { + return &RegionCatalog{byProviderType: map[string][]string{ + "digitalocean": { + "nyc1", "nyc3", "sfo3", "ams3", "sgp1", + "lon1", "fra1", "tor1", "blr1", "syd1", + }, + "aws": { + "us-east-1", "us-east-2", "us-west-1", "us-west-2", + "eu-west-1", "eu-central-1", + "ap-northeast-1", "ap-southeast-1", "ap-southeast-2", + }, + "gcp": { + "us-central1", "us-east1", "us-west1", + "europe-west1", "asia-east1", + }, + "azure": { + "eastus", "westus2", "westeurope", "southeastasia", + }, + "stub": { + "test-region-1", "test-region-2", + }, + }} +} + +// For returns a defensive copy of the catalogued regions for the +// given provider type, or nil when the provider is uncatalogued. +// The defensive copy prevents callers from mutating the catalog +// (the slices are otherwise shared across invocations). +func (r *RegionCatalog) For(providerType string) []string { + if r == nil { + return nil + } + src, ok := r.byProviderType[providerType] + if !ok { + return nil + } + out := make([]string, len(src)) + copy(out, src) + return out +} + +// Providers returns the sorted list of provider-type keys this +// catalog knows about. Useful for tests + diagnostics; not used by +// the form-builder which iterates AdminProviderSummary entries. +func (r *RegionCatalog) Providers() []string { + if r == nil { + return nil + } + out := make([]string, 0, len(r.byProviderType)) + for k := range r.byProviderType { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/iac/admin/catalog/regions_test.go b/iac/admin/catalog/regions_test.go new file mode 100644 index 00000000..b751e004 --- /dev/null +++ b/iac/admin/catalog/regions_test.go @@ -0,0 +1,83 @@ +package catalog_test + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" +) + +// TestRegionCatalog_NonEmptyPerProvider pins the v1 region coverage +// contract: every catalogued provider returns at least one region. +// An empty list would render an empty region dropdown — a footgun +// for the new-resource form-builder. +func TestRegionCatalog_NonEmptyPerProvider(t *testing.T) { + c := catalog.NewRegionCatalog() + for _, p := range c.Providers() { + regs := c.For(p) + if len(regs) == 0 { + t.Errorf("provider %q returned empty region slice", p) + } + } +} + +// TestRegionCatalog_DefensiveCopy verifies callers cannot mutate the +// catalog by writing into the returned slice. The form-builder / +// handler library both receive the slice; an accidental sort or +// append-and-truncate elsewhere would otherwise corrupt subsequent +// invocations. +func TestRegionCatalog_DefensiveCopy(t *testing.T) { + c := catalog.NewRegionCatalog() + a := c.For("digitalocean") + if len(a) == 0 { + t.Fatal("digitalocean returned no regions") + } + a[0] = "MUTATED" + b := c.For("digitalocean") + if b[0] == "MUTATED" { + t.Errorf("catalog mutated via caller-side slice write: b[0]=%q", b[0]) + } +} + +// TestRegionCatalog_UncataloguedProviderReturnsNil pins the contract +// for unknown provider types — handler library degrades gracefully +// by falling back to free-text region input per design's +// populateProviderTypes degradation path. +func TestRegionCatalog_UncataloguedProviderReturnsNil(t *testing.T) { + c := catalog.NewRegionCatalog() + if got := c.For("nonexistent-cloud"); got != nil { + t.Errorf("For(unknown) = %v, want nil", got) + } +} + +// TestRegionCatalog_DigitalOceanSet asserts the design's documented +// DO region set is present verbatim — guards against accidental +// drop / typo when refreshing. +func TestRegionCatalog_DigitalOceanSet(t *testing.T) { + c := catalog.NewRegionCatalog() + got := c.For("digitalocean") + expected := map[string]bool{ + "nyc1": true, "nyc3": true, "sfo3": true, "ams3": true, + "sgp1": true, "lon1": true, "fra1": true, "tor1": true, + "blr1": true, "syd1": true, + } + if len(got) != len(expected) { + t.Errorf("DO regions len=%d, want %d. got=%v", len(got), len(expected), got) + } + for _, r := range got { + if !expected[r] { + t.Errorf("unexpected DO region %q", r) + } + } +} + +// TestRegionCatalog_NilReceiver guards against nil-pointer panics +// when callers pass a zero-value catalog (e.g. degradation mode). +func TestRegionCatalog_NilReceiver(t *testing.T) { + var c *catalog.RegionCatalog + if got := c.For("digitalocean"); got != nil { + t.Errorf("nil receiver For returned %v, want nil", got) + } + if got := c.Providers(); got != nil { + t.Errorf("nil receiver Providers returned %v, want nil", got) + } +} From 5fe88fe4553a64d3168c49c83907e70a006837a0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 13:00:15 -0400 Subject: [PATCH 15/34] =?UTF-8?q?feat(iac/admin/handler):=20T5=20=E2=80=94?= =?UTF-8?q?=20ListResources=20+=20GetResource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 5: the handler library's read-side functions, shared by the host-side infra.admin module's HTTP routes (T15) and the wfctl `infra admin *` CLI subcommands (T19-T20). Files: - handler/authz.go — shared default-deny authz guard. Centralised here so future handlers (T6) use the same refusal-message shape and the "authz" substring (load-bearing per operator-grep convention) is single-sourced. - handler/list_resources.go — ListResources(ctx, store, providers, fieldCat, in). Reads ResourceStates from the iac.state backend, applies type/provider/app_context filters, projects into AdminResourceSummary rows. providers + fieldCat are unused in v1 but preserved in the signature for symmetry with T6 handlers. - handler/get_resource.go — GetResource(ctx, store, in). Reads one ResourceState, JSON-encodes AppliedConfig into bytes applied_config_json, masks sensitive Output keys (per secrets.DefaultSensitiveKeys() — password/secret/token/dsn/ access_key/private_key/api_key/connection_string/secret_key/uri), emits the masked-key list in sensitive_outputs_redacted (sorted for deterministic UI). Design invariants encoded: - Default-deny authz: handlers refuse when evidence is nil OR authz_checked == false OR authz_allowed == false. Refusal surfaces via Output.error per proto tag-100 convention — NOT a Go-level error — so the HTTP transport returns 200 OK + typed payload. The "authz" substring in the error message is pinned by TestListResources_DenyMessageMentionsAuthz. - List view returns no outputs: AdminResourceSummary has no outputs/applied_config fields; secrets stay in state until GetResource. Per design §Secret redaction row. - Sensitive output redaction in GetResource: masked value is the same "(sensitive)" sentinel secrets.MaskSensitiveOutputs uses; sensitive_outputs_redacted lists every key that was masked (sorted, deterministic). - app_context lookup: extracts state.AppliedConfig["labels"] ["app_context"]; empty-filter passes through unlabeled resources (TestListResources_EmptyAppContextSurvivesFilter). - stateToSummary single-source: list + detail paths route through one projection function so the field mapping cannot drift. Lint rationale (`//nolint:nilerr` on 4 error-wrap returns): - Per proto tag-100 convention errors must surface via Output.error, not Go-level errors. The linter flags `err != nil { return nil err }` as a footgun, but here it's the contract. Each site has an explanatory comment. Tests added (TDD, 18 cases pass): - ListResources: HappyPath, DefaultDenyOnMissingEvidence, DefaultDenyOnAuthzNotChecked, DefaultDenyOnAuthzDenied, TypeFilter, ProviderFilterByModuleName, AppContextFilter, CombinedFilters, PopulatesProviderTypeAndModule, EmptyAppContextSurvivesFilter, DenyMessageMentionsAuthz. - GetResource: HappyPath, RedactsSensitiveOutputs (full assertion matrix: masked keys hidden, plain keys preserved, sensitive_outputs_redacted lists exactly the masked set, no non-sensitive leak), NotFound, DefaultDenyOnMissingEvidence, DefaultDenyOnAuthzNotChecked, DefaultDenyOnAuthzDenied, PopulatesSummaryFields. Verified: - GOWORK=off go test ./iac/admin/handler/... green (18 tests; passes against both the post-T7b populated catalog and the T7a-empty skeleton — handler ListResources/GetResource don't depend on catalog content in v1). - GOWORK=off go build ./... clean. - golangci-lint --new-from-rev=origin/main ./iac/admin/... -> 0 issues. Co-Authored-By: Claude Opus 4.7 --- iac/admin/handler/authz.go | 50 ++++ iac/admin/handler/get_resource.go | 115 +++++++++ iac/admin/handler/get_resource_test.go | 193 +++++++++++++++ iac/admin/handler/list_resources.go | 117 ++++++++++ iac/admin/handler/list_resources_test.go | 284 +++++++++++++++++++++++ 5 files changed, 759 insertions(+) create mode 100644 iac/admin/handler/authz.go create mode 100644 iac/admin/handler/get_resource.go create mode 100644 iac/admin/handler/get_resource_test.go create mode 100644 iac/admin/handler/list_resources.go create mode 100644 iac/admin/handler/list_resources_test.go diff --git a/iac/admin/handler/authz.go b/iac/admin/handler/authz.go new file mode 100644 index 00000000..f40858da --- /dev/null +++ b/iac/admin/handler/authz.go @@ -0,0 +1,50 @@ +// Package handler hosts the infra.admin handler library — the shared +// business logic dispatched by both the host-side infra.admin +// workflow module's HTTP routes (T15) and the wfctl `infra admin *` +// CLI subcommands (T19-T20). Functions are pure: they take their +// dependencies as parameters (state store, providers, catalog) and +// return typed adminpb outputs. The HTTP transport + audit logging +// happens at the module layer; the CLI transport happens at wfctl. +// +// Design: docs/plans/2026-05-27-infra-admin-dynamic-design.md §Handler library +// Plan: docs/plans/2026-05-27-infra-admin-dynamic.md (Tasks 5 + 6) +// +// Authz contract (this file): every typed input MUST carry an +// AdminAuthzEvidence whose authz_checked AND authz_allowed are both +// true. The host module attaches admin-auth middleware on every +// registered route; the middleware sets the evidence after running +// authz.Casbin (or whatever the configured authz module is). If the +// evidence is missing or either bit is false, the handler refuses +// the request via the Output.error field (NOT a Go-level error, so +// HTTP transport returns 200 OK with a typed payload that consumers +// must inspect for non-empty error per the proto tag-100 discriminator). +// +// Default-deny semantics: handler refuses unless BOTH bits prove the +// host auth pipeline ran AND approved. A missing evidence means the +// caller bypassed admin auth middleware — likely a wiring bug — and +// must be refused for safety per design §Authz row. +package handler + +import adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + +// authzError returns the operator-facing rejection string when the +// supplied evidence does not meet default-deny criteria. Returns "" +// when evidence is acceptable. Callers funnel the non-empty return +// into Output.error and short-circuit further work. +// +// Per design §Authz: read endpoints require +// authz_checked && authz_allowed. The "authz" substring in the +// message is load-bearing — operator-grep convention and pinned by +// TestListResources_DenyMessageMentionsAuthz. +func authzError(ev *adminpb.AdminAuthzEvidence) string { + if ev == nil { + return "authz evidence missing — admin middleware did not attach to this route (host wiring bug)" + } + if !ev.AuthzChecked { + return "authz check did not run — evidence.authz_checked=false (admin middleware bypassed or misconfigured)" + } + if !ev.AuthzAllowed { + return "authz denied — evidence.authz_allowed=false" + } + return "" +} diff --git a/iac/admin/handler/get_resource.go b/iac/admin/handler/get_resource.go new file mode 100644 index 00000000..effbfa81 --- /dev/null +++ b/iac/admin/handler/get_resource.go @@ -0,0 +1,115 @@ +package handler + +import ( + "context" + "encoding/json" + "sort" + + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/secrets" +) + +// GetResource implements InfraAdminService.GetResource by reading +// the named ResourceState from the host's iac.state backend and +// projecting it into an AdminResourceDetail. AppliedConfig is +// JSON-encoded into applied_config_json verbatim; Outputs are +// MASKED via secrets.MaskSensitiveOutputs against +// secrets.DefaultSensitiveKeys() and then JSON-encoded into +// outputs_json. The masked-key names are surfaced in +// sensitive_outputs_redacted so the UI can render a "redacted" +// affordance per design §Secret redaction: +// +// "GetResource.outputs_json redacts keys matching +// secrets.DefaultSensitiveKeys()." +// +// Per design §Authz row: default-deny when evidence is missing or +// either authz_checked / authz_allowed is false; refusal surfaces +// via Output.error rather than a Go-level error. +// +// Not-found surfaces via Output.error too — the design treats +// missing resources as a non-exceptional condition the UI must +// handle (e.g. a stale URL after a destroy). +func GetResource( + ctx context.Context, + store interfaces.IaCStateStore, + in *adminpb.AdminGetResourceInput, +) (*adminpb.AdminGetResourceOutput, error) { + if msg := authzError(in.GetEvidence()); msg != "" { + return &adminpb.AdminGetResourceOutput{Error: msg}, nil + } + + state, err := store.GetResource(ctx, in.GetName()) + if err != nil { + // Intentional nilerr — see list_resources.go::ListResources for + // the proto tag-100 rationale (errors surface via Output.error, + // not Go-level errors, so HTTP transport returns 200 OK + typed + // payload). + return &adminpb.AdminGetResourceOutput{Error: "get state: " + err.Error()}, nil //nolint:nilerr + } + if state == nil { + return &adminpb.AdminGetResourceOutput{Error: "resource not found: " + in.GetName()}, nil + } + + appliedJSON, err := json.Marshal(state.AppliedConfig) + if err != nil { + return &adminpb.AdminGetResourceOutput{Error: "marshal applied_config: " + err.Error()}, nil //nolint:nilerr + } + + maskedOutputs, redactedKeys := maskOutputsForWire(state.Outputs) + outputsJSON, err := json.Marshal(maskedOutputs) + if err != nil { + return &adminpb.AdminGetResourceOutput{Error: "marshal outputs: " + err.Error()}, nil //nolint:nilerr + } + + return &adminpb.AdminGetResourceOutput{ + Resource: &adminpb.AdminResourceDetail{ + Summary: stateToSummary(state), + AppliedConfigJson: appliedJSON, + OutputsJson: outputsJSON, + ConfigHash: state.ConfigHash, + LastDriftCheckUnix: state.LastDriftCheck.Unix(), + SensitiveOutputsRedacted: redactedKeys, + }, + }, nil +} + +// maskOutputsForWire returns a copy of outputs with sensitive values +// masked + the sorted list of keys that WERE masked. Implementation +// is independent of secrets.MaskSensitiveOutputs (which doesn't +// surface the list of redacted keys); see design §Secret redaction. +// +// "Sensitive" means a key matches one of secrets.DefaultSensitiveKeys() +// — the host-side authoritative list. Future enhancement: merge +// driver-specific keys via secrets.MergeSensitiveKeys; v1 sticks to +// defaults so the masking surface is stable across providers. +// +// Returns the same underlying map reference when outputs has no +// sensitive keys (no copy needed); on any redaction it returns a +// new map so the caller cannot accidentally mutate the original +// state's outputs. +func maskOutputsForWire(outputs map[string]any) (map[string]any, []string) { + if len(outputs) == 0 { + return outputs, nil + } + sensitive := map[string]struct{}{} + for _, k := range secrets.DefaultSensitiveKeys() { + sensitive[k] = struct{}{} + } + var redacted []string + masked := make(map[string]any, len(outputs)) + for k, v := range outputs { + if _, ok := sensitive[k]; ok { + masked[k] = "(sensitive)" + redacted = append(redacted, k) + } else { + masked[k] = v + } + } + sort.Strings(redacted) // deterministic ordering for snapshot tests + diff-friendly UI + if len(redacted) == 0 { + // No redaction happened — return original to avoid an unnecessary copy. + return outputs, nil + } + return masked, redacted +} diff --git a/iac/admin/handler/get_resource_test.go b/iac/admin/handler/get_resource_test.go new file mode 100644 index 00000000..b41361c8 --- /dev/null +++ b/iac/admin/handler/get_resource_test.go @@ -0,0 +1,193 @@ +package handler_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/iac/admin/handler" + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// seedDetailFixture returns a store with one resource carrying +// sensitive output keys + applied config so the GetResource test can +// exercise the redaction path. +func seedDetailFixture() *fakeStateStore { + now := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + return &fakeStateStore{ + resources: []interfaces.ResourceState{{ + ID: "db-prod", + Name: "db-prod", + Type: "infra.database", + Provider: "digitalocean", + ProviderRef: "do-prod", + ProviderID: "db-001", + ConfigHash: "sha256:cafef00d", + AppliedConfig: map[string]any{"engine": "postgres", "size": "m"}, + Outputs: map[string]any{ + "id": "db-001", + "endpoint": "db.internal:5432", + "password": "super-secret-pw", + "access_key": "AKIA...", + "private_key": "-----BEGIN...", + "plain_field": "ok-to-show", + }, + Dependencies: []string{"vpc-prod"}, + UpdatedAt: now, + LastDriftCheck: now.Add(-time.Hour), + }}, + } +} + +func TestGetResource_HappyPath(t *testing.T) { + store := seedDetailFixture() + in := &adminpb.AdminGetResourceInput{Name: "db-prod", Evidence: authzOK()} + out, err := handler.GetResource(context.Background(), store, in) + if err != nil { + t.Fatalf("GetResource: %v", err) + } + if out == nil || out.Resource == nil { + t.Fatal("nil output or detail with nil error") + } + d := out.Resource + if d.Summary == nil || d.Summary.Name != "db-prod" { + t.Errorf("summary.name = %v, want db-prod", d.Summary) + } + if d.ConfigHash != "sha256:cafef00d" { + t.Errorf("config_hash = %q, want sha256:cafef00d", d.ConfigHash) + } + if len(d.AppliedConfigJson) == 0 { + t.Error("applied_config_json empty; expected JSON-encoded AppliedConfig") + } + if len(d.OutputsJson) == 0 { + t.Error("outputs_json empty; expected JSON-encoded redacted outputs") + } +} + +func TestGetResource_RedactsSensitiveOutputs(t *testing.T) { + store := seedDetailFixture() + in := &adminpb.AdminGetResourceInput{Name: "db-prod", Evidence: authzOK()} + out, err := handler.GetResource(context.Background(), store, in) + if err != nil { + t.Fatalf("GetResource: %v", err) + } + + var outputs map[string]any + if err := json.Unmarshal(out.Resource.OutputsJson, &outputs); err != nil { + t.Fatalf("outputs_json not valid JSON: %v", err) + } + + // Sensitive keys must be masked. + sensitiveKeys := []string{"password", "access_key", "private_key"} + for _, k := range sensitiveKeys { + v, ok := outputs[k] + if !ok { + t.Errorf("output key %q dropped entirely; expected mask, not removal", k) + continue + } + if vs, _ := v.(string); strings.Contains(vs, "super-secret") || strings.Contains(vs, "AKIA") || strings.Contains(vs, "BEGIN") { + t.Errorf("output %q value not masked: %v", k, v) + } + } + + // Non-sensitive keys must pass through unchanged. + if outputs["id"] != "db-001" { + t.Errorf("id leaked or mangled: %v", outputs["id"]) + } + if outputs["endpoint"] != "db.internal:5432" { + t.Errorf("endpoint leaked or mangled: %v", outputs["endpoint"]) + } + if outputs["plain_field"] != "ok-to-show" { + t.Errorf("plain_field leaked or mangled: %v", outputs["plain_field"]) + } + + // sensitive_outputs_redacted must list each redacted key. + got := map[string]bool{} + for _, k := range out.Resource.SensitiveOutputsRedacted { + got[k] = true + } + for _, k := range sensitiveKeys { + if !got[k] { + t.Errorf("sensitive_outputs_redacted missing %q (got %v)", k, out.Resource.SensitiveOutputsRedacted) + } + } + // Should NOT list non-sensitive keys. + for _, k := range []string{"id", "endpoint", "plain_field"} { + if got[k] { + t.Errorf("sensitive_outputs_redacted leaked non-sensitive key %q", k) + } + } +} + +func TestGetResource_NotFound(t *testing.T) { + store := seedDetailFixture() + in := &adminpb.AdminGetResourceInput{Name: "does-not-exist", Evidence: authzOK()} + out, err := handler.GetResource(context.Background(), store, in) + if err != nil { + t.Fatalf("GetResource should not error on not-found — surfaces via Output.error: %v", err) + } + if out.Resource != nil { + t.Errorf("expected nil Resource on not-found, got %+v", out.Resource) + } + if out.Error == "" { + t.Error("expected non-empty Error on not-found") + } +} + +func TestGetResource_DefaultDenyOnMissingEvidence(t *testing.T) { + store := seedDetailFixture() + in := &adminpb.AdminGetResourceInput{Name: "db-prod"} // no Evidence + out, _ := handler.GetResource(context.Background(), store, in) + if out.Error == "" { + t.Error("expected non-empty Error on missing evidence (default-deny)") + } + if out.Resource != nil { + t.Errorf("expected nil Resource on auth refusal, got %+v", out.Resource) + } +} + +func TestGetResource_DefaultDenyOnAuthzNotChecked(t *testing.T) { + store := seedDetailFixture() + in := &adminpb.AdminGetResourceInput{ + Name: "db-prod", + Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: false, AuthzAllowed: true}, + } + out, _ := handler.GetResource(context.Background(), store, in) + if out.Error == "" { + t.Error("expected non-empty Error when authz_checked=false") + } +} + +func TestGetResource_DefaultDenyOnAuthzDenied(t *testing.T) { + store := seedDetailFixture() + in := &adminpb.AdminGetResourceInput{ + Name: "db-prod", + Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: false}, + } + out, _ := handler.GetResource(context.Background(), store, in) + if out.Error == "" { + t.Error("expected non-empty Error when authz_allowed=false") + } +} + +func TestGetResource_PopulatesSummaryFields(t *testing.T) { + store := seedDetailFixture() + in := &adminpb.AdminGetResourceInput{Name: "db-prod", Evidence: authzOK()} + out, _ := handler.GetResource(context.Background(), store, in) + s := out.Resource.Summary + if s.ProviderType != "digitalocean" { + t.Errorf("provider_type = %q, want digitalocean", s.ProviderType) + } + if s.ProviderModule != "do-prod" { + t.Errorf("provider_module = %q, want do-prod", s.ProviderModule) + } + if s.ProviderId != "db-001" { + t.Errorf("provider_id = %q, want db-001", s.ProviderId) + } + if len(s.Dependencies) != 1 || s.Dependencies[0] != "vpc-prod" { + t.Errorf("dependencies = %v, want [vpc-prod]", s.Dependencies) + } +} diff --git a/iac/admin/handler/list_resources.go b/iac/admin/handler/list_resources.go new file mode 100644 index 00000000..47d995a3 --- /dev/null +++ b/iac/admin/handler/list_resources.go @@ -0,0 +1,117 @@ +package handler + +import ( + "context" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// ListResources implements InfraAdminService.ListResources by reading +// every ResourceState from the host's iac.state backend, applying the +// type / provider / app-context filters from the input, and +// returning AdminResourceSummary rows (no per-resource secrets — +// outputs are intentionally absent from the list view; the detail +// view uses GetResource). +// +// Per design §Handler library + plan §Task 5: the function takes +// `providers` + `catalog` as parameters so the host module and CLI +// callers can share one dispatch. v1 of this list endpoint does not +// consult providers or catalog directly — every populated field on +// AdminResourceSummary derives from the ResourceState itself — but +// the parameters are preserved in the signature for symmetry with +// ListResourceTypes / GenerateConfig and to keep the dispatch shape +// stable when T6 / future enhancements need to cross-check against +// the live providers map (e.g. dropping resources whose +// state.ProviderRef no longer matches a registered module). +// +// Per design §Authz row: default-deny when evidence is missing or +// either authz_checked / authz_allowed is false; refusal surfaces +// via Output.error rather than a Go-level error so the HTTP +// transport returns 200 OK with the typed payload (consumers sniff +// for non-empty error per the proto tag-100 discriminator). +func ListResources( + ctx context.Context, + store interfaces.IaCStateStore, + providers map[string]interfaces.IaCProvider, //nolint:revive // reserved for symmetry + future use; see godoc + fieldCat *catalog.FieldSpecCatalog, //nolint:revive // reserved for symmetry + future use; see godoc + in *adminpb.AdminListResourcesInput, +) (*adminpb.AdminListResourcesOutput, error) { + if msg := authzError(in.GetEvidence()); msg != "" { + return &adminpb.AdminListResourcesOutput{Error: msg}, nil + } + + states, err := store.ListResources(ctx) + if err != nil { + // Intentional nilerr: per proto tag-100 convention, errors + // surface via Output.error (HTTP transport returns 200 + typed + // payload; consumers sniff for non-empty error). A Go-level + // error would force the HTTP layer into a 5xx and lose the + // typed shape. + return &adminpb.AdminListResourcesOutput{Error: "list state: " + err.Error()}, nil //nolint:nilerr + } + + out := &adminpb.AdminListResourcesOutput{} + for i := range states { + s := &states[i] + summary := stateToSummary(s) + + if in.GetTypeFilter() != "" && summary.Type != in.GetTypeFilter() { + continue + } + if in.GetProviderFilter() != "" && summary.ProviderModule != in.GetProviderFilter() { + continue + } + if in.GetAppContextFilter() != "" && summary.AppContext != in.GetAppContextFilter() { + continue + } + + out.Resources = append(out.Resources, summary) + } + return out, nil +} + +// stateToSummary projects a ResourceState into the AdminResourceSummary +// wire shape. Shared with GetResource (which wraps the same summary +// inside AdminResourceDetail) — extracted so the field mapping is +// single-sourced. +// +// Provider fields: +// - ProviderModule ← state.ProviderRef (host YAML `name:` of the +// iac.provider module that owns this resource) +// - ProviderType ← state.Provider (cloud provider string, +// e.g. "digitalocean", "aws") +// +// app_context derives from state.AppliedConfig["labels"]["app_context"] +// per design §App context. Falls back to empty string when the label +// is absent (so AppContextFilter == "" matches every resource). +func stateToSummary(s *interfaces.ResourceState) *adminpb.AdminResourceSummary { + return &adminpb.AdminResourceSummary{ + Name: s.Name, + Type: s.Type, + ProviderModule: s.ProviderRef, + ProviderType: s.Provider, + ProviderId: s.ProviderID, + Status: "active", + UpdatedAtUnix: s.UpdatedAt.Unix(), + Dependencies: append([]string(nil), s.Dependencies...), + AppContext: extractAppContext(s.AppliedConfig), + } +} + +// extractAppContext digs labels.app_context out of an AppliedConfig +// map. The labels sub-map is the design's convention for free-form +// resource tagging; v1 supports just "app_context" but the shape is +// future-proof for additional well-known labels. +func extractAppContext(cfg map[string]any) string { + if cfg == nil { + return "" + } + labels, _ := cfg["labels"].(map[string]any) + if labels == nil { + return "" + } + v, _ := labels["app_context"].(string) + return v +} diff --git a/iac/admin/handler/list_resources_test.go b/iac/admin/handler/list_resources_test.go new file mode 100644 index 00000000..4cffefc4 --- /dev/null +++ b/iac/admin/handler/list_resources_test.go @@ -0,0 +1,284 @@ +package handler_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" + "github.com/GoCodeAlone/workflow/iac/admin/handler" + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// fakeStateStore is a minimal interfaces.IaCStateStore for handler +// tests. Only the read-side subset {GetResource, ListResources} is +// exercised by the handler library; SaveResource is used by the test +// fixture setup. Out-of-subset methods panic per the wfctlhelpers +// design-cycle-5 convention so accidental misuse is loud. +type fakeStateStore struct { + resources []interfaces.ResourceState +} + +func (s *fakeStateStore) ListResources(_ context.Context) ([]interfaces.ResourceState, error) { + out := make([]interfaces.ResourceState, len(s.resources)) + copy(out, s.resources) + return out, nil +} +func (s *fakeStateStore) GetResource(_ context.Context, name string) (*interfaces.ResourceState, error) { + for i := range s.resources { + if s.resources[i].Name == name { + r := s.resources[i] + return &r, nil + } + } + return nil, nil +} +func (s *fakeStateStore) SaveResource(_ context.Context, state interfaces.ResourceState) error { + s.resources = append(s.resources, state) + return nil +} +func (s *fakeStateStore) DeleteResource(_ context.Context, _ string) error { return nil } +func (s *fakeStateStore) SavePlan(_ context.Context, _ interfaces.IaCPlan) error { + panic("fakeStateStore: SavePlan out-of-subset") +} +func (s *fakeStateStore) GetPlan(_ context.Context, _ string) (*interfaces.IaCPlan, error) { + panic("fakeStateStore: GetPlan out-of-subset") +} +func (s *fakeStateStore) Lock(_ context.Context, _ string, _ time.Duration) (interfaces.IaCLockHandle, error) { + panic("fakeStateStore: Lock out-of-subset") +} +func (s *fakeStateStore) Close() error { return nil } + +// authzOK is the standard "host authz middleware ran + allowed" +// evidence pinned for happy-path tests. Default-deny tests pass nil +// or a partial evidence to trigger the refusal branch. +func authzOK() *adminpb.AdminAuthzEvidence { + return &adminpb.AdminAuthzEvidence{ + AuthzChecked: true, + AuthzAllowed: true, + Subject: "user:alice", + GrantedPermissions: []string{"infra:read"}, + } +} + +// seedFixture returns a 3-resource store + label-bearing state covering +// the filter dimensions: type (infra.vpc vs infra.database), provider +// module (do-prod vs do-staging), and app_context (web vs api). +func seedFixture() *fakeStateStore { + now := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) + return &fakeStateStore{ + resources: []interfaces.ResourceState{ + { + ID: "vpc-prod-web", + Name: "vpc-prod-web", + Type: "infra.vpc", + Provider: "digitalocean", + ProviderRef: "do-prod", + ProviderID: "vpc-001", + AppliedConfig: map[string]any{ + "region": "nyc3", + "labels": map[string]any{"app_context": "web"}, + }, + UpdatedAt: now, + }, + { + ID: "db-prod-web", + Name: "db-prod-web", + Type: "infra.database", + Provider: "digitalocean", + ProviderRef: "do-prod", + ProviderID: "db-001", + AppliedConfig: map[string]any{ + "engine": "postgres", + "labels": map[string]any{"app_context": "web"}, + }, + UpdatedAt: now.Add(time.Hour), + }, + { + ID: "vpc-staging-api", + Name: "vpc-staging-api", + Type: "infra.vpc", + Provider: "digitalocean", + ProviderRef: "do-staging", + ProviderID: "vpc-002", + AppliedConfig: map[string]any{ + "region": "ams3", + "labels": map[string]any{"app_context": "api"}, + }, + UpdatedAt: now.Add(2 * time.Hour), + }, + }, + } +} + +func TestListResources_HappyPath(t *testing.T) { + store := seedFixture() + in := &adminpb.AdminListResourcesInput{Evidence: authzOK()} + out, err := handler.ListResources(context.Background(), store, nil, catalog.New(), in) + if err != nil { + t.Fatalf("ListResources: %v", err) + } + if out == nil { + t.Fatal("nil output with nil error") + } + if out.Error != "" { + t.Errorf("unexpected error field: %q", out.Error) + } + if len(out.Resources) != 3 { + t.Fatalf("got %d resources, want 3 (no filters applied)", len(out.Resources)) + } +} + +func TestListResources_DefaultDenyOnMissingEvidence(t *testing.T) { + store := seedFixture() + in := &adminpb.AdminListResourcesInput{} // no Evidence + out, err := handler.ListResources(context.Background(), store, nil, catalog.New(), in) + if err != nil { + t.Fatalf("ListResources should NOT error on auth refusal — it returns Output.error: %v", err) + } + if out == nil { + t.Fatal("nil output with nil error on auth refusal") + } + if out.Error == "" { + t.Error("expected non-empty Error on missing evidence (default-deny)") + } + if len(out.Resources) != 0 { + t.Errorf("expected empty Resources on auth refusal, got %d", len(out.Resources)) + } +} + +func TestListResources_DefaultDenyOnAuthzNotChecked(t *testing.T) { + store := seedFixture() + in := &adminpb.AdminListResourcesInput{ + Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: false, AuthzAllowed: true}, + } + out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) + if out.Error == "" { + t.Error("expected non-empty Error when authz_checked=false (default-deny)") + } +} + +func TestListResources_DefaultDenyOnAuthzDenied(t *testing.T) { + store := seedFixture() + in := &adminpb.AdminListResourcesInput{ + Evidence: &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: false}, + } + out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) + if out.Error == "" { + t.Error("expected non-empty Error when authz_allowed=false") + } +} + +func TestListResources_TypeFilter(t *testing.T) { + store := seedFixture() + in := &adminpb.AdminListResourcesInput{TypeFilter: "infra.vpc", Evidence: authzOK()} + out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) + if len(out.Resources) != 2 { + t.Fatalf("got %d resources, want 2 (vpc-prod-web + vpc-staging-api)", len(out.Resources)) + } + for _, r := range out.Resources { + if r.Type != "infra.vpc" { + t.Errorf("type_filter leak: got %s", r.Type) + } + } +} + +func TestListResources_ProviderFilterByModuleName(t *testing.T) { + store := seedFixture() + in := &adminpb.AdminListResourcesInput{ProviderFilter: "do-prod", Evidence: authzOK()} + out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) + if len(out.Resources) != 2 { + t.Fatalf("got %d resources, want 2 (vpc-prod-web + db-prod-web)", len(out.Resources)) + } + for _, r := range out.Resources { + if r.ProviderModule != "do-prod" { + t.Errorf("provider_filter leak: got module %q", r.ProviderModule) + } + } +} + +func TestListResources_AppContextFilter(t *testing.T) { + store := seedFixture() + in := &adminpb.AdminListResourcesInput{AppContextFilter: "api", Evidence: authzOK()} + out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) + if len(out.Resources) != 1 { + t.Fatalf("got %d resources, want 1 (vpc-staging-api only)", len(out.Resources)) + } + if out.Resources[0].Name != "vpc-staging-api" { + t.Errorf("got %s, want vpc-staging-api", out.Resources[0].Name) + } + if out.Resources[0].AppContext != "api" { + t.Errorf("app_context not populated: got %q", out.Resources[0].AppContext) + } +} + +func TestListResources_CombinedFilters(t *testing.T) { + store := seedFixture() + in := &adminpb.AdminListResourcesInput{ + TypeFilter: "infra.vpc", + ProviderFilter: "do-prod", + AppContextFilter: "web", + Evidence: authzOK(), + } + out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) + if len(out.Resources) != 1 { + t.Fatalf("got %d resources, want 1 (vpc-prod-web only)", len(out.Resources)) + } + if out.Resources[0].Name != "vpc-prod-web" { + t.Errorf("got %s, want vpc-prod-web", out.Resources[0].Name) + } +} + +func TestListResources_PopulatesProviderTypeAndModule(t *testing.T) { + store := seedFixture() + in := &adminpb.AdminListResourcesInput{TypeFilter: "infra.vpc", ProviderFilter: "do-prod", Evidence: authzOK()} + out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) + if len(out.Resources) != 1 { + t.Fatalf("got %d, want 1", len(out.Resources)) + } + r := out.Resources[0] + if r.ProviderType != "digitalocean" { + t.Errorf("provider_type = %q, want digitalocean (from state.Provider)", r.ProviderType) + } + if r.ProviderModule != "do-prod" { + t.Errorf("provider_module = %q, want do-prod (from state.ProviderRef)", r.ProviderModule) + } +} + +func TestListResources_EmptyAppContextSurvivesFilter(t *testing.T) { + // Edge case: when AppContextFilter is empty, resources with empty + // app_context label must still pass through. + now := time.Now().UTC() + store := &fakeStateStore{ + resources: []interfaces.ResourceState{{ + Name: "no-context", Type: "infra.vpc", + Provider: "digitalocean", ProviderRef: "do-prod", + AppliedConfig: map[string]any{}, // no labels + UpdatedAt: now, + }}, + } + in := &adminpb.AdminListResourcesInput{Evidence: authzOK()} + out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) + if len(out.Resources) != 1 { + t.Errorf("empty AppContextFilter should pass through unlabeled resources; got %d", len(out.Resources)) + } +} + +// containsError fails the test when out.Error does not contain the +// expected substring. Used by default-deny tests to verify the +// operator-facing message is actionable. +func containsError(t *testing.T, out *adminpb.AdminListResourcesOutput, want string) { + t.Helper() + if !strings.Contains(out.Error, want) { + t.Errorf("Error = %q, want substring %q", out.Error, want) + } +} + +func TestListResources_DenyMessageMentionsAuthz(t *testing.T) { + store := seedFixture() + in := &adminpb.AdminListResourcesInput{} // no Evidence + out, _ := handler.ListResources(context.Background(), store, nil, catalog.New(), in) + containsError(t, out, "authz") +} From 98fabd3a0b089c802b355b8112f223c192830706 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 13:01:47 -0400 Subject: [PATCH 16/34] feat(iac/admin): T9 vendor infra.proto + parity test + Makefile target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendors workflow-plugin-infra/internal/contracts/infra.proto into workflow/iac/admin/testdata/infra.proto with header comment recording source version (v1.0.0) + date (2026-05-27). The catalog parity test walks every `message *Config {` in the vendored file via regex and asserts the FieldSpec catalog (T7b) has an entry — except the allowlisted InfraResourceConfig abstract base. Reverse-direction test asserts every catalog entry maps to a still-present proto message. Refresh tooling: `make vendor-infra-proto` re-copies the file with a fresh header date, leaving the `Source version:` line as TODO so the operator updates it manually to the upstream tag. Guard included for the workspace-sibling convention (`../workflow-plugin-infra` must be checked out). Per the design, this is the v1 drift-detection backbone for the cross-repo proto-vendor staleness gap; the workspace-CI cross-repo job is filed as a follow-up post-PR-1 merge per the scope manifest. Tests: - TestCatalog_CoversAllTypedConfigs: vendored proto → catalog (forward). - TestCatalog_NoUncatalogedTypes: catalog → vendored proto (reverse). typeToConfigMessage handles acronym-preserving cases (VPC, K8S, DNS, IAM, API); default path camelizes snake_case tail per protobuf naming convention. Set is closed at the 13 typed Configs in v1. --- Makefile | 20 ++- .../catalog/catalog_proto_parity_test.go | 135 ++++++++++++++ iac/admin/testdata/infra.proto | 167 ++++++++++++++++++ 3 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 iac/admin/catalog/catalog_proto_parity_test.go create mode 100644 iac/admin/testdata/infra.proto diff --git a/Makefile b/Makefile index fc0d1ca9..38e2c52d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-ui build-go test bench bench-baseline bench-compare lint fmt vet fix install-hooks clean ko-build build-wfctl +.PHONY: build build-ui build-go test bench bench-baseline bench-compare lint fmt vet fix install-hooks clean ko-build build-wfctl vendor-infra-proto # Common benchmark flags BENCH_FLAGS = -bench=. -benchmem -run=^$$ -timeout=30m @@ -91,6 +91,24 @@ run-admin: build ko-build: KO_DOCKER_REPO=ko.local ko build ./cmd/server --bare --platform=linux/$(shell go env GOARCH) +# Refresh the vendored workflow-plugin-infra proto descriptor used by +# the FieldSpec catalog parity test (iac/admin/catalog/ +# catalog_proto_parity_test.go). Run on every minor upstream +# workflow-plugin-infra release; then update the `Source version:` +# header inside iac/admin/testdata/infra.proto to match the new tag. +# +# Assumes workflow-plugin-infra is checked out as a workspace sibling +# (../workflow-plugin-infra) per the workspace convention. +vendor-infra-proto: + @if [ ! -f ../workflow-plugin-infra/internal/contracts/infra.proto ]; then \ + echo "vendor-infra-proto: ../workflow-plugin-infra/internal/contracts/infra.proto not found"; \ + exit 1; \ + fi + @printf '// Vendored from GoCodeAlone/workflow-plugin-infra/internal/contracts/infra.proto\n// Source version: TODO-update-tag (sourced %s)\n// Refresh via: make vendor-infra-proto\n// Drift detection: catalog_proto_parity_test.go\n\n' "$$(date +%Y-%m-%d)" > iac/admin/testdata/infra.proto + @cat ../workflow-plugin-infra/internal/contracts/infra.proto >> iac/admin/testdata/infra.proto + @echo "Vendored infra.proto refreshed at iac/admin/testdata/infra.proto." + @echo " -> update the 'Source version:' header to the upstream tag now." + # Clean build artifacts clean: rm -f server diff --git a/iac/admin/catalog/catalog_proto_parity_test.go b/iac/admin/catalog/catalog_proto_parity_test.go new file mode 100644 index 00000000..2b952793 --- /dev/null +++ b/iac/admin/catalog/catalog_proto_parity_test.go @@ -0,0 +1,135 @@ +package catalog_test + +import ( + "os" + "regexp" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" +) + +// Allowlist of vendored *Config message names that are NOT directly +// instantiated as form-rendered types — InfraResourceConfig is the +// abstract base per the design's §FieldSpec Catalog note. +var protoConfigAllowlist = map[string]bool{ + "InfraResourceConfig": true, +} + +// configMessagePattern matches `message Config {` at line start, +// optionally preceded by indentation. The vendored proto is small and +// structurally regular (no nested message types share the *Config +// suffix in v1.0.0), so a regex suffices over a full protoparse. +// +// If a future contract introduces nested *Config messages or a more +// complex shape, swap to google.golang.org/protobuf/types/descriptorpb +// + a protoparse driver. +var configMessagePattern = regexp.MustCompile(`(?m)^\s*message\s+([A-Za-z0-9_]+Config)\s*\{`) + +// typeToConfigMessage maps an "infra." type name to its proto +// CamelCase Config message. The mapping mirrors the per-driver naming +// convention shared between catalog/fields.go and the vendored proto. +// +// Special-case acronym preservations (VPC, K8S, DNS, IAM, API) avoid +// degenerate `Vpc` → `VPC` toggling. The set is closed at 13 entries +// (the design's typed-Config inventory). +func typeToConfigMessage(typeName string) string { + tail := strings.TrimPrefix(typeName, "infra.") + switch tail { + case "vpc": + return "VPCConfig" + case "k8s_cluster": + return "K8SClusterConfig" + case "dns": + return "DNSConfig" + case "iam_role": + return "IAMRoleConfig" + case "api_gateway": + return "APIGatewayConfig" + } + // Default: camelize snake-case tail (e.g. "container_service" → + // "ContainerService") then append "Config". Words are joined + // without separators per protobuf convention. + parts := strings.Split(tail, "_") + for i, p := range parts { + if len(p) == 0 { + continue + } + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + return strings.Join(parts, "") + "Config" +} + +func extractConfigMessages(t *testing.T, src string) []string { + t.Helper() + matches := configMessagePattern.FindAllStringSubmatch(src, -1) + out := make([]string, 0, len(matches)) + for _, m := range matches { + out = append(out, m[1]) + } + return out +} + +// TestCatalog_CoversAllTypedConfigs is the drift-detection backbone +// for the vendored proto. It walks every *Config message in the +// vendored workflow-plugin-infra/internal/contracts/infra.proto and +// asserts the catalog (T7b) has an entry for each (except the +// allowlisted abstract base). +// +// Failure modes this test catches: +// - Upstream adds a new typed Config (e.g. ServerlessFunctionConfig) +// and the vendored copy is refreshed without a paired catalog +// entry → test fails. +// - Catalog loses an entry → AllExpectedTypesRegistered already +// catches that, but this is the cross-repo guard. +func TestCatalog_CoversAllTypedConfigs(t *testing.T) { + data, err := os.ReadFile("../testdata/infra.proto") + if err != nil { + t.Fatalf("read vendored proto: %v", err) + } + + messages := extractConfigMessages(t, string(data)) + if len(messages) == 0 { + t.Fatal("regex extracted zero *Config messages — pattern or vendored file likely broken") + } + + cat := catalog.New() + coveredMessages := map[string]bool{} + for _, typeName := range cat.AllTypes() { + coveredMessages[typeToConfigMessage(typeName)] = true + } + + for _, msg := range messages { + if protoConfigAllowlist[msg] { + continue + } + if !coveredMessages[msg] { + t.Errorf("typed message %s is in vendored infra.proto but missing from FieldSpec catalog (typeToConfigMessage map; T7b)", msg) + } + } +} + +// TestCatalog_NoUncatalogedTypes is the reverse-direction guard: every +// catalog entry's Config message MUST exist in the vendored proto. +// This catches the case where a catalog entry is added pointing at a +// renamed / removed upstream Config. +func TestCatalog_NoUncatalogedTypes(t *testing.T) { + data, err := os.ReadFile("../testdata/infra.proto") + if err != nil { + t.Fatalf("read vendored proto: %v", err) + } + messages := extractConfigMessages(t, string(data)) + protoSet := map[string]bool{} + for _, m := range messages { + protoSet[m] = true + } + + cat := catalog.New() + for _, typeName := range cat.AllTypes() { + msg := typeToConfigMessage(typeName) + if !protoSet[msg] { + t.Errorf("catalog entry %q maps to %q which is NOT in vendored proto — upstream may have renamed/removed it", + typeName, msg) + } + } +} diff --git a/iac/admin/testdata/infra.proto b/iac/admin/testdata/infra.proto new file mode 100644 index 00000000..f41b67a7 --- /dev/null +++ b/iac/admin/testdata/infra.proto @@ -0,0 +1,167 @@ +// Vendored from GoCodeAlone/workflow-plugin-infra/internal/contracts/infra.proto +// Source version: v1.0.0 (sourced 2026-05-27) +// Refresh via: make vendor-infra-proto +// Drift detection: catalog_proto_parity_test.go + +syntax = "proto3"; + +package workflow.plugins.infra.v1; + +import "google/protobuf/struct.proto"; + +option go_package = "github.com/GoCodeAlone/workflow-plugin-infra/internal/contracts;contracts"; + +message InfraResourceConfig { + string provider = 1; + string region = 2; + string workspace = 3; + map labels = 4; + google.protobuf.Struct settings = 5; +} + +message ContainerServiceConfig { + string provider = 1; + string region = 2; + string workspace = 3; + string image = 4; + repeated int32 ports = 5; + map env = 6; + int32 replicas = 7; + map labels = 8; + google.protobuf.Struct settings = 9; +} + +message K8SClusterConfig { + string provider = 1; + string region = 2; + string workspace = 3; + string version = 4; + int32 node_count = 5; + string node_size = 6; + map labels = 7; + google.protobuf.Struct settings = 8; +} + +message DatabaseConfig { + string provider = 1; + string region = 2; + string workspace = 3; + string engine = 4; + string version = 5; + string size = 6; + int32 storage_gb = 7; + bool multi_az = 8; + map labels = 9; + google.protobuf.Struct settings = 10; +} + +message CacheConfig { + string provider = 1; + string region = 2; + string workspace = 3; + string engine = 4; + string version = 5; + string size = 6; + int32 nodes = 7; + map labels = 8; + google.protobuf.Struct settings = 9; +} + +message VPCConfig { + string provider = 1; + string region = 2; + string workspace = 3; + string cidr = 4; + repeated string availability_zones = 5; + map labels = 6; + google.protobuf.Struct settings = 7; +} + +message LoadBalancerConfig { + string provider = 1; + string region = 2; + string workspace = 3; + string scheme = 4; + repeated int32 ports = 5; + map labels = 6; + google.protobuf.Struct settings = 7; +} + +message DNSConfig { + string provider = 1; + string region = 2; + string workspace = 3; + string zone = 4; + string record = 5; + string target = 6; + map labels = 7; + google.protobuf.Struct settings = 8; +} + +message RegistryConfig { + string provider = 1; + string region = 2; + string workspace = 3; + string name = 4; + bool public = 5; + map labels = 6; + google.protobuf.Struct settings = 7; +} + +message APIGatewayConfig { + string provider = 1; + string region = 2; + string workspace = 3; + string protocol = 4; + repeated string routes = 5; + map labels = 6; + google.protobuf.Struct settings = 7; +} + +message FirewallConfig { + string provider = 1; + string region = 2; + string workspace = 3; + repeated string ingress = 4; + repeated string egress = 5; + map labels = 6; + google.protobuf.Struct settings = 7; +} + +message IAMRoleConfig { + string provider = 1; + string region = 2; + string workspace = 3; + string name = 4; + repeated string policies = 5; + map labels = 6; + google.protobuf.Struct settings = 7; +} + +message StorageConfig { + string provider = 1; + string region = 2; + string workspace = 3; + string name = 4; + string class = 5; + bool versioning = 6; + map labels = 7; + google.protobuf.Struct settings = 8; +} + +message CertificateConfig { + string provider = 1; + string region = 2; + string workspace = 3; + string domain = 4; + repeated string subject_alt_names = 5; + map labels = 6; + google.protobuf.Struct settings = 7; +} + +// DNSRecordStepConfig / DNSRecordStepInput / DNSRecordStepOutput removed +// in v1.0.0 (Phase 3b). The infra.dns_record step type was deleted because +// peer-dispatch from a step-handler context is architecturally unsupported +// (cycle 3.5 I-NEW-1). Per-record DNS workflows route through +// `wfctl infra apply` (config-declared records) or `wfctl dns-policy *` +// (policy edits) against any provider-plugin that implements infra.dns. From db9fd96da0de2db277aa667975016569bc533d01 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 13:03:50 -0400 Subject: [PATCH 17/34] fix(iac/admin/catalog): T7b spec-review F1 + F2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 (Important): ports MaxCount 20 -> 65535 on container_service + load_balancer entries. Spec-reviewer caught that MinCount/MaxCount on array_number applies as per-element HTML5 min/max in new.js, so MaxCount=20 was rejecting any port > 20 (80, 443, 8080 all broken). The 1-65535 range matches the TCP/UDP port range from the design comment "ports (array_number 1-65535)". new.js seeds array length from MinCount only, so widening MaxCount doesn't add unwanted length. F2 (Minor): drop region from infra.dns. Design §FieldSpec Catalog (line 427) lists DNSConfig form fields as provider/zone/record/target — region is omitted because DNS is a global resource for most providers (Route53 global, DO DNS, CF DNS). The proto's `region` field exists only because every InfraResourceConfig inherits it. Audit test TestCatalog_EveryTypeHasProviderAndRegion gains a regionOptionalTypes allowlist (currently {infra.dns}) to encode the "provider universal, region per-design" invariant. Both fixes preserve all 18 catalog tests passing. --- iac/admin/catalog/fields.go | 20 +++++++++++++++++--- iac/admin/catalog/fields_audit_test.go | 19 ++++++++++++++----- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/iac/admin/catalog/fields.go b/iac/admin/catalog/fields.go index 052feda7..457b31c6 100644 --- a/iac/admin/catalog/fields.go +++ b/iac/admin/catalog/fields.go @@ -60,12 +60,16 @@ func init() { Description: "Fully-qualified image tag, e.g. registry.example.com/svc:1.4.2", }, { + // MinCount/MaxCount on array_number FieldSpec applies as the + // per-element HTML5 min/max in new.js (NOT as array length — + // that's only seeded from MinCount). Range is the TCP/UDP + // port range, NOT a count of ports. Spec-reviewer F1. Name: "ports", Label: "Container ports", Kind: "array_number", ElementKind: "number", MinCount: 1, - MaxCount: 20, + MaxCount: 65535, Description: "TCP/UDP listen ports (1-65535)", }, { @@ -206,20 +210,30 @@ func init() { DefaultValue: "internet-facing", }, { + // Same overload caveat as container_service.ports — range + // is the TCP port range, applied as per-element HTML5 + // min/max by new.js. Spec-reviewer F1. Name: "ports", Label: "Listener ports", Kind: "array_number", ElementKind: "number", MinCount: 1, - MaxCount: 20, + MaxCount: 65535, Description: "TCP listener ports (1-65535)", }, }, // ---- infra.dns → DNSConfig (proto §85) ---- + // + // Region is intentionally omitted: design §FieldSpec Catalog + // (line 427) lists DNSConfig fields as provider/zone/record/ + // target — DNS is a global-zone resource for most providers + // (Route53 global, DO DNS, CF DNS) and the proto's region + // field exists only because every InfraResourceConfig + // inherits region. Form-builder skips it to avoid prompting + // users for a value the driver ignores. Spec-reviewer F2. "infra.dns": { providerField(), - regionField(), { Name: "zone", Label: "DNS zone", diff --git a/iac/admin/catalog/fields_audit_test.go b/iac/admin/catalog/fields_audit_test.go index 9f29fdf1..215fc1f4 100644 --- a/iac/admin/catalog/fields_audit_test.go +++ b/iac/admin/catalog/fields_audit_test.go @@ -67,10 +67,19 @@ func TestCatalog_AllExpectedTypesRegistered(t *testing.T) { } } +// regionOptionalTypes lists types where the catalog deliberately omits +// the universal region field because the underlying resource is +// region-less in the design table (DNS is global per most providers). +// See infra.dns header comment in fields.go (spec-reviewer F2). +var regionOptionalTypes = map[string]bool{ + "infra.dns": true, +} + // TestCatalog_EveryTypeHasProviderAndRegion confirms the universal -// (provider, region) prefix is wired on every entry. The form-builder -// JS in new.js relies on this convention for dependent-dropdown -// wiring (region depends_on provider). +// (provider, region) prefix is wired on every entry except those +// in regionOptionalTypes. The form-builder JS in new.js relies on +// the provider field being present everywhere (enum_dynamic source +// for dependent dropdowns); region presence is per-design. func TestCatalog_EveryTypeHasProviderAndRegion(t *testing.T) { cat := catalog.New() for _, typeName := range cat.AllTypes() { @@ -95,8 +104,8 @@ func TestCatalog_EveryTypeHasProviderAndRegion(t *testing.T) { if !hasProvider { t.Errorf("%s: missing required `provider` field", typeName) } - if !hasRegion { - t.Errorf("%s: missing required `region` field", typeName) + if !hasRegion && !regionOptionalTypes[typeName] { + t.Errorf("%s: missing `region` field (not in regionOptionalTypes allowlist)", typeName) } } } From eb08d95aaab611678f88f5204c60a9e5a9703889 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 27 May 2026 13:05:01 -0400 Subject: [PATCH 18/34] fix(iac/admin/ui_dist): T10-T12 code-review I-1 + I-2 in new.js I-1 (array_enum_dynamic stale dropdowns): addArrayRow's enum_dynamic branch now tags the row's