From 44e8dbbeb00c5fe65a2ac678f071a34e54d48ce6 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 4 Oct 2026 03:52:42 -0400 Subject: [PATCH 1/2] fix: discover selected-provider IaC resources --- cmd/wfctl/deploy_providers.go | 59 +- cmd/wfctl/infra.go | 55 +- cmd/wfctl/infra_apply.go | 7 +- cmd/wfctl/infra_apply_dryrun.go | 2 +- cmd/wfctl/infra_env_resolve.go | 25 +- cmd/wfctl/infra_inputsnapshot.go | 16 +- cmd/wfctl/infra_resource_discovery.go | 302 ++++++ .../infra_resource_discovery_native_test.go | 867 ++++++++++++++++++ cmd/wfctl/infra_resource_discovery_test.go | 555 +++++++++++ docs/WFCTL.md | 41 + iac/wfctlhelpers/env_resolve.go | 18 +- iac/wfctlhelpers/env_resolve_test.go | 130 +++ 12 files changed, 1983 insertions(+), 94 deletions(-) create mode 100644 cmd/wfctl/infra_resource_discovery.go create mode 100644 cmd/wfctl/infra_resource_discovery_native_test.go create mode 100644 cmd/wfctl/infra_resource_discovery_test.go create mode 100644 iac/wfctlhelpers/env_resolve_test.go diff --git a/cmd/wfctl/deploy_providers.go b/cmd/wfctl/deploy_providers.go index 8d40c936..b1453273 100644 --- a/cmd/wfctl/deploy_providers.go +++ b/cmd/wfctl/deploy_providers.go @@ -3,7 +3,6 @@ package main import ( "bytes" "context" - "encoding/json" "errors" "fmt" "io" @@ -82,16 +81,8 @@ var resolveIaCProvider = discoverAndLoadIaCProvider // same seam pattern used by currentApplyIncludeFlag and applyAllowReplaceSet. var currentInfraPluginDir string -// iacPluginManifest is the minimal shape needed to read both: -// - capabilities.iacProvider.name — used by findIaCPluginDir to -// match a plugin to a desired provider name; AND -// - iacProvider.computePlanVersion — used by W-3b T3.7 to decide -// between v1 (legacy provider.Apply) and v2 -// (wfctlhelpers.ApplyPlan) dispatch at apply time. -// -// Both fields are unmarshaled from the same plugin.json bytes — no -// double parse — and either may be empty without affecting the -// other. +// iacPluginManifest remains the legacy metadata shape consumed by the +// generator. Provider selection uses checked, normalized manifests below. type iacPluginManifest struct { Name string `json:"name"` Version string `json:"version"` @@ -106,7 +97,8 @@ type iacPluginManifest struct { } // findIaCPluginDir scans pluginDir subdirectories for a plugin.json that -// declares capabilities.iacProvider.name == providerName. +// declares the selected IaC provider, using the same checked/normalized +// metadata as resource discovery. // Returns ("", "", false, nil) when not found; ("name", "computePlanVersion", // true/false, nil) when the manifest matches (hasBinary indicates whether // the executable is present). @@ -129,29 +121,16 @@ type iacPluginManifest struct { // MUST NOT assume. Per workflow#699, the authoritative gate is the // typed CapabilitiesResponse check in discoverAndLoadIaCProvider. func findIaCPluginDir(pluginDir, providerName string) (name, computePlanVersion string, hasBinary bool, err error) { - entries, err := os.ReadDir(pluginDir) + catalog, err := scanInstalledIaCProviders(pluginDir) if err != nil { - if os.IsNotExist(err) { - return "", "", false, nil - } - return "", "", false, fmt.Errorf("scan plugin directory %q: %w", pluginDir, err) + return "", "", false, err } - for _, entry := range entries { - if !entry.IsDir() { - continue - } - pluginName := entry.Name() - data, readErr := os.ReadFile(filepath.Join(pluginDir, pluginName, "plugin.json")) - if readErr != nil { - continue - } - var m iacPluginManifest - if jsonErr := json.Unmarshal(data, &m); jsonErr != nil { - continue - } - if m.Capabilities.IaCProvider.Name != providerName { - continue - } + selected, found, err := catalog.selectOwner(providerName) + if err != nil { + return "", "", false, err + } + if found { + pluginName := selected.directory // Per workflow#693 (Phase 2.1 follow-up to #640): validate // iacProvider.computePlanVersion ∈ {"", "v1", "v2"} on the // matching plugin manifest. A typo (e.g. "V2", "v2.0", "two") @@ -161,18 +140,18 @@ func findIaCPluginDir(pluginDir, providerName string) (name, computePlanVersion // is the typed CapabilitiesResponse gate in // discoverAndLoadIaCProvider; this parse-time check is the // pre-load schema sanity (defense-in-depth). - switch m.IaCProvider.ComputePlanVersion { + switch selected.capability.ComputePlanVersion { case "", "v1", "v2": // valid default: return "", "", false, fmt.Errorf( "plugin %q manifest has invalid iacProvider.computePlanVersion %q (must be \"\", \"v1\", or \"v2\")", - pluginName, m.IaCProvider.ComputePlanVersion, + pluginName, selected.capability.ComputePlanVersion, ) } binaryPath := filepath.Join(pluginDir, pluginName, pluginName) _, statErr := os.Stat(binaryPath) - return pluginName, m.IaCProvider.ComputePlanVersion, statErr == nil, nil + return pluginName, selected.capability.ComputePlanVersion, statErr == nil, nil } return "", "", false, nil } @@ -200,13 +179,7 @@ func findIaCPluginDir(pluginDir, providerName string) (name, computePlanVersion // CapabilitiesWithContext) so a transient handshake failure does not // poison the adapter for the rest of the wfctl invocation. func discoverAndLoadIaCProvider(ctx context.Context, providerName string, cfg map[string]any) (interfaces.IaCProvider, io.Closer, error) { - pluginDir := currentInfraPluginDir - if pluginDir == "" { - pluginDir = os.Getenv("WFCTL_PLUGIN_DIR") - } - if pluginDir == "" { - pluginDir = "./data/plugins" - } + pluginDir := infraPluginDirectory() pName, manifestCPV, hasBinary, findErr := findIaCPluginDir(pluginDir, providerName) if findErr != nil { diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 02f93a98..29de9ee8 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -540,22 +540,9 @@ func secretModuleKeys(moduleCfg map[string]any, field string) []string { } // parseInfraResourceSpecs reads an infra config (resolving imports:) and -// returns ResourceSpecs for all infra.* and platform.* (e.g., platform.kubernetes, platform.ecs) modules. +// returns canonical and explicitly selected provider-declared ResourceSpecs. func parseInfraResourceSpecs(cfgFile string) ([]interfaces.ResourceSpec, error) { - cfg, err := config.LoadFromFile(cfgFile) - if err != nil { - return nil, fmt.Errorf("load %s: %w", cfgFile, err) - } - secretVars := declaredSecretKeys(cfg) - var specs []interfaces.ResourceSpec - for _, m := range cfg.Modules { - if !isInfraType(m.Type) { - continue - } - r := &config.ResolvedModule{Name: m.Name, Type: m.Type, Protected: m.Protected, Config: config.ExpandEnvInMapPreservingVars(m.Config, infraPreserveKeys, secretVars)} - specs = append(specs, resourceSpecFromResolvedModule(r)) - } - return specs, nil + return parseInfraResourceSpecsForEnv(cfgFile, "") } // parseInfraResourceSpecsForEnv returns ResourceSpecs for plan computation, @@ -563,9 +550,6 @@ func parseInfraResourceSpecs(cfgFile string) ([]interfaces.ResourceSpec, error) // --env and no-env paths produce the same ResourceSpec shape so callers never // need to duplicate the ResolvedModule->ResourceSpec mapping. func parseInfraResourceSpecsForEnv(cfgFile, envName string) ([]interfaces.ResourceSpec, error) { - if envName == "" { - return parseInfraResourceSpecs(cfgFile) - } resolved, err := planResourcesForEnv(cfgFile, envName) if err != nil { return nil, err @@ -587,6 +571,10 @@ func planResourcesForEnv(path, envName string) ([]*config.ResolvedModule, error) if err != nil { return nil, fmt.Errorf("load %s: %w", path, err) } + discovery, err := newIaCResourceDiscovery(cfg, envName) + if err != nil { + return nil, err + } var topEnv *config.EnvironmentConfig if envName != "" && cfg.Environments != nil { topEnv = cfg.Environments[envName] @@ -595,15 +583,15 @@ func planResourcesForEnv(path, envName string) ([]*config.ResolvedModule, error) var out []*config.ResolvedModule for i := range cfg.Modules { m := &cfg.Modules[i] - if !isInfraType(m.Type) { + resolved, ok := resolveDiscoveryModule(m, envName) + if !ok { continue } - if envName == "" { - out = append(out, &config.ResolvedModule{Name: m.Name, Type: m.Type, Protected: m.Protected, Config: config.ExpandEnvInMapPreservingVars(m.Config, infraPreserveKeys, secretVars)}) - continue + resource, err := discovery.classify(resolved) + if err != nil { + return nil, err } - resolved, ok := m.ResolveForEnv(envName) - if !ok { + if !resource { continue } if topEnv != nil { @@ -1397,6 +1385,13 @@ func runInfraApply(args []string) error { if dryRun { return runInfraApplyDryRun(cfgFile, envName, dryRunFormat, showSensitiveVal) } + directResources, err := hasDirectIaCResources(cfgFile, envName) + if err != nil { + return fmt.Errorf("classify IaC resources: %w", err) + } + if planFile == "" && directResources && hasPlatformModules(cfgFile) { + return fmt.Errorf("config %q mixes direct IaC and platform.* module types; use one style per config file", cfgFile) + } if !*autoApprove { ok, err := confirmAction("Apply infrastructure changes from "+cfgFile+"?", false, os.Stderr, nil) @@ -1455,7 +1450,7 @@ func runInfraApply(args []string) error { // change to --refresh. refreshOutputsRan := false if refreshOutputsFlag { - if hasInfraModules(cfgFile) { + if directResources { if err := applyPreStepRefreshOutputs(ctx, cfgFile, envName, os.Stdout); err != nil { return fmt.Errorf("--refresh-outputs: %w", err) } @@ -1469,7 +1464,7 @@ func runInfraApply(args []string) error { // before running the normal plan + apply. Only applicable for infra.* configs; // silently skipped for legacy platform.* configs. Runs AFTER --refresh-outputs // so the drift check sees the freshest possible Outputs. - if refreshFlag && hasInfraModules(cfgFile) { + if refreshFlag && directResources { fmt.Println("Refreshing state (detecting drift)...") store, storeErr := resolveStateStore(cfgFile, envName) if storeErr != nil { @@ -1519,7 +1514,7 @@ func runInfraApply(args []string) error { // applicable for infra.* configs (legacy platform.* path doesn't // flow through iac/refreshoutputs). Skipped when --refresh-outputs // already ran (refreshOutputsRan guard prevents double-trigger). - if !refreshOutputsRan && applyPreStepRefreshEnabled(skipRefreshFlag) && hasInfraModules(cfgFile) { + if !refreshOutputsRan && applyPreStepRefreshEnabled(skipRefreshFlag) && directResources { if err := applyPreStepRefreshOutputs(ctx, cfgFile, envName, os.Stdout); err != nil { return fmt.Errorf("apply pre-step refresh-outputs: %w", err) } @@ -1564,7 +1559,7 @@ func runInfraApply(args []string) error { // *StaleError: Error() yields the canonical FormatStaleError // output (no sentinel prefix); Unwrap() yields ErrEnvVarChanged // so errors.Is(err, inputsnapshot.ErrEnvVarChanged) still matches. - return inputsnapshot.NewStaleError(drift) + return contextualCLIError{err: inputsnapshot.NewStaleError(drift)} } } // Stale checks hash the same declarative inputs recorded at plan time. @@ -1596,14 +1591,14 @@ func runInfraApply(args []string) error { // platform.* configs fall back to the pipeline runner (pipelines.apply). // Mixing both types in the same config is not supported — fail fast with a // descriptive error rather than silently skipping one class of modules. - if hasInfraModules(cfgFile) && hasPlatformModules(cfgFile) { + if directResources && hasPlatformModules(cfgFile) { return fmt.Errorf( "config %q mixes infra.* and platform.* module types — "+ "use one style per config file, or split into separate configs", cfgFile, ) } - if hasInfraModules(cfgFile) { + if directResources { h, err := applyInfraModules(ctx, cfgFile, envName) if err != nil { return err diff --git a/cmd/wfctl/infra_apply.go b/cmd/wfctl/infra_apply.go index 11a30e36..1ec73aab 100644 --- a/cmd/wfctl/infra_apply.go +++ b/cmd/wfctl/infra_apply.go @@ -166,15 +166,16 @@ func applyInfraModules(ctx context.Context, cfgFile, envName string) (map[string return nil, fmt.Errorf("parse infra resource specs: %w", err) } - // Keep only infra.* specs for the direct path. + // The parser already classified custom specs against their selected + // provider. Only legacy platform.* specs belong to the pipeline path. var infraSpecs []interfaces.ResourceSpec for _, s := range specs { - if strings.HasPrefix(s.Type, "infra.") { + if !strings.HasPrefix(s.Type, "platform.") { infraSpecs = append(infraSpecs, s) } } if len(infraSpecs) == 0 { - fmt.Println("No infra.* modules to apply.") + fmt.Println("No direct IaC resources to apply.") return nil, nil } if err := validateUniqueInfraResourceNames(infraSpecs); err != nil { diff --git a/cmd/wfctl/infra_apply_dryrun.go b/cmd/wfctl/infra_apply_dryrun.go index 450d761e..cd474fdb 100644 --- a/cmd/wfctl/infra_apply_dryrun.go +++ b/cmd/wfctl/infra_apply_dryrun.go @@ -220,7 +220,7 @@ func collectProviderGroups(cfgFile, envName string, specs []interfaces.ResourceS groups := map[string]*groupAcc{} var order []string for _, spec := range specs { - if !strings.HasPrefix(spec.Type, "infra.") { + if strings.HasPrefix(spec.Type, "platform.") { continue } moduleRef := resolveIaCProviderRef(spec.Config) diff --git a/cmd/wfctl/infra_env_resolve.go b/cmd/wfctl/infra_env_resolve.go index 267acd2d..bef51356 100644 --- a/cmd/wfctl/infra_env_resolve.go +++ b/cmd/wfctl/infra_env_resolve.go @@ -1,15 +1,20 @@ package main -import "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" +import ( + "github.com/GoCodeAlone/workflow/config" + "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. +// The CLI supplies provider-bound discovery; the shared writer retains +// canonical-only behavior for callers that do not supply a classifier. func writeEnvResolvedConfig(cfgFile, envName string) (tmpPath string, err error) { - return wfctlhelpers.WriteEnvResolvedConfig(cfgFile, envName) + cfg, err := config.LoadFromFile(cfgFile) + if err != nil { + return "", err + } + discovery, err := newIaCResourceDiscovery(cfg, envName) + if err != nil { + return "", err + } + return wfctlhelpers.WriteEnvResolvedConfigWithClassifier(cfgFile, envName, discovery.classify) } diff --git a/cmd/wfctl/infra_inputsnapshot.go b/cmd/wfctl/infra_inputsnapshot.go index bfb4f132..4e72ec07 100644 --- a/cmd/wfctl/infra_inputsnapshot.go +++ b/cmd/wfctl/infra_inputsnapshot.go @@ -35,6 +35,10 @@ func collectInfraEnvVarRefs(cfgFile, envName string) ([]string, error) { if err != nil { return nil, err } + discovery, err := newIaCResourceDiscovery(cfg, envName) + if err != nil { + return nil, err + } seen := map[string]struct{}{} record := func(name string) string { if name != "" { @@ -44,15 +48,15 @@ func collectInfraEnvVarRefs(cfgFile, envName string) ([]string, error) { } for i := range cfg.Modules { m := &cfg.Modules[i] - if !isInfraType(m.Type) { + resolved, ok := resolveDiscoveryModule(m, envName) + if !ok { continue } - if envName == "" { - walkValueForEnvRefs(m.Config, record) - continue + resource, err := discovery.classify(resolved) + if err != nil { + return nil, err } - resolved, ok := m.ResolveForEnv(envName) - if !ok { + if !resource { continue } walkValueForEnvRefs(resolved.Config, record) diff --git a/cmd/wfctl/infra_resource_discovery.go b/cmd/wfctl/infra_resource_discovery.go new file mode 100644 index 00000000..6b385e21 --- /dev/null +++ b/cmd/wfctl/infra_resource_discovery.go @@ -0,0 +1,302 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/plugin" +) + +type installedIaCProvider struct { + directory string + capability plugin.IaCProviderCapability +} + +type installedIaCProviders struct { + owners map[string]installedIaCProvider + types map[string]struct{} + duplicates map[string]error + issues []installedIaCManifestIssue +} + +type installedIaCManifestIssue struct { + directory string + owners []string + err error +} + +// Check raw ownership before PluginManifest merges legacy resourceTypes. Both +// discovery and executable selection must see the same bytes and owner. +func decodeInstalledIaCManifest(data []byte, directory string) (*plugin.PluginManifest, []string, error) { + type owner struct { + Name string `json:"name"` + } + var raw struct { + IaCProvider json.RawMessage `json:"iacProvider"` + Capabilities json.RawMessage `json:"capabilities"` + } + invalid := func() error { return fmt.Errorf("plugin %q has invalid IaC manifest metadata", directory) } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, nil, invalid() + } + var legacy struct { + IaCProvider json.RawMessage `json:"iacProvider"` + } + if caps := bytes.TrimSpace(raw.Capabilities); len(caps) > 0 && caps[0] == '{' { + if err := json.Unmarshal(caps, &legacy); err != nil { + return nil, nil, invalid() + } + } + var owners []string + var ownerErr error + // Keep both typed owner hints even if one declaration is damaged. They + // diagnose needed metadata; only a fully normalized manifest authorizes. + for _, declaration := range []json.RawMessage{raw.IaCProvider, legacy.IaCProvider} { + if len(declaration) == 0 { + continue + } + var declared owner + if err := json.Unmarshal(declaration, &declared); err != nil { + ownerErr = invalid() + } + if declared.Name != "" { + owners = append(owners, declared.Name) + } + } + if len(owners) == 2 && owners[0] != owners[1] { + return nil, owners, fmt.Errorf("plugin %q has conflicting IaC provider owners", directory) + } + if ownerErr != nil { + return nil, owners, ownerErr + } + var manifest plugin.PluginManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, owners, invalid() + } + for _, typ := range manifest.IaCProvider.ResourceTypes { + if typ == "" || strings.TrimSpace(typ) != typ { + return nil, owners, invalid() + } + } + return &manifest, owners, nil +} + +func scanInstalledIaCProviders(root string) (*installedIaCProviders, error) { + catalog := &installedIaCProviders{ + owners: map[string]installedIaCProvider{}, + types: map[string]struct{}{}, + duplicates: map[string]error{}, + } + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return catalog, nil + } + if err != nil { + return nil, fmt.Errorf("scan plugin directory %q: %w", root, err) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + data, err := os.ReadFile(filepath.Join(root, entry.Name(), "plugin.json")) + if os.IsNotExist(err) { + continue + } + if err != nil { + catalog.issues = append(catalog.issues, installedIaCManifestIssue{ + directory: entry.Name(), err: fmt.Errorf("read plugin %q manifest: %w", entry.Name(), err), + }) + continue + } + manifest, owners, err := decodeInstalledIaCManifest(data, entry.Name()) + if err != nil { + catalog.issues = append(catalog.issues, installedIaCManifestIssue{directory: entry.Name(), owners: owners, err: err}) + continue + } + capability := manifest.IaCProvider + if capability.Name == "" { + continue + } + if _, exists := catalog.owners[capability.Name]; exists { + catalog.duplicates[capability.Name] = fmt.Errorf("multiple installed plugins declare IaC provider %q", capability.Name) + } else { + catalog.owners[capability.Name] = installedIaCProvider{directory: entry.Name(), capability: capability} + } + for _, typ := range capability.ResourceTypes { + catalog.types[typ] = struct{}{} + } + } + return catalog, nil +} + +func (c *installedIaCProviders) selectOwner(name string) (installedIaCProvider, bool, error) { + if err := c.duplicates[name]; err != nil { + return installedIaCProvider{}, false, err + } + for _, issue := range c.issues { + needed := issue.directory == name + for _, owner := range issue.owners { + needed = needed || owner == name + } + if needed { + return installedIaCProvider{}, false, issue.err + } + } + selected, found := c.owners[name] + if !found { + // Unknown damaged ownership cannot supply a missing selection. Retain + // its error instead of returning a misleading successful lookup. + for _, issue := range c.issues { + if len(issue.owners) == 0 { + return installedIaCProvider{}, false, issue.err + } + } + } + return selected, found, nil +} + +func infraPluginDirectory() string { + if currentInfraPluginDir != "" { + return currentInfraPluginDir + } + if root := os.Getenv("WFCTL_PLUGIN_DIR"); root != "" { + return root + } + return "./data/plugins" +} + +type iaCResourceDiscovery struct { + defs map[string]providerDef + disabled map[string]struct{} + defaultRef string + root string + catalog *installedIaCProviders +} + +func newIaCResourceDiscovery(cfg *config.WorkflowConfig, envName string) (*iaCResourceDiscovery, error) { + names := map[string]struct{}{} + for _, mod := range cfg.Modules { + if mod.Type != "iac.provider" { + continue + } + if _, exists := names[mod.Name]; exists { + return nil, fmt.Errorf("duplicate iac.provider module %q", mod.Name) + } + names[mod.Name] = struct{}{} + } + defs, _, disabled := resolveProviderDefs(cfg, envName) + d := &iaCResourceDiscovery{defs: defs, disabled: disabled, root: infraPluginDirectory()} + if envName != "" && cfg.Environments[envName] != nil { + d.defaultRef = os.ExpandEnv(cfg.Environments[envName].Provider) + } + return d, nil +} + +func (d *iaCResourceDiscovery) classify(mod *config.ResolvedModule) (bool, error) { + if isInfraType(mod.Type) { + return true, nil + } + if mod.Type == "iac.provider" || mod.Type == "iac.state" { + return false, nil + } + // Resolve routing with the same expansion as provider dispatch, without + // expanding payloads or changing the raw config used by input scanning. + routing := map[string]any{} + for _, key := range []string{"iac_provider", "provider"} { + if value, ok := mod.Config[key].(string); ok { + routing[key] = config.ExpandEnvInValue(value) + } + } + ref := resolveIaCProviderRef(routing) + _, explicit := mod.Config["iac_provider"] + _, declared := d.defs[ref] + _, disabled := d.disabled[ref] + defaultDef, defaultDeclared := d.defs[d.defaultRef] + if d.catalog == nil { + catalog, err := scanInstalledIaCProviders(d.root) + if err != nil { + if !explicit && !declared && !disabled && (ref != "" || !defaultDeclared) { + return false, nil + } + return false, err + } + d.catalog = catalog + } + if ref == "" && defaultDeclared { + if defaultDef.provType == "" { + return false, fmt.Errorf("IaC resource %q references unconfigured default provider %q", mod.Name, d.defaultRef) + } + // Missing default-owner metadata must not turn a previously declared + // custom resource into an ordinary module and an omission Delete. + _, found, err := d.catalog.selectOwner(defaultDef.provType) + if err != nil { + return false, err + } + if !found { + return false, fmt.Errorf("IaC resource %q: no installed plugin declares selected default provider %q", mod.Name, defaultDef.provType) + } + } + _, advertised := d.catalog.types[mod.Type] + if !explicit && !advertised && !declared && !disabled { + return false, nil + } + if explicit { + value, ok := mod.Config["iac_provider"].(string) + if !ok || value == "" { + return false, fmt.Errorf("IaC resource %q requires a nonempty string iac_provider reference", mod.Name) + } + } + if ref == "" { + ref = d.defaultRef + } + if ref == "" { + return false, fmt.Errorf("IaC resource %q (%s) requires an explicit provider reference", mod.Name, mod.Type) + } + if _, disabled := d.disabled[ref]; disabled { + return false, fmt.Errorf("IaC resource %q references disabled provider %q", mod.Name, ref) + } + def, ok := d.defs[ref] + if !ok || def.provType == "" { + return false, fmt.Errorf("IaC resource %q references undeclared or unconfigured provider %q", mod.Name, ref) + } + selected, ok, err := d.catalog.selectOwner(def.provType) + if err != nil { + return false, err + } + if !ok { + return false, fmt.Errorf("IaC resource %q: no installed plugin declares selected provider %q", mod.Name, def.provType) + } + for _, typ := range selected.capability.ResourceTypes { + if typ == mod.Type { + return true, nil + } + } + return false, fmt.Errorf("IaC resource %q type %q is not declared by selected provider %q", mod.Name, mod.Type, def.provType) +} + +func resolveDiscoveryModule(mod *config.ModuleConfig, envName string) (*config.ResolvedModule, bool) { + if envName != "" { + return mod.ResolveForEnv(envName) + } + return &config.ResolvedModule{ + Name: mod.Name, Type: mod.Type, Protected: mod.Protected, Config: cloneMap(mod.Config), + }, true +} + +func hasDirectIaCResources(cfgFile, envName string) (bool, error) { + specs, err := parseInfraResourceSpecsForEnv(cfgFile, envName) + if err != nil { + return false, err + } + for _, spec := range specs { + if !strings.HasPrefix(spec.Type, "platform.") { + return true, nil + } + } + return false, nil +} diff --git a/cmd/wfctl/infra_resource_discovery_native_test.go b/cmd/wfctl/infra_resource_discovery_native_test.go new file mode 100644 index 00000000..3a9350d8 --- /dev/null +++ b/cmd/wfctl/infra_resource_discovery_native_test.go @@ -0,0 +1,867 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "sort" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/plugin" +) + +const discoveryBaselineSHA = "64bfdf1a3f99bd634dd2a0c8118a5145cc322149bf34eb1ea30a64836a3adf20" + +type discoveryNativeEvent struct { + PID int + Owner, Op, Type, Name, Ref, ProviderID string +} +type discoveryNativeHarness struct{ root, plugins, bin string } + +func discoveryBuildEnv() []string { + env := []string{"LC_ALL=C", "GOWORK=off", "GOTOOLCHAIN=go1.26.5", "GOPROXY=off", "GOFLAGS=-mod=readonly"} + for _, name := range []string{"PATH", "HOME", "TMPDIR", "GOCACHE", "GOPATH", "GOMODCACHE", "CGO_ENABLED", "CC"} { + if value := os.Getenv(name); value != "" { + env = append(env, name+"="+value) + } + } + return env +} + +func discoveryBuild(t *testing.T, cwd, output, source string) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "build", "-race", "-o", output, source) + cmd.Dir, cmd.Env, cmd.WaitDelay = cwd, discoveryBuildEnv(), 5*time.Second + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build actual artifact: %v\n%s", err, out) + } + t.Logf("go build -race -o %s %s: exit 0", output, source) +} + +func newDiscoveryNativeHarness(t *testing.T) *discoveryNativeHarness { + t.Helper() + h := &discoveryNativeHarness{root: t.TempDir()} + h.plugins, h.bin = filepath.Join(h.root, "plugins"), filepath.Join(h.root, "wfctl") + repo, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + discoveryBuild(t, repo, h.bin, "./cmd/wfctl") + for _, owner := range []string{"alpha", "beta"} { + name := "fixture-" + owner + dir := filepath.Join(h.plugins, name) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + types := []string{owner + ".widget"} + if owner == "alpha" { + types = append(types, "infra.fixture_alpha") + } + h.manifest(t, name, owner, types) + source := filepath.Join(h.root, owner+".go") + discoveryWrite(t, source, strings.ReplaceAll(discoveryNativeSource, "FIXTURE_OWNER", owner)) + discoveryBuild(t, repo, filepath.Join(dir, name), source) + } + t.Cleanup(func() { h.reap(t) }) + return h +} + +func (h *discoveryNativeHarness) manifest(t *testing.T, name, owner string, types []string) { + t.Helper() + type legacy struct { + IaCProvider plugin.IaCProviderCapability `json:"iacProvider"` + } + manifest := struct { + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + IaCProvider plugin.IaCProviderCapability `json:"iacProvider"` + Capabilities legacy `json:"capabilities"` + }{Name: name, Version: "0.1.0", Author: "workflow-tests", Description: "Independent native SDK resource fixture", + IaCProvider: plugin.IaCProviderCapability{Name: owner, ResourceTypes: types, ComputePlanVersion: "v2"}} + manifest.Capabilities.IaCProvider = manifest.IaCProvider + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + discoveryWrite(t, filepath.Join(h.plugins, name, "plugin.json"), string(data)) +} + +func (h *discoveryNativeHarness) events(t *testing.T) []discoveryNativeEvent { + t.Helper() + var events []discoveryNativeEvent + for _, owner := range []string{"alpha", "beta"} { + data, err := os.ReadFile(filepath.Join(h.root, owner+".jsonl")) + if os.IsNotExist(err) { + continue + } + if err != nil { + t.Fatal(err) + } + decoder := json.NewDecoder(bytes.NewReader(data)) + for { + var event discoveryNativeEvent + if err := decoder.Decode(&event); err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + events = append(events, event) + } + } + return events +} + +func (h *discoveryNativeHarness) mutations(t *testing.T) []discoveryNativeEvent { + t.Helper() + var result []discoveryNativeEvent + for _, event := range h.events(t) { + if event.Op == "Create" || event.Op == "Update" || event.Op == "Delete" { + result = append(result, event) + } + } + return result +} + +func (h *discoveryNativeHarness) reap(t *testing.T) { + t.Helper() + pids := map[int]struct{}{} + for _, event := range h.events(t) { + pids[event.PID] = struct{}{} + } + for pid := range pids { + process, err := os.FindProcess(pid) + if err != nil { + t.Error(err) + continue + } + if err := process.Signal(syscall.Signal(0)); err == nil { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + cmd := exec.CommandContext(ctx, "ps", "-p", strconv.Itoa(pid), "-o", "command=") + out, psErr := cmd.Output() + cancel() + if psErr == nil && strings.Contains(string(out), h.plugins+string(os.PathSeparator)) { + _ = process.Kill() + t.Errorf("CLI left owned SDK child %d alive; killed exact fixture child", pid) + } else { + t.Errorf("fixture PID %d still present; refusing unrelated process cleanup", pid) + } + } + _ = process.Release() + } + t.Logf("owned SDK PID readback: %d children absent after CLI wait/manager shutdown", len(pids)) +} + +func (h *discoveryNativeHarness) run(t *testing.T, binary string, extra []string, args ...string) ([]byte, error) { + t.Helper() + entries, err := os.ReadDir(h.plugins) + if err != nil || len(entries) < 2 { + t.Fatalf("both independent providers must remain installed: %v %v", entries, err) + } + for _, names := range [][]string{{"fixture-alpha", "z-alpha"}, {"fixture-beta", "a-beta"}} { + found := false + for _, name := range names { + if info, err := os.Stat(filepath.Join(h.plugins, name)); err == nil && info.IsDir() { + found = true + } + } + if !found { + t.Fatalf("independent fixture directories %v are absent", names) + } + } + ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Dir, cmd.WaitDelay = h.root, 5*time.Second + cmd.Env = []string{"LC_ALL=C", "CI=true", "WFCTL_NO_UPDATE_CHECK=1", "HOME=" + h.root, "TASK26_FIXTURE_ROOT=" + h.root} + if path := os.Getenv("PATH"); path != "" { + cmd.Env = append(cmd.Env, "PATH="+path) + } + cmd.Env = append(cmd.Env, extra...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err = cmd.Run() + out := stdout.Bytes() + if err != nil { + out = append(out, stderr.Bytes()...) + } + t.Logf("native CLI %s %s: exit=%v", binary, strings.Join(args, " "), err) + h.reap(t) + return out, err +} + +func (h *discoveryNativeHarness) config(t *testing.T, resources, prefix string) string { + t.Helper() + path := filepath.Join(h.root, "infra.yaml") + header := "modules:\n" + + " - name: provider-a\n type: iac.provider\n config:\n provider: alpha\n Ref: provider-a\n Root: " + strconv.Quote(h.root) + "\n" + + " - name: provider-b\n type: iac.provider\n config:\n provider: beta\n Ref: provider-b\n Root: " + strconv.Quote(h.root) + "\n" + + " - name: state\n type: iac.state\n config:\n backend: filesystem\n directory: " + strconv.Quote(filepath.Join(h.root, "state")) + "\n" + discoveryWrite(t, path, prefix+header+resources) + return path +} + +const discoveryNativeCustom = ` - name: custom-a + type: alpha.widget + config: {iac_provider: provider-a, value: one, adopt_existing: true} + - name: custom-b + type: beta.widget + config: {provider: provider-b, value: one, adopt_existing: true} +` +const discoveryNativeCanonical = ` - name: canonical + type: infra.fixture_alpha + config: {provider: provider-a, value: one, adopt_existing: true} +` + +func (h *discoveryNativeHarness) plan(t *testing.T, binary, cfg, path string, extra []string) interfaces.IaCPlan { + t.Helper() + out, err := h.run(t, binary, extra, "infra", "plan", "--config", cfg, "--plugin-dir", h.plugins, "--env", "fixture", "--format", "json", "-o", path) + if err != nil { + t.Fatalf("actual CLI plan: %v\n%s", err, out) + } + plan, err := loadPlanFromFile(path) + if err != nil { + t.Fatal(err) + } + return plan +} +func discoveryPlanNames(plan interfaces.IaCPlan) []string { + names := make([]string, 0, len(plan.Actions)) + for _, action := range plan.Actions { + names = append(names, action.Resource.Name) + } + sort.Strings(names) + return names +} + +func (h *discoveryNativeHarness) frozenState(t *testing.T, directory string) map[string]string { + t.Helper() + result := map[string]string{} + err := filepath.WalkDir(directory, func(path string, entry os.DirEntry, err error) error { + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + relative, err := filepath.Rel(directory, path) + if err != nil { + return err + } + sum := sha256.Sum256(data) + result["workflow/"+relative] = hex.EncodeToString(sum[:]) + return nil + }) + if err != nil { + t.Fatal(err) + } + for _, owner := range []string{"alpha", "beta"} { + data, err := os.ReadFile(filepath.Join(h.root, owner+".state.json")) + if os.IsNotExist(err) { + continue + } + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(data) + result["provider/"+owner] = hex.EncodeToString(sum[:]) + } + return result +} + +func discoveryVerifyBaseline(t *testing.T) string { + t.Helper() + path, selected := os.LookupEnv("TASK26_BASELINE_WFCTL") + if !selected { + return "" + } + if path == "" { + t.Fatal("explicit TASK26_BASELINE_WFCTL is empty; acceptance requires the verified artifact") + } + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Fatal("selected acceptance artifact requires native darwin/arm64") + } + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + stat, err := file.Stat() + if err != nil { + t.Fatal(err) + } + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + t.Fatal(err) + } + if stat.Size() != 112815442 || hex.EncodeToString(hash.Sum(nil)) != discoveryBaselineSHA { + t.Fatal("baseline does not match verified immutable v0.86.1 artifact") + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, path, "--version") + cmd.Env = []string{"LC_ALL=C", "CI=true", "WFCTL_NO_UPDATE_CHECK=1"} + cmd.WaitDelay = 5 * time.Second + out, err := cmd.CombinedOutput() + if err != nil || strings.TrimSpace(string(out)) != "v0.86.1" { + t.Fatalf("baseline version must be v0.86.1: %v %q", err, out) + } + t.Logf("verified native baseline size=%d SHA256=%s version=v0.86.1", stat.Size(), discoveryBaselineSHA) + return path +} + +func TestIaCResourceDiscoveryNative_Integration(t *testing.T) { + baseline := discoveryVerifyBaseline(t) + h := newDiscoveryNativeHarness(t) + planPath := filepath.Join(h.root, "plan.json") + applyArgs := func(cfg string) []string { + return []string{"infra", "apply", "--config", cfg, "--plugin-dir", h.plugins, "--env", "fixture", "--auto-approve", "--skip-bootstrap", "--skip-refresh"} + } + t.Run("custom-only-and-mixed-dry-run", func(t *testing.T) { + cfg := h.config(t, discoveryNativeCustom, "") + plan := h.plan(t, h.bin, cfg, planPath, nil) + if got := discoveryPlanNames(plan); !reflect.DeepEqual(got, []string{"custom-a", "custom-b"}) { + t.Fatalf("actual CLI custom-only resources=%v; want both providers", got) + } + cfg = h.config(t, discoveryNativeCanonical+discoveryNativeCustom, "") + plan = h.plan(t, h.bin, cfg, planPath, nil) + if got := discoveryPlanNames(plan); !reflect.DeepEqual(got, []string{"canonical", "custom-a", "custom-b"}) { + t.Fatalf("actual CLI mixed resources=%v; want exactly three", got) + } + out, err := h.run(t, h.bin, nil, append(applyArgs(cfg), "--dry-run", "--format", "json")...) + if err != nil { + t.Fatalf("dry-run: %v\n%s", err, out) + } + var dry DryRunApplyPlan + if err := json.Unmarshal(out, &dry); err != nil { + t.Fatalf("real dry-run JSON: %v\n%s", err, out) + } + want := []DryRunProviderGroup{{ModuleRef: "provider-a", ProviderType: "alpha", ResourceCount: 2}, {ModuleRef: "provider-b", ProviderType: "beta", ResourceCount: 1}} + if len(dry.Actions) != 3 || !reflect.DeepEqual(dry.Providers, want) || len(h.mutations(t)) != 0 { + t.Fatalf("dry-run exact groups/actions/zero CUD: %+v", dry) + } + }) + t.Run("direct-restart-repeat-saved-update", func(t *testing.T) { + cfg := h.config(t, discoveryNativeCanonical+discoveryNativeCustom, "") + out, err := h.run(t, h.bin, nil, applyArgs(cfg)...) + if err != nil { + t.Fatalf("direct native apply: %v\n%s", err, out) + } + mutations := h.mutations(t) + if len(mutations) != 3 { + t.Fatalf("direct CUD count=%d; want exactly 3", len(mutations)) + } + for _, e := range mutations { + if e.Op != "Create" || (e.Name == "custom-b" && (e.Owner != "beta" || e.Ref != "provider-b")) || (e.Name != "custom-b" && (e.Owner != "alpha" || e.Ref != "provider-a")) { + t.Fatalf("provider identity redirected: %+v", e) + } + } + states, err := loadCurrentState(cfg, "fixture") + if err != nil || len(states) != 3 { + t.Fatalf("real state reload: count=%d err=%v", len(states), err) + } + for _, s := range states { + owner, ref := "alpha", "provider-a" + if s.Name == "custom-b" { + owner, ref = "beta", "provider-b" + } + if s.Provider != owner || s.ProviderRef != ref || s.ProviderID != owner+"/"+s.Name { + t.Fatalf("persisted exact identity lost: %+v", s) + } + } + out, err = h.run(t, h.bin, nil, applyArgs(cfg)...) + if err != nil || len(h.mutations(t)) != 3 { + t.Fatalf("restart/repeat must not mutate: %v\n%s", err, out) + } + updated := strings.Replace(discoveryNativeCanonical+discoveryNativeCustom, "iac_provider: provider-a, value: one", "iac_provider: provider-a, value: two", 1) + cfg = h.config(t, updated, "") + plan := h.plan(t, h.bin, cfg, planPath, nil) + if len(plan.Actions) != 1 || plan.Actions[0].Action != "update" || plan.Actions[0].Resource.Name != "custom-a" { + t.Fatalf("actual dependency Diff must find only custom-a update: %+v", plan.Actions) + } + out, err = h.run(t, h.bin, nil, append(applyArgs(cfg), "--plan", planPath)...) + if err != nil || len(h.mutations(t)) != 4 { + t.Fatalf("actual saved update: %v\n%s", err, out) + } + }) + t.Run("binding-order-and-runtime-authority", func(t *testing.T) { + before := len(h.mutations(t)) + cfg := h.config(t, strings.Replace(discoveryNativeCustom, "iac_provider: provider-a", "iac_provider: provider-b", 1), "") + out, err := h.run(t, h.bin, nil, "infra", "plan", "--config", cfg, "--plugin-dir", h.plugins) + if err == nil || !bytes.Contains(out, []byte("not declared by selected provider")) { + t.Fatalf("wrong selection must be binding denial: %v\n%s", err, out) + } + for _, mode := range []string{"no-required", "no-driver", "v1", "wrong-type"} { + cfg = h.config(t, discoveryNativeCustom, "") + out, err = h.run(t, h.bin, []string{"TASK26_ALPHA_MODE=" + mode}, applyArgs(cfg)...) + if err == nil { + t.Fatalf("runtime authority %s permitted apply", mode) + } + marker := map[string]string{"no-required": "does not register the required", "no-driver": "unimplemented", "v1": "requires \"v2\"", "wrong-type": "fixture type unavailable"}[mode] + if !bytes.Contains(out, []byte(marker)) { + t.Fatalf("runtime %s denial must not be a transport false positive: %s", mode, out) + } + } + if len(h.mutations(t)) != before { + t.Fatal("binding/runtime denials must have zero CUD") + } + func() { + binary := filepath.Join(h.plugins, "fixture-alpha", "fixture-alpha") + held := filepath.Join(h.root, "held-alpha") + if err := os.Rename(binary, held); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Rename(held, binary); err != nil { + t.Error(err) + } + }() + out, err := h.run(t, h.bin, nil, applyArgs(cfg)...) + if err == nil || !bytes.Contains(out, []byte("binary is missing")) || len(h.mutations(t)) != before { + t.Fatalf("missing executable must fail before CUD: %v\n%s", err, out) + } + }() + for _, pair := range [][2]string{{"fixture-alpha", "z-alpha"}, {"fixture-beta", "a-beta"}} { + old, name := pair[0], pair[1] + dir := filepath.Join(h.plugins, old) + if err := os.Rename(filepath.Join(dir, old), filepath.Join(dir, name)); err != nil { + t.Fatal(err) + } + if err := os.Rename(dir, filepath.Join(h.plugins, name)); err != nil { + t.Fatal(err) + } + owner := strings.TrimPrefix(old, "fixture-") + types := []string{owner + ".widget"} + if owner == "alpha" { + types = append(types, "infra.fixture_alpha") + } + h.manifest(t, name, owner, types) + } + cfg = h.config(t, discoveryNativeCanonical+discoveryNativeCustom, "") + data, err := os.ReadFile(cfg) + if err != nil { + t.Fatal(err) + } + text := string(data) + start, middle, end := strings.Index(text, " - name: provider-a"), strings.Index(text, " - name: provider-b"), strings.Index(text, " - name: state") + text = text[:start] + text[middle:end] + text[start:middle] + text[end:] + customStart, customMiddle := strings.Index(text, " - name: custom-a"), strings.Index(text, " - name: custom-b") + text = text[:customStart] + text[customMiddle:] + text[customStart:customMiddle] + discoveryWrite(t, cfg, text) + plan := h.plan(t, h.bin, cfg, planPath, nil) + for _, a := range plan.Actions { + if a.Resource.Name == "custom-a" && resolveIaCProviderRef(a.Resource.Config) != "provider-a" { + t.Fatal("reversed order redirected alpha") + } + if a.Action == "delete" { + t.Fatal("order-only changes must not delete") + } + } + out, err = h.run(t, h.bin, nil, append(applyArgs(cfg), "--plan", planPath)...) + if err != nil { + t.Fatalf("reversed order saved apply: %v\n%s", err, out) + } + }) + t.Run("imported-env-fingerprint-before-mutation", func(t *testing.T) { + cfg := h.config(t, discoveryNativeCanonical, "imports: [custom.yaml]\n") + discoveryWrite(t, filepath.Join(h.root, "custom.yaml"), discoveryNativeImported) + extra := []string{"TASK26_VALUE=task26-private-value-a"} + plan := h.plan(t, h.bin, cfg, planPath, extra) + if len(plan.InputSnapshot) != 1 || plan.InputSnapshot["TASK26_VALUE"] == "" { + t.Fatalf("custom fingerprint missing: %v", plan.InputSnapshot) + } + data, err := os.ReadFile(planPath) + if err != nil || !bytes.Contains(data, []byte("${TASK26_VALUE}")) || bytes.Contains(data, []byte("task26-private-value-a")) { + t.Fatal("plan must preserve custom ref and omit value") + } + out, err := h.run(t, h.bin, extra, append(applyArgs(cfg), "--plan", planPath)...) + if err != nil { + t.Fatalf("unchanged saved input: %v\n%s", err, out) + } + directory := filepath.Join(h.root, "state") + frozen, before := h.frozenState(t, directory), len(h.mutations(t)) + out, err = h.run(t, h.bin, []string{"TASK26_VALUE=task26-private-value-b"}, append(applyArgs(cfg), "--plan", planPath)...) + if err == nil || !bytes.Contains(out, []byte("TASK26_VALUE")) { + t.Fatalf("changed fingerprint must reject by key: %v\n%s", err, out) + } + if len(h.mutations(t)) != before || !reflect.DeepEqual(h.frozenState(t, directory), frozen) { + t.Fatal("fingerprint denial mutated provider or Workflow state") + } + for _, value := range []string{"task26-private-value-a", "task26-private-value-b"} { + if bytes.Contains(out, []byte(value)) { + t.Fatal("drift diagnostic disclosed input bytes") + } + err := filepath.WalkDir(h.root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".json") && !strings.HasSuffix(path, ".jsonl") { + return nil + } + data, err := os.ReadFile(path) + if err == nil && bytes.Contains(data, []byte(value)) { + return fmt.Errorf("fixture input persisted at %s", path) + } + return err + }) + if err != nil { + t.Fatal(err) + } + } + }) + t.Run("actual-old-plan-denial", func(t *testing.T) { + if baseline == "" { + t.Skip("baseline unconfigured: ordinary local coverage only, NOT downgrade acceptance") + } + cfg := h.config(t, discoveryNativeCanonical+discoveryNativeCustom, "") + out, err := h.run(t, h.bin, nil, applyArgs(cfg)...) + if err != nil { + t.Fatalf("candidate baseline state: %v\n%s", err, out) + } + source, clone := filepath.Join(h.root, "state"), filepath.Join(h.root, "state-clone") + err = filepath.WalkDir(source, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + dest := filepath.Join(clone, relative) + if entry.IsDir() { + return os.MkdirAll(dest, 0o700) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(dest, data, 0o600) + }) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(cfg) + if err != nil { + t.Fatal(err) + } + clonedCfg := filepath.Join(h.root, "clone.yaml") + discoveryWrite(t, clonedCfg, strings.Replace(string(data), strconv.Quote(source), strconv.Quote(clone), 1)) + oldPath := filepath.Join(h.root, "old-plan.json") + before, frozen := len(h.mutations(t)), h.frozenState(t, clone) + old := h.plan(t, baseline, clonedCfg, oldPath, nil) + found := false + for _, a := range old.Actions { + if a.Action == "delete" && a.Resource.Name == "custom-a" && a.Resource.Type == "alpha.widget" { + found = true + } + } + if !found { + t.Fatalf("actual old CLI must emit omission-derived custom Delete: %+v", old.Actions) + } + out, err = h.run(t, h.bin, nil, append(applyArgs(clonedCfg), "--plan", oldPath)...) + if err == nil || !bytes.Contains(out, []byte("plan stale: config hash mismatch")) { + t.Fatalf("candidate must reject actual old plan: %v\n%s", err, out) + } + if len(h.mutations(t)) != before || !reflect.DeepEqual(frozen, h.frozenState(t, clone)) { + t.Fatal("old-plan denial changed provider or cloned state") + } + current := h.plan(t, h.bin, clonedCfg, planPath, nil) + if len(current.Actions) != 0 { + t.Fatalf("candidate replan must have no omission deletes: %+v", current.Actions) + } + states, err := loadCurrentState(clonedCfg, "fixture") + if err != nil || len(states) != 3 { + t.Fatalf("all state identities must remain: %d %v", len(states), err) + } + }) +} + +const discoveryNativeImported = `modules: + - name: custom-a + type: alpha.widget + config: {iac_provider: provider-a, value: three, adopt_existing: true} + environments: + fixture: + config: + name: driver-facing-name + env_vars: {VALUE: "${TASK26_VALUE}"} + - name: custom-b + type: beta.widget + config: {provider: provider-b, value: one, adopt_existing: true} +` + +func TestIaCResourceDiscoveryNative_ReviewCorrections(t *testing.T) { + _ = discoveryVerifyBaseline(t) + h := newDiscoveryNativeHarness(t) + cfg := h.config(t, "", "imports: [routed.yaml]\nenvironments:\n fixture: {provider: \"${TASK26_R1_REF_A}\", region: local}\n") + data, err := os.ReadFile(cfg) + if err != nil { + t.Fatal(err) + } + text := strings.Replace(string(data), "provider: alpha", `provider: "${TASK26_R1_TYPE_A}"`, 1) + text = strings.Replace(text, "provider: beta", `provider: "$TASK26_R1_TYPE_B"`, 1) + discoveryWrite(t, cfg, text) + discoveryWrite(t, filepath.Join(h.root, "routed.yaml"), `modules: + - name: routed-a + type: alpha.widget + config: {iac_provider: provider-b, value: one, adopt_existing: true} + environments: + fixture: + config: + iac_provider: "${TASK26_R1_REF_A}" + env_vars: {VALUE: "${TASK26_R1_VALUE}"} + - name: routed-b + type: beta.widget + config: {provider: "$TASK26_R1_REF_B", value: one, adopt_existing: true} + - name: routed-default + type: alpha.widget + config: {value: one, adopt_existing: true} +`) + extra := []string{"TASK26_R1_TYPE_A=alpha", "TASK26_R1_TYPE_B=beta", "TASK26_R1_REF_A=provider-a", "TASK26_R1_REF_B=provider-b", "TASK26_R1_VALUE=task26-routing-private-a"} + args := []string{"infra", "apply", "--config", cfg, "--plugin-dir", h.plugins, "--env", "fixture", "--auto-approve", "--skip-bootstrap", "--skip-refresh"} + planPath := filepath.Join(h.root, "routed-plan.json") + t.Run("R1-interpolated-routing", func(t *testing.T) { + plan := h.plan(t, h.bin, cfg, planPath, extra) + if !reflect.DeepEqual(discoveryPlanNames(plan), []string{"routed-a", "routed-b", "routed-default"}) || len(plan.InputSnapshot) != 3 { + t.Fatal("actual CLI must discover both owners/default and fingerprint raw routing/payload refs") + } + for _, name := range []string{"TASK26_R1_REF_A", "TASK26_R1_REF_B", "TASK26_R1_VALUE"} { + if plan.InputSnapshot[name] == "" { + t.Fatalf("missing raw reference fingerprint %s", name) + } + } + data, err := os.ReadFile(planPath) + if err != nil || !bytes.Contains(data, []byte("${TASK26_R1_VALUE}")) || bytes.Contains(data, []byte("task26-routing-private-a")) { + t.Fatal("routing classification expanded preserved payload bytes") + } + out, err := h.run(t, h.bin, extra, append(args, "--plan", planPath)...) + if err != nil || len(h.mutations(t)) != 3 { + t.Fatalf("unchanged interpolated saved apply: %v\n%s", err, out) + } + for _, event := range h.mutations(t) { + owner, ref := "alpha", "provider-a" + if event.Name == "routed-b" { + owner, ref = "beta", "provider-b" + } + if event.Op != "Create" || event.Owner != owner || event.Ref != ref || event.ProviderID != owner+"/"+event.Name { + t.Fatal("effective routing redirected actual driver ownership") + } + } + states, err := loadCurrentState(cfg, "fixture") + if err != nil || len(states) != 3 { + t.Fatalf("actual state reload after interpolated routing: %d %v", len(states), err) + } + frozen := h.frozenState(t, filepath.Join(h.root, "state")) + for _, change := range []struct{ before, after, marker string }{ + {"TASK26_R1_VALUE=task26-routing-private-a", "TASK26_R1_VALUE=task26-routing-private-b", "TASK26_R1_VALUE"}, + {"TASK26_R1_REF_A=provider-a", "TASK26_R1_REF_A=provider-b", "not declared by selected provider"}, + } { + changed := append([]string(nil), extra...) + for i := range changed { + if changed[i] == change.before { + changed[i] = change.after + } + } + out, err := h.run(t, h.bin, changed, append(args, "--plan", planPath)...) + if err == nil || !bytes.Contains(out, []byte(change.marker)) || bytes.Contains(out, []byte("task26-routing-private-")) { + t.Fatalf("interpolated drift must fail without payload bytes: %v\n%s", err, out) + } + if len(h.mutations(t)) != 3 || !reflect.DeepEqual(frozen, h.frozenState(t, filepath.Join(h.root, "state"))) { + t.Fatal("interpolated drift changed state or performed CUD") + } + } + if plan := h.plan(t, h.bin, cfg, planPath, extra); len(plan.Actions) != 0 { + t.Fatal("interpolated replan after reload must not omit or redirect resources") + } + }) + t.Run("R2-unneeded-metadata", func(t *testing.T) { + before := len(h.mutations(t)) + dir := filepath.Join(h.plugins, "unrelated") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + manifest := filepath.Join(dir, "plugin.json") + for _, unreadable := range []bool{false, true} { + if unreadable { + if err := os.Remove(manifest); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(manifest, 0o700); err != nil { + t.Fatal(err) + } + } else { + discoveryWrite(t, manifest, "{broken") + } + h.plan(t, h.bin, cfg, planPath, extra) + out, err := h.run(t, h.bin, extra, append(args, "--dry-run", "--format", "json")...) + var dry DryRunApplyPlan + if err != nil || json.Unmarshal(out, &dry) != nil || !reflect.DeepEqual(dry.Providers, []DryRunProviderGroup{{ModuleRef: "provider-a", ProviderType: "${TASK26_R1_TYPE_A}", ResourceCount: 2}, {ModuleRef: "provider-b", ProviderType: "$TASK26_R1_TYPE_B", ResourceCount: 1}}) || len(h.mutations(t)) != before { + t.Fatalf("unneeded metadata vetoed actual SDK plan/dry-run or changed CUD: %v\n%s", err, out) + } + } + discoveryWrite(t, filepath.Join(h.plugins, "fixture-alpha", "plugin.json"), `{"iacProvider":{"name":"alpha","resourceTypes":[42]}}`) + out, err := h.run(t, h.bin, extra, args...) + if err == nil || !bytes.Contains(out, []byte("invalid IaC manifest metadata")) || len(h.mutations(t)) != before { + t.Fatalf("needed metadata must deny before CUD: %v\n%s", err, out) + } + }) +} + +// The only simulated component is the external dependency. Workflow owns +// planning, persistence and execution; SDK owns registration and transport. +const discoveryNativeSource = `package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + pluginpkg "github.com/GoCodeAlone/workflow/plugin" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const owner="FIXTURE_OWNER" +type event struct { PID int; Owner,Op,Type,Name,Ref,ProviderID string } +type resource struct { Name,Type,ID,Value string } +type provider struct { + pb.UnimplementedIaCProviderRequiredServer + pb.UnimplementedResourceDriverServer + pb.UnimplementedResourceSensitiveInputDeclarerServer + mu sync.Mutex + root,ref,mode string +} +func (p *provider) record(op,typ,name,id string)error{ + file,err:=os.OpenFile(filepath.Join(p.root,owner+".jsonl"),os.O_CREATE|os.O_WRONLY|os.O_APPEND,0600) + if err!=nil{return err};defer file.Close() + return json.NewEncoder(file).Encode(event{os.Getpid(),owner,op,typ,name,p.ref,id}) +} +func (p *provider) Initialize(_ context.Context,req *pb.InitializeRequest)(*pb.InitializeResponse,error){ + p.mu.Lock();defer p.mu.Unlock() + var cfg struct{Root,Ref string} + if err:=json.Unmarshal(req.ConfigJson,&cfg);err!=nil{return nil,err} + if cfg.Root!=p.root{return nil,status.Error(codes.InvalidArgument,"fixture root mismatch")} + p.ref=cfg.Ref + if err:=p.record("Initialize","","","");err!=nil{return nil,err} + return &pb.InitializeResponse{},nil +} +func (*provider) Name(context.Context,*pb.NameRequest)(*pb.NameResponse,error){return &pb.NameResponse{Name:owner},nil} +func (*provider) Version(context.Context,*pb.VersionRequest)(*pb.VersionResponse,error){return &pb.VersionResponse{Version:"0.1.0"},nil} +func (p *provider) Capabilities(context.Context,*pb.CapabilitiesRequest)(*pb.CapabilitiesResponse,error){ + version:="v2";if p.mode=="v1"{version="v1"} + return &pb.CapabilitiesResponse{ComputePlanVersion:version,Capabilities:[]*pb.IaCCapabilityDeclaration{{ResourceType:owner+".widget",Operations:[]string{"create","read","update","delete"}}}},nil +} +func (p *provider) check(typ string)error{ + if p.mode=="wrong-type"||(typ!=owner+".widget"&&!(owner=="alpha"&&typ=="infra.fixture_alpha")){return status.Error(codes.InvalidArgument,"fixture type unavailable")} + return nil +} +func (p *provider) SensitiveInputPaths(_ context.Context,req *pb.ResourceSensitiveInputPathsRequest)(*pb.ResourceSensitiveInputPathsResponse,error){ + if err:=p.check(req.ResourceType);err!=nil{return nil,err};return &pb.ResourceSensitiveInputPathsResponse{},nil +} +func (p *provider) load()(map[string]resource,error){ + records:=map[string]resource{} + data,err:=os.ReadFile(filepath.Join(p.root,owner+".state.json"));if os.IsNotExist(err){return records,nil};if err!=nil{return nil,err} + err=json.Unmarshal(data,&records);return records,err +} +func (p *provider) save(records map[string]resource)error{ + data,err:=json.Marshal(records);if err!=nil{return err};return os.WriteFile(filepath.Join(p.root,owner+".state.json"),data,0600) +} +func output(r resource)*pb.ResourceOutput{ + data,_:=json.Marshal(struct{Value string}{r.Value}) + return &pb.ResourceOutput{Name:r.Name,Type:r.Type,ProviderId:r.ID,OutputsJson:data,Status:"running"} +} +func (p *provider) put(op,typ string,spec *pb.ResourceSpec,ref *pb.ResourceRef)(*pb.ResourceOutput,error){ + if err:=p.check(typ);err!=nil{return nil,err} + if spec==nil||spec.Type!=typ||spec.Name==""{return nil,status.Error(codes.InvalidArgument,"fixture identity mismatch")} + var cfg struct{Value string};if err:=json.Unmarshal(spec.ConfigJson,&cfg);err!=nil{return nil,err} + records,err:=p.load();if err!=nil{return nil,err} + if op=="Update"{ + current,ok:=records[spec.Name] + if !ok||ref==nil||ref.Name!=spec.Name||ref.Type!=typ||ref.ProviderId!=current.ID{return nil,status.Error(codes.InvalidArgument,"fixture update identity mismatch")} + }else if _,ok:=records[spec.Name];ok{return nil,status.Error(codes.AlreadyExists,"fixture already exists")} + r:=resource{Name:spec.Name,Type:typ,ID:owner+"/"+spec.Name,Value:cfg.Value};records[r.Name]=r + if err:=p.save(records);err!=nil{return nil,err};if err:=p.record(op,typ,r.Name,r.ID);err!=nil{return nil,err} + return output(r),nil +} +func (p *provider) Create(_ context.Context,req *pb.ResourceCreateRequest)(*pb.ResourceCreateResponse,error){ + p.mu.Lock();defer p.mu.Unlock();out,err:=p.put("Create",req.ResourceType,req.Spec,nil);return &pb.ResourceCreateResponse{Output:out},err +} +func (p *provider) Update(_ context.Context,req *pb.ResourceUpdateRequest)(*pb.ResourceUpdateResponse,error){ + p.mu.Lock();defer p.mu.Unlock();out,err:=p.put("Update",req.ResourceType,req.Spec,req.Ref);return &pb.ResourceUpdateResponse{Output:out},err +} +func (p *provider) Read(_ context.Context,req *pb.ResourceReadRequest)(*pb.ResourceReadResponse,error){ + p.mu.Lock();defer p.mu.Unlock() + if err:=p.check(req.ResourceType);err!=nil{return nil,err} + if req.Ref==nil||req.Ref.Type!=req.ResourceType{return nil,status.Error(codes.InvalidArgument,"fixture read identity mismatch")} + if err:=p.record("Read",req.ResourceType,req.Ref.Name,req.Ref.ProviderId);err!=nil{return nil,err} + records,err:=p.load();if err!=nil{return nil,err} + r,ok:=records[req.Ref.Name];if !ok{return nil,status.Error(codes.NotFound,"iac: resource not found")} + if r.Type!=req.ResourceType||(req.Ref.ProviderId!=""&&req.Ref.ProviderId!=r.ID){return nil,status.Error(codes.InvalidArgument,"fixture read identity mismatch")} + return &pb.ResourceReadResponse{Output:output(r)},nil +} +func (p *provider) Diff(_ context.Context,req *pb.ResourceDiffRequest)(*pb.ResourceDiffResponse,error){ + p.mu.Lock();defer p.mu.Unlock() + if err:=p.check(req.ResourceType);err!=nil{return nil,err} + if req.Desired==nil||req.Desired.Type!=req.ResourceType{return nil,status.Error(codes.InvalidArgument,"fixture diff identity mismatch")} + if err:=p.record("Diff",req.ResourceType,req.Desired.Name,"");err!=nil{return nil,err} + var desired,current struct{Value string} + if err:=json.Unmarshal(req.Desired.ConfigJson,&desired);err!=nil{return nil,err} + if req.Current!=nil{ + if req.Current.Name!=req.Desired.Name||req.Current.Type!=req.ResourceType{return nil,status.Error(codes.InvalidArgument,"fixture diff output identity mismatch")} + if err:=json.Unmarshal(req.Current.OutputsJson,¤t);err!=nil{return nil,err} + } + return &pb.ResourceDiffResponse{Result:&pb.DiffResult{NeedsUpdate:desired.Value!=current.Value}},nil +} +func (p *provider) Delete(_ context.Context,req *pb.ResourceDeleteRequest)(*pb.ResourceDeleteResponse,error){ + p.mu.Lock();defer p.mu.Unlock() + if err:=p.check(req.ResourceType);err!=nil{return nil,err} + if req.Ref==nil||req.Ref.Type!=req.ResourceType{return nil,status.Error(codes.InvalidArgument,"fixture delete identity mismatch")} + records,err:=p.load();if err!=nil{return nil,err} + current,ok:=records[req.Ref.Name];if !ok||current.ID!=req.Ref.ProviderId{return nil,status.Error(codes.NotFound,"iac: resource not found")} + delete(records,req.Ref.Name) + if err:=p.save(records);err!=nil{return nil,err} + if err:=p.record("Delete",req.ResourceType,req.Ref.Name,req.Ref.ProviderId);err!=nil{return nil,err} + return &pb.ResourceDeleteResponse{},nil +} +func (*provider) SensitiveKeys(context.Context,*pb.SensitiveKeysRequest)(*pb.SensitiveKeysResponse,error){return &pb.SensitiveKeysResponse{},nil} +type generic struct{manifest *pluginpkg.PluginManifest} +func (g generic) Manifest()sdk.PluginManifest{return sdk.PluginManifest{Name:g.manifest.Name,Version:g.manifest.Version,Author:g.manifest.Author,Description:g.manifest.Description}} +type requiredOnly struct{pb.IaCProviderRequiredServer} +func main(){ + manifest,err:=pluginpkg.LoadManifest(filepath.Join(filepath.Dir(os.Args[0]),"plugin.json"));if err!=nil{panic(err)} + p:=&provider{root:os.Getenv("TASK26_FIXTURE_ROOT")} + if owner=="alpha"{p.mode=os.Getenv("TASK26_ALPHA_MODE")} + if err:=p.record("Start","","","");err!=nil{panic(err)} + opts:=sdk.IaCServeOptions{ManifestProvider:manifest} + if p.mode=="no-required"{sdk.Serve(generic{manifest});return} + if p.mode=="no-driver"{sdk.ServeIaCPlugin(requiredOnly{p},opts);return} + sdk.ServeIaCPlugin(p,opts) +} +` diff --git a/cmd/wfctl/infra_resource_discovery_test.go b/cmd/wfctl/infra_resource_discovery_test.go new file mode 100644 index 00000000..171ed342 --- /dev/null +++ b/cmd/wfctl/infra_resource_discovery_test.go @@ -0,0 +1,555 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/plugin" +) + +func discoveryWrite(t *testing.T, path, data string) { + t.Helper() + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} + +func discoveryPlugin(t *testing.T, root, directory, owner string, types ...string) { + t.Helper() + dir := filepath.Join(root, directory) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(plugin.PluginManifest{ + Name: directory, Version: "0.1.0", + IaCProvider: plugin.IaCProviderCapability{Name: owner, ResourceTypes: types, ComputePlanVersion: "v2"}, + }) + if err != nil { + t.Fatal(err) + } + discoveryWrite(t, filepath.Join(dir, "plugin.json"), string(data)) +} + +func discoveryFixture(t *testing.T, resources string) (string, string) { + t.Helper() + root := t.TempDir() + plugins := filepath.Join(root, "plugins") + discoveryPlugin(t, plugins, "fixture-alpha", "alpha", "alpha.widget", "shared.widget") + discoveryPlugin(t, plugins, "fixture-beta", "beta", "beta.widget", "shared.widget") + prev := currentInfraPluginDir + currentInfraPluginDir = plugins + t.Cleanup(func() { currentInfraPluginDir = prev }) + path := filepath.Join(root, "infra.yaml") + discoveryWrite(t, path, "modules:\n"+ + " - name: provider-a\n type: iac.provider\n config: {provider: alpha}\n"+ + " - name: provider-b\n type: iac.provider\n config: {provider: beta}\n"+resources) + return path, plugins +} + +const discoveryCustomResources = ` - name: custom-a + type: alpha.widget + config: {iac_provider: provider-a} + - name: custom-b + type: beta.widget + config: {provider: provider-b} +` + +func discoverySpecNames(specs []interfaces.ResourceSpec) []string { + names := make([]string, 0, len(specs)) + for _, spec := range specs { + names = append(names, spec.Name) + } + return names +} + +func TestIaCResourceDiscovery_Extraction(t *testing.T) { + for _, tt := range []struct { + name, resources string + want []string + }{ + {"custom-only", discoveryCustomResources, []string{"custom-a", "custom-b"}}, + {"mixed", " - name: canonical\n type: infra.database\n config: {provider: provider-a}\n" + discoveryCustomResources, []string{"canonical", "custom-a", "custom-b"}}, + {"ordinary-provider-key", " - name: application\n type: http.server\n config: {provider: application-backend}\n", []string{}}, + {"shared-type-selected", " - name: shared\n type: shared.widget\n config: {iac_provider: provider-b}\n", []string{"shared"}}, + } { + t.Run(tt.name, func(t *testing.T) { + path, _ := discoveryFixture(t, tt.resources) + for _, env := range []string{"", "fixture"} { + specs, err := parseInfraResourceSpecsForEnv(path, env) + if err != nil { + t.Fatal(err) + } + if got := discoverySpecNames(specs); !reflect.DeepEqual(got, tt.want) { + t.Errorf("env %q resource names = %v; want %v", env, got, tt.want) + } + } + }) + } +} + +func TestIaCResourceDiscovery_BindingDenials(t *testing.T) { + for _, tt := range []struct{ name, typ, cfg string }{ + {"wrong-owner", "alpha.widget", "{provider: provider-b}"}, + {"missing-reference", "alpha.widget", "{}"}, + {"undeclared-reference", "alpha.widget", "{iac_provider: absent}"}, + {"unknown-explicit-type", "unknown.widget", "{iac_provider: provider-a}"}, + {"unknown-declared-provider-type", "unknown.widget", "{provider: provider-a}"}, + {"invalid-reference-type", "alpha.widget", "{iac_provider: [provider-a]}"}, + } { + t.Run(tt.name, func(t *testing.T) { + path, _ := discoveryFixture(t, " - name: denied\n type: "+tt.typ+"\n config: "+tt.cfg+"\n") + if _, err := parseInfraResourceSpecsForEnv(path, "fixture"); err == nil { + t.Fatal("explicit custom IaC intent was silently omitted or accepted") + } + if _, err := collectInfraEnvVarRefs(path, "fixture"); err == nil { + t.Fatal("input classification must return the same binding denial") + } + }) + } +} + +func TestIaCResourceDiscovery_EnvironmentAndImports(t *testing.T) { + path, _ := discoveryFixture(t, ` - name: disabled + type: alpha.widget + environments: {fixture: null} + - name: canonical + type: infra.database + config: {provider: provider-a} + environments: + fixture: + config: {name: canonical-fixture} +`) + importPath := filepath.Join(filepath.Dir(path), "custom.yaml") + discoveryWrite(t, importPath, `modules: + - name: custom + type: alpha.widget + config: + name: driver-name + env_vars: {A: "${TASK26_A}"} + env_vars_secret: {B: "$TASK26_B"} + secret_env_vars: {C: "${TASK26_A}"} + environments: + fixture: + config: + name: driver-env-name + env_vars: {D: "${TASK26_D}"} +`) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + discoveryWrite(t, path, "imports: [custom.yaml]\nenvironments:\n fixture:\n provider: provider-a\n region: fixture-region\n envVars: {DEFAULT_ONLY: '$TASK26_DEFAULT_ONLY'}\n"+string(data)) + specs, err := parseInfraResourceSpecsForEnv(path, "fixture") + if err != nil { + t.Fatal(err) + } + if len(specs) != 2 { + t.Fatalf("resolved/imported resource count = %d; want 2", len(specs)) + } + for _, spec := range specs { + if spec.Type == "infra.database" && spec.Name != "canonical-fixture" { + t.Errorf("non-container canonical name lift lost: %q", spec.Name) + } + if spec.Type == "alpha.widget" { + if spec.Name != "custom" || spec.Config["name"] != "driver-env-name" { + t.Errorf("custom driver config.name must not become resource identity: %+v", spec) + } + if resolveIaCProviderRef(spec.Config) != "provider-a" || spec.Config["region"] != "fixture-region" { + t.Errorf("custom resource lost effective defaults: %+v", spec) + } + ev := spec.Config["env_vars"].(map[string]any) + if ev["A"] != "${TASK26_A}" || ev["DEFAULT_ONLY"] != nil { + t.Errorf("preserved refs/container-only defaults changed: %v", ev) + } + } + } + refs, err := collectInfraEnvVarRefs(path, "fixture") + if err != nil { + t.Fatal(err) + } + if want := []string{"TASK26_A", "TASK26_B", "TASK26_D"}; !reflect.DeepEqual(refs, want) { + t.Errorf("raw custom input refs = %v; want %v", refs, want) + } + tmp, err := writeEnvResolvedConfig(path, "fixture") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(tmp) }) + resolved, err := config.LoadFromFile(tmp) + if err != nil { + t.Fatal(err) + } + for _, mod := range resolved.Modules { + if mod.Type == "alpha.widget" && (mod.Config["provider"] != "provider-a" || mod.Config["region"] != "fixture-region" || mod.Config["name"] != "driver-env-name") { + t.Errorf("CLI env writer disagrees with extraction: %+v", mod) + } + } +} + +func TestIaCResourceDiscovery_ProviderOverrideAndDisabled(t *testing.T) { + path, _ := discoveryFixture(t, ` - name: custom + type: beta.widget + config: {provider: provider-a} + environments: + fixture: {provider: provider-b} +`) + specs, err := parseInfraResourceSpecsForEnv(path, "fixture") + if err != nil || len(specs) != 1 { + t.Fatalf("env provider override: specs=%v err=%v", specs, err) + } + path, _ = discoveryFixture(t, discoveryCustomResources) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + discoveryWrite(t, path, strings.Replace(string(data), "config: {provider: alpha}", "config: {provider: alpha}\n environments: {fixture: null}", 1)) + if _, err := parseInfraResourceSpecsForEnv(path, "fixture"); err == nil { + t.Fatal("active custom resource must not silently reference disabled provider") + } +} + +func TestIaCResourceDiscovery_RawOwnerConflict(t *testing.T) { + path, plugins := discoveryFixture(t, " - name: custom\n type: beta.widget\n config: {iac_provider: provider-a}\n") + discoveryWrite(t, filepath.Join(plugins, "fixture-alpha", "plugin.json"), `{ + "name":"fixture-alpha","version":"0.1.0", + "iacProvider":{"name":"alpha","resourceTypes":["alpha.widget"],"computePlanVersion":"v2"}, + "capabilities":{"iacProvider":{"name":"beta","resourceTypes":["beta.widget"]}} +}`) + for _, owner := range []string{"alpha", "beta"} { + if _, _, _, err := findIaCPluginDir(plugins, owner); err == nil { + t.Errorf("raw unequal owners must fail before normalization for selection %q", owner) + } + } + if _, err := parseInfraResourceSpecsForEnv(path, ""); err == nil { + t.Fatal("raw beta types must not become alpha-owned through normalized merging") + } +} + +func TestIaCResourceDiscovery_ManifestLookup(t *testing.T) { + t.Run("top-level-and-legacy", func(t *testing.T) { + _, plugins := discoveryFixture(t, "") + name, ver, _, err := findIaCPluginDir(plugins, "alpha") + if err != nil || name != "fixture-alpha" || ver != "v2" { + t.Fatalf("top-level minimal manifest lookup: %q %q %v", name, ver, err) + } + discoveryWrite(t, filepath.Join(plugins, "fixture-alpha", "plugin.json"), `{"capabilities":{"iacProvider":{"name":"alpha","resourceTypes":["alpha.widget"]}},"iacProvider":{"computePlanVersion":"v2"}}`) + name, _, _, err = findIaCPluginDir(plugins, "alpha") + if err != nil || name != "fixture-alpha" { + t.Fatalf("legacy minimal manifest lookup: %q %v", name, err) + } + }) + t.Run("duplicate-owner", func(t *testing.T) { + _, plugins := discoveryFixture(t, "") + discoveryPlugin(t, plugins, "another-alpha", "alpha", "alpha.widget") + if _, _, _, err := findIaCPluginDir(plugins, "alpha"); err == nil { + t.Fatal("multiple installed owners must not select the first directory") + } + }) + t.Run("malformed-needed", func(t *testing.T) { + path, plugins := discoveryFixture(t, discoveryCustomResources) + discoveryWrite(t, filepath.Join(plugins, "fixture-alpha", "plugin.json"), `{"iacProvider":{"name":"alpha","resourceTypes":[41]}}`) + if _, err := parseInfraResourceSpecsForEnv(path, ""); err == nil { + t.Fatal("malformed needed metadata must not silently omit custom resources") + } + }) + t.Run("duplicate-provider-module", func(t *testing.T) { + path, _ := discoveryFixture(t, " - name: provider-a\n type: iac.provider\n config: {provider: beta}\n"+discoveryCustomResources) + if _, err := parseInfraResourceSpecsForEnv(path, ""); err == nil { + t.Fatal("duplicate provider identity must fail rather than overwrite") + } + }) + t.Run("missing-selected-owner", func(t *testing.T) { + path, plugins := discoveryFixture(t, discoveryCustomResources) + if err := os.Remove(filepath.Join(plugins, "fixture-alpha", "plugin.json")); err != nil { + t.Fatal(err) + } + if _, err := parseInfraResourceSpecs(path); err == nil || !strings.Contains(err.Error(), "no installed plugin") { + t.Fatalf("missing selected declaration must not omit custom intent: %v", err) + } + }) + t.Run("malformed-no-payload", func(t *testing.T) { + path, plugins := discoveryFixture(t, discoveryCustomResources) + discoveryWrite(t, filepath.Join(plugins, "fixture-alpha", "plugin.json"), `{"iacProvider":{"name":"alpha","resourceTypes":"TASK26_PRIVATE_METADATA"}}`) + _, err := parseInfraResourceSpecs(path) + if err == nil || strings.Contains(err.Error(), "TASK26_PRIVATE_METADATA") { + t.Fatalf("invalid metadata must fail without payload bytes: %v", err) + } + }) + t.Run("duplicate-types-are-not-ambiguous", func(t *testing.T) { + path, plugins := discoveryFixture(t, discoveryCustomResources) + discoveryPlugin(t, plugins, "fixture-alpha", "alpha", "alpha.widget", "alpha.widget") + specs, err := parseInfraResourceSpecs(path) + if err != nil || len(specs) != 2 { + t.Fatalf("duplicate type strings normalize for classification: %v %v", specs, err) + } + }) +} + +func TestIaCResourceDiscovery_LegacyAndPrecedence(t *testing.T) { + path, _ := discoveryFixture(t, " - name: custom\n type: alpha.widget\n config: {iac_provider: provider-a, provider: implementation-name}\n") + specs, err := parseInfraResourceSpecs(path) + if err != nil || len(specs) != 1 || resolveIaCProviderRef(specs[0].Config) != "provider-a" { + t.Fatalf("iac_provider precedence: specs=%v err=%v", specs, err) + } + path = filepath.Join(t.TempDir(), "legacy.yaml") + discoveryWrite(t, path, "modules:\n - {name: canonical, type: infra.database}\n - {name: legacy, type: platform.kubernetes}\n - {name: ordinary, type: http.server}\n") + specs, err = parseInfraResourceSpecs(path) + if err != nil || !reflect.DeepEqual(discoverySpecNames(specs), []string{"canonical", "legacy"}) { + t.Fatalf("canonical no-provider compatibility: %v %v", specs, err) + } +} + +func TestIaCResourceDiscovery_InputRefs(t *testing.T) { + path, _ := discoveryFixture(t, ` - name: custom + type: alpha.widget + config: + iac_provider: provider-a + env_vars: {A: "${TASK26_A}"} + env_vars_secret: {B: "$TASK26_B"} + secret_env_vars: {C: "${TASK26_A}"} +`) + refs, err := collectInfraEnvVarRefs(path, "") + if err != nil { + t.Fatal(err) + } + if want := []string{"TASK26_A", "TASK26_B"}; !reflect.DeepEqual(refs, want) { + t.Fatalf("custom input refs = %v; want %v", refs, want) + } +} + +func TestIaCResourceDiscovery_EnvWriter(t *testing.T) { + path, _ := discoveryFixture(t, " - name: custom\n type: alpha.widget\n config: {iac_provider: provider-a}\n") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + discoveryWrite(t, path, "environments:\n fixture: {provider: provider-a, region: local}\n"+string(data)) + tmp, err := writeEnvResolvedConfig(path, "fixture") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(tmp) }) + cfg, err := config.LoadFromFile(tmp) + if err != nil { + t.Fatal(err) + } + for _, mod := range cfg.Modules { + if mod.Type == "alpha.widget" && mod.Config["region"] != "local" { + t.Fatal("custom IaC defaults missing from CLI environment writer") + } + } +} + +// R1: classification uses effective routing, but payloads and fingerprints +// must still come from the unexpanded configuration. +func TestIaCResourceDiscovery_R1EffectiveRouting(t *testing.T) { + for _, tc := range []struct { + name, routing, overrides, defaults string + rawKey, rawValue string + wantRefs []string + }{ + {"iac-braced", `iac_provider: "${TASK26_R1_REF}"`, "", "", "iac_provider", "${TASK26_R1_REF}", []string{"TASK26_R1_PAYLOAD", "TASK26_R1_REF"}}, + {"iac-bare", `iac_provider: "$TASK26_R1_REF"`, "", "", "iac_provider", "$TASK26_R1_REF", []string{"TASK26_R1_PAYLOAD", "TASK26_R1_REF"}}, + {"provider-braced", `provider: "${TASK26_R1_REF}"`, "", "", "provider", "${TASK26_R1_REF}", []string{"TASK26_R1_PAYLOAD", "TASK26_R1_REF"}}, + {"provider-bare", `provider: "$TASK26_R1_REF"`, "", "", "provider", "$TASK26_R1_REF", []string{"TASK26_R1_PAYLOAD", "TASK26_R1_REF"}}, + {"iac-precedence", `iac_provider: "${TASK26_R1_REF}", provider: provider-b`, "", "", "iac_provider", "${TASK26_R1_REF}", []string{"TASK26_R1_PAYLOAD", "TASK26_R1_REF"}}, + {"env-iac-override", "iac_provider: provider-b", " environments:\n fixture:\n config: {iac_provider: \"$TASK26_R1_REF\"}\n", "", "iac_provider", "$TASK26_R1_REF", []string{"TASK26_R1_PAYLOAD", "TASK26_R1_REF"}}, + {"env-provider-override", "provider: provider-b", " environments:\n fixture: {provider: \"${TASK26_R1_REF}\"}\n", "", "provider", "${TASK26_R1_REF}", []string{"TASK26_R1_PAYLOAD", "TASK26_R1_REF"}}, + {"default-braced", "", "", "environments:\n fixture: {provider: \"${TASK26_R1_REF}\", region: local}\n", "provider", "${TASK26_R1_REF}", []string{"TASK26_R1_PAYLOAD"}}, + {"default-bare", "", "", "environments:\n fixture: {provider: \"$TASK26_R1_REF\", region: local}\n", "provider", "$TASK26_R1_REF", []string{"TASK26_R1_PAYLOAD"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("TASK26_R1_REF", "provider-a") + t.Setenv("TASK26_R1_TYPE", "alpha") + t.Setenv("TASK26_R1_PAYLOAD", "task26-routing-private-canary") + path, _ := discoveryFixture(t, "") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + header := strings.Replace(string(data), "provider: alpha", `provider: "${TASK26_R1_TYPE}"`, 1) + discoveryWrite(t, path, tc.defaults+"imports: [resource.yaml]\n"+header) + routing := tc.routing + if routing != "" { + routing += ", " + } + discoveryWrite(t, filepath.Join(filepath.Dir(path), "resource.yaml"), "modules:\n - name: routed\n type: alpha.widget\n config: {"+routing+`env_vars: {A: "${TASK26_R1_PAYLOAD}"}, env_vars_secret: {B: "$TASK26_R1_PAYLOAD"}, secret_env_vars: {C: "${TASK26_R1_PAYLOAD}"}}`+"\n"+tc.overrides) + for _, entry := range []string{"extraction", "writer", "inputs"} { + t.Run(entry, func(t *testing.T) { + switch entry { + case "extraction": + specs, err := parseInfraResourceSpecsForEnv(path, "fixture") + if err != nil || len(specs) != 1 { + t.Fatalf("effective routing must extract imported resource: count=%d err=%v", len(specs), err) + } + if resolveIaCProviderRef(specs[0].Config) != "provider-a" || specs[0].Config["env_vars"].(map[string]any)["A"] != "${TASK26_R1_PAYLOAD}" { + t.Fatal("effective routing or preserved payload differs") + } + case "writer": + tmp, err := writeEnvResolvedConfig(path, "fixture") + if err != nil { + t.Fatalf("effective routing must allow env writer: %v", err) + } + defer os.Remove(tmp) + cfg, err := config.LoadFromFile(tmp) + if err != nil { + t.Fatal(err) + } + found := false + for _, mod := range cfg.Modules { + if mod.Name == "routed" { + found = true + if mod.Config[tc.rawKey] != tc.rawValue || mod.Config["env_vars"].(map[string]any)["A"] != "${TASK26_R1_PAYLOAD}" { + t.Fatal("classification expanded raw writer configuration") + } + } + } + if !found { + t.Fatal("writer omitted imported custom resource") + } + case "inputs": + refs, err := collectInfraEnvVarRefs(path, "fixture") + if err != nil || !reflect.DeepEqual(refs, tc.wantRefs) { + t.Fatalf("raw routing/payload fingerprints: refs=%v err=%v", refs, err) + } + // Defaults retain the existing raw-module-only input scan. + snapshot, err := computeInfraInputSnapshot(path, "fixture") + if err != nil || len(snapshot) != len(tc.wantRefs) || snapshot["TASK26_R1_PAYLOAD"] == "" { + t.Fatalf("raw input snapshot: %v", err) + } + encoded, err := json.Marshal(snapshot) + if err != nil || strings.Contains(string(encoded), "task26-routing-private-canary") { + t.Fatal("snapshot contains payload instead of fingerprint") + } + } + }) + } + }) + } +} + +// R2: metadata that cannot claim the needed owner is not valid authority and +// must not veto unrelated modules or another valid selected owner. +func TestIaCResourceDiscovery_R2UnneededMetadata(t *testing.T) { + for _, damage := range []string{"malformed", "unreadable", "other-owner-invalid-types", "unneeded-duplicate-beta"} { + t.Run(damage, func(t *testing.T) { + path, plugins := discoveryFixture(t, "") + dir := filepath.Join(plugins, "unrelated") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + manifest := filepath.Join(dir, "plugin.json") + switch damage { + case "malformed": + discoveryWrite(t, manifest, "{broken") + case "unreadable": + if err := os.Mkdir(manifest, 0o700); err != nil { + t.Fatal(err) + } + case "other-owner-invalid-types": + discoveryWrite(t, manifest, `{"iacProvider":{"name":"unrelated","resourceTypes":[42]}}`) + case "unneeded-duplicate-beta": + discoveryPlugin(t, plugins, "duplicate-beta", "beta", "beta.widget") + } + for _, selected := range []bool{false, true} { + t.Run(fmt.Sprintf("selected=%t", selected), func(t *testing.T) { + resources := "modules:\n - {name: canonical, type: infra.database}\n - {name: application, type: http.server, config: {provider: app-backend}}\n" + want := []string{"canonical"} + if selected { + resources = "modules:\n - {name: provider-a, type: iac.provider, config: {provider: alpha}}\n - {name: custom, type: alpha.widget, config: {iac_provider: provider-a}}\n - {name: application, type: http.server}\n" + want = []string{"custom"} + } + discoveryWrite(t, path, resources) + for _, entry := range []string{"extraction", "writer", "inputs", "lookup"} { + t.Run(entry, func(t *testing.T) { + switch entry { + case "extraction": + for _, env := range []string{"", "fixture"} { + specs, err := parseInfraResourceSpecsForEnv(path, env) + if err != nil || !reflect.DeepEqual(discoverySpecNames(specs), want) { + t.Fatalf("unneeded metadata vetoed resources: count=%d err=%v", len(specs), err) + } + } + case "writer": + tmp, err := writeEnvResolvedConfig(path, "fixture") + if err != nil { + t.Fatalf("unneeded metadata vetoed writer: %v", err) + } + defer os.Remove(tmp) + case "inputs": + if _, err := collectInfraEnvVarRefs(path, "fixture"); err != nil { + t.Fatalf("unneeded metadata vetoed inputs: %v", err) + } + case "lookup": + name, _, _, err := findIaCPluginDir(plugins, "alpha") + if err != nil || name != "fixture-alpha" { + t.Fatalf("unneeded metadata vetoed valid alpha lookup: name=%q err=%v", name, err) + } + } + }) + } + }) + } + }) + } +} + +func TestIaCResourceDiscovery_R2NeededDamage(t *testing.T) { + for _, damage := range []string{"selected-types", "duplicate-valid", "duplicate-invalid", "invalid-top-known-legacy", "raw-owner-conflict", "unreadable-needed", "malformed-needed", "missing-default-owner"} { + t.Run(damage, func(t *testing.T) { + path, plugins := discoveryFixture(t, " - {name: custom, type: alpha.widget, config: {iac_provider: provider-a}}\n") + selected := filepath.Join(plugins, "fixture-alpha", "plugin.json") + switch damage { + case "selected-types": + discoveryWrite(t, selected, `{"iacProvider":{"name":"alpha","resourceTypes":[42]}}`) + case "duplicate-valid": + discoveryPlugin(t, plugins, "duplicate", "alpha", "alpha.widget") + case "duplicate-invalid": + discoveryPlugin(t, plugins, "duplicate", "alpha", "alpha.widget") + discoveryWrite(t, filepath.Join(plugins, "duplicate", "plugin.json"), `{"iacProvider":{"name":"alpha","resourceTypes":[42]}}`) + case "invalid-top-known-legacy": + discoveryPlugin(t, plugins, "duplicate", "alpha", "alpha.widget") + discoveryWrite(t, filepath.Join(plugins, "duplicate", "plugin.json"), `{"iacProvider":{"name":42},"capabilities":{"iacProvider":{"name":"alpha","resourceTypes":["alpha.widget"]}}}`) + case "raw-owner-conflict": + discoveryWrite(t, selected, `{"iacProvider":{"name":"beta","resourceTypes":["beta.widget"]},"capabilities":{"iacProvider":{"name":"alpha","resourceTypes":["alpha.widget"]}}}`) + case "unreadable-needed": + if err := os.Remove(selected); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(selected, 0o700); err != nil { + t.Fatal(err) + } + case "malformed-needed": + discoveryWrite(t, selected, "{broken") + case "missing-default-owner": + if err := os.Remove(selected); err != nil { + t.Fatal(err) + } + discoveryWrite(t, path, "environments:\n fixture: {provider: provider-a}\nmodules:\n - {name: provider-a, type: iac.provider, config: {provider: alpha}}\n - {name: custom, type: alpha.widget}\n") + } + if _, err := parseInfraResourceSpecsForEnv(path, "fixture"); err == nil { + t.Fatal("needed metadata damage permitted extraction") + } + if _, _, _, err := findIaCPluginDir(plugins, "alpha"); err == nil && damage != "missing-default-owner" { + t.Fatal("needed metadata damage permitted executable selection") + } + }) + } +} + +func TestIaCResourceDiscovery_R2InvalidTypesCannotAuthorize(t *testing.T) { + path, plugins := discoveryFixture(t, " - {name: custom, type: malformed.widget, config: {iac_provider: provider-a}}\n") + discoveryPlugin(t, plugins, "unrelated", "unrelated", "malformed.widget") + discoveryWrite(t, filepath.Join(plugins, "unrelated", "plugin.json"), `{"iacProvider":{"name":"unrelated","resourceTypes":["malformed.widget",42]}}`) + _, err := parseInfraResourceSpecs(path) + if err == nil || !strings.Contains(err.Error(), "not declared by selected provider") { + t.Fatalf("invalid unrelated types are not valid selected-owner authority: %v", err) + } +} diff --git a/docs/WFCTL.md b/docs/WFCTL.md index d25f8c39..03081a7f 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -2116,6 +2116,47 @@ wfctl infra apply --dry-run --plugin-dir /tmp/ci-plugins -c infra.yaml wfctl infra plan --plugin-dir /tmp/ci-plugins -c infra.yaml ``` +#### Provider-Declared Resource Types + +In addition to legacy `infra.*` and `platform.*` modules, `infra plan` and +`infra apply` discover exact resource types declared by installed IaC provider +manifests. A custom resource must select a declared `iac.provider` module using +`config.iac_provider` (preferred) or `config.provider`; the selected provider +must advertise that exact type. Environment provider defaults and overrides, +disabled resources/providers, and imported configurations use the same +classification as direct apply, saved-plan apply and dry-run summaries. +Routing references and defaults use the existing `${VAR}` / `$VAR` +environment expansion to select the provider without expanding or mutating +raw resource payloads and preserved input references. + +Discovery never selects a provider by resource prefix or by the union of +installed types. Missing, ambiguous, malformed or conflicting **needed** +provider declarations fail closed. Unrelated malformed or unreadable installed +metadata cannot veto canonical/application modules or a valid selected +provider, and damaged metadata never authorizes discovery. Ordinary application +modules are not promoted by an unrelated `provider` key. Installed metadata +enables discovery only: runtime typed-service, v2 capability, driver, resource identity and ownership checks +still govern execution. Legacy no-provider canonical planning and public +prefix-based helpers remain compatible; `platform.*` pipeline routing is not +mixed with direct custom-resource application. + +Custom resources retain provider-owned `config.name` fields. Existing +`infra.*` environment name lifting is separate from the container-only +top-level `envVars` defaults. Preserved custom input references, including +`env_vars`, `env_vars_secret` and `secret_env_vars`, contribute fingerprints to +saved plans. Changing their values rejects saved-plan apply before driver +mutation without disclosing values. The existing limitation for references +originating solely in top-level environment `envVars` defaults is unchanged. + +Before custom state exists, a prior immutable release may be retained with a +fail-closed renderer. After custom state exists, downgrading means **freeze +automatic and manual apply/delete**, preserve configuration/state/provider +identity, and resume only with a verified discovery-capable release. An older +binary may omit custom desired resources and propose their deletion; never +apply that omission-derived plan with the old binary. A discovery-capable +consumer rejects that stale desired hash, but cannot make continued management +by the older binary safe. + #### `infra test` `wfctl infra test` validates infrastructure expectations without contacting live diff --git a/iac/wfctlhelpers/env_resolve.go b/iac/wfctlhelpers/env_resolve.go index 23f9047a..64c6c242 100644 --- a/iac/wfctlhelpers/env_resolve.go +++ b/iac/wfctlhelpers/env_resolve.go @@ -20,6 +20,18 @@ import ( // 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) { + return WriteEnvResolvedConfigWithClassifier(cfgFile, envName, func(mod *config.ResolvedModule) (bool, error) { + return IsInfraType(mod.Type), nil + }) +} + +// WriteEnvResolvedConfigWithClassifier applies resource defaults using an +// explicit caller-owned classifier. It never discovers installed plugins. +// The caller must remove the returned temporary file. +func WriteEnvResolvedConfigWithClassifier(cfgFile, envName string, classify func(*config.ResolvedModule) (bool, error)) (tmpPath string, err error) { + if classify == nil { + return "", fmt.Errorf("resource classifier is required") + } cfg, err := config.LoadFromFile(cfgFile) if err != nil { return "", fmt.Errorf("load %s: %w", cfgFile, err) @@ -37,7 +49,11 @@ func WriteEnvResolvedConfig(cfgFile, envName string) (tmpPath string, err error) if !ok { continue } - if topEnv != nil && IsInfraType(rm.Type) { + resource, err := classify(rm) + if err != nil { + return "", err + } + if topEnv != nil && resource { if rm.Region == "" { rm.Region = topEnv.Region if rm.Region != "" { diff --git a/iac/wfctlhelpers/env_resolve_test.go b/iac/wfctlhelpers/env_resolve_test.go new file mode 100644 index 00000000..a544ec8f --- /dev/null +++ b/iac/wfctlhelpers/env_resolve_test.go @@ -0,0 +1,130 @@ +package wfctlhelpers + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/GoCodeAlone/workflow/config" +) + +func envClassifierFixture(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "infra.yaml") + data := `environments: + fixture: + provider: selected + region: local + envVars: {DEFAULT: "${TOP_ONLY}"} +modules: + - name: canonical + type: infra.database + environments: + fixture: + config: {name: canonical-fixture} + - name: container + type: infra.container_service + - name: custom + type: example.widget + config: {name: driver-name, env_vars: {VALUE: "${PRESERVED}"}} + environments: + fixture: + config: {name: driver-fixture-name} + - name: ordinary + type: http.server + - name: disabled + type: example.widget + environments: {fixture: null} +` + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestWriteEnvResolvedConfigWithClassifier(t *testing.T) { + path := envClassifierFixture(t) + calls := 0 + tmp, err := WriteEnvResolvedConfigWithClassifier(path, "fixture", func(mod *config.ResolvedModule) (bool, error) { + calls++ + return IsInfraType(mod.Type) || mod.Type == "example.widget", nil + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(tmp) }) + cfg, err := config.LoadFromFile(tmp) + if err != nil { + t.Fatal(err) + } + if calls != 4 || len(cfg.Modules) != 4 { + t.Fatalf("disabled resolution/callbacks: calls=%d modules=%d", calls, len(cfg.Modules)) + } + for _, mod := range cfg.Modules { + switch mod.Type { + case "example.widget": + if mod.Name != "custom" || mod.Config["name"] != "driver-fixture-name" { + t.Errorf("custom config.name changed identity: %+v", mod) + } + if mod.Config["provider"] != "selected" || mod.Config["region"] != "local" { + t.Errorf("classified defaults missing: %+v", mod) + } + ev := mod.Config["env_vars"].(map[string]any) + if ev["VALUE"] != "${PRESERVED}" || ev["DEFAULT"] != nil { + t.Errorf("custom refs or container-only defaults changed: %v", ev) + } + case "infra.database": + if mod.Name != "canonical-fixture" || mod.Config["name"] != nil { + t.Errorf("non-container infra-prefix name lift lost: %+v", mod) + } + case "infra.container_service": + if mod.Config["env_vars"].(map[string]any)["DEFAULT"] != "${TOP_ONLY}" { + t.Fatal("container defaults lost") + } + case "http.server": + if mod.Config["provider"] != nil || mod.Config["region"] != nil { + t.Fatal("ordinary module received IaC defaults") + } + } + } +} + +func TestWriteEnvResolvedConfig_LegacyClassifier(t *testing.T) { + path := envClassifierFixture(t) + tmp, err := WriteEnvResolvedConfig(path, "fixture") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(tmp) }) + cfg, err := config.LoadFromFile(tmp) + if err != nil { + t.Fatal(err) + } + for _, mod := range cfg.Modules { + if mod.Type == "example.widget" && (mod.Config["provider"] != nil || mod.Config["region"] != nil) { + t.Fatal("public legacy helper must not discover custom plugin types") + } + } + if !IsInfraType("infra.database") || !IsInfraType("platform.kubernetes") || IsInfraType("example.widget") { + t.Fatal("public prefix contract changed") + } +} + +func TestWriteEnvResolvedConfigWithClassifier_Error(t *testing.T) { + path := envClassifierFixture(t) + sentinel := errors.New("selected provider denied") + tmp, err := WriteEnvResolvedConfigWithClassifier(path, "fixture", func(*config.ResolvedModule) (bool, error) { + return false, sentinel + }) + if tmp != "" || !errors.Is(err, sentinel) { + t.Fatalf("callback failure must propagate without a temp config: path=%q err=%v", tmp, err) + } + files, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".wfctl-env-resolved-*")) + if err != nil || len(files) != 0 { + t.Fatalf("failed classification leaked temp files: %v %v", files, err) + } + if _, err := WriteEnvResolvedConfigWithClassifier(path, "fixture", nil); err == nil { + t.Fatal("nil classifier must fail") + } +} From 5ea61d7d39d1faab2bc4f25693edecca4e2b8813 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 4 Oct 2026 04:57:09 -0400 Subject: [PATCH 2/2] test: remove native fixture race exit padding --- .../infra_resource_discovery_native_test.go | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/cmd/wfctl/infra_resource_discovery_native_test.go b/cmd/wfctl/infra_resource_discovery_native_test.go index 3a9350d8..6f9c49f1 100644 --- a/cmd/wfctl/infra_resource_discovery_native_test.go +++ b/cmd/wfctl/infra_resource_discovery_native_test.go @@ -169,6 +169,38 @@ func (h *discoveryNativeHarness) reap(t *testing.T) { t.Logf("owned SDK PID readback: %d children absent after CLI wait/manager shutdown", len(pids)) } +func discoveryNativeEnv(root, racePolicy string) []string { + env := []string{"LC_ALL=C", "CI=true", "WFCTL_NO_UPDATE_CHECK=1", "HOME=" + root, "TASK26_FIXTURE_ROOT=" + root} + // Preserve race reporting; fixture exit sleep must not consume the package budget. + env = append(env, "GORACE="+racePolicy+" atexit_sleep_ms=0") + if path := os.Getenv("PATH"); path != "" { + env = append(env, "PATH="+path) + } + return env +} + +func TestIaCResourceDiscovery_FixtureRaceOptions(t *testing.T) { + for _, tc := range []struct{ name, policy string }{ + {"empty", ""}, + {"exit-code", "exitcode=77"}, + {"halt-on-error", "halt_on_error=1"}, + {"existing-exit-sleep", "exitcode=87 halt_on_error=1 strip_path_prefix=/task26/report-policy atexit_sleep_ms=1000"}, + } { + t.Run(tc.name, func(t *testing.T) { + var policies []string + for _, variable := range discoveryNativeEnv(t.TempDir(), tc.policy) { + if value, ok := strings.CutPrefix(variable, "GORACE="); ok { + policies = append(policies, value) + } + } + want := tc.policy + " atexit_sleep_ms=0" + if len(policies) != 1 || policies[0] != want { + t.Fatalf("fixture must preserve race reporting policy and disable only exit sleep: got %q, want %q", policies, want) + } + }) + } +} + func (h *discoveryNativeHarness) run(t *testing.T, binary string, extra []string, args ...string) ([]byte, error) { t.Helper() entries, err := os.ReadDir(h.plugins) @@ -190,10 +222,7 @@ func (h *discoveryNativeHarness) run(t *testing.T, binary string, extra []string defer cancel() cmd := exec.CommandContext(ctx, binary, args...) cmd.Dir, cmd.WaitDelay = h.root, 5*time.Second - cmd.Env = []string{"LC_ALL=C", "CI=true", "WFCTL_NO_UPDATE_CHECK=1", "HOME=" + h.root, "TASK26_FIXTURE_ROOT=" + h.root} - if path := os.Getenv("PATH"); path != "" { - cmd.Env = append(cmd.Env, "PATH="+path) - } + cmd.Env = discoveryNativeEnv(h.root, os.Getenv("GORACE")) cmd.Env = append(cmd.Env, extra...) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr