diff --git a/Makefile b/Makefile index fc0d1ca9..3e209b32 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 test-integration-admin # Common benchmark flags BENCH_FLAGS = -bench=. -benchmem -run=^$$ -timeout=30m @@ -52,6 +52,24 @@ lint: echo "workflow#699 guard: rpc Apply correctly absent"; \ fi +# Run the T17 host-module integration test that exercises the live +# workflow-plugin-admin gRPC plugin subprocess. The test itself +# (module/infra_admin_integration_test.go) probes for the sibling +# repo at ../workflow-plugin-admin and skips when absent — this +# target makes the dependency explicit + lets CI pass an env var +# to point at a pre-checked-out clone. Per +# docs/plans/2026-05-27-infra-admin-dynamic.md Task 17. +# +# Usage: +# make test-integration-admin # uses ../workflow-plugin-admin +# WORKFLOW_PLUGIN_ADMIN_PATH=/path make ... # explicit override +test-integration-admin: + @if [ ! -f "$${WORKFLOW_PLUGIN_ADMIN_PATH:-../workflow-plugin-admin}/go.mod" ]; then \ + echo "workflow-plugin-admin not found at $${WORKFLOW_PLUGIN_ADMIN_PATH:-../workflow-plugin-admin}; set WORKFLOW_PLUGIN_ADMIN_PATH or checkout the sibling repo"; \ + exit 1; \ + fi + GOWORK=off go test -run TestInfraAdmin_IntegrationWithLiveAdminPlugin -v ./module/ + # Format code fmt: go fmt ./... @@ -91,6 +109,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/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 3814de9a..24cabd3d 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" @@ -110,6 +111,8 @@ func runInfra(args []string) error { return fmt.Errorf("audit-state-secrets exited with code %d", rc) } return nil + case "admin": + return runInfraAdmin(args[1:]) default: return infraUsage() } @@ -136,6 +139,9 @@ Actions: align Validate IaC config + plan alignment (8 rule families) test Hermetically validate expected infra config and plan outcomes security-check Scan plan.json for security policy violations + admin Query the infra.admin host-side module surface + (list-resources, get-resource, list-types, + list-providers, generate-config, audit-tail) cleanup Tag-based force-cleanup across providers (--tag NAME [--fix]) audit-secrets Report provider_credential anti-patterns in secrets.generate audit-keys List cloud-side resources of --type via the provider's EnumeratorAll @@ -414,9 +420,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. @@ -645,8 +655,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. @@ -1191,8 +1203,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_admin.go b/cmd/wfctl/infra_admin.go new file mode 100644 index 00000000..4b62320d --- /dev/null +++ b/cmd/wfctl/infra_admin.go @@ -0,0 +1,701 @@ +package main + +// wfctl infra admin — CLI mirror of the host-side infra.admin module's +// typed RPC surface. Each subcommand resolves the iac.state backend + +// iac.provider modules from a workflow config via wfctlhelpers (T1-T3), +// invokes the shared handler library (T5/T6), and renders the output. +// +// The CLI is the second half of the design's "handler library imported +// by both module HTTP routes and wfctl CLI subcommands" contract — the +// same handler functions back HTTP and CLI so behavior cannot drift. +// +// Subcommands (per plan §Task 19): +// +// wfctl infra admin list-resources [--type T] [--provider P] +// [--app-context CTX] [--env E] +// [--format json|table] [-c FILE] +// wfctl infra admin get-resource NAME [--env E] [--format json|table] +// [-c FILE] +// wfctl infra admin list-types [--provider P] +// [--format json|json-schema|table] +// [-c FILE] +// wfctl infra admin list-providers [--env E] [-c FILE] +// wfctl infra admin generate-config --type T --name N +// --provider P [--field K=V...] +// [-c FILE] +// wfctl infra admin audit-tail --base-url URL [--since DUR] +// [--format json|table] +// +// Authz: the CLI runs as the operator with full filesystem access to +// the workflow config; AdminAuthzEvidence is implicitly satisfied +// (authz_checked=true, authz_allowed=true). The handler library still +// runs its default-deny guard, so we ALWAYS populate evidence — the +// authz layer above the CLI is the OS filesystem permission on the +// config file, not a separate token check. +// +// Plan §Task 20 covers the parity test asserting JSON output decodes +// as the matching adminpb proto type. + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/GoCodeAlone/workflow/config" + "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/iac/wfctlhelpers" + "github.com/GoCodeAlone/workflow/interfaces" + "google.golang.org/protobuf/encoding/protojson" +) + +// infraAdminEvidence returns the AdminAuthzEvidence stamp every +// CLI-side invocation needs. CLI operators have already been vetted by +// filesystem ACL on the config file; the handler library's default- +// deny guard still requires the evidence to be populated. +// +// Subject preference per implementer-1's T19 guidance: the OS-side +// USER env var when present, falling back to a static "wfctl-cli" +// sentinel. The env var is best-effort (operators can spoof it); its +// only purpose is improving the audit-log breadcrumb for routine +// CLI use, NOT as an authz primitive. Authz is the filesystem ACL on +// the config file. +func infraAdminEvidence() *adminpb.AdminAuthzEvidence { + subject := os.Getenv("USER") + if subject == "" { + subject = "wfctl-cli" + } + return &adminpb.AdminAuthzEvidence{ + AuthzChecked: true, + AuthzAllowed: true, + Subject: subject, + GrantedPermissions: []string{"infra:read"}, + } +} + +// infraAdminFormat is the shared --format value type. Accepts a small +// allowlist to keep usage predictable. +type infraAdminFormat string + +const ( + infraAdminFormatJSON infraAdminFormat = "json" + infraAdminFormatJSONSchema infraAdminFormat = "json-schema" + infraAdminFormatTable infraAdminFormat = "table" +) + +func runInfraAdmin(args []string) error { + if len(args) < 1 { + return infraAdminUsage() + } + switch args[0] { + case "list-resources": + return runInfraAdminListResources(args[1:]) + case "get-resource": + return runInfraAdminGetResource(args[1:]) + case "list-types": + return runInfraAdminListTypes(args[1:]) + case "list-providers": + return runInfraAdminListProviders(args[1:]) + case "generate-config": + return runInfraAdminGenerateConfig(args[1:]) + case "audit-tail": + return runInfraAdminAuditTail(args[1:]) + case "--help", "-h", "help": + return infraAdminUsage() + default: + fmt.Fprintf(os.Stderr, "wfctl infra admin: unknown subcommand %q\n\n", args[0]) + return infraAdminUsage() + } +} + +func infraAdminUsage() error { + fmt.Fprintf(flag.CommandLine.Output(), `Usage: wfctl infra admin [options] + +Mirror of the infra.admin host-side module's typed RPC surface. Reads +the iac.state backend + iac.provider modules from a workflow config +and renders results. + +Subcommands: + list-resources [--type T] [--provider P] [--app-context CTX] [--env E] + [--format json|table] [-c FILE] + get-resource NAME [--env E] [--format json|table] [-c FILE] + list-types [--provider P] [--format json|json-schema|table] [-c FILE] + list-providers [--env E] [-c FILE] + generate-config --type T --name N --provider P [--field K=V ...] + [-c FILE] + audit-tail --base-url URL [--since DUR] [--format json|table] + +Common flags: + -c FILE Path to workflow config (default: workflow.yaml). + Required for all subcommands except audit-tail. + --env NAME Per-environment backend overrides applied to iac.state. + --format VAL Output format (json or table). audit-tail emits ndjson + on stdout when --format=json. +`) + return nil +} + +// --- dependency resolution ------------------------------------------------ + +// adminDeps bundles the three things every read subcommand needs to +// invoke the handler library: state backend, provider map (keyed by +// host YAML module name), the catalog triple, and the +// providerTypeByModule lookup populated from the YAML config at +// resolve-time (per design cycle-5/6 + spec-reviewer T6 F1 — the +// YAML-config `provider:` field is the stable identifier the +// catalogs key against; provider.Name() returns the plugin's +// display name and is NOT a stable identifier). +type adminDeps struct { + store interfaces.IaCStateStore + providers map[string]interfaces.IaCProvider + providerTypeByModule map[string]string + closers []io.Closer + fieldCatalog *catalog.FieldSpecCatalog + regionCatalog *catalog.RegionCatalog + engineCatalog *catalog.EngineCatalog +} + +// resolveAdminDeps loads the workflow config and instantiates everything +// the handler library needs. envName is forwarded to ResolveStateStore +// for per-env backend overrides. +func resolveAdminDeps(ctx context.Context, cfgFile, envName string) (*adminDeps, error) { + if cfgFile == "" { + return nil, errors.New("config file path required (-c FILE)") + } + store, err := wfctlhelpers.ResolveStateStore(cfgFile, envName, currentInfraPluginDir) + if err != nil { + return nil, fmt.Errorf("resolve state store: %w", err) + } + providers, closers, err := wfctlhelpers.LoadAllIaCProvidersFromConfig(ctx, cfgFile) + if err != nil { + return nil, fmt.Errorf("load providers: %w", err) + } + providerTypeByModule, err := loadProviderTypeByModule(cfgFile) + if err != nil { + // Roll back the loaded providers so we don't leak subprocesses. + for _, c := range closers { + _ = c.Close() + } + return nil, fmt.Errorf("load providerTypeByModule: %w", err) + } + return &adminDeps{ + store: store, + providers: providers, + providerTypeByModule: providerTypeByModule, + closers: closers, + fieldCatalog: catalog.New(), + regionCatalog: catalog.NewRegionCatalog(), + engineCatalog: catalog.NewEngineCatalog(), + }, nil +} + +// loadProviderTypeByModule walks cfgFile's modules and returns a map +// of {iac.provider module name -> YAML `provider:` string}. Per +// spec-reviewer T6 F1 + design cycle-5/6: this is the captured-at- +// Init contract handler.ListProviders relies on for the +// provider_type field in AdminProviderSummary. T15 (host module) +// will populate the same map from its app.GetService-resolved +// modules; the CLI side reads it from disk. +func loadProviderTypeByModule(cfgFile string) (map[string]string, error) { + cfg, err := config.LoadFromFile(cfgFile) + if err != nil { + return nil, fmt.Errorf("load %s: %w", cfgFile, err) + } + out := map[string]string{} + for i := range cfg.Modules { + m := &cfg.Modules[i] + if m.Type != "iac.provider" { + continue + } + modCfg := config.ExpandEnvInMap(m.Config) + pt, _ := modCfg["provider"].(string) + if pt == "" { + // Skip silently — matches LoadAllIaCProvidersFromConfig's + // same-shape behavior; misconfigured module already won't + // have a provider entry in the providers map either. + continue + } + out[m.Name] = pt + } + return out, nil +} + +func (d *adminDeps) Close() { + for _, c := range d.closers { + if c != nil { + _ = c.Close() + } + } +} + +// --- subcommand: list-resources ------------------------------------------- + +func runInfraAdminListResources(args []string) error { + fs := flag.NewFlagSet("infra admin list-resources", flag.ContinueOnError) + cfg := fs.String("c", "workflow.yaml", "config file path") + typeFilter := fs.String("type", "", "filter by resource type (e.g. infra.vpc)") + providerFilter := fs.String("provider", "", "filter by provider module name") + appContext := fs.String("app-context", "", "filter by app_context label") + envName := fs.String("env", "", "per-env backend overrides") + format := fs.String("format", string(infraAdminFormatJSON), "json or table") + if err := fs.Parse(args); err != nil { + return err + } + + ctx := context.Background() + deps, err := resolveAdminDeps(ctx, *cfg, *envName) + if err != nil { + return err + } + defer deps.Close() + + in := &adminpb.AdminListResourcesInput{ + TypeFilter: *typeFilter, + ProviderFilter: *providerFilter, + AppContextFilter: *appContext, + EnvName: *envName, + Evidence: infraAdminEvidence(), + } + out, err := handler.ListResources(ctx, deps.store, deps.providers, deps.fieldCatalog, in) + if err != nil { + return err + } + if out.Error != "" { + return errors.New(out.Error) + } + + switch infraAdminFormat(*format) { + case infraAdminFormatJSON: + return emitJSON(out) + case infraAdminFormatTable: + return renderResourcesTable(out.Resources) + default: + return fmt.Errorf("--format %q not supported (use json or table)", *format) + } +} + +func renderResourcesTable(rows []*adminpb.AdminResourceSummary) error { + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + defer func() { _ = w.Flush() }() + fmt.Fprintln(w, "NAME\tTYPE\tPROVIDER\tSTATUS\tUPDATED") + for _, r := range rows { + updated := "" + if r.UpdatedAtUnix > 0 { + updated = time.Unix(r.UpdatedAtUnix, 0).UTC().Format(time.RFC3339) + } + providerCell := r.ProviderModule + if r.ProviderType != "" { + providerCell = providerCell + " / " + r.ProviderType + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + r.Name, r.Type, providerCell, r.Status, updated) + } + return nil +} + +// --- subcommand: get-resource --------------------------------------------- + +func runInfraAdminGetResource(args []string) error { + fs := flag.NewFlagSet("infra admin get-resource", flag.ContinueOnError) + cfg := fs.String("c", "workflow.yaml", "config file path") + envName := fs.String("env", "", "per-env backend overrides") + format := fs.String("format", string(infraAdminFormatJSON), "json or table") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() < 1 { + return errors.New("get-resource: NAME positional argument required") + } + name := fs.Arg(0) + + ctx := context.Background() + deps, err := resolveAdminDeps(ctx, *cfg, *envName) + if err != nil { + return err + } + defer deps.Close() + + in := &adminpb.AdminGetResourceInput{ + Name: name, + EnvName: *envName, + Evidence: infraAdminEvidence(), + } + out, err := handler.GetResource(ctx, deps.store, in) + if err != nil { + return err + } + if out.Error != "" { + return errors.New(out.Error) + } + + switch infraAdminFormat(*format) { + case infraAdminFormatJSON: + return emitJSON(out) + case infraAdminFormatTable: + return renderResourceDetail(out.Resource) + default: + return fmt.Errorf("--format %q not supported (use json or table)", *format) + } +} + +func renderResourceDetail(r *adminpb.AdminResourceDetail) error { + if r == nil { + fmt.Println("(empty)") + return nil + } + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + s := r.Summary + if s != nil { + fmt.Fprintf(w, "Name:\t%s\n", s.Name) + fmt.Fprintf(w, "Type:\t%s\n", s.Type) + fmt.Fprintf(w, "Provider module:\t%s\n", s.ProviderModule) + fmt.Fprintf(w, "Provider type:\t%s\n", s.ProviderType) + fmt.Fprintf(w, "Provider id:\t%s\n", s.ProviderId) + fmt.Fprintf(w, "Status:\t%s\n", s.Status) + fmt.Fprintf(w, "App context:\t%s\n", s.AppContext) + if s.UpdatedAtUnix > 0 { + fmt.Fprintf(w, "Updated:\t%s\n", time.Unix(s.UpdatedAtUnix, 0).UTC().Format(time.RFC3339)) + } + fmt.Fprintf(w, "Dependencies:\t%s\n", strings.Join(s.Dependencies, ", ")) + } + fmt.Fprintf(w, "Config hash:\t%s\n", r.ConfigHash) + if len(r.SensitiveOutputsRedacted) > 0 { + fmt.Fprintf(w, "Redacted outputs:\t%s\n", strings.Join(r.SensitiveOutputsRedacted, ", ")) + } + if err := w.Flush(); err != nil { + return err + } + if len(r.AppliedConfigJson) > 0 { + fmt.Println("\nApplied config:") + fmt.Println(string(r.AppliedConfigJson)) + } + if len(r.OutputsJson) > 0 { + fmt.Println("\nOutputs (redacted):") + fmt.Println(string(r.OutputsJson)) + } + return nil +} + +// --- subcommand: list-types ----------------------------------------------- + +func runInfraAdminListTypes(args []string) error { + fs := flag.NewFlagSet("infra admin list-types", flag.ContinueOnError) + cfg := fs.String("c", "workflow.yaml", "config file path") + providerFilter := fs.String("provider", "", "filter by provider") + envName := fs.String("env", "", "per-env backend overrides") + format := fs.String("format", string(infraAdminFormatJSON), "json, json-schema, or table") + if err := fs.Parse(args); err != nil { + return err + } + + ctx := context.Background() + deps, err := resolveAdminDeps(ctx, *cfg, *envName) + if err != nil { + return err + } + defer deps.Close() + + in := &adminpb.AdminListResourceTypesInput{ + ProviderFilter: *providerFilter, + Evidence: infraAdminEvidence(), + } + out, err := handler.ListResourceTypes(ctx, deps.fieldCatalog, deps.providers, in) + if err != nil { + return err + } + if out.Error != "" { + return errors.New(out.Error) + } + + switch infraAdminFormat(*format) { + case infraAdminFormatJSON, infraAdminFormatJSONSchema: + return emitJSON(out) + case infraAdminFormatTable: + return renderTypesTable(out.Types) + default: + return fmt.Errorf("--format %q not supported (use json, json-schema, or table)", *format) + } +} + +func renderTypesTable(types []*adminpb.AdminResourceTypeMetadata) error { + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + defer func() { _ = w.Flush() }() + fmt.Fprintln(w, "TYPE\tFIELDS\tFQN") + for _, t := range types { + names := make([]string, 0, len(t.Fields)) + for _, f := range t.Fields { + names = append(names, f.Name) + } + fmt.Fprintf(w, "%s\t%s\t%s\n", t.Type, strings.Join(names, ","), t.ConfigMessageFqn) + } + return nil +} + +// --- subcommand: list-providers ------------------------------------------- + +func runInfraAdminListProviders(args []string) error { + fs := flag.NewFlagSet("infra admin list-providers", flag.ContinueOnError) + cfg := fs.String("c", "workflow.yaml", "config file path") + envName := fs.String("env", "", "per-env backend overrides") + format := fs.String("format", string(infraAdminFormatJSON), "json or table") + if err := fs.Parse(args); err != nil { + return err + } + + ctx := context.Background() + deps, err := resolveAdminDeps(ctx, *cfg, *envName) + if err != nil { + return err + } + defer deps.Close() + + in := &adminpb.AdminListProvidersInput{ + EnvName: *envName, + Evidence: infraAdminEvidence(), + } + out, err := handler.ListProviders(ctx, deps.providers, deps.providerTypeByModule, deps.fieldCatalog, deps.regionCatalog, deps.engineCatalog, in) + if err != nil { + return err + } + if out.Error != "" { + return errors.New(out.Error) + } + + switch infraAdminFormat(*format) { + case infraAdminFormatJSON: + return emitJSON(out) + case infraAdminFormatTable: + return renderProvidersTable(out.Providers) + default: + return fmt.Errorf("--format %q not supported (use json or table)", *format) + } +} + +func renderProvidersTable(providers []*adminpb.AdminProviderSummary) error { + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + defer func() { _ = w.Flush() }() + fmt.Fprintln(w, "MODULE\tTYPE\tREGIONS\tENGINES\tREGIONS_SOURCE") + for _, p := range providers { + fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n", + p.ModuleName, p.ProviderType, + len(p.SupportedRegions), len(p.SupportedEngines), + p.RegionsSource) + } + return nil +} + +// --- subcommand: generate-config ------------------------------------------ + +// fieldFlag implements flag.Value for repeated --field KEY=VALUE flags. +// Multiple invocations append to the captured map; duplicate keys are +// last-write-wins (consistent with form-builder submit behavior where +// later inputs override earlier). +type fieldFlag struct { + values map[string]string +} + +func newFieldFlag() *fieldFlag { + return &fieldFlag{values: map[string]string{}} +} + +func (f *fieldFlag) String() string { + if f == nil || len(f.values) == 0 { + return "" + } + keys := make([]string, 0, len(f.values)) + for k := range f.values { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+f.values[k]) + } + return strings.Join(parts, ",") +} + +func (f *fieldFlag) Set(s string) error { + i := strings.Index(s, "=") + if i <= 0 { + return fmt.Errorf("--field must be KEY=VALUE, got %q", s) + } + if f.values == nil { + f.values = map[string]string{} + } + f.values[s[:i]] = s[i+1:] + return nil +} + +func runInfraAdminGenerateConfig(args []string) error { + fs := flag.NewFlagSet("infra admin generate-config", flag.ContinueOnError) + cfg := fs.String("c", "workflow.yaml", "config file path") + rType := fs.String("type", "", "resource type (e.g. infra.vpc)") + rName := fs.String("name", "", "resource name") + provider := fs.String("provider", "", "provider module name") + envName := fs.String("env", "", "per-env backend overrides") + fields := newFieldFlag() + fs.Var(fields, "field", "field KEY=VALUE (repeatable; array values are JSON-encoded e.g. --field ports='[80,443]')") + if err := fs.Parse(args); err != nil { + return err + } + if *rType == "" || *rName == "" || *provider == "" { + return errors.New("generate-config: --type, --name, --provider all required") + } + + ctx := context.Background() + deps, err := resolveAdminDeps(ctx, *cfg, *envName) + if err != nil { + return err + } + defer deps.Close() + + in := &adminpb.AdminGenerateConfigInput{ + ResourceType: *rType, + ResourceName: *rName, + ProviderModule: *provider, + FieldValues: fields.values, + Evidence: infraAdminEvidence(), + } + out, err := handler.GenerateConfig(ctx, deps.fieldCatalog, in) + if err != nil { + return err + } + if out.Error != "" { + return errors.New(out.Error) + } + if len(out.ValidationErrors) > 0 { + for _, e := range out.ValidationErrors { + fmt.Fprintf(os.Stderr, "validation error: %s\n", e) + } + return fmt.Errorf("generate-config returned %d validation error(s)", len(out.ValidationErrors)) + } + fmt.Print(out.YamlSnippet) + if !strings.HasSuffix(out.YamlSnippet, "\n") { + fmt.Println() + } + return nil +} + +// --- subcommand: audit-tail ----------------------------------------------- + +// runInfraAdminAuditTail does NOT load a config — it talks HTTP to a +// running infra.admin module instance via its /api/infra-admin/audit +// endpoint. Per design §Security Review row "Access logging". +// +// The endpoint streams newline-delimited AdminAuditEntry proto-JSON. +// We pass --format=json through unchanged (forward the body bytes); +// --format=table parses each line and renders a compact view. +func runInfraAdminAuditTail(args []string) error { + fs := flag.NewFlagSet("infra admin audit-tail", flag.ContinueOnError) + baseURL := fs.String("base-url", "", "host base URL (e.g. https://admin.example.com)") + since := fs.Duration("since", 0, "tail entries newer than this duration (e.g. 1h, 24h)") + format := fs.String("format", string(infraAdminFormatJSON), "json or table") + if err := fs.Parse(args); err != nil { + return err + } + if *baseURL == "" { + return errors.New("audit-tail: --base-url required") + } + + u, err := url.Parse(*baseURL) + if err != nil { + return fmt.Errorf("--base-url: %w", err) + } + u.Path = strings.TrimRight(u.Path, "/") + "/api/infra-admin/audit" + q := u.Query() + if *since > 0 { + q.Set("since", fmt.Sprintf("%d", time.Now().Add(-*since).Unix())) + } + u.RawQuery = q.Encode() + + resp, err := http.Get(u.String()) //nolint:gosec // operator-supplied base URL + if err != nil { + return fmt.Errorf("audit-tail GET: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("audit-tail: HTTP %d", resp.StatusCode) + } + + switch infraAdminFormat(*format) { + case infraAdminFormatJSON: + _, err := io.Copy(os.Stdout, resp.Body) + return err + case infraAdminFormatTable: + return renderAuditTable(resp.Body) + default: + return fmt.Errorf("--format %q not supported (use json or table)", *format) + } +} + +func renderAuditTable(body io.Reader) error { + w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + defer func() { _ = w.Flush() }() + fmt.Fprintln(w, "TS\tSUBJECT\tACTION\tRESULT\tTARGETS") + + // Per code-reviewer T19+T20 I-2 + cross-task contract: the audit + // endpoint emits AdminAuditEntry protojson (T14 writer uses + // protojson.MarshalOptions{UseProtoNames: true} on disk; T15's + // audit-tail HTTP handler will stream those lines verbatim). + // protojson encodes int64 fields (e.g. ts_unix) as DECIMAL + // STRINGS for JS BigInt safety — `encoding/json` decoding into a + // Go int64 field rejects the string with "json: cannot unmarshal + // string into Go struct field". Per-line protojson.Unmarshal is + // the only correct decoder. + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var entry adminpb.AdminAuditEntry + if err := protojson.Unmarshal([]byte(line), &entry); err != nil { + return fmt.Errorf("decode audit entry (line %q): %w", line, err) + } + ts := "" + if entry.TsUnix > 0 { + ts = time.Unix(entry.TsUnix, 0).UTC().Format(time.RFC3339) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + ts, entry.Subject, entry.Action, entry.Result, strings.Join(entry.Targets, ",")) + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("read audit body: %w", err) + } + return nil +} + +// --- shared output helper ------------------------------------------------- + +// emitJSON writes the proto message as indented JSON to stdout. Used +// by every --format=json branch. We use encoding/json with Go struct +// tags (snake_case via the proto-generated json tags) for the +// AdminListResourcesOutput / etc. shapes. The parity test (T20) asserts +// json.Unmarshal of stdout into the matching adminpb type round-trips. +// +// We intentionally do NOT use protojson here. The CLI's wire is JSON +// over stdout; UseProtoNames vs camelCase divergence isn't a concern +// because both the encoder (Go's encoding/json with proto-generated +// snake_case tags) and the parity-test decoder (same) operate on the +// same struct tag set. The HTTP module on the host side uses protojson +// because there the wire is HTTP and the JS client expects snake_case; +// here the wire is the same Go process round-trip and using Go's +// stdlib keeps the dependency surface minimal. +func emitJSON(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} diff --git a/cmd/wfctl/infra_admin_audit_test.go b/cmd/wfctl/infra_admin_audit_test.go new file mode 100644 index 00000000..ddaba948 --- /dev/null +++ b/cmd/wfctl/infra_admin_audit_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "bytes" + "os" + "strings" + "testing" + "time" + + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "google.golang.org/protobuf/encoding/protojson" +) + +// TestRenderAuditTable_DecodesProtojsonNdjson is the regression +// guard for code-reviewer T19+T20 I-2: the CLI's audit-tail body +// decoder MUST handle protojson's int64-as-decimal-string wire +// convention. An earlier draft used encoding/json which rejected +// `"ts_unix": "1234567890"` (string form) when decoding into +// AdminAuditEntry's int64 TsUnix field. This test feeds a +// protojson-encoded fixture (the same shape T14's writer emits + +// T15's audit-tail HTTP endpoint serves) and asserts the decoder +// round-trips every line + renders the expected table columns. +func TestRenderAuditTable_DecodesProtojsonNdjson(t *testing.T) { + // Build two AdminAuditEntry fixtures and serialise via protojson — + // mimics exactly what T14's audit.Writer emits to disk and what + // T15's audit-tail HTTP handler will stream over ndjson. + entries := []*adminpb.AdminAuditEntry{ + { + SchemaVersion: 1, + TsUnix: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC).Unix(), + Subject: "user:alice", + Action: "list_resources", + Result: "ok", + AppContext: "web", + }, + { + SchemaVersion: 1, + TsUnix: time.Date(2026, 5, 27, 12, 0, 1, 0, time.UTC).Unix(), + Subject: "user:bob", + Action: "get_resource", + Targets: []string{"vpc-prod"}, + Result: "ok", + AppContext: "api", + }, + } + opts := protojson.MarshalOptions{UseProtoNames: true} + var body bytes.Buffer + for _, e := range entries { + data, err := opts.Marshal(e) + if err != nil { + t.Fatalf("protojson.Marshal fixture: %v", err) + } + body.Write(data) + body.WriteByte('\n') + } + + // Sanity: the fixture must actually carry the int64-as-string + // form for ts_unix — this is the bit `encoding/json` chokes on. + // If protojson ever changes encoding rules, this assertion + // catches it so the decoder swap can be re-evaluated. + if !strings.Contains(body.String(), `"ts_unix":"`) { + t.Fatalf("fixture missing int64-as-string ts_unix encoding — protojson convention changed?\n%s", body.String()) + } + + // Redirect stdout for renderAuditTable. We don't assert on the + // exact rendered bytes (column widths depend on tabwriter); we + // only assert the decode loop completes + the row count matches + // the fixture count. + oldStdout := opensTabwriterStdout(t) + defer oldStdout.restore() + if err := renderAuditTable(&body); err != nil { + t.Fatalf("renderAuditTable: %v", err) + } + out := oldStdout.captured() + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + // One header + 2 data rows = 3 lines. + if len(lines) != 3 { + t.Fatalf("got %d lines, want 3 (header + 2 entries):\n%s", len(lines), out) + } + if !strings.Contains(lines[0], "TS") || !strings.Contains(lines[0], "SUBJECT") || !strings.Contains(lines[0], "ACTION") { + t.Errorf("header row missing expected columns: %q", lines[0]) + } + if !strings.Contains(lines[1], "user:alice") || !strings.Contains(lines[1], "list_resources") { + t.Errorf("row 1 missing expected content: %q", lines[1]) + } + if !strings.Contains(lines[2], "user:bob") || !strings.Contains(lines[2], "vpc-prod") { + t.Errorf("row 2 missing expected content: %q", lines[2]) + } +} + +// TestRenderAuditTable_HandlesEmptyBody guards against decoder +// crashes on an empty endpoint response (e.g. no entries since +// --since cutoff). +func TestRenderAuditTable_HandlesEmptyBody(t *testing.T) { + oldStdout := opensTabwriterStdout(t) + defer oldStdout.restore() + if err := renderAuditTable(strings.NewReader("")); err != nil { + t.Errorf("renderAuditTable on empty body: %v", err) + } + out := oldStdout.captured() + // Header line should still print so operators see column shape + // even when there are no entries. + if !strings.Contains(out, "TS") { + t.Errorf("header missing on empty body: %q", out) + } +} + +// opensTabwriterStdout swaps os.Stdout for a buffer for the test +// duration. renderAuditTable writes to os.Stdout directly, so we +// can't inject a writer without refactoring the function — the +// stdout redirect is the minimum-invasive approach. +type stdoutCapture struct { + prev *os.File + r, w *os.File + done chan struct{} + collect *bytes.Buffer +} + +func opensTabwriterStdout(t *testing.T) *stdoutCapture { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + prev := os.Stdout + os.Stdout = w + c := &stdoutCapture{ + prev: prev, + r: r, + w: w, + done: make(chan struct{}), + collect: &bytes.Buffer{}, + } + go func() { + _, _ = c.collect.ReadFrom(c.r) + close(c.done) + }() + return c +} + +func (c *stdoutCapture) captured() string { + _ = c.w.Close() + <-c.done + return c.collect.String() +} + +func (c *stdoutCapture) restore() { + os.Stdout = c.prev +} diff --git a/cmd/wfctl/infra_admin_parity_test.go b/cmd/wfctl/infra_admin_parity_test.go new file mode 100644 index 00000000..793ffa6b --- /dev/null +++ b/cmd/wfctl/infra_admin_parity_test.go @@ -0,0 +1,290 @@ +package main + +// Plan §Task 20 parity test for the wfctl infra admin CLI surface. +// +// What's covered here (in-process, no exec): +// - The CLI's JSON output round-trips through the matching adminpb +// proto-message via encoding/json (the CLI uses encoding/json with +// proto-generated snake_case tags; see emitJSON godoc on the +// "we intentionally don't use protojson" rationale). +// - The subcommand dispatcher rejects unknown subcommands without +// panicking and emits a stable usage on `--help`. +// - --field flag parses repeated KEY=VALUE pairs with last-write-wins +// semantics. +// +// What's NOT covered here (intentionally — exec-driven full smoke +// lives in workflow-scenarios/scenarios/92-infra-admin-demo/test/run.sh +// per plan §CLI end-to-end smoke): +// - Live state-store + provider resolution (requires a real workflow +// config + filesystem state). The scenario harness exercises that +// path against the docker-compose stack. +// - audit-tail HTTP round-trip (scenario harness via curl). +// +// The CLI's JSON output uses snake_case field names (the proto- +// generated `json:"snake_case,omitempty"` tags on adminpb structs). +// json.Unmarshal of the bytes back into the same struct must produce +// an equivalent value — that's the parity assertion. + +import ( + "bytes" + "encoding/json" + "flag" + "strings" + "testing" + + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" +) + +// captureEmit invokes emitJSON's path by marshaling the same way the +// CLI does to a buffer instead of stdout. We mirror the body of +// emitJSON here to keep the test hermetic (no os.Stdout redirection +// race in parallel-test mode). +func captureEmit(t *testing.T, v any) []byte { + t.Helper() + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + t.Fatalf("encode: %v", err) + } + return buf.Bytes() +} + +func TestInfraAdminCLI_ListResourcesOutput_RoundTrip(t *testing.T) { + original := &adminpb.AdminListResourcesOutput{ + Resources: []*adminpb.AdminResourceSummary{ + { + Name: "demo-vpc", + Type: "infra.vpc", + ProviderModule: "do-provider", + ProviderType: "digitalocean", + ProviderId: "vpc-abc123", + Status: "applied", + UpdatedAtUnix: 1717000000, + Dependencies: []string{"infra.firewall"}, + AppContext: "production", + }, + }, + } + payload := captureEmit(t, original) + + var decoded adminpb.AdminListResourcesOutput + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal CLI output: %v\npayload=%s", err, payload) + } + if len(decoded.Resources) != 1 { + t.Fatalf("decoded.Resources len=%d, want 1", len(decoded.Resources)) + } + got := decoded.Resources[0] + want := original.Resources[0] + if got.Name != want.Name || got.Type != want.Type || + got.ProviderModule != want.ProviderModule || + got.ProviderType != want.ProviderType || + got.ProviderId != want.ProviderId || + got.Status != want.Status || + got.UpdatedAtUnix != want.UpdatedAtUnix || + got.AppContext != want.AppContext { + t.Errorf("AdminResourceSummary round-trip lost fields.\n got=%+v\nwant=%+v", got, want) + } + if len(got.Dependencies) != 1 || got.Dependencies[0] != "infra.firewall" { + t.Errorf("dependencies lost: got=%v", got.Dependencies) + } +} + +func TestInfraAdminCLI_GetResourceOutput_RoundTrip(t *testing.T) { + original := &adminpb.AdminGetResourceOutput{ + Resource: &adminpb.AdminResourceDetail{ + Summary: &adminpb.AdminResourceSummary{Name: "demo-db", Type: "infra.database"}, + AppliedConfigJson: []byte(`{"engine":"postgres"}`), + OutputsJson: []byte(`{"endpoint":"db.example.com"}`), + ConfigHash: "sha256:abc", + LastDriftCheckUnix: 1717000123, + SensitiveOutputsRedacted: []string{"password"}, + }, + } + payload := captureEmit(t, original) + + var decoded adminpb.AdminGetResourceOutput + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal CLI output: %v\npayload=%s", err, payload) + } + if decoded.Resource == nil { + t.Fatal("decoded.Resource is nil") + } + if decoded.Resource.ConfigHash != original.Resource.ConfigHash { + t.Errorf("ConfigHash: got %q, want %q", decoded.Resource.ConfigHash, original.Resource.ConfigHash) + } + if string(decoded.Resource.AppliedConfigJson) != string(original.Resource.AppliedConfigJson) { + t.Errorf("AppliedConfigJson round-trip mismatch: got=%s want=%s", + decoded.Resource.AppliedConfigJson, original.Resource.AppliedConfigJson) + } + if len(decoded.Resource.SensitiveOutputsRedacted) != 1 || + decoded.Resource.SensitiveOutputsRedacted[0] != "password" { + t.Errorf("SensitiveOutputsRedacted round-trip mismatch: got=%v", decoded.Resource.SensitiveOutputsRedacted) + } +} + +func TestInfraAdminCLI_ListTypesOutput_RoundTrip(t *testing.T) { + original := &adminpb.AdminListResourceTypesOutput{ + Types: []*adminpb.AdminResourceTypeMetadata{ + { + Type: "infra.vpc", + // Plural "plugins" + acronym-preserving VPC per T6 F2 fix + // (commit 8ac54ca84). Matches real handler output now. + ConfigMessageFqn: "workflow.plugins.infra.v1.VPCConfig", + Fields: []*adminpb.AdminFieldSpec{ + {Name: "provider", Kind: "enum_dynamic", EnumSource: "providers", Required: true}, + {Name: "cidr", Kind: "string", Required: true}, + }, + }, + }, + } + payload := captureEmit(t, original) + + var decoded adminpb.AdminListResourceTypesOutput + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal CLI output: %v\npayload=%s", err, payload) + } + if len(decoded.Types) != 1 || decoded.Types[0].Type != "infra.vpc" { + t.Errorf("types round-trip mismatch: got=%+v", decoded.Types) + } + if len(decoded.Types[0].Fields) != 2 { + t.Errorf("fields lost: got=%d, want 2", len(decoded.Types[0].Fields)) + } + if decoded.Types[0].Fields[0].EnumSource != "providers" { + t.Errorf("EnumSource lost: got=%q", decoded.Types[0].Fields[0].EnumSource) + } +} + +func TestInfraAdminCLI_ListProvidersOutput_RoundTrip(t *testing.T) { + original := &adminpb.AdminListProvidersOutput{ + Providers: []*adminpb.AdminProviderSummary{ + { + ModuleName: "do-provider", + ProviderType: "digitalocean", + Capabilities: []string{"plan", "apply"}, + SupportedRegions: []string{"nyc1", "nyc3"}, + SupportedTypes: []string{"infra.vpc", "infra.database"}, + SupportedEngines: []string{"postgres", "mysql"}, + RegionsSource: "local-catalog", + }, + }, + } + payload := captureEmit(t, original) + + var decoded adminpb.AdminListProvidersOutput + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal CLI output: %v\npayload=%s", err, payload) + } + if len(decoded.Providers) != 1 { + t.Fatalf("decoded.Providers len=%d, want 1", len(decoded.Providers)) + } + p := decoded.Providers[0] + if p.ModuleName != "do-provider" || p.ProviderType != "digitalocean" || + p.RegionsSource != "local-catalog" { + t.Errorf("provider summary fields lost: got=%+v", p) + } + if len(p.SupportedRegions) != 2 || p.SupportedRegions[0] != "nyc1" { + t.Errorf("SupportedRegions lost: got=%v", p.SupportedRegions) + } + if len(p.SupportedEngines) != 2 { + t.Errorf("SupportedEngines lost: got=%v", p.SupportedEngines) + } +} + +func TestInfraAdminCLI_GenerateConfigOutput_RoundTrip(t *testing.T) { + original := &adminpb.AdminGenerateConfigOutput{ + YamlSnippet: "name: demo-vpc\ntype: infra.vpc\nconfig:\n cidr: 10.0.0.0/16\n", + } + payload := captureEmit(t, original) + + var decoded adminpb.AdminGenerateConfigOutput + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal CLI output: %v\npayload=%s", err, payload) + } + if decoded.YamlSnippet != original.YamlSnippet { + t.Errorf("YamlSnippet round-trip mismatch.\n got=%q\nwant=%q", + decoded.YamlSnippet, original.YamlSnippet) + } +} + +func TestInfraAdminCLI_UnknownSubcommand(t *testing.T) { + // Cases that should not panic and should return without crashing. + // Output goes to os.Stderr / flag.CommandLine — we only assert + // the dispatcher behavior. + cases := [][]string{ + {}, // no args → usage + {"--help"}, // help → usage + {"-h"}, // -h → usage + {"help"}, // help → usage + {"completely-bogus"}, // unknown → usage + } + for _, args := range cases { + // Discard errors: --help and unknown both return nil (usage + // path) per the dispatcher implementation, and the no-arg + // case returns nil too. Just confirm no panic. + _ = runInfraAdmin(args) + } +} + +func TestInfraAdminCLI_FieldFlag_RepeatableAndLastWriteWins(t *testing.T) { + f := newFieldFlag() + if err := f.Set("provider=do-provider"); err != nil { + t.Fatalf("Set: %v", err) + } + if err := f.Set("region=nyc1"); err != nil { + t.Fatalf("Set: %v", err) + } + if err := f.Set("region=nyc3"); err != nil { // overwrite + t.Fatalf("Set: %v", err) + } + if got := f.values["provider"]; got != "do-provider" { + t.Errorf("provider: got %q, want do-provider", got) + } + if got := f.values["region"]; got != "nyc3" { + t.Errorf("region last-write-wins: got %q, want nyc3", got) + } + if !strings.Contains(f.String(), "provider=do-provider") { + t.Errorf("String() missing provider entry: %q", f.String()) + } + // Bad input: missing `=` should error rather than panic. + if err := f.Set("no-equal"); err == nil { + t.Error("Set(\"no-equal\") returned nil, want error") + } + // Empty key (leading `=`) should also error — the index check is + // `<= 0`, so `=val` (idx=0) is rejected. + if err := f.Set("=val"); err == nil { + t.Error("Set(\"=val\") returned nil, want error") + } +} + +// TestInfraAdminCLI_HelpListsAllSubcommands ensures the dispatcher's +// usage block stays in sync with the 6 documented subcommands. The +// usage text is the source of truth users see when typing `wfctl infra +// admin --help`; missing entries here surface as quiet UX failures. +func TestInfraAdminCLI_HelpListsAllSubcommands(t *testing.T) { + // Capture flag.CommandLine output into a buffer so we can grep. + var buf bytes.Buffer + origOut := flag.CommandLine.Output() + flag.CommandLine.SetOutput(&buf) + defer flag.CommandLine.SetOutput(origOut) + + if err := infraAdminUsage(); err != nil { + t.Fatalf("infraAdminUsage: %v", err) + } + out := buf.String() + + expected := []string{ + "list-resources", + "get-resource", + "list-types", + "list-providers", + "generate-config", + "audit-tail", + } + for _, name := range expected { + if !strings.Contains(out, name) { + t.Errorf("usage missing subcommand %q in output:\n%s", name, out) + } + } +} 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/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/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/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/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/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/engine.go b/engine.go index 20babd81..22832897 100644 --- a/engine.go +++ b/engine.go @@ -171,6 +171,18 @@ func NewStdEngine(app modular.Application, logger modular.Logger) *StdEngine { }, )) } + + // T18: register the engine-side infra.admin module factory so the + // host loads `type: infra.admin` without requiring a plugin. Per + // docs/plans/2026-05-27-infra-admin-dynamic.md + ADR-0002 the + // admin surface is engine-side rather than a plugin contribution, + // so the factory belongs here in NewStdEngine alongside the + // step.workflow_call registration. Also registers with the + // schema's known-module-types so TestSchemaKnowsPluginModuleTypes + // stays green and config-validation accepts `type: infra.admin`. + const infraAdminType = "infra.admin" + e.moduleFactories[infraAdminType] = module.NewInfraAdmin + schema.RegisterModuleType(infraAdminType) return e } diff --git a/engine_infra_admin_test.go b/engine_infra_admin_test.go new file mode 100644 index 00000000..58d40c33 --- /dev/null +++ b/engine_infra_admin_test.go @@ -0,0 +1,58 @@ +package workflow + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/module" +) + +// TestEngineFactory_InfraAdminRegistered pins T18: the engine-side +// infra.admin module factory MUST be registered by NewStdEngine +// so the host loads `type: infra.admin` without requiring a +// plugin. Per docs/plans/2026-05-27-infra-admin-dynamic.md Task 18. +// +// Reads the factory map via the public RegisteredModuleTypes +// surface AND constructs an instance to assert the factory +// actually produces a *module.InfraAdmin (not nil, not a fake +// type). +func TestEngineFactory_InfraAdminRegistered(t *testing.T) { + e := NewStdEngine(nil, nopLogger{}) + types := e.RegisteredModuleTypes() + found := false + for _, ty := range types { + if ty == "infra.admin" { + found = true + break + } + } + if !found { + t.Fatalf("infra.admin not in RegisteredModuleTypes (got %v)", types) + } + + // Construct an instance through the factory to verify the + // factory closure produces a real module — not a panic, not nil. + factory := e.moduleFactories["infra.admin"] + if factory == nil { + t.Fatal("moduleFactories[infra.admin] is nil even though listed") + } + mod := factory("test-infra-admin", map[string]any{}) + if mod == nil { + t.Fatal("factory returned nil module") + } + if _, ok := mod.(*module.InfraAdmin); !ok { + t.Errorf("factory returned %T, want *module.InfraAdmin", mod) + } + if mod.Name() != "test-infra-admin" { + t.Errorf("module Name = %q, want test-infra-admin", mod.Name()) + } +} + +// nopLogger is the minimum modular.Logger for engine tests. +// Production wires the slog adapter; we just want a non-nil +// logger so NewStdEngine doesn't crash. +type nopLogger struct{} + +func (nopLogger) Debug(string, ...any) {} +func (nopLogger) Info(string, ...any) {} +func (nopLogger) Warn(string, ...any) {} +func (nopLogger) Error(string, ...any) {} diff --git a/iac/admin/audit/writer.go b/iac/admin/audit/writer.go new file mode 100644 index 00000000..8fc8efa7 --- /dev/null +++ b/iac/admin/audit/writer.go @@ -0,0 +1,212 @@ +// Package audit hosts the JSONL audit-log writer used by the host-side +// infra.admin workflow module to record every admin action (read or +// future-mutating). Writer is concurrent-safe, append-only, and +// reopens its file handle on SIGHUP so external log-rotation tools +// (logrotate, etc.) can move the file aside without losing +// subsequent entries. +// +// Design: docs/plans/2026-05-27-infra-admin-dynamic-design.md §Security Review +// Plan: docs/plans/2026-05-27-infra-admin-dynamic.md (Task 14) +// +// **Wire format**: protojson over workflow.iac.v1.AdminAuditEntry. +// Per design §Access logging: "Each line is AdminAuditEntry +// proto-JSON. Reader `wfctl infra admin audit-tail --base-url ...` +// (HTTP-backed)". Writing via protojson preserves the strict-contract +// invariant that on-disk lines are byte-identical to what the HTTP +// audit-tail endpoint serves, so the CLI's protojson.Unmarshal +// decoder works end-to-end. +// +// Earlier T14 draft (commit 42b9e1c11) defined an Entry struct with +// 10 plan-listed fields including `ts time.Time`, `action_id`, +// `dry_run`, `confirm_destroy` — but the design proto AdminAuditEntry +// has only 7 fields and uses `ts_unix int64`. Per spec-reviewer T14 +// F1 + strict-interpretation invariant ("design wins when plan/ +// design diverge"), Entry is now a thin alias for the proto message +// with no host-only extras. If v1.1 needs the extras, that's an +// ADR-tracked schema amendment, not a quiet plan-extra add. +package audit + +import ( + "errors" + "fmt" + "os" + "os/signal" + "sync" + "syscall" + + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "google.golang.org/protobuf/encoding/protojson" +) + +// Entry is a re-export of the proto AdminAuditEntry message so +// audit-package callers don't have to import the adminpb package +// directly. The writer's Write method marshals via protojson, so +// any field added to the proto becomes available to writers +// without an audit-package code change. +// +// Strict contract: this MUST stay an alias rather than a parallel +// struct definition. The spec-reviewer T14 F1 follow-up moved from +// a host-only struct to this alias precisely to eliminate the +// drift surface between the writer + the typed wire shape. +type Entry = adminpb.AdminAuditEntry + +// marshalOpts is the single protojson marshaling configuration the +// writer uses. UseProtoNames=true emits snake_case JSON keys +// matching the .proto field names (the same configuration T15's +// writeProto helper uses for HTTP responses), so the on-disk JSONL +// shape matches what the HTTP audit-tail endpoint will serve. +var marshalOpts = protojson.MarshalOptions{UseProtoNames: true} + +// Writer wraps an append-only JSONL file with concurrent-safe writes +// and SIGHUP reopen. The host module (T15) holds one Writer for the +// lifetime of the infra.admin module; tests can create + close them +// at will. +// +// Close-safety: double-Close is a no-op. Post-Close Write returns a +// clear error rather than silently dropping audit entries — losing +// audit data is worse than a noisy error per design Security Review. +// +// SIGHUP handling: the writer registers a signal handler on Open +// that reopens the file path under the mutex. External rotation +// (logrotate, mv + SIGHUP) works without losing in-flight writes. +type Writer struct { + path string + + mu sync.Mutex + file *os.File + closed bool + + sigC chan os.Signal + done chan struct{} +} + +// Open creates or appends-to the audit file at path and starts the +// SIGHUP-reopen goroutine. Per design Security Review: a non-nil +// error MUST be treated as FATAL at module Init — silently failing +// to open the audit log is the opposite of the "default-audit- +// everything" posture the design mandates. The caller (T15 module +// Init) propagates Open errors up as a module-init failure. +func Open(path string) (*Writer, error) { + if path == "" { + return nil, errors.New("audit.Open: empty path") + } + // 0o600 (owner-only) per gosec G302 + design Security Review's + // "audit logs MUST NOT be world-readable" stance — even the + // host's syslog group should not have read access without an + // explicit operator decision. + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return nil, fmt.Errorf("audit.Open %q: %w", path, err) + } + w := &Writer{ + path: path, + file: f, + sigC: make(chan os.Signal, 1), + done: make(chan struct{}), + } + // Register SIGHUP handler. signal.Notify is goroutine-safe and + // multiple writers in the same process all receive the signal + // (each reopens its own file). Stop() in Close() unregisters. + signal.Notify(w.sigC, syscall.SIGHUP) + go w.reopenLoop() + return w, nil +} + +// reopenLoop is the background SIGHUP-reopen goroutine. Runs until +// done is closed by Close(). +func (w *Writer) reopenLoop() { + for { + select { + case <-w.sigC: + w.reopen() + case <-w.done: + return + } + } +} + +// reopen closes the current file handle (if any) and opens a fresh +// handle at the original path. Called from the SIGHUP handler +// goroutine. Errors during reopen are not propagated (no caller is +// listening) but a future enhancement could emit a stderr line so +// operators see the failure. For now, log via fmt.Fprintln so the +// host process's stderr captures it. +func (w *Writer) reopen() { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return + } + if w.file != nil { + _ = w.file.Close() + } + f, err := os.OpenFile(w.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) // 0o600 per gosec G302; see Open + if err != nil { + fmt.Fprintf(os.Stderr, "audit.Writer: SIGHUP reopen %q failed: %v (subsequent writes will error)\n", w.path, err) + w.file = nil + return + } + w.file = f +} + +// Write serializes the entry to one protojson line + newline and +// appends it under the mutex. Closed-after returns a clear error +// rather than silently dropping the entry — losing audit data is +// worse than a noisy error per design Security Review. +// +// SchemaVersion is set to 1 on the caller-provided entry before +// marshaling so callers cannot accidentally emit a different +// version. If the schema ever bumps to 2, this is the single +// change-point. +// +// The entry is taken by pointer because adminpb.AdminAuditEntry +// (the alias target) holds internal protobuf state; passing by +// value would copy that state and trigger a vet warning. +func (w *Writer) Write(e *Entry) error { + if e == nil { + return errors.New("audit.Write: nil entry") + } + e.SchemaVersion = 1 + data, err := marshalOpts.Marshal(e) + if err != nil { + return fmt.Errorf("audit.Write: protojson marshal: %w", err) + } + + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return errors.New("audit.Write: writer is closed") + } + if w.file == nil { + return errors.New("audit.Write: writer has no file handle (SIGHUP reopen failed earlier)") + } + if _, err := w.file.Write(append(data, '\n')); err != nil { + return fmt.Errorf("audit.Write: %w", err) + } + return nil +} + +// Close stops the SIGHUP goroutine, unregisters the signal handler, +// and closes the file handle. Double-Close is a no-op; post-Close +// Write returns a clear error. +func (w *Writer) Close() error { + w.mu.Lock() + if w.closed { + w.mu.Unlock() + return nil + } + w.closed = true + file := w.file + w.file = nil + w.mu.Unlock() + + // Stop the goroutine + unregister the signal handler. signal.Stop + // is goroutine-safe; the channel close signals reopenLoop to exit. + signal.Stop(w.sigC) + close(w.done) + + if file != nil { + return file.Close() + } + return nil +} diff --git a/iac/admin/audit/writer_test.go b/iac/admin/audit/writer_test.go new file mode 100644 index 00000000..10e5021b --- /dev/null +++ b/iac/admin/audit/writer_test.go @@ -0,0 +1,288 @@ +package audit_test + +import ( + "bufio" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/iac/admin/audit" + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "google.golang.org/protobuf/encoding/protojson" +) + +// TestOpen_CreatesFileIfMissing pins that Open creates the audit +// file on first run rather than erroring on ENOENT — the host +// module's first start with a fresh access_log_path config must +// succeed. +func TestOpen_CreatesFileIfMissing(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + w, err := audit.Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer w.Close() + if _, err := os.Stat(path); err != nil { + t.Errorf("expected audit file at %s, stat err: %v", path, err) + } +} + +// TestOpen_FatalOnDirPath verifies the design's "FATAL on open +// failure" contract surfaces a clear error rather than silently +// swallowing. Passing an existing directory triggers an OS-level +// open error. +func TestOpen_FatalOnDirPath(t *testing.T) { + dir := t.TempDir() // existing directory; opening it as a regular file fails + _, err := audit.Open(dir) + if err == nil { + t.Fatal("expected Open to error when path is a directory, got nil") + } +} + +// TestWrite_AppendsOneProtojsonLineWithSchemaVersion1 pins the +// wire shape per design + spec-reviewer T14 F1: each Write emits +// exactly one protojson line carrying schema_version:1. Multiple +// writes append; lines do not overlap. Per-line round-trip +// through protojson.Unmarshal into AdminAuditEntry asserts the +// contract end-to-end (this is the test that would catch the +// earlier draft's schema-mismatch bug). +func TestWrite_AppendsOneProtojsonLineWithSchemaVersion1(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + w, err := audit.Open(path) + if err != nil { + t.Fatal(err) + } + defer w.Close() + + entries := []*audit.Entry{ + { + TsUnix: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC).Unix(), + Subject: "user:alice", + Action: "list_resources", + Result: "ok", + AppContext: "web", + }, + { + TsUnix: time.Date(2026, 5, 27, 12, 0, 1, 0, time.UTC).Unix(), + Subject: "user:bob", + Action: "get_resource", + Targets: []string{"vpc-prod"}, + Result: "ok", + AppContext: "api", + }, + } + for _, e := range entries { + if err := w.Write(e); err != nil { + t.Fatalf("Write: %v", err) + } + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d:\n%s", len(lines), string(data)) + } + for i, line := range lines { + // Round-trip through protojson against the actual proto + // message. This is the contract guard: a future regression + // to encoding/json or to a non-proto struct will fail here. + var got adminpb.AdminAuditEntry + if err := protojson.Unmarshal([]byte(line), &got); err != nil { + t.Fatalf("line %d: protojson.Unmarshal into AdminAuditEntry: %v\n%s", i, err, line) + } + if got.SchemaVersion != 1 { + t.Errorf("line %d schema_version = %d, want 1", i, got.SchemaVersion) + } + // snake_case key shape: assert literal "ts_unix" / "schema_version" appear. + if !strings.Contains(line, "\"schema_version\"") { + t.Errorf("line %d missing snake_case schema_version key: %s", i, line) + } + if !strings.Contains(line, "\"ts_unix\"") { + t.Errorf("line %d missing snake_case ts_unix key (writer must use UseProtoNames=true): %s", i, line) + } + } +} + +// TestWrite_ConcurrentAppendsAreSerialised pins the mutex around +// the write path so two goroutines writing simultaneously don't +// interleave bytes. We launch N writers; final line count == N AND +// every line is valid protojson (decoded back into AdminAuditEntry). +func TestWrite_ConcurrentAppendsAreSerialised(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + w, err := audit.Open(path) + if err != nil { + t.Fatal(err) + } + defer w.Close() + + const writers = 32 + const writesEach = 16 + var wg sync.WaitGroup + for i := range writers { + wg.Add(1) + go func(id int) { + defer wg.Done() + for range writesEach { + _ = w.Write(&audit.Entry{ + TsUnix: time.Now().UTC().Unix(), + Subject: "user:test", + Action: "list_resources", + Result: "ok", + }) + } + }(i) + } + wg.Wait() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + scanner := bufio.NewScanner(strings.NewReader(string(data))) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + count := 0 + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + var got adminpb.AdminAuditEntry + if err := protojson.Unmarshal([]byte(line), &got); err != nil { + t.Fatalf("line %d not valid protojson (interleaved write?): %v\n%s", count, err, line) + } + count++ + } + want := writers * writesEach + if count != want { + t.Errorf("got %d valid protojson lines, want %d (lost writes or interleaved bytes)", count, want) + } +} + +// TestSIGHUP_ReopensFileHandle verifies the SIGHUP-reopen contract +// from design Security Review: when an external log-rotation tool +// (logrotate, etc.) renames the audit file and sends SIGHUP, the +// writer reopens at the original path. Subsequent writes land in +// the NEW file (the original on-disk path), not in the moved +// inode the old fd still pointed at. +// +// Subject is used as the pre/post discriminator (the proto-aligned +// Entry doesn't carry an `action_id` field; subject is the closest +// per-entry label that survives the protojson round-trip). +func TestSIGHUP_ReopensFileHandle(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + w, err := audit.Open(path) + if err != nil { + t.Fatal(err) + } + defer w.Close() + + // Write one entry pre-rotation. + if err := w.Write(&audit.Entry{Subject: "subject:pre", Action: "list_resources", Result: "ok"}); err != nil { + t.Fatal(err) + } + + // Simulate log-rotation: move the file aside. + rotated := path + ".1" + if err := os.Rename(path, rotated); err != nil { + t.Fatalf("rename to simulate rotation: %v", err) + } + + // Send SIGHUP to ourselves; the writer's signal handler should + // reopen `path` (creating a new file since the old name was + // renamed away). + if err := syscall.Kill(syscall.Getpid(), syscall.SIGHUP); err != nil { + t.Fatalf("send SIGHUP: %v", err) + } + // Give the signal handler a moment to process. + time.Sleep(100 * time.Millisecond) + + // Write one entry post-rotation. + if err := w.Write(&audit.Entry{Subject: "subject:post", Action: "list_resources", Result: "ok"}); err != nil { + t.Fatalf("post-rotation Write: %v", err) + } + + // The rotated file holds only the pre-rotation entry. + preData, err := os.ReadFile(rotated) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(preData), `"subject":"subject:pre"`) { + t.Errorf("rotated file missing pre-rotation entry: %s", string(preData)) + } + if strings.Contains(string(preData), `"subject":"subject:post"`) { + t.Errorf("rotated file contains POST-rotation entry — SIGHUP reopen failed: %s", string(preData)) + } + + // The current (re-created) file holds only the post-rotation entry. + postData, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read post-rotation file: %v", err) + } + if !strings.Contains(string(postData), `"subject":"subject:post"`) { + t.Errorf("post-rotation file missing post-entry: %s", string(postData)) + } + if strings.Contains(string(postData), `"subject":"subject:pre"`) { + t.Errorf("post-rotation file contains pre-rotation entry — SIGHUP reopen wrote to wrong path: %s", string(postData)) + } +} + +// TestClose_IsIdempotent verifies double-Close doesn't panic or +// error — host module's Stop() path may race with shutdown signals. +func TestClose_IsIdempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + w, err := audit.Open(path) + if err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Errorf("first Close: %v", err) + } + if err := w.Close(); err != nil { + t.Errorf("second Close (should be no-op): %v", err) + } +} + +// TestWrite_AfterCloseReturnsError verifies post-Close Writes +// surface a clear error rather than silently dropping audit +// entries. +func TestWrite_AfterCloseReturnsError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + w, err := audit.Open(path) + if err != nil { + t.Fatal(err) + } + w.Close() + if err := w.Write(&audit.Entry{Subject: "after-close"}); err == nil { + t.Error("expected Write after Close to error, got nil") + } +} + +// TestWrite_NilEntryReturnsError pins the defensive nil-guard so +// a future caller accidentally passing nil doesn't crash the host +// process — audit data integrity is preserved by surfacing the +// programming error. +func TestWrite_NilEntryReturnsError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + w, err := audit.Open(path) + if err != nil { + t.Fatal(err) + } + defer w.Close() + if err := w.Write(nil); err == nil { + t.Error("expected Write(nil) to error, got nil") + } +} diff --git a/iac/admin/catalog/catalog.go b/iac/admin/catalog/catalog.go new file mode 100644 index 00000000..0948c12f --- /dev/null +++ b/iac/admin/catalog/catalog.go @@ -0,0 +1,196 @@ +// 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 (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{} +} + +// 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 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{} 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..0df61f48 --- /dev/null +++ b/iac/admin/catalog/catalog_proto_parity_test.go @@ -0,0 +1,110 @@ +package catalog_test + +import ( + "os" + "regexp" + "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 is a thin shim onto the lifted shared helper +// catalog.ConfigMessageShortName (see naming.go). Kept as a local +// alias so existing T9 parity-test code reads unchanged; the actual +// mapping table moved to a non-test file per spec-reviewer T6 F2 +// (commit 1ea231fdd) so the T6 handler library can call the same +// algorithm rather than reimplementing it and drifting. +func typeToConfigMessage(typeName string) string { + return catalog.ConfigMessageShortName(typeName) +} + +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/catalog/catalog_test.go b/iac/admin/catalog/catalog_test.go new file mode 100644 index 00000000..2792a2e0 --- /dev/null +++ b/iac/admin/catalog/catalog_test.go @@ -0,0 +1,60 @@ +package catalog_test + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" +) + +// 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.Fatal("AllTypes() returned no entries; T7b should have populated 13 typed Configs") + } +} + +// 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: 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", "nope_missing_field") + if ok { + t.Errorf("FreeformReason for missing field returned ok=true (reason=%q)", reason) + } + if 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/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/fields.go b/iac/admin/catalog/fields.go new file mode 100644 index 00000000..457b31c6 --- /dev/null +++ b/iac/admin/catalog/fields.go @@ -0,0 +1,468 @@ +// 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", + }, + { + // 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: 65535, + 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", + }, + { + // 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: 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(), + { + 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..215fc1f4 --- /dev/null +++ b/iac/admin/catalog/fields_audit_test.go @@ -0,0 +1,129 @@ +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) + } + } +} + +// 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 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() { + 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 && !regionOptionalTypes[typeName] { + t.Errorf("%s: missing `region` field (not in regionOptionalTypes allowlist)", 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) + } + } + } + } +} diff --git a/iac/admin/catalog/naming.go b/iac/admin/catalog/naming.go new file mode 100644 index 00000000..772a9e0f --- /dev/null +++ b/iac/admin/catalog/naming.go @@ -0,0 +1,72 @@ +package catalog + +import "strings" + +// ConfigProtoPackage is the fully-qualified proto package the vendored +// workflow-plugin-infra/internal/contracts/infra.proto declares. Used +// to build `config_message_fqn` references in AdminResourceTypeMetadata +// responses so cross-language consumers can correlate against the +// vendored proto descriptor. +// +// Note the **plural** "plugins" — earlier draft code (T6 commit +// 1ea231fdd) used the singular "plugin" which produced FQNs that +// nothing on the wire matched. Per spec-reviewer T6 F2 (commit +// 1ea231fdd). The vendored proto at iac/admin/testdata/infra.proto:8 +// is authoritative for this string. +const ConfigProtoPackage = "workflow.plugins.infra.v1" + +// ConfigMessageShortName maps an "infra." type name to its +// proto CamelCase Config message short name (e.g. "infra.vpc" → +// "VPCConfig"). Single-sourced here so the T9 vendored-proto parity +// test and the T6 handler library cannot drift on acronym +// preservation — earlier T6 code reimplemented snake→PascalCase +// without the acronym table and produced "VpcConfig", which doesn't +// exist in the proto. Per spec-reviewer T6 F2. +// +// Special-case acronym preservations (VPC, K8S, DNS, IAM, API) avoid +// degenerate `Vpc` ⇆ `VPC` toggling. The set is closed at 13 entries +// today (the design's typed-Config inventory); new acronyms in +// future Configs require both extending this switch AND updating +// the catalog. The vendored-proto parity test detects misses. +func ConfigMessageShortName(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" +} + +// ConfigMessageFQN returns the fully-qualified proto message name for +// a given catalog type. Composition of ConfigProtoPackage + "." + +// ConfigMessageShortName so both halves can be tested independently +// and the FQN is always consistent between catalog handler usage +// and the vendored-proto parity test. +// +// Returns the empty string when typeName lacks the "infra." prefix — +// callers treat empty as "no FQN known" rather than emitting a +// malformed reference. +func ConfigMessageFQN(typeName string) string { + if !strings.HasPrefix(typeName, "infra.") { + return "" + } + return ConfigProtoPackage + "." + ConfigMessageShortName(typeName) +} 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) + } +} diff --git a/iac/admin/handler/authz.go b/iac/admin/handler/authz.go new file mode 100644 index 00000000..84ece2ce --- /dev/null +++ b/iac/admin/handler/authz.go @@ -0,0 +1,64 @@ +// 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 + +// **Error-string credential-leak caveat** (per code-reviewer T5 M-5, +// commit 5fe88fe45): every handler in this package returns upstream +// error messages through Output.error verbatim (e.g. +// "list state: " + err.Error()). Current upstream errors come from +// os.ReadFile / json.Marshal / fake stores in tests — none carry +// credentials. But future backends (e.g. a Postgres-backed state +// store that errors with a DSN-in-message) could leak secrets +// through this channel. Scrub well-known credential-bearing +// patterns (URLs with userinfo, etc.) at the backend boundary OR +// in this package before concatenating, OR pin the no-credential +// upstream assumption per backend at integration-test time (T17). +// Not in T5 scope; flagged here so a future contributor sees the +// risk before extending the handler family. + +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/generate_config.go b/iac/admin/handler/generate_config.go new file mode 100644 index 00000000..892dd3bd --- /dev/null +++ b/iac/admin/handler/generate_config.go @@ -0,0 +1,210 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "gopkg.in/yaml.v3" +) + +// moduleEntry is the YAML shape GenerateConfig emits — a single +// module entry under a host's `modules:` block. Field order is the +// canonical workflow config layout (name → type → config). Using a +// typed struct + yaml.Marshal is the strict-contract path the plan +// mandates: we never fmt.Sprintf user input into YAML, which would +// admit injection-of-arbitrary-keys via crafted field values. +type moduleEntry struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Config map[string]any `yaml:"config,omitempty"` +} + +// GenerateConfig implements InfraAdminService.GenerateConfig by +// type-coercing the form-builder's field_values map against the +// FieldSpecCatalog Kind dispatch, assembling a module config map, +// and yaml.Marshal-ing the result. Output is a single module entry +// (name + type + config) the user pastes under their existing +// `modules:` block. Per plan §Task 6. +// +// **Strict-contract invariant**: never fmt.Sprintf user input into +// YAML. All values flow through yaml.Marshal of a typed struct or +// map. TestGenerateConfig_NoFmtSprintfUserInput pins this against +// regression. +// +// Array encoding contract (cross-task, locked 2026-05-27): +// array_string + array_object field values arrive JSON-encoded +// (e.g. `field_values["ingress"] = "[\"rule a\", \"rule b, c\"]"`) +// so values containing commas survive the wire. Handler decodes +// via json.Unmarshal. Defensive fallback: a value that doesn't +// parse as a JSON array is wrapped into a one-element slice so a +// malformed UI submission doesn't crash the server. See +// TestGenerateConfig_ArrayValuesJSONDecoded + +// TestGenerateConfig_PlainStringNotJSONDecoded. +// +// Per design §Authz: default-deny via the shared authz guard. +func GenerateConfig( + ctx context.Context, + fieldCat *catalog.FieldSpecCatalog, + in *adminpb.AdminGenerateConfigInput, +) (*adminpb.AdminGenerateConfigOutput, error) { + if msg := authzError(in.GetEvidence()); msg != "" { + return &adminpb.AdminGenerateConfigOutput{Error: msg}, nil + } + + specs, ok := fieldCat.Get(in.GetResourceType()) + if !ok { + return &adminpb.AdminGenerateConfigOutput{ + ValidationErrors: []string{ + fmt.Sprintf("unknown resource_type %q — not in FieldSpec catalog", in.GetResourceType()), + }, + }, nil + } + + cfg := map[string]any{} + var validationErrors []string + for i := range specs { + spec := &specs[i] // gocritic rangeValCopy: avoid 176-byte copy per iteration + raw, present := in.GetFieldValues()[spec.Name] + if !present || raw == "" { + if spec.Required { + validationErrors = append(validationErrors, + fmt.Sprintf("missing required field %q", spec.Name)) + } + continue + } + coerced, verrs := coerceFieldValue(spec, raw) + if len(verrs) > 0 { + validationErrors = append(validationErrors, verrs...) + continue + } + cfg[spec.Name] = coerced + } + + entry := moduleEntry{ + Name: in.GetResourceName(), + Type: in.GetResourceType(), + Config: cfg, + } + yamlBytes, err := yaml.Marshal(entry) + if err != nil { + //nolint:nilerr // proto tag-100 convention; see list_resources.go for rationale + return &adminpb.AdminGenerateConfigOutput{ + Error: "marshal yaml: " + err.Error(), + ValidationErrors: validationErrors, + }, nil + } + + return &adminpb.AdminGenerateConfigOutput{ + YamlSnippet: string(yamlBytes), + ValidationErrors: validationErrors, + }, nil +} + +// coerceFieldValue parses the string-encoded form value into the +// catalog-declared Kind. Returns (coercedValue, nil) on success or +// (nil, []validationError) on parse failure. The handler accumulates +// validation errors and continues processing other fields so the +// UI can surface every problem in one round-trip. +func coerceFieldValue(spec *catalog.FieldSpec, raw string) (any, []string) { + switch spec.Kind { + case "string": + return raw, nil + case "enum", "enum_dynamic": + // Dropdowns submit a string value. Catalog cannot validate + // enum_dynamic values without the live providers/regions + // data; the host module can post-validate via T6 future + // enhancement. v1 accepts the string verbatim. + return raw, nil + case "bool": + v, err := strconv.ParseBool(raw) + if err != nil { + return nil, []string{fmt.Sprintf("field %q: invalid bool %q", spec.Name, raw)} + } + return v, nil + case "number": + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil, []string{fmt.Sprintf("field %q: invalid number %q", spec.Name, raw)} + } + // Bounds check: MaxCount/MinCount on number-kind fields carry + // the value range per design's "number-with-bounds" + // convention. + if spec.MinCount != 0 && v < int64(spec.MinCount) { + return nil, []string{fmt.Sprintf("field %q: %d below min %d", spec.Name, v, spec.MinCount)} + } + if spec.MaxCount != 0 && v > int64(spec.MaxCount) { + return nil, []string{fmt.Sprintf("field %q: %d above max %d", spec.Name, v, spec.MaxCount)} + } + return v, nil + case "array_string", "array_object", "array_enum_dynamic", "array_number": + return coerceArrayValue(spec, raw) + case "object": + // Object kind: expect JSON-encoded payload, decode to + // map[string]any so yaml.Marshal emits a nested map. + var m map[string]any + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return nil, []string{fmt.Sprintf("field %q: invalid object JSON: %v", spec.Name, err)} + } + return m, nil + default: + // Unknown kind — accept verbatim with a validation warning so + // the catalog can introduce new kinds without immediately + // crashing in-flight requests. + return raw, []string{fmt.Sprintf("field %q: unrecognized kind %q (accepted verbatim)", spec.Name, spec.Kind)} + } +} + +// coerceArrayValue handles the cross-task contract for array-shaped +// field_values: input arrives as a JSON-encoded array string (the +// canonical form) OR a plain literal (defensive fallback). Returns +// a []any whose elements are coerced per spec.ElementKind so yaml. +// Marshal emits a proper YAML sequence. +func coerceArrayValue(spec *catalog.FieldSpec, raw string) (any, []string) { + trimmed := strings.TrimSpace(raw) + var elements []any + if strings.HasPrefix(trimmed, "[") { + // JSON-encoded array — canonical shape per cross-task contract. + var stringElems []string + if err := json.Unmarshal([]byte(trimmed), &stringElems); err == nil { + for _, s := range stringElems { + elements = append(elements, s) + } + } else { + // Try heterogeneous array (e.g. array_number). + if err := json.Unmarshal([]byte(trimmed), &elements); err != nil { + return nil, []string{fmt.Sprintf("field %q: invalid array JSON: %v", spec.Name, err)} + } + } + } else { + // Defensive fallback: plain literal becomes a one-element array. + // TestGenerateConfig_PlainStringNotJSONDecoded pins this shape. + elements = []any{raw} + } + // Element-kind coercion: array_number elements come back as + // float64 from json.Unmarshal — coerce to int64 so YAML emits + // integers rather than floats. + if spec.ElementKind == "number" { + coerced := make([]any, 0, len(elements)) + for _, e := range elements { + switch v := e.(type) { + case float64: + coerced = append(coerced, int64(v)) + case string: + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + coerced = append(coerced, n) + } else { + return nil, []string{fmt.Sprintf("field %q: invalid number element %q", spec.Name, v)} + } + default: + coerced = append(coerced, e) + } + } + elements = coerced + } + return elements, nil +} diff --git a/iac/admin/handler/generate_config_test.go b/iac/admin/handler/generate_config_test.go new file mode 100644 index 00000000..00fa1826 --- /dev/null +++ b/iac/admin/handler/generate_config_test.go @@ -0,0 +1,303 @@ +package handler_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" + "github.com/GoCodeAlone/workflow/iac/admin/handler" + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "gopkg.in/yaml.v3" +) + +// TestGenerateConfig_HappyPath_VPC verifies the typical form +// submission for an infra.vpc resource produces a well-formed +// module YAML snippet. The YAML round-trips through yaml.Unmarshal +// to a generic map so the test asserts shape semantically rather +// than via string-equality (whitespace + key ordering vary). +func TestGenerateConfig_HappyPath_VPC(t *testing.T) { + in := &adminpb.AdminGenerateConfigInput{ + ResourceType: "infra.vpc", + ResourceName: "site-vpc", + ProviderModule: "do-prod", + FieldValues: map[string]string{ + "provider": "do-prod", + "region": "nyc3", + "cidr": "10.10.0.0/16", + }, + Evidence: authzOK(), + } + out, err := handler.GenerateConfig(context.Background(), catalog.New(), in) + if err != nil { + t.Fatalf("GenerateConfig: %v", err) + } + if out.Error != "" { + t.Errorf("unexpected error: %q", out.Error) + } + if out.YamlSnippet == "" { + t.Fatal("YamlSnippet empty") + } + + // Parse the YAML to assert shape. + var got map[string]any + if err := yaml.Unmarshal([]byte(out.YamlSnippet), &got); err != nil { + t.Fatalf("YAML output not parseable: %v\n%s", err, out.YamlSnippet) + } + if got["name"] != "site-vpc" { + t.Errorf("name = %v, want site-vpc", got["name"]) + } + if got["type"] != "infra.vpc" { + t.Errorf("type = %v, want infra.vpc", got["type"]) + } + cfg, _ := got["config"].(map[string]any) + if cfg == nil { + t.Fatalf("config missing or wrong shape: %v", got["config"]) + } + if cfg["region"] != "nyc3" { + t.Errorf("config.region = %v, want nyc3", cfg["region"]) + } + if cfg["cidr"] != "10.10.0.0/16" { + t.Errorf("config.cidr = %v, want 10.10.0.0/16", cfg["cidr"]) + } +} + +// TestGenerateConfig_DefaultDeny pins the authz contract. +func TestGenerateConfig_DefaultDeny(t *testing.T) { + in := &adminpb.AdminGenerateConfigInput{ + ResourceType: "infra.vpc", + ResourceName: "site-vpc", + FieldValues: map[string]string{"region": "nyc3"}, + } // no Evidence + out, _ := handler.GenerateConfig(context.Background(), catalog.New(), in) + if out.Error == "" { + t.Error("expected non-empty Error on missing evidence") + } + if out.YamlSnippet != "" { + t.Errorf("YamlSnippet leaked on auth refusal: %q", out.YamlSnippet) + } +} + +// TestGenerateConfig_UnknownTypeReturnsValidationError pins the +// catalog-driven type guard. An unknown resource_type cannot be +// safely coerced; refuse with a clear message. +func TestGenerateConfig_UnknownTypeReturnsValidationError(t *testing.T) { + in := &adminpb.AdminGenerateConfigInput{ + ResourceType: "infra.not_real", + ResourceName: "x", + ProviderModule: "do-prod", + Evidence: authzOK(), + } + out, _ := handler.GenerateConfig(context.Background(), catalog.New(), in) + if out.Error == "" && len(out.ValidationErrors) == 0 { + t.Error("expected error or validation_errors for unknown resource_type") + } +} + +// TestGenerateConfig_BoolCoercion verifies `bool`-kind fields parse +// the form's string-encoded value ("true" / "false") to a proper YAML +// bool — the form-builder doesn't carry a typed map, so coercion +// lives in the handler. +func TestGenerateConfig_BoolCoercion(t *testing.T) { + in := &adminpb.AdminGenerateConfigInput{ + ResourceType: "infra.database", + ResourceName: "db", + ProviderModule: "do-prod", + FieldValues: map[string]string{ + "provider": "do-prod", + "region": "nyc3", + "engine": "postgres", + "size": "m", + "multi_az": "true", + }, + Evidence: authzOK(), + } + out, err := handler.GenerateConfig(context.Background(), catalog.New(), in) + if err != nil { + t.Fatalf("GenerateConfig: %v", err) + } + var got map[string]any + if err := yaml.Unmarshal([]byte(out.YamlSnippet), &got); err != nil { + t.Fatalf("YAML output not parseable: %v\n%s", err, out.YamlSnippet) + } + cfg, _ := got["config"].(map[string]any) + v, ok := cfg["multi_az"].(bool) + if !ok || !v { + t.Errorf("multi_az = %v (type %T), want bool true (catalog kind=bool coercion failed)", cfg["multi_az"], cfg["multi_az"]) + } +} + +// TestGenerateConfig_NumberCoercion verifies `number`-kind fields +// coerce to numeric YAML values. +func TestGenerateConfig_NumberCoercion(t *testing.T) { + in := &adminpb.AdminGenerateConfigInput{ + ResourceType: "infra.database", + ResourceName: "db", + ProviderModule: "do-prod", + FieldValues: map[string]string{ + "provider": "do-prod", + "region": "nyc3", + "engine": "postgres", + "size": "m", + "storage_gb": "100", + }, + Evidence: authzOK(), + } + out, _ := handler.GenerateConfig(context.Background(), catalog.New(), in) + var got map[string]any + if err := yaml.Unmarshal([]byte(out.YamlSnippet), &got); err != nil { + t.Fatalf("YAML not parseable: %v", err) + } + cfg, _ := got["config"].(map[string]any) + // yaml.Unmarshal returns numeric values as int. + if v, ok := cfg["storage_gb"].(int); !ok || v != 100 { + t.Errorf("storage_gb = %v (type %T), want int 100", cfg["storage_gb"], cfg["storage_gb"]) + } +} + +// TestGenerateConfig_ArrayValuesJSONDecoded honors the cross-task +// contract locked in 2026-05-27: array_string field_values arrive +// JSON-encoded (e.g. `field_values["ingress"] = +// "[\"rule a\", \"rule b, c\"]"`) so values containing commas +// survive the wire. The handler decodes via json.Unmarshal. +func TestGenerateConfig_ArrayValuesJSONDecoded(t *testing.T) { + // FirewallConfig.ingress is array_string (FREEFORM_OK per design + // line 429). A rule DSL value can contain commas — pin the + // round-trip via JSON encoding. + rulesJSON, _ := json.Marshal([]string{ + "allow tcp 80", + "allow tcp 443,80", // contains comma — CSV would split this incorrectly + }) + in := &adminpb.AdminGenerateConfigInput{ + ResourceType: "infra.firewall", + ResourceName: "fw", + ProviderModule: "do-prod", + FieldValues: map[string]string{ + "provider": "do-prod", + "region": "nyc3", + "ingress": string(rulesJSON), + }, + Evidence: authzOK(), + } + out, err := handler.GenerateConfig(context.Background(), catalog.New(), in) + if err != nil { + t.Fatalf("GenerateConfig: %v", err) + } + if out.Error != "" { + t.Fatalf("unexpected error: %q", out.Error) + } + var got map[string]any + if err := yaml.Unmarshal([]byte(out.YamlSnippet), &got); err != nil { + t.Fatalf("YAML not parseable: %v\n%s", err, out.YamlSnippet) + } + cfg, _ := got["config"].(map[string]any) + ingress, ok := cfg["ingress"].([]any) + if !ok { + t.Fatalf("ingress not a list: %v (type %T)", cfg["ingress"], cfg["ingress"]) + } + if len(ingress) != 2 { + t.Fatalf("ingress count = %d, want 2", len(ingress)) + } + if ingress[1] != "allow tcp 443,80" { + t.Errorf("ingress[1] = %v, want 'allow tcp 443,80' (comma-in-value lossless via JSON encoding)", ingress[1]) + } +} + +// TestGenerateConfig_PlainStringNotJSONDecoded verifies that when +// field_values carries a literal string for an array_string field +// (operator typed CSV manually, or a single-value submission), the +// handler accepts it gracefully — defensively wrap a non-JSON string +// into a one-element array. +// +// The JSON-encoded shape is canonical; the literal-string fallback is +// for safety so a malformed UI submission doesn't crash the server. +func TestGenerateConfig_PlainStringNotJSONDecoded(t *testing.T) { + in := &adminpb.AdminGenerateConfigInput{ + ResourceType: "infra.firewall", + ResourceName: "fw", + ProviderModule: "do-prod", + FieldValues: map[string]string{ + "provider": "do-prod", + "region": "nyc3", + "ingress": "allow tcp 80", // not JSON; defensive parse + }, + Evidence: authzOK(), + } + out, err := handler.GenerateConfig(context.Background(), catalog.New(), in) + if err != nil { + t.Fatalf("GenerateConfig: %v", err) + } + if out.Error != "" { + t.Errorf("unexpected error: %q", out.Error) + } + var got map[string]any + yaml.Unmarshal([]byte(out.YamlSnippet), &got) + cfg, _ := got["config"].(map[string]any) + ingress, _ := cfg["ingress"].([]any) + if len(ingress) != 1 || ingress[0] != "allow tcp 80" { + t.Errorf("ingress = %v, want one-element array [allow tcp 80] (defensive wrap)", ingress) + } +} + +// TestGenerateConfig_NoFmtSprintfUserInput is the strict-contract +// guard from plan §Task 6: GenerateConfig MUST NOT use fmt.Sprintf +// on user input to construct YAML. Verified by submitting a value +// that would mangle YAML if string-interpolated (line breaks, YAML +// reserved chars) and asserting the output still parses + the value +// round-trips intact. +func TestGenerateConfig_NoFmtSprintfUserInput(t *testing.T) { + maliciousName := "x: y\n injected: true" + in := &adminpb.AdminGenerateConfigInput{ + ResourceType: "infra.storage", + ResourceName: "store", + ProviderModule: "do-prod", + FieldValues: map[string]string{ + "provider": "do-prod", + "region": "nyc3", + "name": maliciousName, + "class": "standard", + }, + Evidence: authzOK(), + } + out, err := handler.GenerateConfig(context.Background(), catalog.New(), in) + if err != nil { + t.Fatalf("GenerateConfig: %v", err) + } + if out.Error != "" { + t.Errorf("unexpected error: %q", out.Error) + } + var got map[string]any + if err := yaml.Unmarshal([]byte(out.YamlSnippet), &got); err != nil { + t.Fatalf("YAML not parseable — possible Sprintf injection: %v\n%s", err, out.YamlSnippet) + } + cfg, _ := got["config"].(map[string]any) + if cfg["name"] != maliciousName { + t.Errorf("name not round-tripped intact (Sprintf injection?): got %v, want %v", cfg["name"], maliciousName) + } + if _, leaked := cfg["injected"]; leaked { + t.Error("'injected' key leaked into config — yaml.Marshal not used (Sprintf injection succeeded)") + } +} + +// TestGenerateConfig_OutputIsAMapModuleEntry verifies the YAML +// produced is a single module-entry shape (with name + type + +// config), NOT wrapped in `modules: [...]`. The form-builder's +// "copy" button + the docs expect the user to paste under their +// existing `modules:` block. +func TestGenerateConfig_OutputIsAMapModuleEntry(t *testing.T) { + in := &adminpb.AdminGenerateConfigInput{ + ResourceType: "infra.vpc", + ResourceName: "vpc1", + ProviderModule: "do-prod", + FieldValues: map[string]string{"provider": "do-prod", "region": "nyc3"}, + Evidence: authzOK(), + } + out, _ := handler.GenerateConfig(context.Background(), catalog.New(), in) + if strings.HasPrefix(strings.TrimSpace(out.YamlSnippet), "modules:") { + t.Errorf("YAML wraps in modules: — should be a bare module entry. Got:\n%s", out.YamlSnippet) + } + if !strings.Contains(out.YamlSnippet, "name: vpc1") { + t.Errorf("YAML missing name: vpc1\n%s", out.YamlSnippet) + } +} diff --git a/iac/admin/handler/get_resource.go b/iac/admin/handler/get_resource.go new file mode 100644 index 00000000..597200b8 --- /dev/null +++ b/iac/admin/handler/get_resource.go @@ -0,0 +1,126 @@ +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 + } + + detail := &adminpb.AdminResourceDetail{ + Summary: stateToSummary(state), + AppliedConfigJson: appliedJSON, + OutputsJson: outputsJSON, + ConfigHash: state.ConfigHash, + SensitiveOutputsRedacted: redactedKeys, + } + // Guard against zero time.Time → year-1 BCE Unix epoch (per + // code-reviewer T5 M-2). Resources that have never been drift- + // checked carry a zero LastDriftCheck; leave the proto field at + // 0 so the JS fmtTs helper's `!unix` check renders "—". + if !state.LastDriftCheck.IsZero() { + detail.LastDriftCheckUnix = state.LastDriftCheck.Unix() + } + return &adminpb.AdminGetResourceOutput{Resource: detail}, nil +} + +// maskOutputsForWire returns the masked outputs map + the sorted list +// of keys that WERE masked. The masking itself is delegated to +// secrets.MaskSensitiveOutputs so the handler library and any other +// caller of that helper agree byte-for-byte on the redaction +// algorithm — single source of truth. The handler's own contract +// (per design §Secret redaction) is the additional +// `sensitive_outputs_redacted` list, which the helper does NOT +// surface; we compute it here via one extra pass over the map. +// +// Per code-reviewer T5 I-1 (commit 5fe88fe45): an earlier draft +// hand-rolled the masking with a duplicate `(sensitive)` literal, +// which would have silently drifted if secrets ever extended its +// helper to do partial-value masking. Routing through +// secrets.MaskSensitiveOutputs eliminates that drift surface — same +// bug class as T1's sanitizeStateID allowlist-vs-replacer +// divergence the same reviewer caught earlier. +// +// "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. +func maskOutputsForWire(outputs map[string]any) (map[string]any, []string) { + if len(outputs) == 0 { + return outputs, nil + } + keys := secrets.DefaultSensitiveKeys() + sensitiveSet := make(map[string]bool, len(keys)) + for _, k := range keys { + sensitiveSet[k] = true + } + var redacted []string + for k := range outputs { + if sensitiveSet[k] { + redacted = append(redacted, k) + } + } + if len(redacted) == 0 { + // No sensitive keys present — return original (no copy) so the + // caller cannot accidentally mutate the original state's + // outputs but we don't pay for an unused allocation either. + return outputs, nil + } + sort.Strings(redacted) // deterministic ordering for snapshot tests + diff-friendly UI + return secrets.MaskSensitiveOutputs(outputs, keys), 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..46553ac4 --- /dev/null +++ b/iac/admin/handler/get_resource_test.go @@ -0,0 +1,251 @@ +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) + } +} + +// TestGetResource_NilAppliedConfig pins the json.Marshal(nil) → +// "null" path so the JS receives a parseable JSON literal rather +// than an empty payload. Per code-reviewer T5 M-3 (commit 5fe88fe45). +func TestGetResource_NilAppliedConfig(t *testing.T) { + store := &fakeStateStore{ + resources: []interfaces.ResourceState{{ + Name: "nil-cfg", + Type: "infra.vpc", + Provider: "stub", + ProviderRef: "stub-mod", + AppliedConfig: nil, // explicit nil + Outputs: nil, + }}, + } + in := &adminpb.AdminGetResourceInput{Name: "nil-cfg", Evidence: authzOK()} + out, err := handler.GetResource(context.Background(), store, in) + if err != nil { + t.Fatalf("GetResource: %v", err) + } + if out.Error != "" { + t.Errorf("unexpected error: %q", out.Error) + } + if string(out.Resource.AppliedConfigJson) != "null" { + t.Errorf("applied_config_json = %q, want \"null\" (JSON encoding of nil map)", string(out.Resource.AppliedConfigJson)) + } + if string(out.Resource.OutputsJson) != "null" { + t.Errorf("outputs_json = %q, want \"null\"", string(out.Resource.OutputsJson)) + } +} + +// TestGetResource_ZeroLastDriftCheckEmitsZero pins the T5 M-2 fix: +// a zero LastDriftCheck.Time MUST emit 0 (not the year-1-BCE Unix +// epoch -6795364578871), so the JS `!unix` guard renders "—" rather +// than "0001-01-01T00:00:00.000Z". +func TestGetResource_ZeroLastDriftCheckEmitsZero(t *testing.T) { + store := &fakeStateStore{ + resources: []interfaces.ResourceState{{ + Name: "never-checked", + Type: "infra.vpc", + Provider: "stub", + ProviderRef: "stub-mod", + AppliedConfig: map[string]any{}, + // LastDriftCheck unset → zero value + }}, + } + in := &adminpb.AdminGetResourceInput{Name: "never-checked", Evidence: authzOK()} + out, err := handler.GetResource(context.Background(), store, in) + if err != nil { + t.Fatalf("GetResource: %v", err) + } + if out.Resource.LastDriftCheckUnix != 0 { + t.Errorf("LastDriftCheckUnix = %d, want 0 for zero time.Time", out.Resource.LastDriftCheckUnix) + } + if out.Resource.Summary.UpdatedAtUnix != 0 { + t.Errorf("UpdatedAtUnix = %d, want 0 for zero time.Time", out.Resource.Summary.UpdatedAtUnix) + } +} diff --git a/iac/admin/handler/list_providers.go b/iac/admin/handler/list_providers.go new file mode 100644 index 00000000..59ca35ba --- /dev/null +++ b/iac/admin/handler/list_providers.go @@ -0,0 +1,95 @@ +package handler + +import ( + "context" + "sort" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// ListProviders implements InfraAdminService.ListProviders by +// walking the provided `providers` map (keyed by host YAML module +// name) and emitting one AdminProviderSummary per entry. The +// summary carries the YAML-config provider_type string from the +// caller-supplied providerTypeByModule map (NOT provider.Name() — +// see invariant below), the catalogued region + engine lists for +// that provider type, and the full catalog type list as +// supported_types. +// +// **provider_type MUST come from the YAML config string, not +// provider.Name()** — per spec-reviewer T6 F1 (commit 1ea231fdd) + +// design cycle-5/6 backports: +// - interfaces.IaCProvider.Name() returns the plugin's DISPLAY +// name (e.g. "DigitalOcean Provider"). This is operator-facing +// decoration, not a stable identifier. +// - The YAML-config provider: field (e.g. "digitalocean") is the +// stable identifier the region + engine catalogs key against. +// - The host module (T15) reads each iac.provider module's +// config at Init time and populates providerTypeByModule +// keyed by module-name → provider-type-string. +// - If providerTypeByModule[modName] is missing (e.g. a stale +// module loaded without re-Init), provider_type stays empty +// and SupportedRegions + SupportedEngines come back empty — +// UI degrades gracefully rather than rendering wrong dropdowns. +// +// Signature deviation from design §Handler library (informational — +// not blocking): the design listed +// +// ListProviders(ctx, providers, regionCat, in) +// +// The proto's AdminProviderSummary requires supported_engines + +// supported_types (so fieldCat + engineCat are needed) AND the F1 +// fix requires providerTypeByModule. Final shape is 7 params; +// design line 233 was underspecified. +// +// regions_source is the literal "local-catalog" per design §FieldSpec +// Catalog so consumers can distinguish v1's local lookup from a +// future v1.1 IaCProviderRegionLister gRPC service. +// +// Per design §Authz: default-deny via the shared authz guard. +func ListProviders( + ctx context.Context, + providers map[string]interfaces.IaCProvider, //nolint:revive // reserved for symmetry + future per-provider RPCs (e.g. live capability probe) + providerTypeByModule map[string]string, + fieldCat *catalog.FieldSpecCatalog, + regionCat *catalog.RegionCatalog, + engineCat *catalog.EngineCatalog, + in *adminpb.AdminListProvidersInput, +) (*adminpb.AdminListProvidersOutput, error) { + if msg := authzError(in.GetEvidence()); msg != "" { + return &adminpb.AdminListProvidersOutput{Error: msg}, nil + } + + // Sort module names so the output ordering is deterministic. + // Downstream snapshot tests + the form-builder dropdown order + // both benefit from a stable iteration order; map iteration is + // random in Go. + moduleNames := make([]string, 0, len(providers)) + for name := range providers { + moduleNames = append(moduleNames, name) + } + sort.Strings(moduleNames) + + // supported_types is catalog-derived and uniform across providers + // in v1 (every typed Config can be applied to every iac.provider + // per the design's FieldSpec table). Cache the sorted list once + // rather than re-deriving per provider. + allTypes := fieldCat.AllTypes() + + out := &adminpb.AdminListProvidersOutput{} + for _, modName := range moduleNames { + providerType := providerTypeByModule[modName] // may be "" if Init didn't populate + summary := &adminpb.AdminProviderSummary{ + ModuleName: modName, + ProviderType: providerType, + SupportedRegions: regionCat.For(providerType), + SupportedEngines: engineCat.For(providerType), + SupportedTypes: append([]string(nil), allTypes...), + RegionsSource: "local-catalog", + } + out.Providers = append(out.Providers, summary) + } + return out, nil +} diff --git a/iac/admin/handler/list_providers_test.go b/iac/admin/handler/list_providers_test.go new file mode 100644 index 00000000..fa2ddb23 --- /dev/null +++ b/iac/admin/handler/list_providers_test.go @@ -0,0 +1,294 @@ +package handler_test + +import ( + "context" + "errors" + "sort" + "testing" + + "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" +) + +// nameableProvider is the minimal interfaces.IaCProvider that +// list_providers_test needs. Only Name() returns a useful value — the +// rest exist to satisfy the interface. Distinct from stubProvider in +// provider_test.go so the two test files don't collide on type names. +type nameableProvider struct{ name string } + +func (p *nameableProvider) Name() string { return p.name } +func (p *nameableProvider) Version() string { return "test" } +func (p *nameableProvider) Initialize(_ context.Context, _ map[string]any) error { return nil } +func (p *nameableProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil } +func (p *nameableProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + return nil, errors.New("stub") +} +func (p *nameableProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + return nil, errors.New("stub") +} +func (p *nameableProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + return nil, errors.New("stub") +} +func (p *nameableProvider) DetectDrift(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { + return nil, errors.New("stub") +} +func (p *nameableProvider) Import(_ context.Context, _, _ string) (*interfaces.ResourceState, error) { + return nil, errors.New("stub") +} +func (p *nameableProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + return nil, errors.New("stub") +} +func (p *nameableProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return nil, errors.New("stub") +} +func (p *nameableProvider) SupportedCanonicalKeys() []string { return nil } +func (p *nameableProvider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) { + return nil, nil +} +func (p *nameableProvider) Close() error { return nil } + +// providersFixture returns the providers map + a parallel +// providerTypeByModule map captured "as if" at module Init from the +// host YAML config. Per spec-reviewer T6 F1: the test fixture must +// separate the two so a regression that mistakenly uses provider.Name() +// can't be masked by a fake that happens to return the YAML-config +// string. We deliberately set the fake Names to DIFFERENT, +// DISPLAY-style strings so the bug would surface as wrong +// provider_type / empty region+engine lists. +func providersFixture() (map[string]interfaces.IaCProvider, map[string]string) { + providers := map[string]interfaces.IaCProvider{ + "do-prod": &nameableProvider{name: "DigitalOcean Provider"}, // display name + "aws-prod": &nameableProvider{name: "AWS Provider Plugin"}, // display name + "stub-tester": &nameableProvider{name: "Stub IaC Provider"}, // display name + } + providerTypeByModule := map[string]string{ + "do-prod": "digitalocean", // YAML-config string (stable identifier) + "aws-prod": "aws", + "stub-tester": "stub", + } + return providers, providerTypeByModule +} + +func TestListProviders_HappyPath(t *testing.T) { + providers, providerTypeByModule := providersFixture() + in := &adminpb.AdminListProvidersInput{Evidence: authzOK()} + out, err := handler.ListProviders( + context.Background(), + providers, + providerTypeByModule, + catalog.New(), + catalog.NewRegionCatalog(), + catalog.NewEngineCatalog(), + in, + ) + if err != nil { + t.Fatalf("ListProviders: %v", err) + } + if out.Error != "" { + t.Errorf("unexpected error: %q", out.Error) + } + if len(out.Providers) != len(providers) { + t.Fatalf("got %d providers, want %d", len(out.Providers), len(providers)) + } +} + +func TestListProviders_PopulatesRegionsAndEnginesAndTypes(t *testing.T) { + providers, providerTypeByModule := providersFixture() + in := &adminpb.AdminListProvidersInput{Evidence: authzOK()} + out, _ := handler.ListProviders( + context.Background(), providers, providerTypeByModule, + catalog.New(), catalog.NewRegionCatalog(), catalog.NewEngineCatalog(), in, + ) + var doProv *adminpb.AdminProviderSummary + for _, p := range out.Providers { + if p.ModuleName == "do-prod" { + doProv = p + } + } + if doProv == nil { + t.Fatal("do-prod missing from result") + } + if doProv.ProviderType != "digitalocean" { + t.Errorf("ProviderType = %q, want digitalocean (from providerTypeByModule, NOT provider.Name())", doProv.ProviderType) + } + if doProv.RegionsSource != "local-catalog" { + t.Errorf("RegionsSource = %q, want local-catalog (v1 per design)", doProv.RegionsSource) + } + if len(doProv.SupportedRegions) == 0 { + t.Error("SupportedRegions empty for digitalocean — region catalog lookup failed") + } + if !contains(doProv.SupportedRegions, "nyc3") { + t.Errorf("SupportedRegions missing nyc3: %v", doProv.SupportedRegions) + } + if len(doProv.SupportedEngines) == 0 { + t.Error("SupportedEngines empty for digitalocean — engine catalog lookup failed") + } + if len(doProv.SupportedTypes) == 0 { + t.Error("SupportedTypes empty — fieldCat reverse-index produced no types") + } + // All 13 catalog types should be present in supported_types since v1 + // of the catalog treats every type as cross-provider. + if len(doProv.SupportedTypes) < 13 { + t.Errorf("SupportedTypes count = %d, want >= 13 (full catalog)", len(doProv.SupportedTypes)) + } +} + +// TestListProviders_UsesCapturedConfigStringNotProviderName is the +// regression guard for spec-reviewer T6 F1 (commit 1ea231fdd): +// provider.Name() returns the plugin's display name in production; +// the YAML-config provider: string (captured at module Init via +// providerTypeByModule) is what the catalogs key against. If the +// handler ever reverts to p.Name(), this test fails because the +// fake's Name() returns "DigitalOcean Provider" — not in any +// catalog — so SupportedRegions + SupportedEngines come back empty. +func TestListProviders_UsesCapturedConfigStringNotProviderName(t *testing.T) { + providers, providerTypeByModule := providersFixture() + in := &adminpb.AdminListProvidersInput{Evidence: authzOK()} + out, _ := handler.ListProviders( + context.Background(), providers, providerTypeByModule, + catalog.New(), catalog.NewRegionCatalog(), catalog.NewEngineCatalog(), in, + ) + var doProv *adminpb.AdminProviderSummary + for _, p := range out.Providers { + if p.ModuleName == "do-prod" { + doProv = p + } + } + if doProv == nil { + t.Fatal("do-prod missing") + } + // Bug-class assertion: handler MUST NOT carry the display name + // from provider.Name() into provider_type. The fixture's fake + // Name() returns "DigitalOcean Provider" — if that leaks + // through, the catalog lookup downstream will fail and + // SupportedRegions will be empty. + if doProv.ProviderType == "DigitalOcean Provider" { + t.Fatal("BUG: provider_type carries provider.Name() (display name) instead of providerTypeByModule (YAML config string)") + } + if doProv.ProviderType != "digitalocean" { + t.Errorf("ProviderType = %q, want exactly 'digitalocean'", doProv.ProviderType) + } + if len(doProv.SupportedRegions) == 0 { + t.Error("SupportedRegions empty — provider_type isn't a catalog key (the F1 bug symptom)") + } +} + +// TestListProviders_MissingProviderTypeByModule_DegradesGracefully +// guards the F1 fix's degradation path: when providerTypeByModule +// doesn't include a key for a registered iac.provider module (e.g. +// stale Init, hot-reload race), the handler still emits a summary +// entry with empty provider_type + empty regions/engines so the UI +// renders a graceful empty-dropdown affordance instead of crashing +// or dropping the provider entirely. +func TestListProviders_MissingProviderTypeByModule_DegradesGracefully(t *testing.T) { + providers, _ := providersFixture() + // Pass an EMPTY map — simulates "Init never populated for these modules". + emptyTypeMap := map[string]string{} + in := &adminpb.AdminListProvidersInput{Evidence: authzOK()} + out, _ := handler.ListProviders( + context.Background(), providers, emptyTypeMap, + catalog.New(), catalog.NewRegionCatalog(), catalog.NewEngineCatalog(), in, + ) + if len(out.Providers) != len(providers) { + t.Fatalf("got %d providers, want %d (handler dropped entries instead of degrading)", len(out.Providers), len(providers)) + } + for _, p := range out.Providers { + if p.ProviderType != "" { + t.Errorf("module %q ProviderType = %q, want empty when providerTypeByModule key is missing", p.ModuleName, p.ProviderType) + } + if len(p.SupportedRegions) != 0 { + t.Errorf("module %q SupportedRegions = %v, want empty (regions keyed by empty string)", p.ModuleName, p.SupportedRegions) + } + } +} + +func TestListProviders_SortedByModuleName(t *testing.T) { + providers, providerTypeByModule := providersFixture() + in := &adminpb.AdminListProvidersInput{Evidence: authzOK()} + out, _ := handler.ListProviders( + context.Background(), providers, providerTypeByModule, + catalog.New(), catalog.NewRegionCatalog(), catalog.NewEngineCatalog(), in, + ) + gotNames := make([]string, 0, len(out.Providers)) + for _, p := range out.Providers { + gotNames = append(gotNames, p.ModuleName) + } + if !sort.StringsAreSorted(gotNames) { + t.Errorf("ListProviders not sorted by module_name: %v", gotNames) + } +} + +func TestListProviders_DefaultDeny(t *testing.T) { + providers, providerTypeByModule := providersFixture() + cases := []struct { + name string + ev *adminpb.AdminAuthzEvidence + }{ + {"nil", nil}, + {"checked=false", &adminpb.AdminAuthzEvidence{AuthzChecked: false, AuthzAllowed: true}}, + {"allowed=false", &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: false}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + in := &adminpb.AdminListProvidersInput{Evidence: c.ev} + out, _ := handler.ListProviders( + context.Background(), providers, providerTypeByModule, + catalog.New(), catalog.NewRegionCatalog(), catalog.NewEngineCatalog(), in, + ) + if out.Error == "" { + t.Error("expected non-empty Error on default-deny") + } + if len(out.Providers) != 0 { + t.Errorf("expected empty Providers on refusal, got %d", len(out.Providers)) + } + }) + } +} + +func TestListProviders_UnknownProviderTypeStillSurfaces(t *testing.T) { + // A registered iac.provider module whose YAML-config provider: + // string isn't in the region/engine catalog should still appear + // in the listing, but with empty supported_regions / + // supported_engines (so the UI can render a "no regions available" + // placeholder rather than dropping the provider). + providers := map[string]interfaces.IaCProvider{ + "unknown-mod": &nameableProvider{name: "Mystery Cloud Provider"}, // display name; irrelevant + } + providerTypeByModule := map[string]string{ + "unknown-mod": "mystery-cloud", // YAML config string; not in any catalog + } + in := &adminpb.AdminListProvidersInput{Evidence: authzOK()} + out, _ := handler.ListProviders( + context.Background(), providers, providerTypeByModule, + catalog.New(), catalog.NewRegionCatalog(), catalog.NewEngineCatalog(), in, + ) + if len(out.Providers) != 1 { + t.Fatalf("got %d providers, want 1", len(out.Providers)) + } + p := out.Providers[0] + if p.ProviderType != "mystery-cloud" { + t.Errorf("ProviderType = %q, want mystery-cloud (from providerTypeByModule)", p.ProviderType) + } + if len(p.SupportedRegions) != 0 { + t.Errorf("SupportedRegions = %v, want empty (mystery-cloud not in catalog)", p.SupportedRegions) + } + if len(p.SupportedEngines) != 0 { + t.Errorf("SupportedEngines = %v, want empty (mystery-cloud not in catalog)", p.SupportedEngines) + } + // supported_types is catalog-derived (NOT per-provider) in v1 so + // it stays populated even for uncatalogued provider types. + if len(p.SupportedTypes) == 0 { + t.Error("SupportedTypes empty — should still list the full catalog regardless of provider") + } +} + +func contains(slice []string, v string) bool { + for _, s := range slice { + if s == v { + return true + } + } + return false +} diff --git a/iac/admin/handler/list_resources.go b/iac/admin/handler/list_resources.go new file mode 100644 index 00000000..bfee1fc3 --- /dev/null +++ b/iac/admin/handler/list_resources.go @@ -0,0 +1,131 @@ +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). +// +// Status is hardcoded "active" in v1 because interfaces.ResourceState +// has no Status field; the on-disk StateRecord.Status IS captured by +// the wfctlhelpers fs/postgres backends but dropped during the +// ResourceState conversion. Promoting Status into interfaces. +// ResourceState is a v1.1 item that lands alongside reconciliation +// (design §Personas explicitly excludes mid-cycle states from v1). +// Per spec-reviewer + code-reviewer T5 M-1 on commit 5fe88fe45. +func stateToSummary(s *interfaces.ResourceState) *adminpb.AdminResourceSummary { + out := &adminpb.AdminResourceSummary{ + Name: s.Name, + Type: s.Type, + ProviderModule: s.ProviderRef, + ProviderType: s.Provider, + ProviderId: s.ProviderID, + Status: "active", // TODO(v1.1): promote Status to interfaces.ResourceState + Dependencies: append([]string(nil), s.Dependencies...), + AppContext: extractAppContext(s.AppliedConfig), + } + // Guard against zero time.Time → year-1 BCE Unix epoch (per + // code-reviewer T5 M-2). The JS fmtTs helper checks `!unix`, so + // a 0 here renders as "—" rather than a misleading "0001-01-01". + if !s.UpdatedAt.IsZero() { + out.UpdatedAtUnix = s.UpdatedAt.Unix() + } + return out +} + +// 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") +} diff --git a/iac/admin/handler/list_types.go b/iac/admin/handler/list_types.go new file mode 100644 index 00000000..0e1a7e42 --- /dev/null +++ b/iac/admin/handler/list_types.go @@ -0,0 +1,91 @@ +package handler + +import ( + "context" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// ListResourceTypes implements InfraAdminService.ListResourceTypes by +// walking the FieldSpecCatalog and emitting one +// AdminResourceTypeMetadata per registered type. Each metadata +// entry carries the catalog's full FieldSpec list so the new-resource +// form-builder UI can render the right inputs without an extra RPC +// per type. Per plan §Task 6 + design §Handler library. +// +// The providers parameter is reserved for symmetry with the other +// handler functions; v1 of this endpoint does not filter types by +// live providers (the FieldSpecCatalog is the authoritative type +// list and assumes every registered type is supportable by every +// known provider — see TestListProviders_PopulatesRegionsAndEngines +// AndTypes for the cross-task assumption). +// +// Per design §Authz: default-deny via the shared authz guard. +func ListResourceTypes( + ctx context.Context, + fieldCat *catalog.FieldSpecCatalog, + providers map[string]interfaces.IaCProvider, //nolint:revive // reserved for symmetry + future use + in *adminpb.AdminListResourceTypesInput, +) (*adminpb.AdminListResourceTypesOutput, error) { + if msg := authzError(in.GetEvidence()); msg != "" { + return &adminpb.AdminListResourceTypesOutput{Error: msg}, nil + } + + out := &adminpb.AdminListResourceTypesOutput{} + for _, typeName := range fieldCat.AllTypes() { + fields, ok := fieldCat.Get(typeName) + if !ok { + continue + } + out.Types = append(out.Types, &adminpb.AdminResourceTypeMetadata{ + Type: typeName, + ConfigMessageFqn: typeNameToConfigFQN(typeName), + Fields: projectFieldSpecs(fields), + SupportedProviders: nil, // v1: empty = "any catalogued provider"; see godoc on this function. + Description: "", // populated by future catalog enhancement. + }) + } + return out, nil +} + +// projectFieldSpecs converts the host-side FieldSpec slice into the +// wire-typed AdminFieldSpec slice field-for-field. Single-sourced +// here so the projection cannot drift across handlers. +func projectFieldSpecs(in []catalog.FieldSpec) []*adminpb.AdminFieldSpec { + out := make([]*adminpb.AdminFieldSpec, 0, len(in)) + for i := range in { + f := in[i] + out = append(out, &adminpb.AdminFieldSpec{ + Name: f.Name, + Label: f.Label, + Kind: f.Kind, + Required: f.Required, + EnumValues: append([]string(nil), f.EnumValues...), + EnumSource: f.EnumSource, + Description: f.Description, + DefaultValue: f.DefaultValue, + Sensitive: f.Sensitive, + ElementKind: f.ElementKind, + MinCount: f.MinCount, + MaxCount: f.MaxCount, + DependsOnField: f.DependsOnField, + }) + } + return out +} + +// typeNameToConfigFQN maps a catalog type name (e.g. "infra.vpc") +// to its fully-qualified proto message name in +// workflow-plugins-infra/internal/contracts/infra.proto (e.g. +// "workflow.plugins.infra.v1.VPCConfig"). Delegates to +// catalog.ConfigMessageFQN so the package prefix + acronym-preserving +// CamelCase transform are single-sourced — earlier inline code in +// this file produced "workflow.plugin.infra.v1.VpcConfig" (wrong on +// both halves) per spec-reviewer T6 F2 (commit 1ea231fdd). The +// vendored proto at iac/admin/testdata/infra.proto is authoritative +// for both the package name and the message-name acronym table. +func typeNameToConfigFQN(typeName string) string { + return catalog.ConfigMessageFQN(typeName) +} diff --git a/iac/admin/handler/list_types_fqn_test.go b/iac/admin/handler/list_types_fqn_test.go new file mode 100644 index 00000000..eda850cd --- /dev/null +++ b/iac/admin/handler/list_types_fqn_test.go @@ -0,0 +1,95 @@ +package handler_test + +import ( + "context" + "os" + "regexp" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" + "github.com/GoCodeAlone/workflow/iac/admin/handler" + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" +) + +// TestListResourceTypes_ConfigMessageFQNMatchesVendoredProto is the +// F2 regression guard (spec-reviewer T6 finding on commit 1ea231fdd). +// It walks every AdminResourceTypeMetadata returned by the handler, +// parses out the short message name from each `config_message_fqn`, +// and asserts (a) the package prefix matches the vendored proto's +// `package workflow.plugins.infra.v1;` declaration AND (b) the +// short name exists as a `message ` line in the vendored +// proto. Without this test, the earlier T6 bug (wrong "plugin" +// singular prefix + missing acronym preservation producing +// "VpcConfig"/"DnsConfig"/etc.) would have shipped silently — +// the original AllFieldsMatchProto test only checked non-emptiness. +func TestListResourceTypes_ConfigMessageFQNMatchesVendoredProto(t *testing.T) { + const vendoredPath = "../catalog/../testdata/infra.proto" + // Walk up to iac/admin/testdata/infra.proto regardless of test + // cwd. The relative path from iac/admin/handler/ is + // ../testdata/infra.proto. + protoBytes, err := os.ReadFile("../testdata/infra.proto") + if err != nil { + t.Fatalf("read vendored proto (%s): %v", vendoredPath, err) + } + src := string(protoBytes) + + // Confirm the vendored proto declares the package we expect — if + // upstream ever renames, this catches it before the FQN parity + // check yields a confusing failure. + pkgRe := regexp.MustCompile(`(?m)^package\s+([A-Za-z0-9_.]+)\s*;`) + pkgMatch := pkgRe.FindStringSubmatch(src) + if len(pkgMatch) < 2 { + t.Fatal("vendored proto missing `package` declaration") + } + gotPkg := pkgMatch[1] + if gotPkg != catalog.ConfigProtoPackage { + t.Fatalf("vendored proto package = %q but catalog.ConfigProtoPackage = %q — update one to match", gotPkg, catalog.ConfigProtoPackage) + } + + // Collect the message names declared in the vendored proto. + msgRe := regexp.MustCompile(`(?m)^\s*message\s+([A-Za-z0-9_]+Config)\s*\{`) + matches := msgRe.FindAllStringSubmatch(src, -1) + vendoredMessages := map[string]bool{} + for _, m := range matches { + vendoredMessages[m[1]] = true + } + if len(vendoredMessages) == 0 { + t.Fatal("regex found 0 *Config messages in vendored proto") + } + + // Now exercise the handler and validate every emitted FQN. + in := &adminpb.AdminListResourceTypesInput{Evidence: authzOK()} + out, err := handler.ListResourceTypes(context.Background(), catalog.New(), nil, in) + if err != nil { + t.Fatalf("ListResourceTypes: %v", err) + } + if len(out.Types) == 0 { + t.Fatal("no types in output; T7b should have populated 13") + } + wantPrefix := catalog.ConfigProtoPackage + "." + for _, ty := range out.Types { + if ty.ConfigMessageFqn == "" { + t.Errorf("type %q: config_message_fqn empty", ty.Type) + continue + } + if !strings.HasPrefix(ty.ConfigMessageFqn, wantPrefix) { + t.Errorf("type %q: config_message_fqn = %q, want prefix %q", ty.Type, ty.ConfigMessageFqn, wantPrefix) + continue + } + shortName := strings.TrimPrefix(ty.ConfigMessageFqn, wantPrefix) + if !vendoredMessages[shortName] { + t.Errorf("type %q: emitted FQN %q references a *Config message NOT present in vendored proto (got short=%q; available=%v)", + ty.Type, ty.ConfigMessageFqn, shortName, sortedKeys(vendoredMessages)) + } + } +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + // Don't pull in sort here; just stringify for the error message. + return out +} diff --git a/iac/admin/handler/list_types_test.go b/iac/admin/handler/list_types_test.go new file mode 100644 index 00000000..065d7969 --- /dev/null +++ b/iac/admin/handler/list_types_test.go @@ -0,0 +1,105 @@ +package handler_test + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/workflow/iac/admin/catalog" + "github.com/GoCodeAlone/workflow/iac/admin/handler" + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" +) + +// TestListResourceTypes_HappyPath verifies the handler returns one +// AdminResourceTypeMetadata per type registered in FieldSpecCatalog. +// Post-T7b the catalog has 13 typed Configs; this test asserts the +// shape rather than the exact count so a future catalog refresh +// doesn't break the assertion. +func TestListResourceTypes_HappyPath(t *testing.T) { + cat := catalog.New() + in := &adminpb.AdminListResourceTypesInput{Evidence: authzOK()} + out, err := handler.ListResourceTypes(context.Background(), cat, nil, in) + if err != nil { + t.Fatalf("ListResourceTypes: %v", err) + } + if out.Error != "" { + t.Errorf("unexpected error: %q", out.Error) + } + if len(out.Types) == 0 { + t.Fatal("got 0 types — catalog should be populated post-T7b") + } + // Each entry must have a type name + at least one field. + for _, ty := range out.Types { + if ty.Type == "" { + t.Errorf("AdminResourceTypeMetadata.type empty: %+v", ty) + } + if len(ty.Fields) == 0 { + t.Errorf("AdminResourceTypeMetadata for %q has zero fields", ty.Type) + } + } +} + +// TestListResourceTypes_DefaultDeny pins the authz contract — same +// shape as ListResources: refusal via Output.error, no resources +// leaked. +func TestListResourceTypes_DefaultDeny(t *testing.T) { + cat := catalog.New() + cases := []struct { + name string + ev *adminpb.AdminAuthzEvidence + }{ + {"nil evidence", nil}, + {"checked=false", &adminpb.AdminAuthzEvidence{AuthzChecked: false, AuthzAllowed: true}}, + {"allowed=false", &adminpb.AdminAuthzEvidence{AuthzChecked: true, AuthzAllowed: false}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + in := &adminpb.AdminListResourceTypesInput{Evidence: c.ev} + out, _ := handler.ListResourceTypes(context.Background(), cat, nil, in) + if out.Error == "" { + t.Error("expected non-empty Error on default-deny") + } + if len(out.Types) != 0 { + t.Errorf("expected empty Types on auth refusal, got %d", len(out.Types)) + } + }) + } +} + +// TestListResourceTypes_AllFieldsMatchProto checks every FieldSpec in +// the catalog projects into AdminFieldSpec field-for-field so the +// form-builder UI gets full metadata. +func TestListResourceTypes_AllFieldsMatchProto(t *testing.T) { + cat := catalog.New() + in := &adminpb.AdminListResourceTypesInput{Evidence: authzOK()} + out, _ := handler.ListResourceTypes(context.Background(), cat, nil, in) + + var sawNonEmptyKind, sawEnumValues, sawDependsOn bool + for _, ty := range out.Types { + for _, f := range ty.Fields { + if f.Name == "" { + t.Errorf("type %q: field with empty name", ty.Type) + } + if f.Kind == "" { + t.Errorf("type %q field %q: empty kind", ty.Type, f.Name) + } + if f.Kind != "" { + sawNonEmptyKind = true + } + if len(f.EnumValues) > 0 { + sawEnumValues = true + } + if f.DependsOnField != "" { + sawDependsOn = true + } + } + } + if !sawNonEmptyKind { + t.Error("no FieldSpec carried a non-empty Kind — projection dropped data") + } + if !sawEnumValues { + t.Error("no FieldSpec carried EnumValues — projection dropped enum lists") + } + if !sawDependsOn { + t.Error("no FieldSpec carried DependsOnField — projection dropped dependency edges") + } +} diff --git a/iac/admin/proto/infra_admin.pb.go b/iac/admin/proto/infra_admin.pb.go new file mode 100644 index 00000000..7b663bf5 --- /dev/null +++ b/iac/admin/proto/infra_admin.pb.go @@ -0,0 +1,1595 @@ +// 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 +} + +// 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"` + 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 +} + +// 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"` + 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 "" +} + +// 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"` + 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 +} + +// 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"` + 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.plugins.infra.v1.VPCConfig" — note +// the plural "plugins" matching the vendored proto's package +// declaration; earlier doc-example used "plugin" singular which +// didn't match the wire, see spec-reviewer T6 F2 on commit 1ea231fdd) +// 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 "" +} + +// 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"` + 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 +} + +// 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"` + 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 "" +} + +// 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"` + 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 +} + +// 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"` + 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"` + // field_values carries the form-builder submission keyed by + // AdminFieldSpec.name. Single-valued fields (string/enum/bool/ + // number) are encoded as their literal string. Array-shaped + // fields (kind ∈ {array_string, array_object, array_number, + // array_enum_dynamic}) are JSON-encoded — e.g. + // + // field_values["ingress"] = "[\"rule a\", \"rule b, c\"]" + // + // — so values containing commas, quotes, or other delimiters + // survive the wire losslessly. The server decodes via + // json.Unmarshal in iac/admin/handler/generate_config.go. + // + // Cross-task contract locked 2026-05-27 between T6 (server) + + // T10-T12 (form-builder JS). Defensive fallback in the handler + // wraps a non-JSON literal into a one-element array so a + // malformed UI submission doesn't crash the server. + 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 +} + +// 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"` + 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\"\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\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\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\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\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" + + "\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\"\x94\x01\n" + + "\x1bAdminListResourceTypesInput\x12'\n" + + "\x0fprovider_filter\x18\x01 \x01(\tR\x0eproviderFilter\x12?\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\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" + + "\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\"\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\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\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" + + "\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\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\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" + + "\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..21669cbc --- /dev/null +++ b/iac/admin/proto/infra_admin.proto @@ -0,0 +1,284 @@ +// 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; +} + +// 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 +// 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.plugins.infra.v1.VPCConfig" — note +// the plural "plugins" matching the vendored proto's package +// declaration; earlier doc-example used "plugin" singular which +// didn't match the wire, see spec-reviewer T6 F2 on commit 1ea231fdd) +// 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; +} + +// 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 +// 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 +} + +// 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. +// 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; + // field_values carries the form-builder submission keyed by + // AdminFieldSpec.name. Single-valued fields (string/enum/bool/ + // number) are encoded as their literal string. Array-shaped + // fields (kind ∈ {array_string, array_object, array_number, + // array_enum_dynamic}) are JSON-encoded — e.g. + // field_values["ingress"] = "[\"rule a\", \"rule b, c\"]" + // — so values containing commas, quotes, or other delimiters + // survive the wire losslessly. The server decodes via + // json.Unmarshal in iac/admin/handler/generate_config.go. + // + // Cross-task contract locked 2026-05-27 between T6 (server) + + // T10-T12 (form-builder JS). Defensive fallback in the handler + // wraps a non-JSON literal into a one-element array so a + // malformed UI submission doesn't crash the server. + 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 +// 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) + } +} 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. diff --git a/iac/admin/ui.go b/iac/admin/ui.go new file mode 100644 index 00000000..059b4f9a --- /dev/null +++ b/iac/admin/ui.go @@ -0,0 +1,25 @@ +// Package admin hosts the host-side infra.admin module's UI assets + +// audit subsystem. The handler library lives in the sibling +// handler/ subpackage; the catalog lives in catalog/; the proto in +// proto/. This package itself exposes only the asset filesystem + +// audit writer surface the host module (workflow/module/infra_admin.go, +// T15) imports. +// +// Design: docs/plans/2026-05-27-infra-admin-dynamic-design.md +// Plan: docs/plans/2026-05-27-infra-admin-dynamic.md (Tasks 13 + 14) +package admin + +import "embed" + +// AssetFS embeds the static UI pages + scripts + styles authored in +// T10-T12 under ui_dist/. The host module (T15) mounts this via +// http.FileServerFS at config.AssetPrefix so the admin dashboard +// iframe can load resources.html / resource.html / new.html. +// +// Per plan §Task 13. The glob covers the three file types the asset +// pages use (.html / .js / .css); future additions (icons, fonts) +// require both extending this glob AND updating +// TestAssetFS_ListsAllAndOnlyExpected so the test catches the change. +// +//go:embed ui_dist/*.html ui_dist/*.js ui_dist/*.css +var AssetFS embed.FS 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..9e5590d9 --- /dev/null +++ b/iac/admin/ui_dist/new.js @@ -0,0 +1,450 @@ +// 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()), + ); + // Code-review I-1: tag the row's select with the same + // data-enum-dynamic / data-depends-on attributes that + // refreshDependentDynamics() looks for, so changing the parent + // field (e.g. provider) rebuilds the array rows' options too. + // Without these, array_enum_dynamic dropdowns go stale. + if (spec.enum_source) input.dataset.enumDynamic = spec.enum_source; + if (spec.depends_on_field) input.dataset.dependsOn = spec.depends_on_field; + 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() { + // Collect array values into JS arrays first, scalars into strings, + // then JSON.stringify the arrays into their map slots + // before returning. Code-review I-2: per spec-reviewer + code-reviewer + // contract lock, array field_values are JSON-encoded so the server + // (T6 GenerateConfig) can `json.Unmarshal([]byte(s), &arr)` and + // recover the original slice — robust against array elements that + // contain commas (firewall rule DSLs, etc.). Scalars stay as plain + // strings to keep simple paths cheap. + const scalars = {}; + const arrays = {}; + const fields = document.getElementById('fields'); + for (const el of fields.querySelectorAll('input, select')) { + if (!el.name) continue; + if (el.type === 'checkbox') { + scalars[el.name] = el.checked ? 'true' : 'false'; + continue; + } + if (el.name.endsWith('[]')) { + const key = el.name.slice(0, -2); + const val = el.value; + if (val === '' || val == null) continue; + if (!arrays[key]) arrays[key] = []; + arrays[key].push(val); + continue; + } + if (el.value !== '' && el.value != null) scalars[el.name] = el.value; + } + const out = { ...scalars }; + for (const key of Object.keys(arrays)) { + out[key] = JSON.stringify(arrays[key]); + } + 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; } diff --git a/iac/admin/ui_test.go b/iac/admin/ui_test.go new file mode 100644 index 00000000..503cc1e2 --- /dev/null +++ b/iac/admin/ui_test.go @@ -0,0 +1,80 @@ +package admin_test + +import ( + "io/fs" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/iac/admin" +) + +// TestAssetFS_AllExpectedFilesEmbedded pins the file list the host +// module (T15) serves via http.FileServerFS. If the //go:embed +// directive misses a file (typo in glob, file deleted, etc.) the +// host module's GET routes return 404 silently; this test catches +// the omission at build time. +// +// Per plan §Task 13. +func TestAssetFS_AllExpectedFilesEmbedded(t *testing.T) { + expected := []string{ + "ui_dist/resources.html", + "ui_dist/resources.js", + "ui_dist/resource.html", + "ui_dist/resource.js", + "ui_dist/new.html", + "ui_dist/new.js", + "ui_dist/styles.css", + } + for _, path := range expected { + t.Run(path, func(t *testing.T) { + f, err := admin.AssetFS.Open(path) + if err != nil { + t.Fatalf("AssetFS.Open(%q): %v", path, err) + } + defer f.Close() + stat, err := f.Stat() + if err != nil { + t.Fatalf("AssetFS.Stat(%q): %v", path, err) + } + if stat.Size() == 0 { + t.Errorf("%s is empty — embed glob matched but the file is empty", path) + } + }) + } +} + +// TestAssetFS_ListsAllAndOnlyExpected catches accidental inclusion +// of non-asset files (test fixtures, sourcemaps, .DS_Store) AND +// drift in the embed glob coverage. +func TestAssetFS_ListsAllAndOnlyExpected(t *testing.T) { + var got []string + err := fs.WalkDir(admin.AssetFS, "ui_dist", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + got = append(got, path) + return nil + }) + if err != nil { + t.Fatalf("fs.WalkDir: %v", err) + } + // Every embedded file must be .html, .js, or .css — the embed + // directive's glob shape. A future addition (e.g. .png assets) + // requires updating BOTH the //go:embed line + this test. + for _, path := range got { + switch { + case strings.HasSuffix(path, ".html"), + strings.HasSuffix(path, ".js"), + strings.HasSuffix(path, ".css"): + // allowed + default: + t.Errorf("AssetFS contains non-html/js/css file %q — update //go:embed glob OR remove the file", path) + } + } + if len(got) == 0 { + t.Fatal("AssetFS empty — //go:embed glob did not match any files") + } +} diff --git a/iac/wfctlhelpers/env_resolve.go b/iac/wfctlhelpers/env_resolve.go new file mode 100644 index 00000000..23f9047a --- /dev/null +++ b/iac/wfctlhelpers/env_resolve.go @@ -0,0 +1,125 @@ +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). +// +// 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) + } + + 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. 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]. 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/provider.go b/iac/wfctlhelpers/provider.go new file mode 100644 index 00000000..c8ff608d --- /dev/null +++ b/iac/wfctlhelpers/provider.go @@ -0,0 +1,168 @@ +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. +// +// 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 +// 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 + } + prov, closer, ok, err := loadProviderModule(ctx, mod) + if err != nil { + 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). +// +// 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 { + 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. + // 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() + } + 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() } diff --git a/iac/wfctlhelpers/provider_test.go b/iac/wfctlhelpers/provider_test.go new file mode 100644 index 00000000..d886c440 --- /dev/null +++ b/iac/wfctlhelpers/provider_test.go @@ -0,0 +1,296 @@ +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") + } +} + +// 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) + } +} diff --git a/iac/wfctlhelpers/state.go b/iac/wfctlhelpers/state.go new file mode 100644 index 00000000..1d15408b --- /dev/null +++ b/iac/wfctlhelpers/state.go @@ -0,0 +1,544 @@ +package wfctlhelpers + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "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). 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 +// 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. +// +// 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 + mgr *external.ExternalPluginManager // non-nil for plugin-served backends; Shutdown on Close + closed bool +} + +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 { + if a.closed { + return nil + } + a.closed = true + 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 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 { + 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 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 { + 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_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 +} 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") + } +} diff --git a/module/infra_admin.go b/module/infra_admin.go new file mode 100644 index 00000000..3bb5e0fa --- /dev/null +++ b/module/infra_admin.go @@ -0,0 +1,817 @@ +package module + +// infra_admin.go (T15) hosts the engine-side `infra.admin` workflow +// module — the integration centerpiece for the host-side IaC admin +// surface. The module wires together every prior task's deliverable: +// +// * Handler library (T5/T6) — pure read-side functions taking +// state / providers / catalogs / proto inputs and returning +// typed proto outputs. +// * State store (T1) — interfaces.IaCStateStore resolved from a +// declared iac.state module via app.GetService. +// * Provider loader (T2/T3) — interfaces.IaCProvider map resolved +// from each declared iac.provider module via app.GetService. +// * providerTypeByModule map (T6 F1) — populated at Init by +// walking the loaded *config.WorkflowConfig via +// app.GetConfigSection("workflow") and reading each +// iac.provider module's config["provider"] string. This is the +// stable identifier handler.ListProviders uses to key the +// region + engine catalogs — provider.Name() returns the +// plugin's display name and would not match the catalogs. +// * FieldSpec + Region + Engine catalogs (T7a/T7b/T8) — three +// in-process tables driving the new-resource form-builder UI. +// * AssetFS (T13) — embedded UI pages served via http.FileServerFS +// at the module's asset_prefix. +// * Audit writer (T14) — protojson-shaped AdminAuditEntry JSONL +// opened at Init when access_log_path is non-empty, closed at +// Stop. FATAL on open failure per design Security Review. +// +// Lifecycle (per design §Module lifecycle): +// * Init resolves state/providers/router/security-headers +// services + the providerTypeByModule map. Catalogs are +// instantiated in-process. +// * Start resolves the workflowEngine service (registered AFTER +// module.Init by engine.configureTriggers), mounts the typed +// API routes + asset routes under the configured prefixes with +// explicit security-headers middleware, then fires the three +// admin-plugin contribution registration pipelines via +// engine.TriggerWorkflow. +// * Stop closes the audit writer (if open). + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "strconv" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/admin" + "github.com/GoCodeAlone/workflow/iac/admin/audit" + "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" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +// InfraAdminConfig is the YAML-config shape the host expects under +// the `infra.admin` module entry. Field names use snake_case yaml +// tags to match the rest of the workflow config; defaults match the +// design's reference app config. +type InfraAdminConfig struct { + // RoutePrefix is the URL prefix for typed API routes (e.g. + // /api/infra-admin/resources). Default: "/api/infra-admin". + RoutePrefix string `yaml:"route_prefix" json:"route_prefix"` + + // AssetPrefix is the URL prefix for the embedded UI pages + // (e.g. /admin/infra-admin/resources.html). Default: + // "/admin/infra-admin". + AssetPrefix string `yaml:"asset_prefix" json:"asset_prefix"` + + // StateModule names the host's iac.state module to resolve + // via app.GetService for the IaCStateStore handle. + StateModule string `yaml:"state_module" json:"state_module"` + + // HTTPModule names the *StandardHTTPRouter to mount routes on + // (typically "http-router"). Resolved via app.GetService at + // Init and type-asserted at Start before AddRouteWithMiddleware + // calls. + HTTPModule string `yaml:"http_module" json:"http_module"` + + // SecurityHeadersModule names the HTTPMiddleware module to + // attach explicitly on every registered API + asset route. + // Per design §Security Review the SAMEORIGIN + restrictive CSP + // must wrap admin responses even if the host's global + // middleware ordering changes. + SecurityHeadersModule string `yaml:"security_headers_module" json:"security_headers_module"` + + // AuthModule names the HTTPMiddleware module that enforces + // authentication on every infra-admin API + asset route. Per + // design §Security Review: "All /api/infra-admin/* and + // /admin/infra-admin/* sit behind the host's auth route + // filter (same as /admin/*)". The middleware MUST reject + // unauthenticated requests with 401 before the handler runs; + // without it the handler-side AdminAuthzEvidence default-deny + // is trivially bypassable because the client supplies the + // evidence in the request body. Resolved via app.GetService + // at Init. Empty disables auth (test-only / single-tenant + // dev mode); production deployments MUST set this. + AuthModule string `yaml:"auth_module" json:"auth_module"` + + // ProviderModules lists the iac.provider module names to + // resolve. Each is resolved to an interfaces.IaCProvider via + // app.GetService at Init. + ProviderModules []string `yaml:"provider_modules" json:"provider_modules"` + + // AccessLogPath is the on-disk path for the audit JSONL file. + // Empty disables the audit writer; non-empty opens the writer + // at Init and propagates open errors as a module-init failure + // (FATAL per design Security Review). + AccessLogPath string `yaml:"access_log_path" json:"access_log_path"` +} + +// InfraAdmin is the engine-side workflow module. Implements +// modular.Module + the Init/Start/Stop lifecycle hooks. +type InfraAdmin struct { + name string + config InfraAdminConfig + + // Resolved at Init. + app modular.Application + state interfaces.IaCStateStore + providers map[string]interfaces.IaCProvider + providerTypeByModule map[string]string + router *StandardHTTPRouter + secHdrs HTTPMiddleware + auth HTTPMiddleware + + // Catalogs are instantiated in-process at Init. + fieldCatalog *catalog.FieldSpecCatalog + regionCatalog *catalog.RegionCatalog + engineCatalog *catalog.EngineCatalog + + // audit is non-nil iff config.AccessLogPath != "" and Open + // succeeded at Init. + audit *audit.Writer + + // Resolved at Start (workflowEngine is registered by engine. + // configureTriggers AFTER app.Init returns). + engine WorkflowEngine +} + +// (The shared WorkflowEngine interface — TriggerWorkflow only — +// already exists in module/http_trigger.go; we reuse it here so +// the package has a single definition.) + +// NewInfraAdmin is the module factory the engine's BuildFromConfig +// dispatches to for `type: infra.admin` entries. T18 registers this +// with the engine via AddModuleType. The factory decodes the loose +// config map into the typed InfraAdminConfig + applies defaults so +// callers can omit fields with sensible fallbacks. +func NewInfraAdmin(name string, cfg map[string]any) modular.Module { + c := InfraAdminConfig{ + RoutePrefix: "/api/infra-admin", + AssetPrefix: "/admin/infra-admin", + } + // Round-trip the loose map through JSON to populate the typed + // struct — uses the same json tags the proto/wfctlhelpers layers + // use elsewhere in the codebase. Map keys not present in the + // struct are silently ignored (e.g. `_config_dir` injected by + // engine.BuildFromConfig at line 612). + if raw, err := json.Marshal(cfg); err == nil { + _ = json.Unmarshal(raw, &c) + } + return &InfraAdmin{ + name: name, + config: c, + providers: map[string]interfaces.IaCProvider{}, + } +} + +// Name returns the host-side module name. +func (m *InfraAdmin) Name() string { return m.name } + +// Dependencies returns the names of modules that MUST initialise +// before this one — the modular framework uses this for its +// init-order DAG. Per design §Module lifecycle: state + http + +// security-headers + every declared provider. +func (m *InfraAdmin) Dependencies() []string { + deps := []string{} + if m.config.StateModule != "" { + deps = append(deps, m.config.StateModule) + } + if m.config.HTTPModule != "" { + deps = append(deps, m.config.HTTPModule) + } + if m.config.SecurityHeadersModule != "" { + deps = append(deps, m.config.SecurityHeadersModule) + } + if m.config.AuthModule != "" { + deps = append(deps, m.config.AuthModule) + } + deps = append(deps, m.config.ProviderModules...) + return deps +} + +// RequiresServices declares the same set as Dependencies, but +// shaped for the service-dependency resolver. Both are needed — +// Dependencies drives Init ordering; RequiresServices drives +// service-graph wiring. +// +// NB: workflowEngine is intentionally NOT listed here — it's +// registered by engine.configureTriggers AFTER app.Init returns, +// so listing it as a required service would cause Init to fail +// when modular's resolver runs before the engine has registered +// itself. Resolved at Start instead. Per design line 749-750. +func (m *InfraAdmin) RequiresServices() []modular.ServiceDependency { + deps := []modular.ServiceDependency{} + if m.config.StateModule != "" { + deps = append(deps, modular.ServiceDependency{Name: m.config.StateModule}) + } + if m.config.HTTPModule != "" { + deps = append(deps, modular.ServiceDependency{Name: m.config.HTTPModule}) + } + if m.config.SecurityHeadersModule != "" { + deps = append(deps, modular.ServiceDependency{Name: m.config.SecurityHeadersModule}) + } + if m.config.AuthModule != "" { + deps = append(deps, modular.ServiceDependency{Name: m.config.AuthModule}) + } + for _, pm := range m.config.ProviderModules { + deps = append(deps, modular.ServiceDependency{Name: pm}) + } + return deps +} + +// ProvidesServices is nil — this module is a sink, not a source. +// Per design §Module lifecycle: external consumers do not call +// this module via service-graph lookup; they hit it via the HTTP +// routes mounted in Start. +func (m *InfraAdmin) ProvidesServices() []modular.ServiceProvider { return nil } + +// Init resolves the host-side service dependencies + populates the +// providerTypeByModule map + opens the audit writer. +// +// Per design line 749: workflowEngine is NOT resolved here — see +// Start. The intermediate state where the engine isn't yet +// registered is real and intentional; Init must succeed in that +// window so app.Init() returns and configureTriggers can register +// the engine. +func (m *InfraAdmin) Init(app modular.Application) error { + m.app = app + + // State store. + if m.config.StateModule != "" { + if err := app.GetService(m.config.StateModule, &m.state); err != nil { + return fmt.Errorf("infra.admin: state module %q: %w", m.config.StateModule, err) + } + } + + // HTTP router — type-assert to *StandardHTTPRouter so we can + // call AddRouteWithMiddleware at Start. The interface alone + // (HTTPRouter) doesn't expose middleware-aware route + // registration; the design explicitly requires the typed + // concrete (per §Module lifecycle "ProvidesServices the + // *StandardHTTPRouter typed instance"). We resolve via the + // generic interface{} and then type-assert so the failure + // message is operator-actionable. + if m.config.HTTPModule != "" { + var router any + if err := app.GetService(m.config.HTTPModule, &router); err != nil { + return fmt.Errorf("infra.admin: http module %q: %w", m.config.HTTPModule, err) + } + sr, ok := router.(*StandardHTTPRouter) + if !ok { + return fmt.Errorf("infra.admin: http module %q is %T, need *StandardHTTPRouter", m.config.HTTPModule, router) + } + m.router = sr + } + + // Security headers middleware. + if m.config.SecurityHeadersModule != "" { + var mw any + if err := app.GetService(m.config.SecurityHeadersModule, &mw); err != nil { + return fmt.Errorf("infra.admin: security-headers module %q: %w", m.config.SecurityHeadersModule, err) + } + secMw, ok := mw.(HTTPMiddleware) + if !ok { + return fmt.Errorf("infra.admin: security-headers module %q is %T, need HTTPMiddleware", m.config.SecurityHeadersModule, mw) + } + m.secHdrs = secMw + } + + // Auth middleware — per design §Security Review the + // /api/infra-admin/* and /admin/infra-admin/* routes MUST + // sit behind the host's auth route filter (same as + // /admin/*). Without it, the handler-side AdminAuthzEvidence + // default-deny is bypassable: the client supplies + // {authz_checked, authz_allowed} in the request body, so an + // unauthenticated network actor can send + // {evidence:{authz_checked:true,authz_allowed:true}} and the + // handler accepts it. The auth middleware rejects requests + // without a valid Bearer token at 401 before the handler ever + // runs, closing that gap. + if m.config.AuthModule != "" { + var mw any + if err := app.GetService(m.config.AuthModule, &mw); err != nil { + return fmt.Errorf("infra.admin: auth module %q: %w", m.config.AuthModule, err) + } + authMw, ok := mw.(HTTPMiddleware) + if !ok { + return fmt.Errorf("infra.admin: auth module %q is %T, need HTTPMiddleware", m.config.AuthModule, mw) + } + m.auth = authMw + } + + // Per-provider IaCProvider handles. + for _, pm := range m.config.ProviderModules { + var p interfaces.IaCProvider + if err := app.GetService(pm, &p); err != nil { + return fmt.Errorf("infra.admin: provider %q: %w", pm, err) + } + m.providers[pm] = p + } + + // Populate providerTypeByModule from the loaded WorkflowConfig + // per spec-reviewer T6 F1 + design cycle-5/6: handler. + // ListProviders needs the YAML-config `provider:` string, NOT + // the plugin's display name from provider.Name(). + if err := m.populateProviderTypes(app); err != nil { + return fmt.Errorf("infra.admin: populate provider types: %w", err) + } + + // In-process catalogs. + m.fieldCatalog = catalog.New() + m.regionCatalog = catalog.NewRegionCatalog() + m.engineCatalog = catalog.NewEngineCatalog() + + // Audit writer (optional — empty path disables; non-empty path + // MUST succeed per design Security Review). + if m.config.AccessLogPath != "" { + w, err := audit.Open(m.config.AccessLogPath) + if err != nil { + return fmt.Errorf("infra.admin: open audit log %q: %w", m.config.AccessLogPath, err) + } + m.audit = w + } + return nil +} + +// populateProviderTypes walks the loaded WorkflowConfig and captures +// each iac.provider module's config["provider"] string keyed by +// module name. The result feeds handler.ListProviders' provider_type +// + region/engine-catalog-key parameter. +// +// Per spec-reviewer T6 F1: this string is the stable identifier; +// provider.Name() returns the plugin's display name and would fail +// region/engine catalog lookups. +// +// The WorkflowConfig is registered as a config-section under the +// "workflow" name by engine.go:672. Module fall-back (no config +// section) leaves the map empty, which degrades gracefully — +// handler.ListProviders emits per-module entries with empty +// provider_type + empty regions/engines per +// TestListProviders_MissingProviderTypeByModule_DegradesGracefully. +func (m *InfraAdmin) populateProviderTypes(app modular.Application) error { + m.providerTypeByModule = map[string]string{} + + section, err := app.GetConfigSection("workflow") + if err != nil || section == nil { + // Config section missing — graceful degradation per design: + // UI shows empty region/engine dropdowns rather than the + // admin module refusing to start. The `err` is intentionally + // not propagated; the section's absence is a normal state in + // unit-test fakes and during early bootstrap. + return nil //nolint:nilerr // intentional: graceful degradation, see comment + } + wfCfg, ok := section.GetConfig().(*config.WorkflowConfig) + if !ok || wfCfg == nil { + return nil + } + for i := range wfCfg.Modules { + mod := &wfCfg.Modules[i] + if mod.Type != "iac.provider" { + continue + } + modCfg := config.ExpandEnvInMap(mod.Config) + pt, _ := modCfg["provider"].(string) + if pt == "" { + continue + } + m.providerTypeByModule[mod.Name] = pt + } + return nil +} + +// Start resolves the workflowEngine service (registered after +// app.Init by engine.configureTriggers), mounts the typed API + +// asset routes with the explicit security-headers middleware, and +// fires the three admin-plugin contribution registration pipelines +// via engine.TriggerWorkflow. +// +// Per design line 820-882: the workflowEngine resolution MUST be +// here, not Init. +func (m *InfraAdmin) Start(ctx context.Context) error { + if m.app == nil { + return fmt.Errorf("infra.admin: Start called before Init") + } + if err := m.app.GetService("workflowEngine", &m.engine); err != nil { + return fmt.Errorf("infra.admin: workflowEngine: %w", err) + } + + if m.router == nil { + return fmt.Errorf("infra.admin: router unresolved — Init failed silently?") + } + + // Middleware chain: auth FIRST so unauthenticated requests + // short-circuit at 401 before any handler / security-headers + // processing runs. Per design §Security Review + + // AddRouteWithMiddleware contract (http_router.go:228-235): + // middlewares execute in slice order, so [auth, secHdrs] means + // auth wraps secHdrs wraps the handler. + mws := []HTTPMiddleware{} + if m.auth != nil { + mws = append(mws, m.auth) + } + if m.secHdrs != nil { + mws = append(mws, m.secHdrs) + } + + // Typed API routes. + apiRoutes := []struct { + method string + path string + handler http.HandlerFunc + }{ + {"POST", m.config.RoutePrefix + "/resources", m.handleListResources}, + {"POST", m.config.RoutePrefix + "/resources/{name}", m.handleGetResource}, + {"POST", m.config.RoutePrefix + "/types", m.handleListResourceTypes}, + {"POST", m.config.RoutePrefix + "/providers", m.handleListProviders}, + {"POST", m.config.RoutePrefix + "/generate-config", m.handleGenerateConfig}, + {"GET", m.config.RoutePrefix + "/audit", m.handleAuditTail}, + } + for _, r := range apiRoutes { + adapter := NewHTTPHandlerAdapter(r.handler) + m.router.AddRouteWithMiddleware(r.method, r.path, adapter, mws) + } + + // Asset routes — http.FileServer over the embedded admin.AssetFS. + // fs.Sub strips the leading "ui_dist/" so a request for + // /admin/infra-admin/resources.html (after StripPrefix removes + // /admin/infra-admin) resolves to ui_dist/resources.html inside + // the embed FS. Without the Sub, FileServer would look for + // resources.html at the FS root and 404. + uiSub, err := fs.Sub(admin.AssetFS, "ui_dist") + if err != nil { + return fmt.Errorf("infra.admin: subfs ui_dist: %w", err) + } + assetHandler := http.StripPrefix(m.config.AssetPrefix, http.FileServer(http.FS(uiSub))) + assetAdapter := NewHTTPHandlerAdapter(assetHandler) + m.router.AddRouteWithMiddleware("GET", m.config.AssetPrefix+"/{rest...}", assetAdapter, mws) + + // Admin-plugin contribution registration pipelines. The admin + // plugin defines three pipelines that accept contributions + // (resource list / resource detail / new-resource form); each + // fires once at module Start so the admin dashboard renders + // the entries after the host comes up. + contributions := []struct { + pipelineName string + payload map[string]any + }{ + {"register-infra-admin-resources", map[string]any{ + "module": "admin", + "contribution": map[string]any{ + "id": "infra.resources", + "title": "Infra Resources", + "category": "infra", + "path": m.config.AssetPrefix + "/resources.html", + "render_mode": "iframe", + "permissions": []map[string]any{{ + "resource": "infra", "action": "read", "permission": "infra:read", + }}, + }, + }}, + {"register-infra-admin-resource-detail", map[string]any{ + "module": "admin", + "contribution": map[string]any{ + "id": "infra.resource-detail", + "title": "Resource Detail", + "category": "infra", + "path": m.config.AssetPrefix + "/resource.html", + "render_mode": "iframe", + "permissions": []map[string]any{{ + "resource": "infra", "action": "read", "permission": "infra:read", + }}, + }, + }}, + {"register-infra-admin-new-resource", map[string]any{ + "module": "admin", + "contribution": map[string]any{ + "id": "infra.new", + "title": "Draft New Resource", + "category": "infra", + "path": m.config.AssetPrefix + "/new.html", + "render_mode": "iframe", + "permissions": []map[string]any{{ + "resource": "infra", "action": "read", "permission": "infra:read", + }}, + }, + }}, + } + for _, c := range contributions { + if err := m.engine.TriggerWorkflow(ctx, "pipeline:"+c.pipelineName, "", c.payload); err != nil { + return fmt.Errorf("infra.admin: register contribution via pipeline:%s: %w", c.pipelineName, err) + } + } + return nil +} + +// Stop closes the audit writer (idempotent — double-Stop is a +// no-op because audit.Writer.Close is idempotent). +func (m *InfraAdmin) Stop(_ context.Context) error { + if m.audit != nil { + return m.audit.Close() + } + return nil +} + +// ── HTTP handlers ─────────────────────────────────────────────── + +// marshalOpts is the protojson configuration every handler uses on +// the response path. UseProtoNames=true emits snake_case JSON keys +// matching the proto field names — required by the asset JS pages +// (T10-T12) which access r.provider_module / r.applied_config_json +// / etc. Per spec-reviewer's cross-task contract. +var marshalOpts = protojson.MarshalOptions{UseProtoNames: true} + +// unmarshalOpts is the protojson decode configuration. We allow +// unknown fields so the host can ride out a v1.1 client emitting +// new request fields the handler hasn't seen yet — strict refusal +// would create a backward-compat trap. +var unmarshalOpts = protojson.UnmarshalOptions{DiscardUnknown: true} + +// readAdminBody reads the request body up to a sensible cap (256KB) +// so a pathological client can't OOM the host. AdminListResources +// Input and friends are tiny structs; 256KB is generous headroom. +// Named distinctly from module/api_v1_featureflags.go's readBody +// to avoid the package-level collision. +func readAdminBody(r *http.Request) ([]byte, error) { + const maxBody = 256 * 1024 + return io.ReadAll(io.LimitReader(r.Body, maxBody)) +} + +// auditAccess writes one AdminAuditEntry to the audit log if the +// writer is configured. Errors are logged via stderr (via the +// audit package) but never propagate — the access log is a +// best-effort observability surface, not a request-path +// dependency. +// +// The result string distinguishes outcomes per the proto field's +// semantic intent: "ok" for served requests, "denied" for authz +// refusals (handler's Output.error non-empty). Per spec-reviewer +// T15 F2 (commit 60971783d): hardcoding "ok" hid real denial +// attempts in the access log, defeating the audit log's +// security-review purpose. +func (m *InfraAdmin) auditAccess(r *http.Request, action string, ev *adminpb.AdminAuthzEvidence, result string) { + if m.audit == nil { + return + } + subject := "" + if ev != nil { + subject = ev.GetSubject() + } + entry := &audit.Entry{ + TsUnix: nowUnix(), + Subject: subject, + Action: action, + Result: result, + } + _ = m.audit.Write(entry) + _ = r // r reserved for future targets/app_context extraction +} + +// auditResultFor maps a handler output's Error field to the +// audit log's `result` value. Empty error → "ok"; non-empty → +// "denied" (the handler library's primary refusal path is +// authz-default-deny, so "denied" is the most informative +// label for v1; future v1.1 might split into "denied"/"error" +// /"not_found" but the proto field is a free-form string). +func auditResultFor(errMsg string) string { + if errMsg == "" { + return "ok" + } + return "denied" +} + +// nowUnix is a package-level var so tests can substitute a fixed +// clock without touching time. Default is defaultNowUnix (declared +// in infra_admin_clock.go) → time.Now().UTC().Unix(). +var nowUnix = defaultNowUnix + +func (m *InfraAdmin) handleListResources(w http.ResponseWriter, r *http.Request) { + body, err := readAdminBody(r) + if err != nil { + http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) + return + } + var in adminpb.AdminListResourcesInput + if len(body) > 0 { + if err := unmarshalOpts.Unmarshal(body, &in); err != nil { + http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) + return + } + } + out, _ := handler.ListResources(r.Context(), m.state, m.providers, m.fieldCatalog, &in) + writeProtoMsg(w, out) + m.auditAccess(r, "list_resources", in.GetEvidence(), auditResultFor(out.GetError())) +} + +func (m *InfraAdmin) handleGetResource(w http.ResponseWriter, r *http.Request) { + body, err := readAdminBody(r) + if err != nil { + http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) + return + } + var in adminpb.AdminGetResourceInput + if len(body) > 0 { + if err := unmarshalOpts.Unmarshal(body, &in); err != nil { + http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) + return + } + } + // Route-level path param: /resources/{name}. Falls through to + // body-level Name when path param absent (e.g. tests posting + // directly). + if v := r.PathValue("name"); v != "" { + in.Name = v + } + out, _ := handler.GetResource(r.Context(), m.state, &in) + writeProtoMsg(w, out) + m.auditAccess(r, "get_resource", in.GetEvidence(), auditResultFor(out.GetError())) +} + +func (m *InfraAdmin) handleListResourceTypes(w http.ResponseWriter, r *http.Request) { + body, err := readAdminBody(r) + if err != nil { + http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) + return + } + var in adminpb.AdminListResourceTypesInput + if len(body) > 0 { + if err := unmarshalOpts.Unmarshal(body, &in); err != nil { + http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) + return + } + } + out, _ := handler.ListResourceTypes(r.Context(), m.fieldCatalog, m.providers, &in) + writeProtoMsg(w, out) + m.auditAccess(r, "list_types", in.GetEvidence(), auditResultFor(out.GetError())) +} + +func (m *InfraAdmin) handleListProviders(w http.ResponseWriter, r *http.Request) { + body, err := readAdminBody(r) + if err != nil { + http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) + return + } + var in adminpb.AdminListProvidersInput + if len(body) > 0 { + if err := unmarshalOpts.Unmarshal(body, &in); err != nil { + http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) + return + } + } + out, _ := handler.ListProviders( + r.Context(), + m.providers, + m.providerTypeByModule, + m.fieldCatalog, + m.regionCatalog, + m.engineCatalog, + &in, + ) + writeProtoMsg(w, out) + m.auditAccess(r, "list_providers", in.GetEvidence(), auditResultFor(out.GetError())) +} + +func (m *InfraAdmin) handleGenerateConfig(w http.ResponseWriter, r *http.Request) { + body, err := readAdminBody(r) + if err != nil { + http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) + return + } + var in adminpb.AdminGenerateConfigInput + if len(body) > 0 { + if err := unmarshalOpts.Unmarshal(body, &in); err != nil { + http.Error(w, "decode request: "+err.Error(), http.StatusBadRequest) + return + } + } + out, _ := handler.GenerateConfig(r.Context(), m.fieldCatalog, &in) + writeProtoMsg(w, out) + m.auditAccess(r, "generate_config", in.GetEvidence(), auditResultFor(out.GetError())) +} + +// handleAuditTail streams the audit log file as ndjson when the +// audit writer is enabled. Honors `?since=&limit=N` query +// params per design §Security Review row "Access logging": +// "GET /api/infra-admin/audit?since=&limit=N returning +// ndjson". The CLI's `wfctl infra admin audit-tail --since 1h` +// translates the duration to a unix timestamp; the host filters +// by ts_unix > since AND emits at most `limit` entries (0 = no +// limit). Per spec-reviewer T15 F1 (commit 60971783d). +// +// Implementation: scans the file line-by-line via bufio.Scanner +// (1MB max line, same as the CLI decoder), protojson-decodes each +// line to read ts_unix, drops out-of-window entries, forwards the +// rest as ndjson. Lines that fail to decode are skipped silently +// — append-only file may contain partial writes mid-rotation; the +// audit-tail consumer treats those as benign. +// +// Status semantics: opens the file with os.Open BEFORE writing +// any response headers so a missing/permission-denied file +// produces a clean 404 / 500. ServeFile's pre-WriteHeader contract +// is the source of the F3 collision the prior draft had. +func (m *InfraAdmin) handleAuditTail(w http.ResponseWriter, r *http.Request) { + if m.audit == nil { + http.Error(w, "audit log not configured (set access_log_path on infra.admin module)", http.StatusNotFound) + return + } + + // Parse query params. Empty / unparseable values default to 0 + // (no filter / no limit) — matches the design's permissive shape. + q := r.URL.Query() + var sinceUnix int64 + if v := q.Get("since"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + sinceUnix = n + } + } + var limit int + if v := q.Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + limit = n + } + } + + f, err := os.Open(m.config.AccessLogPath) + if err != nil { + // File missing or permission denied — 404 mirrors + // http.ServeFile's IsNotExist branch; 500 covers other + // I/O failures. Body is plain text since the CLI's + // renderAuditTable surfaces the error string verbatim. + if errors.Is(err, fs.ErrNotExist) { + http.Error(w, "audit log file not found", http.StatusNotFound) + return + } + http.Error(w, "open audit log: "+err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = f.Close() }() + + // Only set headers once we know the file is readable. Header + // + status MUST be set before the first body write, but we + // stream after — clearing F3. + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + var emitted int + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + // Filter by ts_unix when since is set. Decode just enough + // to read the field; ignore decode errors so partial-write + // lines don't truncate the stream for downstream entries. + if sinceUnix > 0 { + var entry adminpb.AdminAuditEntry + if err := protojson.Unmarshal(line, &entry); err != nil { + continue + } + if entry.GetTsUnix() < sinceUnix { + continue + } + } + // Forward the line as-is so the protojson byte sequence is + // preserved byte-for-byte (the CLI's decoder expects the + // exact wire format the writer emitted, not a re-marshaled + // shape — preserves the int64-as-decimal-string convention). + if _, werr := w.Write(append(line, '\n')); werr != nil { + return + } + emitted++ + if limit > 0 && emitted >= limit { + return + } + } + // Scanner errors mid-stream get swallowed — the client already + // received bytes so we can't change the HTTP status. The next + // audit-tail request will re-attempt. +} + +// writeProtoMsg marshals a proto message via the shared protojson +// MarshalOptions (UseProtoNames=true so snake_case keys match the +// asset JS pages' expectations per the cross-task wire contract). +// On marshal failure, returns 500 with a plain-text body so the +// client always sees an actionable status code. +func writeProtoMsg(w http.ResponseWriter, msg proto.Message) { + data, err := marshalOpts.Marshal(msg) + if err != nil { + http.Error(w, "marshal response: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} diff --git a/module/infra_admin_clock.go b/module/infra_admin_clock.go new file mode 100644 index 00000000..eb8a441c --- /dev/null +++ b/module/infra_admin_clock.go @@ -0,0 +1,9 @@ +package module + +import "time" + +// defaultNowUnix is the production clock for the audit subsystem. +// Factored out so tests can override `nowUnix` (declared as a var +// in infra_admin.go) with a fixed-clock fake without pulling time +// into the test surface. +func defaultNowUnix() int64 { return time.Now().UTC().Unix() } diff --git a/module/infra_admin_integration_test.go b/module/infra_admin_integration_test.go new file mode 100644 index 00000000..05b830c6 --- /dev/null +++ b/module/infra_admin_integration_test.go @@ -0,0 +1,623 @@ +// Integration test for the host-side infra.admin module exercising +// a real workflow engine boot + live workflow-plugin-admin gRPC +// plugin subprocess. Per docs/plans/2026-05-27-infra-admin-dynamic.md +// Task 17 + design §Multi-Component Validation row "Module +// integration test": +// +// "Boot mini workflow app with admin.dashboard (plugin form) + +// infra.admin + stub provider in-process; manually run +// step.admin_register_contribution for the three pages; verify +// contributions appear in GET /api/admin/contributions; verify +// /api/infra-admin/resources returns 200." +// +// Test structure (lighter harness — per team-lead's option-4 +// directive 2026-05-27, bypasses engine.BuildFromConfig + its +// auto-inject hook so the admin plugin's ConfigTransformHook +// doesn't drag in its full auxiliary stack): +// +// 1. Probe for the sibling workflow-plugin-admin repo + build +// its binary into the runtime layout the external-plugin +// loader expects (path/plugin.json + path/binary). +// 2. Boot a real *workflow.StdEngine via NewStdEngine + load all +// built-in engine plugins via pluginall.LoadAll(engine) so +// the engine's module/step/trigger factory maps are +// populated. +// 3. Load the external workflow-plugin-admin via +// external.NewExternalPluginManager → DiscoverPlugins → +// LoadPlugin → engine.LoadPlugin(adapter). Adapter +// registers admin.dashboard module factory + the 4 admin +// step factories into the engine's registries. +// 4. Manually construct + register each module the assertions +// need: admin.dashboard (via the loaded plugin's factory), +// http.router, security-headers, iac.state (memory), +// infra.admin. No BuildFromConfig, no ConfigTransformHook +// auto-inject. +// 5. app.Init() once, then call Start on the router + infra.admin +// so routes mount. +// 6. Assert: +// (a) Manual Init + Start succeeded. +// (b) Live admin plugin subprocess registers 3 contributions +// via 3 step.admin_register_contribution invocations and +// step.admin_list_contributions reads them back. +// (c) httptest POST /api/infra-admin/resources returns 200 + +// valid AdminListResourcesOutput protojson against the +// live infra.admin router. +// (d) httptest GET /admin/infra-admin/resources.html returns +// 200 + text/html with the embedded body. +// +// Skip conditions (each surfaces a distinct cause string so CI +// failure modes are unambiguous): +// - testing.Short() — fast-path skip for tight CI sweeps. +// - sibling workflow-plugin-admin repo absent — plan T17 +// graceful-degradation per design §Personas. +// - plugin build failure — pure-unit-test env. +// - LoadPlugin fails — plugin may need a newer engine ABI. +// - admin.dashboard factory not exposed by adapter — plugin +// subprocess didn't publish module types (gRPC handshake gap). + +package module_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + workflow "github.com/GoCodeAlone/workflow" + adminpb "github.com/GoCodeAlone/workflow/iac/admin/proto" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/module" + pluginexternal "github.com/GoCodeAlone/workflow/plugin/external" + pluginall "github.com/GoCodeAlone/workflow/plugins/all" + "google.golang.org/protobuf/encoding/protojson" +) + +// integrationLogger is the minimum modular.Logger the engine needs +// to boot. We discard outputs by default; tests that want to debug +// boot can swap the implementation. +type integrationLogger struct{} + +func (integrationLogger) Debug(string, ...any) {} +func (integrationLogger) Info(string, ...any) {} +func (integrationLogger) Warn(string, ...any) {} +func (integrationLogger) Error(string, ...any) {} + +// TestInfraAdmin_IntegrationWithLiveAdminPlugin boots a real +// engine with the workflow-plugin-admin external subprocess + +// infra.admin module wired manually (lighter harness — no +// BuildFromConfig), then asserts the 4 plan §Step 1-N integration +// boundary properties. +func TestInfraAdmin_IntegrationWithLiveAdminPlugin(t *testing.T) { + if testing.Short() { + t.Skip("integration: -short flag set; skipping plugin-binary build + engine boot") + } + + // ── 1. Probe + build the workflow-plugin-admin binary ────── + pluginRepoCandidates := []string{ + os.Getenv("WORKFLOW_PLUGIN_ADMIN_PATH"), + "../../workflow-plugin-admin", + "../workflow-plugin-admin", + } + var pluginRepoPath string + for _, p := range pluginRepoCandidates { + if p == "" { + continue + } + if _, err := os.Stat(filepath.Join(p, "go.mod")); err == nil { + pluginRepoPath = p + break + } + } + if pluginRepoPath == "" { + t.Skip("workflow-plugin-admin repo not found on disk (sibling or WORKFLOW_PLUGIN_ADMIN_PATH); skipping per plan T17 graceful-degradation path") + } + + tmpDir := t.TempDir() + pluginDir := filepath.Join(tmpDir, "plugins", "workflow-plugin-admin") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + binPath := filepath.Join(pluginDir, "workflow-plugin-admin") + if runtime.GOOS == "windows" { + binPath += ".exe" + } + build := exec.Command("go", "build", "-o", binPath, "./cmd/workflow-plugin-admin") + build.Dir = pluginRepoPath + build.Env = append(os.Environ(), "GOWORK=off", "CGO_ENABLED=0") + if out, err := build.CombinedOutput(); err != nil { + t.Skipf("admin plugin build failed (expected in pure-unit-test envs): %v\n%s", err, out) + } + if info, err := os.Stat(binPath); err != nil { + t.Fatalf("plugin binary not at expected layout %s: %v", binPath, err) + } else if info.Mode().Perm()&0o111 == 0 { + t.Fatalf("plugin binary at %s is not executable", binPath) + } + manifest, err := os.ReadFile(filepath.Join(pluginRepoPath, "plugin.json")) + if err != nil { + t.Fatalf("read plugin.json: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), manifest, 0o600); err != nil { + t.Fatal(err) + } + pluginsDir := filepath.Join(tmpDir, "plugins") + t.Setenv("WFCTL_PLUGIN_DIR", pluginsDir) + + // ── 2. Boot a fresh app + engine + load built-in plugins ─── + // + // We intentionally DEFER app.Init() until step 5 — modular + // only allows one Init pass and all modules must be + // registered first. NewStdEngine doesn't call Init itself. + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), integrationLogger{}) + engine := workflow.NewStdEngine(app, integrationLogger{}) + + // pluginall.LoadAll mirrors cmd/server's production boot + // path and populates engine.moduleFactories / + // engine.stepRegistry for every built-in plugin type + // (http.router, http.middleware.securityheaders, iac.state, + // step.json_response, etc.). We don't depend on every + // factory below — but we do depend on http.router + + // security-headers + iac.state to construct the infra.admin + // dependency surface, so loading them all is the simplest + // way to keep the test aligned with what the engine + // actually exposes in production. + if err := pluginall.LoadAll(engine); err != nil { + t.Fatalf("pluginall.LoadAll: %v", err) + } + + // ── 3. Load the external workflow-plugin-admin subprocess ── + extMgr := pluginexternal.NewExternalPluginManager(pluginsDir, nil) + discovered, derr := extMgr.DiscoverPlugins() + if derr != nil { + t.Fatalf("DiscoverPlugins: %v", derr) + } + if len(discovered) == 0 { + t.Fatalf("DiscoverPlugins found 0 plugins in %s — layout wrong?", pluginsDir) + } + t.Cleanup(func() { extMgr.Shutdown() }) + for _, name := range discovered { + adapter, lerr := extMgr.LoadPlugin(name) + if lerr != nil { + t.Skipf("LoadPlugin(%s) failed: %v — workflow-plugin-admin may need a newer engine ABI; defer to PR-2 scenario harness for full validation", name, lerr) + } + if err := engine.LoadPlugin(adapter); err != nil { + t.Fatalf("engine.LoadPlugin(%s): %v", name, err) + } + } + + // ── 4. Manually construct + register modules ────────────── + // + // Skip BuildFromConfig + its ConfigTransformHook. Manually + // wire the minimum surface area the 4 assertions need: an + // http router for the route adapter, a security-headers + // middleware for the route-wrap path, an iac.state backend + // (memory) for ListResources, the admin.dashboard module + // from the live plugin subprocess for the contribution + // registry, and infra.admin itself. + loader := engine.PluginLoader() + moduleFactories := loader.ModuleFactories() + adminFactory, ok := moduleFactories["admin.dashboard"] + if !ok { + t.Skipf("admin.dashboard module factory not published by the plugin adapter (loaded plugins: %v) — plugin gRPC handshake may not have completed; defer to PR-2 scenario harness", discovered) + } + adminMod := adminFactory("admin", map[string]any{ + "route_prefix": "/admin", + "app_name": "Integration Test", + }) + + httpRouter := module.NewStandardHTTPRouter("http-router") + secHdrs := module.NewSecurityHeadersMiddleware( + "security-headers", + module.SecurityHeadersConfig{ + FrameOptions: "SAMEORIGIN", + ContentSecurityPolicy: "default-src 'self'", + }, + ) + // interfaces.IaCStateStore is the ResourceState-based contract + // the InfraAdmin module expects — distinct from the legacy + // IaCState interface that module.NewIaCModule wraps. Workflow + // core ships no built-in ResourceState backend (those land + // over gRPC from workflow-plugin-infra). Register a stub + // module that provides the service under the expected name + // so modular's Dependencies() resolver finds a real module + // AND the service registry has an iac-state entry. + iacStateModule := &integrationStateStubModule{name: "iac-state"} + + // Auth middleware — design §Security Review requires every + // /api/infra-admin/* and /admin/infra-admin/* request to sit + // behind a route-filter auth layer. Wire a Bearer-token auth + // stub that accepts the test-token below. Assertions (c) + + // (d) supply the matching Bearer header. + authStub := &integrationAuthStubModule{name: "auth", validToken: "integration-test-token"} + + auditPath := filepath.Join(tmpDir, "audit.jsonl") + infraAdmin := module.NewInfraAdmin("infra-admin", map[string]any{ + "route_prefix": "/api/infra-admin", + "asset_prefix": "/admin/infra-admin", + "state_module": "iac-state", + "http_module": "http-router", + "security_headers_module": "security-headers", + "auth_module": "auth", + "provider_modules": []string{}, + "access_log_path": auditPath, + }) + + app.RegisterModule(adminMod) + app.RegisterModule(httpRouter) + app.RegisterModule(secHdrs) + app.RegisterModule(iacStateModule) + app.RegisterModule(authStub) + app.RegisterModule(infraAdmin) + + // ── 5. Single Init pass ─────────────────────────────────── + if err := app.Init(); err != nil { + // Surface manual-wiring failures with full evidence — + // these mean the admin plugin (or one of the built-in + // modules above) depends on an additional service / + // config-section that the lighter harness doesn't + // provide. PR-2 docker-compose harness owns the full + // chain integration. + t.Skipf("manual app.Init failed (likely transitive service dep gap): %v — PR-2 workflow-scenarios/92-infra-admin-demo covers full chain via docker-compose", err) + } + t.Log("assertion (a): manual module wiring + app.Init succeeded") + + // Modular's Constructor pattern on *StandardHTTPRouter creates + // a fresh instance during injectServices (http_router.go:76-93) + // — the original `httpRouter` pointer we registered is NOT the + // one that lands in the service registry. Re-resolve the live + // router from the registry so route assertions hit the same + // instance infra.admin's Start mounts routes onto. + var liveRouterAny any + if err := app.GetService("http-router", &liveRouterAny); err != nil { + t.Fatalf("GetService(http-router): %v", err) + } + liveRouter, ok := liveRouterAny.(*module.StandardHTTPRouter) + if !ok { + t.Fatalf("http-router service is %T, want *module.StandardHTTPRouter", liveRouterAny) + } + + ctx := context.Background() + // Register a no-op WorkflowEngine service. Production wires + // this in engine.configureTriggers AFTER app.Init; the + // infra.admin module's Start resolves it then. Per design + // line 749, the deferred lookup matches that ordering. The + // stub records nothing — the assertion (b) flow exercises + // the live admin plugin subprocess via direct step + // invocation, not via this engine path. + if err := app.RegisterService("workflowEngine", noopWorkflowEngine{}); err != nil { + t.Fatalf("RegisterService(workflowEngine): %v", err) + } + infraStartable, ok := infraAdmin.(modular.Startable) + if !ok { + t.Fatal("infraAdmin module does not implement modular.Startable") + } + if err := infraStartable.Start(ctx); err != nil { + t.Fatalf("infraAdmin.Start: %v", err) + } + // Router builds its mux at Start — call AFTER infra.admin so + // the routes infra.admin registered via AddRouteWithMiddleware + // land in the freshly built mux. Mirrors the unit-test + // ordering in TestInfraAdmin_Start_MountsRoutesWithMiddleware. + if err := liveRouter.Start(ctx); err != nil { + t.Fatalf("liveRouter.Start: %v", err) + } + t.Cleanup(func() { + if s, ok := adminMod.(modular.Stoppable); ok { + _ = s.Stop(ctx) + } + }) + t.Cleanup(func() { + if s, ok := infraAdmin.(modular.Stoppable); ok { + _ = s.Stop(ctx) + } + }) + + // ── 6a. Assertion (b): manual step.admin_register_contribution ─ + // + // Per design line: "manually run step.admin_register_contribution + // for the three pages". Each call lands the contribution in the + // admin.dashboard module's in-subprocess registry; the matching + // step.admin_list_contributions then reads it back. Both step + // factories are gRPC proxies into the live workflow-plugin- + // admin subprocess, so this exercises the real cross-process + // contribution flow without depending on the HTTP pipeline + // auto-inject path. + stepRegistry := engine.GetStepRegistry() + contributions := []struct { + ID string + Title string + Path string + }{ + {"infra.resources", "Resources", "/admin/infra-admin/resources.html"}, + {"infra.resource-detail", "Resource Detail", "/admin/infra-admin/resource.html"}, + {"infra.new", "New Resource", "/admin/infra-admin/new.html"}, + } + for _, c := range contributions { + // AdminStepConfig (the step's strict-proto config message) + // has a single field: module. Contribution payload travels + // in pc.Current via RegisterContributionInput.contribution + // per the typed step contract — wire keys are snake_case, + // per cross-task contract UseProtoNames=true. + step, err := stepRegistry.Create( + "step.admin_register_contribution", + "register-"+c.ID, + map[string]any{"module": "admin"}, + app, + ) + if err != nil { + t.Fatalf("stepRegistry.Create(step.admin_register_contribution, %s): %v", c.ID, err) + } + pc := interfaces.NewPipelineContext(map[string]any{ + "module": "admin", + "contribution": map[string]any{ + "id": c.ID, + "title": c.Title, + "path": c.Path, + "app_context": "infra", + }, + }, map[string]any{}) + if _, err := step.Execute(ctx, pc); err != nil { + t.Fatalf("step.admin_register_contribution(%s).Execute: %v", c.ID, err) + } + } + + // Invoke step.admin_list_contributions, assert all 3 land. + listStep, err := stepRegistry.Create("step.admin_list_contributions", "list", map[string]any{"module": "admin"}, app) + if err != nil { + t.Fatalf("stepRegistry.Create(step.admin_list_contributions): %v", err) + } + listPC := interfaces.NewPipelineContext(map[string]any{"module": "admin"}, map[string]any{}) + listRes, err := listStep.Execute(ctx, listPC) + if err != nil { + t.Fatalf("step.admin_list_contributions.Execute: %v", err) + } + got := extractContributionIDs(t, listRes.Output) + for _, c := range contributions { + if _, ok := got[c.ID]; !ok { + t.Errorf("assertion (b): contribution %q missing in list_contributions output (got %v)", c.ID, keys(got)) + } + } + if !t.Failed() { + t.Log("assertion (b): 3 contributions registered + listed back via live admin plugin subprocess step factories") + } + + // Sanity-probe: verify the route landed in the live router. + if !liveRouter.HasRoute("POST", "/api/infra-admin/resources") { + t.Errorf("HasRoute(POST /api/infra-admin/resources) = false on live router; infra.admin.Start did not register its routes") + } + + // ── 6b. Assertion (c): POST /api/infra-admin/resources ─── + listResReq := &adminpb.AdminListResourcesInput{ + Evidence: &adminpb.AdminAuthzEvidence{ + AuthzChecked: true, AuthzAllowed: true, + Subject: "integration-test", + }, + } + listResBody, err := protojson.Marshal(listResReq) + if err != nil { + t.Fatalf("marshal AdminListResourcesInput: %v", err) + } + postReq := httptest.NewRequest(http.MethodPost, "/api/infra-admin/resources", bytes.NewReader(listResBody)) + postReq.Header.Set("Content-Type", "application/json") + postReq.Header.Set("Authorization", "Bearer integration-test-token") + postRec := httptest.NewRecorder() + liveRouter.ServeHTTP(postRec, postReq) + if postRec.Code != http.StatusOK { + t.Errorf("POST /api/infra-admin/resources status = %d, want 200; body=%s", postRec.Code, postRec.Body.String()) + } else { + var out adminpb.AdminListResourcesOutput + if err := protojson.Unmarshal(postRec.Body.Bytes(), &out); err != nil { + t.Errorf("assertion (c): response not valid AdminListResourcesOutput protojson: %v\n%s", err, postRec.Body.String()) + } else { + t.Log("assertion (c): POST /api/infra-admin/resources returned 200 + valid AdminListResourcesOutput protojson") + } + } + + // ── 6c. Assertion (d): GET /admin/infra-admin/resources.html ─ + assetReq := httptest.NewRequest(http.MethodGet, "/admin/infra-admin/resources.html", nil) + assetReq.Header.Set("Authorization", "Bearer integration-test-token") + assetRec := httptest.NewRecorder() + liveRouter.ServeHTTP(assetRec, assetReq) + if assetRec.Code != http.StatusOK { + t.Errorf("GET asset status = %d, want 200; body=%s", assetRec.Code, assetRec.Body.String()) + } else { + ct := assetRec.Header().Get("Content-Type") + if !strings.Contains(ct, "html") { + t.Errorf("assertion (d): asset Content-Type = %q, want text/html", ct) + } + bodyLower := strings.ToLower(assetRec.Body.String()) + if !strings.Contains(bodyLower, "` query-param contract — the CLI's +// `wfctl infra admin audit-tail --since 1h` depends on the host +// filtering by timestamp; without this, --since is silently a +// no-op. Per spec-reviewer T15 F1 (commit 60971783d). +// +// Writes 3 audit entries with staggered timestamps, then queries +// the audit endpoint with since= and asserts +// only the 2 newest entries are returned. +func TestInfraAdmin_AuditTail_FiltersBySince(t *testing.T) { + app, _, _ := newAppWithWorkflowSection(t, "digitalocean") + dir := t.TempDir() + cfg := standardCfg() + cfg.AccessLogPath = filepath.Join(dir, "audit.jsonl") + m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) + if err := m.Init(app); err != nil { + t.Fatal(err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.router.Start(context.Background()); err != nil { + t.Fatal(err) + } + + // Stamp three entries with explicit ts_unix values so we can + // assert filtering deterministically. Use module's audit + // writer directly so the file is in the exact protojson + // shape the handler reads. + t0 := int64(1716800000) + t1 := int64(1716800100) + t2 := int64(1716800200) + for _, ts := range []int64{t0, t1, t2} { + entry := &adminpb.AdminAuditEntry{ + TsUnix: ts, + Subject: fmt.Sprintf("user:t-%d", ts), + Action: "list_resources", + Result: "ok", + } + if err := m.audit.Write(entry); err != nil { + t.Fatal(err) + } + } + + // Filter with since=t1 → expect t1 + t2 (entries with + // ts_unix < t1 dropped). The handler uses < not <= on the + // since threshold per design ("entries newer than this + // duration" — strict). + req := httptest.NewRequest(http.MethodGet, + fmt.Sprintf("/api/infra-admin/audit?since=%d", t1), nil) + rec := httptest.NewRecorder() + m.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if rec.Header().Get("Content-Type") != "application/x-ndjson" { + t.Errorf("Content-Type = %q, want application/x-ndjson", rec.Header().Get("Content-Type")) + } + lines := strings.Split(strings.TrimRight(rec.Body.String(), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("filtered ndjson has %d lines, want 2 (entries at t1 + t2)\n%s", len(lines), rec.Body.String()) + } + // Decode each and verify the timestamps match what we expect. + var got []int64 + for _, line := range lines { + var entry adminpb.AdminAuditEntry + if err := protojson.Unmarshal([]byte(line), &entry); err != nil { + t.Fatalf("decode line: %v\n%s", err, line) + } + got = append(got, entry.GetTsUnix()) + } + if got[0] != t1 || got[1] != t2 { + t.Errorf("got ts_unix sequence %v, want [%d, %d]", got, t1, t2) + } +} + +// TestInfraAdmin_AuditTail_FiltersByLimit pins the `?limit=N` +// query-param contract — caller can cap the number of returned +// entries to avoid response-size explosions on long logs. +func TestInfraAdmin_AuditTail_FiltersByLimit(t *testing.T) { + app, _, _ := newAppWithWorkflowSection(t, "digitalocean") + dir := t.TempDir() + cfg := standardCfg() + cfg.AccessLogPath = filepath.Join(dir, "audit.jsonl") + m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) + if err := m.Init(app); err != nil { + t.Fatal(err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.router.Start(context.Background()); err != nil { + t.Fatal(err) + } + + for i := range 5 { + entry := &adminpb.AdminAuditEntry{ + TsUnix: int64(1716800000 + i*10), + Subject: fmt.Sprintf("user:%d", i), + Action: "list_resources", + Result: "ok", + } + if err := m.audit.Write(entry); err != nil { + t.Fatal(err) + } + } + + req := httptest.NewRequest(http.MethodGet, "/api/infra-admin/audit?limit=2", nil) + rec := httptest.NewRecorder() + m.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + lines := strings.Split(strings.TrimRight(rec.Body.String(), "\n"), "\n") + if len(lines) != 2 { + t.Errorf("got %d lines with limit=2, want 2\n%s", len(lines), rec.Body.String()) + } +} + +// TestInfraAdmin_AuditTail_NoFilterReturnsAll verifies the +// no-query-param case (CLI invoked without --since / --limit) +// returns every line. +func TestInfraAdmin_AuditTail_NoFilterReturnsAll(t *testing.T) { + app, _, _ := newAppWithWorkflowSection(t, "digitalocean") + dir := t.TempDir() + cfg := standardCfg() + cfg.AccessLogPath = filepath.Join(dir, "audit.jsonl") + m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) + if err := m.Init(app); err != nil { + t.Fatal(err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.router.Start(context.Background()); err != nil { + t.Fatal(err) + } + + for i := range 3 { + if err := m.audit.Write(&adminpb.AdminAuditEntry{TsUnix: int64(1716800000 + i*10), Action: "list_resources", Result: "ok"}); err != nil { + t.Fatal(err) + } + } + + req := httptest.NewRequest(http.MethodGet, "/api/infra-admin/audit", nil) + rec := httptest.NewRecorder() + m.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + lines := strings.Split(strings.TrimRight(rec.Body.String(), "\n"), "\n") + if len(lines) != 3 { + t.Errorf("got %d lines without filters, want 3", len(lines)) + } +} + +// TestInfraAdmin_AuditTail_FileMissingReturns404 pins the F3 +// status-code fix — file open failure surfaces as a clean 404 +// rather than a 200-with-error-body. The earlier draft pre-set +// the 200 status before http.ServeFile, which then surfaced +// "404 page not found" as body content with status 200. +func TestInfraAdmin_AuditTail_FileMissingReturns404(t *testing.T) { + app, _, _ := newAppWithWorkflowSection(t, "digitalocean") + dir := t.TempDir() + cfg := standardCfg() + cfg.AccessLogPath = filepath.Join(dir, "audit.jsonl") + m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) + if err := m.Init(app); err != nil { + t.Fatal(err) + } + // Delete the audit file AFTER Init creates it so the open + // fails at handler-time. + if err := os.Remove(cfg.AccessLogPath); err != nil { + t.Fatal(err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.router.Start(context.Background()); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/infra-admin/audit", nil) + rec := httptest.NewRecorder() + m.router.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404 (file missing); body=%s", rec.Code, rec.Body.String()) + } +} + +// TestInfraAdmin_AuditTail_NotConfiguredReturns404 pins the +// "audit log not configured" 404 path — distinct from the +// file-missing 404 (different operator diagnosis). +func TestInfraAdmin_AuditTail_NotConfiguredReturns404(t *testing.T) { + app, _, _ := newAppWithWorkflowSection(t, "digitalocean") + cfg := standardCfg() + // cfg.AccessLogPath left empty — audit writer not opened. + m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) + if err := m.Init(app); err != nil { + t.Fatal(err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.router.Start(context.Background()); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/infra-admin/audit", nil) + rec := httptest.NewRecorder() + m.router.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404 (not configured)", rec.Code) + } + if !strings.Contains(rec.Body.String(), "not configured") { + t.Errorf("expected 'not configured' in body, got %q", rec.Body.String()) + } +} + +// TestInfraAdmin_AuditAccess_RecordsDeniedResult pins F2: +// authz-denied requests MUST log result="denied", not "ok". +// Otherwise security-event review hides actual denial attempts. +func TestInfraAdmin_AuditAccess_RecordsDeniedResult(t *testing.T) { + app, _, _ := newAppWithWorkflowSection(t, "digitalocean") + dir := t.TempDir() + cfg := standardCfg() + cfg.AccessLogPath = filepath.Join(dir, "audit.jsonl") + m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) + if err := m.Init(app); err != nil { + t.Fatal(err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.router.Start(context.Background()); err != nil { + t.Fatal(err) + } + + // Send a request WITHOUT evidence — handler library rejects + // with default-deny → out.Error non-empty → audit should + // record result="denied". + body := []byte(`{}`) + req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/resources", bytes.NewReader(body)) + rec := httptest.NewRecorder() + m.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (authz refusal surfaces via Output.error per tag-100)", rec.Code) + } + + // Read the audit log + assert the recorded result is "denied". + data, err := os.ReadFile(cfg.AccessLogPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"result":"denied"`) { + t.Errorf("audit log missing denied result for refused request:\n%s", string(data)) + } + if strings.Contains(string(data), `"result":"ok"`) { + t.Errorf("audit log records 'ok' for a denied request — F2 regression:\n%s", string(data)) + } +} + +// TestInfraAdmin_AuditAccess_RecordsOkResult is the positive +// counterpart — happy-path requests log result="ok". +func TestInfraAdmin_AuditAccess_RecordsOkResult(t *testing.T) { + app, _, _ := newAppWithWorkflowSection(t, "digitalocean") + dir := t.TempDir() + cfg := standardCfg() + cfg.AccessLogPath = filepath.Join(dir, "audit.jsonl") + m := NewInfraAdmin("infra-admin", configToMap(t, cfg)).(*InfraAdmin) + if err := m.Init(app); err != nil { + t.Fatal(err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.router.Start(context.Background()); err != nil { + t.Fatal(err) + } + + body := []byte(`{"evidence":{"authz_checked":true,"authz_allowed":true,"subject":"user:alice"}}`) + req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/resources", bytes.NewReader(body)) + rec := httptest.NewRecorder() + m.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + data, err := os.ReadFile(cfg.AccessLogPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"result":"ok"`) { + t.Errorf("audit log missing 'ok' result for happy-path request:\n%s", string(data)) + } +} + +// authMwStub is a Bearer-token HTTPMiddleware used by the T15 +// auth-route-filter regression tests. It rejects every request +// missing an `Authorization: Bearer …` header with 401 BEFORE the +// handler runs — mirrors module.AuthMiddleware's production +// behaviour for the route-filter contract that closes the +// AdminAuthzEvidence-spoofing gap (design §Security Review). +type authMwStub struct { + name string + // validToken, when non-empty, is the only Bearer token accepted. + // Empty string means "any non-empty Bearer token passes" (matches + // AuthMiddleware default-provider behaviour when no provider + // matches the token). + validToken string +} + +func (s *authMwStub) Process(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + http.Error(w, "Authorization header required", http.StatusUnauthorized) + return + } + token := strings.TrimPrefix(authHeader, "Bearer ") + if s.validToken != "" && token != s.validToken { + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// newAuthEnabledApp builds a test app with the standard T15 +// dependency surface plus an auth.jwt-shaped HTTPMiddleware +// registered under "auth". Pairs with standardAuthCfg() below. +func newAuthEnabledApp(t *testing.T, providerType string) (*withConfigSectionApp, *recordingEngine, *stateStoreStub, *authMwStub) { + t.Helper() + app, engine, store := newAppWithWorkflowSection(t, providerType) + auth := &authMwStub{name: "auth"} + _ = app.RegisterService("auth", auth) + return app, engine, store, auth +} + +// standardAuthCfg returns the design's reference config WITH the +// auth_module field set — the production shape per §Security +// Review. Mirrors the YAML the workflow-scenarios/92 config will +// declare in PR-2. +func standardAuthCfg() InfraAdminConfig { + c := standardCfg() + c.AuthModule = "auth" + return c +} + +// TestInfraAdmin_UnauthenticatedRequest_Returns401 — design §Security +// Review regression: a request with NO Bearer token MUST be rejected +// with 401 BEFORE the handler runs. Without the auth middleware in +// front of the route, the handler-side AdminAuthzEvidence +// default-deny gives 403 with a body — which is still a leak compared +// to the explicit auth-first 401 contract. +func TestInfraAdmin_UnauthenticatedRequest_Returns401(t *testing.T) { + app, _, _, _ := newAuthEnabledApp(t, "digitalocean") + m := NewInfraAdmin("infra-admin", configToMap(t, standardAuthCfg())).(*InfraAdmin) + if err := m.Init(app); err != nil { + t.Fatal(err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.router.Start(context.Background()); err != nil { + t.Fatal(err) + } + + // No Authorization header at all. + req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/resources", + bytes.NewReader([]byte(`{}`))) + rec := httptest.NewRecorder() + m.router.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("unauthenticated request: status = %d, want 401; body=%s", rec.Code, rec.Body.String()) + } +} + +// TestInfraAdmin_ClientCannotSpoofAuthzEvidence — design §Security +// Review hard contract: a client supplying +// {evidence:{authz_checked:true,authz_allowed:true}} in the request +// body with NO Authorization header MUST still get 401. Without the +// auth route filter, this spoofing trivially bypasses the +// handler-side default-deny. With the auth filter, the request +// never reaches the handler. +// +// This test is the explicit regression gate for the security gap +// team-lead identified during PR-1 review: AdminAuthzEvidence is +// client-supplied data; the host MUST authenticate before +// believing it. +func TestInfraAdmin_ClientCannotSpoofAuthzEvidence(t *testing.T) { + app, _, _, _ := newAuthEnabledApp(t, "digitalocean") + m := NewInfraAdmin("infra-admin", configToMap(t, standardAuthCfg())).(*InfraAdmin) + if err := m.Init(app); err != nil { + t.Fatal(err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.router.Start(context.Background()); err != nil { + t.Fatal(err) + } + + spoof := []byte(`{"evidence":{"authz_checked":true,"authz_allowed":true,"subject":"attacker"}}`) + req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/resources", + bytes.NewReader(spoof)) + // Deliberately NO Authorization header — attacker sets the + // evidence flags in the body but has no credential. + rec := httptest.NewRecorder() + m.router.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("evidence-spoof attempt: status = %d, want 401 (security gap)\n"+ + "client supplied authz_checked+authz_allowed in body without Bearer token; auth filter should reject before handler\n"+ + "body=%s", rec.Code, rec.Body.String()) + } + // Asset routes are equally protected. + assetReq := httptest.NewRequest(http.MethodGet, "/admin/infra-admin/resources.html", nil) + assetRec := httptest.NewRecorder() + m.router.ServeHTTP(assetRec, assetReq) + if assetRec.Code != http.StatusUnauthorized { + t.Errorf("unauthenticated asset request: status = %d, want 401; body=%s", assetRec.Code, assetRec.Body.String()) + } +} + +// TestInfraAdmin_AuthenticatedRequest_ReachesHandler is the +// positive counterpart — a valid Bearer token lets the request +// flow through the auth middleware to the handler, which then +// applies its own default-deny / authz check. Pins that auth is +// NOT a blanket-deny — properly authenticated requests still get +// to the typed protojson API surface. +func TestInfraAdmin_AuthenticatedRequest_ReachesHandler(t *testing.T) { + app, _, _, _ := newAuthEnabledApp(t, "digitalocean") + m := NewInfraAdmin("infra-admin", configToMap(t, standardAuthCfg())).(*InfraAdmin) + if err := m.Init(app); err != nil { + t.Fatal(err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.router.Start(context.Background()); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/infra-admin/resources", + bytes.NewReader([]byte(`{"evidence":{"authz_checked":true,"authz_allowed":true,"subject":"user:alice"}}`))) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + m.router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("authenticated request: status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } +} + +// configToMap round-trips a typed InfraAdminConfig through JSON +// into the map shape the factory expects (matching how the engine +// passes config maps to module factories at BuildFromConfig time). +func configToMap(t *testing.T, cfg InfraAdminConfig) map[string]any { + t.Helper() + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal cfg: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal cfg map: %v", err) + } + return m +}