diff --git a/cmd/wfctl/deploy_providers.go b/cmd/wfctl/deploy_providers.go index 395c96a9..613ecbfc 100644 --- a/cmd/wfctl/deploy_providers.go +++ b/cmd/wfctl/deploy_providers.go @@ -75,6 +75,13 @@ func newDeployProvider(provider string, wfCfg *config.WorkflowConfig, envName st // they may return nil for the closer. var resolveIaCProvider = discoverAndLoadIaCProvider +// currentInfraPluginDir is the per-invocation plugin directory override set by +// infra commands that accept -plugin-dir. It takes precedence over the +// WFCTL_PLUGIN_DIR environment variable and the default "./data/plugins" path. +// Set at the top of each runInfra* function and reset via defer, matching the +// 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 @@ -166,7 +173,10 @@ func findIaCPluginDir(pluginDir, providerName string) (name, computePlanVersion // that do not register the typed IaCProviderRequired service are // rejected at load time with an actionable upgrade message. func discoverAndLoadIaCProvider(ctx context.Context, providerName string, cfg map[string]any) (interfaces.IaCProvider, io.Closer, error) { - pluginDir := os.Getenv("WFCTL_PLUGIN_DIR") + pluginDir := currentInfraPluginDir + if pluginDir == "" { + pluginDir = os.Getenv("WFCTL_PLUGIN_DIR") + } if pluginDir == "" { pluginDir = "./data/plugins" } diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 644ebc40..5c05a19a 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -243,9 +243,14 @@ func runInfraPlan(args []string) error { var planIncludeFlag string fs.StringVar(&planIncludeFlag, "include", "", "Comma-separated list of resource names to scope this command to (filters both desired specs and current state)") + var pluginDirFlag string + fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") if err := fs.Parse(args); err != nil { return err } + prevInfraPluginDir := currentInfraPluginDir + currentInfraPluginDir = pluginDirFlag + defer func() { currentInfraPluginDir = prevInfraPluginDir }() format := &formatVal output := &outputVal showSensitive := showSensitiveVal @@ -936,9 +941,14 @@ func runInfraImport(args []string) error { fs.StringVar(&envName, "env", "", "Environment name") fs.StringVar(&nameVal, "name", "", "Desired resource name from config") fs.StringVar(&cloudIDVal, "id", "", "Cloud-provider resource ID") + var pluginDirFlag string + fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") if err := fs.Parse(args); err != nil { return err } + prevInfraPluginDir := currentInfraPluginDir + currentInfraPluginDir = pluginDirFlag + defer func() { currentInfraPluginDir = prevInfraPluginDir }() cfgFile, err := resolveInfraConfig(fs, configFile) if err != nil { return err @@ -1187,6 +1197,8 @@ func runInfraApply(args []string) error { var includeFlag string fs.StringVar(&includeFlag, "include", "", "Comma-separated list of resource names to scope this command to (filters both desired specs and current state)") + var pluginDirFlag string + fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") autoApprove := &autoApproveVal showSensitive := showSensitiveVal if err := fs.Parse(args); err != nil { @@ -1225,6 +1237,12 @@ func runInfraApply(args []string) error { currentApplyIncludeFlag = includeFlag defer func() { currentApplyIncludeFlag = "" }() + // Publish the --plugin-dir override so discoverAndLoadIaCProvider picks it + // up for this invocation. Reset after the command exits. + prevInfraPluginDir := currentInfraPluginDir + currentInfraPluginDir = pluginDirFlag + defer func() { currentInfraPluginDir = prevInfraPluginDir }() + cfgFile := configFlag if cfgFile == "" { var err error @@ -1264,6 +1282,9 @@ func runInfraApply(args []string) error { if envName != "" { bootstrapArgs = append(bootstrapArgs, "--env", envName) } + if pluginDirFlag != "" { + bootstrapArgs = append(bootstrapArgs, "--plugin-dir", pluginDirFlag) + } if err := runInfraBootstrap(bootstrapArgs); err != nil { return fmt.Errorf("bootstrap: %w", err) } @@ -1521,9 +1542,14 @@ func runInfraStatus(args []string) error { fs.StringVar(&configFile, "c", "", "Config file (short for --config)") var envName string fs.StringVar(&envName, "env", "", "Environment name (resolves per-module environments: overrides)") + var pluginDirFlag string + fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") if err := fs.Parse(args); err != nil { return err } + prevInfraPluginDir := currentInfraPluginDir + currentInfraPluginDir = pluginDirFlag + defer func() { currentInfraPluginDir = prevInfraPluginDir }() cfgFile, err := resolveInfraConfig(fs, configFile) if err != nil { @@ -1556,9 +1582,14 @@ func runInfraDrift(args []string) error { fs.StringVar(&configFile, "c", "", "Config file (short for --config)") var envName string fs.StringVar(&envName, "env", "", "Environment name (resolves per-module environments: overrides)") + var pluginDirFlag string + fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") if err := fs.Parse(args); err != nil { return err } + prevInfraPluginDir := currentInfraPluginDir + currentInfraPluginDir = pluginDirFlag + defer func() { currentInfraPluginDir = prevInfraPluginDir }() cfgFile, err := resolveInfraConfig(fs, configFile) if err != nil { @@ -1594,10 +1625,15 @@ func runInfraDestroy(args []string) error { fs.BoolVar(&autoApproveVal, "y", false, "Skip confirmation (short for --auto-approve)") var envName string fs.StringVar(&envName, "env", "", "Environment name (resolves per-module environments: overrides)") + var pluginDirFlag string + fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") autoApprove := &autoApproveVal if err := fs.Parse(args); err != nil { return err } + prevInfraPluginDir := currentInfraPluginDir + currentInfraPluginDir = pluginDirFlag + defer func() { currentInfraPluginDir = prevInfraPluginDir }() cfgFile := configFlag if cfgFile == "" { diff --git a/cmd/wfctl/infra_align.go b/cmd/wfctl/infra_align.go index a435b1a5..67729b38 100644 --- a/cmd/wfctl/infra_align.go +++ b/cmd/wfctl/infra_align.go @@ -40,9 +40,14 @@ func runInfraAlign(args []string) error { fs.BoolVar(&opts.strictHealth, "strict-health", false, "Treat R-A2 health-check WARNs as FAILs") fs.BoolVar(&opts.strictCIDR, "strict-cidr", false, "Enable strict CIDR overlap checks (reserved for future use)") fs.IntVar(&opts.maxChanges, "max-changes", 50, "Warn when plan has more than N changes") + var pluginDirFlag string + fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") if err := fs.Parse(args); err != nil { return err } + prevInfraPluginDir := currentInfraPluginDir + currentInfraPluginDir = pluginDirFlag + defer func() { currentInfraPluginDir = prevInfraPluginDir }() // Resolve config file if opts.configFile == "" { diff --git a/cmd/wfctl/infra_audit_keys.go b/cmd/wfctl/infra_audit_keys.go index c07aa761..4cd067d1 100644 --- a/cmd/wfctl/infra_audit_keys.go +++ b/cmd/wfctl/infra_audit_keys.go @@ -143,12 +143,17 @@ func runInfraAuditKeysCmd(args []string) error { fs.StringVar(&configFile, "c", "", "Config file (short for --config)") fs.StringVar(&envName, "env", "", "Environment name for config resolution") fs.StringVar(&resourceType, "type", "", "Resource type to enumerate (e.g. infra.spaces_key)") + var pluginDirFlag string + fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") if err := fs.Parse(args); err != nil { return err } if resourceType == "" { return fmt.Errorf("audit-keys: --type is required") } + prevInfraPluginDir := currentInfraPluginDir + currentInfraPluginDir = pluginDirFlag + defer func() { currentInfraPluginDir = prevInfraPluginDir }() ctx := context.Background() providers, closers, err := auditKeysLoadProviders(ctx, fs, configFile, envName) diff --git a/cmd/wfctl/infra_bootstrap.go b/cmd/wfctl/infra_bootstrap.go index af28e3e5..eced1050 100644 --- a/cmd/wfctl/infra_bootstrap.go +++ b/cmd/wfctl/infra_bootstrap.go @@ -56,9 +56,14 @@ func runInfraBootstrap(args []string) error { fs.StringVar(&envName, "env", "", "Environment name (resolves per-module environments: overrides)") var rotateNames multiStringFlag fs.Var(&rotateNames, "force-rotate", "Comma-separated list of secret names to regenerate, replacing existing values. Repeatable. Use for recovery from known-bad secrets (empty value, leak, etc).") + var pluginDirFlag string + fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") if err := fs.Parse(args); err != nil { return err } + prevInfraPluginDir := currentInfraPluginDir + currentInfraPluginDir = pluginDirFlag + defer func() { currentInfraPluginDir = prevInfraPluginDir }() cfgFile, err := resolveInfraConfig(fs, configFile) if err != nil { diff --git a/cmd/wfctl/infra_cleanup.go b/cmd/wfctl/infra_cleanup.go index e8e0ac15..d163326e 100644 --- a/cmd/wfctl/infra_cleanup.go +++ b/cmd/wfctl/infra_cleanup.go @@ -65,6 +65,8 @@ func runInfraCleanup(args []string) error { //nolint:cyclop tag := fs.String("tag", "", "tag to match resources for cleanup (required)") dryRun := fs.Bool("dry-run", true, "preview only; do not delete resources (default: true)") fix := fs.Bool("fix", false, "actually delete resources (overrides --dry-run)") + var pluginDirFlag string + fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") if err := fs.Parse(args); err != nil { return err @@ -77,6 +79,10 @@ func runInfraCleanup(args []string) error { //nolint:cyclop // default invariant (cleanup is destructive: never delete without --fix). *dryRun = !*fix + prevInfraPluginDir := currentInfraPluginDir + currentInfraPluginDir = pluginDirFlag + defer func() { currentInfraPluginDir = prevInfraPluginDir }() + ctx := context.Background() providers, closers, err := cleanupLoadProviders(ctx, fs, configFile, envName) if err != nil { diff --git a/cmd/wfctl/infra_plugin_dir_test.go b/cmd/wfctl/infra_plugin_dir_test.go new file mode 100644 index 00000000..3755fc48 --- /dev/null +++ b/cmd/wfctl/infra_plugin_dir_test.go @@ -0,0 +1,282 @@ +package main + +// TestInfraCommandsAcceptPluginDirFlag verifies that all infra subcommands that +// load external plugins accept the -plugin-dir flag without a parse error, and +// that the flag value is threaded through to discoverAndLoadIaCProvider via the +// currentInfraPluginDir seam variable. +// +// Each test: +// 1. Writes a minimal infra.yaml with an iac.provider module so the command +// actually reaches the provider-loading code path. +// 2. Overrides resolveIaCProvider to capture the currentInfraPluginDir value +// at the moment the provider is loaded, and returns an error so the command +// fails fast without executing real cloud operations. +// 3. Invokes the command with -plugin-dir set to a sentinel value. +// 4. Asserts the sentinel was visible inside resolveIaCProvider — proving that +// the flag set currentInfraPluginDir before the first provider load. + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +const pluginDirSentinel = "/tmp/test-plugin-dir-sentinel" + +// minimalInfraYAML returns a minimal infra.yaml with one iac.provider that +// references provider type "testprovider", plus one infra resource that +// references it. This is enough to trigger provider loading for plan/apply/etc. +func minimalInfraYAML(t *testing.T) string { + t.Helper() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "infra.yaml") + yaml := `name: test-app +modules: + - name: myprovider + type: iac.provider + config: + provider: testprovider + - name: myvpc + type: infra.vpc + config: + provider: myprovider + region: us-east-1 +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0o600); err != nil { + t.Fatalf("write infra.yaml: %v", err) + } + return cfgPath +} + +// capturePluginDirAndFail installs a resolveIaCProvider override that records +// the currentInfraPluginDir at call time into *got and returns a sentinel +// error so the command exits immediately without any real provider work. +// The returned restore function must be deferred by the caller. +func capturePluginDirAndFail(got *string) (restore func()) { + orig := resolveIaCProvider + resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + *got = currentInfraPluginDir + return nil, nil, errSentinelProviderLoad + } + return func() { resolveIaCProvider = orig } +} + +// errSentinelProviderLoad is the fast-exit error injected by the test override. +// Declared as a package-level var so assertions can use strings.Contains without +// hardcoding the literal message. +var errSentinelProviderLoad = infraErrSentinel("sentinel provider load error for plugin-dir test") + +type infraErrSentinel string + +func (e infraErrSentinel) Error() string { return string(e) } + +// TestInfraPlanAcceptsPluginDirFlag verifies -plugin-dir is accepted by +// runInfraPlan and sets currentInfraPluginDir before provider loading. +func TestInfraPlanAcceptsPluginDirFlag(t *testing.T) { + cfgPath := minimalInfraYAML(t) + var got string + defer capturePluginDirAndFail(&got)() + + err := runInfraPlan([]string{"--config", cfgPath, "--plugin-dir", pluginDirSentinel}) + // The command must fail (provider won't load) but must NOT fail because the + // flag was unknown. + if err == nil { + t.Fatal("expected error (sentinel provider load), got nil") + } + if strings.Contains(err.Error(), "flag provided but not defined") { + t.Fatalf("-plugin-dir not accepted by runInfraPlan: %v", err) + } + if got != pluginDirSentinel { + t.Errorf("currentInfraPluginDir inside resolveIaCProvider = %q; want %q", got, pluginDirSentinel) + } +} + +// TestInfraApplyAcceptsPluginDirFlag verifies -plugin-dir is accepted by +// runInfraApply and sets currentInfraPluginDir before provider loading. +func TestInfraApplyAcceptsPluginDirFlag(t *testing.T) { + cfgPath := minimalInfraYAML(t) + var got string + defer capturePluginDirAndFail(&got)() + + err := runInfraApply([]string{"--config", cfgPath, "--plugin-dir", pluginDirSentinel, "--auto-approve"}) + if err == nil { + t.Fatal("expected error (sentinel provider load), got nil") + } + if strings.Contains(err.Error(), "flag provided but not defined") { + t.Fatalf("-plugin-dir not accepted by runInfraApply: %v", err) + } + if got != pluginDirSentinel { + t.Errorf("currentInfraPluginDir inside resolveIaCProvider = %q; want %q", got, pluginDirSentinel) + } +} + +// TestInfraApplyDryRunAcceptsPluginDirFlag verifies -plugin-dir works with +// --dry-run (which goes through the same currentInfraPluginDir seam since the +// var is set before the dry-run early-return branch). +func TestInfraApplyDryRunAcceptsPluginDirFlag(t *testing.T) { + cfgPath := minimalInfraYAML(t) + var got string + defer capturePluginDirAndFail(&got)() + + err := runInfraApply([]string{"--config", cfgPath, "--plugin-dir", pluginDirSentinel, "--dry-run"}) + if err == nil { + t.Fatal("expected error (sentinel provider load), got nil") + } + if strings.Contains(err.Error(), "flag provided but not defined") { + t.Fatalf("-plugin-dir not accepted by runInfraApply --dry-run: %v", err) + } + if got != pluginDirSentinel { + t.Errorf("currentInfraPluginDir inside resolveIaCProvider = %q; want %q", got, pluginDirSentinel) + } +} + +// TestInfraPluginDirResetsAfterCommand verifies that currentInfraPluginDir is +// reset to "" after a runInfraApply invocation completes (via defer), so +// subsequent commands in the same process see the clean default. +func TestInfraPluginDirResetsAfterCommand(t *testing.T) { + cfgPath := minimalInfraYAML(t) + orig := resolveIaCProvider + resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return nil, nil, errSentinelProviderLoad + } + defer func() { resolveIaCProvider = orig }() + + _ = runInfraApply([]string{"--config", cfgPath, "--plugin-dir", pluginDirSentinel, "--auto-approve"}) + + if currentInfraPluginDir != "" { + t.Errorf("currentInfraPluginDir not reset after runInfraApply; got %q", currentInfraPluginDir) + } +} + +// TestInfraRefreshOutputsAcceptsPluginDirFlag verifies -plugin-dir is accepted +// by runInfraRefreshOutputs and sets currentInfraPluginDir before provider loading. +func TestInfraRefreshOutputsAcceptsPluginDirFlag(t *testing.T) { + // refresh-outputs needs a state with at least one entry to reach + // provider loading. Build a config with an iac.provider + filesystem state + // backend and seed one resource entry via resolveStateStore/SaveResource so + // the refresh path actually dispatches to resolveIaCProvider. + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(dir, "infra.yaml") + cfgYAML := `name: test-app +modules: + - name: myprovider + type: iac.provider + config: + provider: testprovider + - name: mystate + type: iac.state + config: + backend: filesystem + directory: ` + stateDir + ` +` + if err := os.WriteFile(cfgPath, []byte(cfgYAML), 0o600); err != nil { + t.Fatalf("write cfg: %v", err) + } + // Seed the state backend using the canonical store API so the record is + // in the format the filesystem backend actually reads. + store, err := resolveStateStore(cfgPath, "") + if err != nil { + t.Fatalf("resolveStateStore: %v", err) + } + if err := store.SaveResource(context.Background(), interfaces.ResourceState{ + ID: "myvpc", Name: "myvpc", Type: "infra.vpc", + Provider: "testprovider", ProviderRef: "myprovider", + ProviderID: "vpc-123", ConfigHash: "abc", + AppliedConfig: map[string]any{}, Outputs: map[string]any{}, + }); err != nil { + t.Fatalf("seed state: %v", err) + } + + var got string + defer capturePluginDirAndFail(&got)() + + err = runInfraRefreshOutputs([]string{"--config", cfgPath, "--plugin-dir", pluginDirSentinel}) + if err == nil { + t.Fatal("expected error (sentinel provider load), got nil") + } + if strings.Contains(err.Error(), "flag provided but not defined") { + t.Fatalf("-plugin-dir not accepted by runInfraRefreshOutputs: %v", err) + } + if got != pluginDirSentinel { + t.Errorf("currentInfraPluginDir inside resolveIaCProvider = %q; want %q", got, pluginDirSentinel) + } +} + +// TestInfraBootstrapAcceptsPluginDirFlag verifies -plugin-dir is accepted by +// runInfraBootstrap. Bootstrap only reaches resolveIaCProvider when there are +// provider_credential secrets to force-rotate; for flag-acceptance we just need +// the parse to succeed (no "flag provided but not defined" error). +func TestInfraBootstrapAcceptsPluginDirFlag(t *testing.T) { + cfgPath := minimalInfraYAMLWithState(t) + + err := runInfraBootstrap([]string{"--config", cfgPath, "--plugin-dir", pluginDirSentinel}) + // Bootstrap may succeed (no secrets to generate) or fail for config reasons, + // but must NOT fail with "flag provided but not defined". + if err != nil && strings.Contains(err.Error(), "flag provided but not defined") { + t.Fatalf("-plugin-dir not accepted by runInfraBootstrap: %v", err) + } +} + +// minimalInfraYAMLWithState is like minimalInfraYAML but also adds an iac.state +// module backed by a temp filesystem directory so commands like bootstrap that +// require a state backend can proceed past config validation. +func minimalInfraYAMLWithState(t *testing.T) string { + t.Helper() + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + if err := os.MkdirAll(stateDir, 0o750); err != nil { + t.Fatalf("mkdir state: %v", err) + } + cfgPath := filepath.Join(dir, "infra.yaml") + yaml := `name: test-app +modules: + - name: myprovider + type: iac.provider + config: + provider: testprovider + - name: mystate + type: iac.state + config: + backend: filesystem + directory: ` + stateDir + ` + - name: myvpc + type: infra.vpc + config: + provider: myprovider + region: us-east-1 +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0o600); err != nil { + t.Fatalf("write infra.yaml: %v", err) + } + return cfgPath +} +func TestDiscoverAndLoadIaCProvider_UsesCurrentInfraPluginDir(t *testing.T) { + // Set a custom sentinel dir (no plugins in it → specific error message). + customDir := t.TempDir() + currentInfraPluginDir = customDir + defer func() { currentInfraPluginDir = "" }() + + // Ensure WFCTL_PLUGIN_DIR points somewhere different so we can distinguish. + t.Setenv("WFCTL_PLUGIN_DIR", "/nonexistent-env-dir") + + _, _, err := discoverAndLoadIaCProvider(context.Background(), "any-provider", nil) + if err == nil { + t.Skip("unexpectedly found a provider in temp dir") + } + // The error must mention customDir, not /nonexistent-env-dir. + if !strings.Contains(err.Error(), customDir) { + t.Errorf("error %q does not mention customDir %q — currentInfraPluginDir was not consulted first", err.Error(), customDir) + } + if strings.Contains(err.Error(), "/nonexistent-env-dir") { + t.Errorf("error %q mentions WFCTL_PLUGIN_DIR value — env var took precedence over currentInfraPluginDir", err.Error()) + } +} diff --git a/cmd/wfctl/infra_prune.go b/cmd/wfctl/infra_prune.go index 713c7070..851ecc98 100644 --- a/cmd/wfctl/infra_prune.go +++ b/cmd/wfctl/infra_prune.go @@ -341,9 +341,14 @@ func runInfraPruneCmd(args []string) error { fs.BoolVar(&confirm, "confirm", false, "Confirmation flag") fs.BoolVar(&nonInteractive, "non-interactive", false, "Skip y/N prompt") fs.BoolVar(&recoveryFromLastRotation, "recovery-from-last-rotation", false, "Read recovery file") + var pluginDirFlag string + fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") if err := fs.Parse(args); err != nil { return err } + prevInfraPluginDir := currentInfraPluginDir + currentInfraPluginDir = pluginDirFlag + defer func() { currentInfraPluginDir = prevInfraPluginDir }() ctx := context.Background() providers, closers, err := pruneLoadProviders(ctx, fs, configFile, envName) diff --git a/cmd/wfctl/infra_refresh_outputs.go b/cmd/wfctl/infra_refresh_outputs.go index c8763240..804ba2de 100644 --- a/cmd/wfctl/infra_refresh_outputs.go +++ b/cmd/wfctl/infra_refresh_outputs.go @@ -41,9 +41,14 @@ func runInfraRefreshOutputs(args []string) error { // values < 1 as "use default" so callers passing an explicit 0 (or // negative) keep working; this default just makes `--help` honest. fs.IntVar(&concurrency, "concurrency", 8, "Maximum concurrent Read calls") + var pluginDirFlag string + fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") if err := fs.Parse(args); err != nil { return err } + prevInfraPluginDir := currentInfraPluginDir + currentInfraPluginDir = pluginDirFlag + defer func() { currentInfraPluginDir = prevInfraPluginDir }() cfgFile, err := resolveInfraConfig(fs, configFile) if err != nil { diff --git a/cmd/wfctl/infra_rotate_and_prune.go b/cmd/wfctl/infra_rotate_and_prune.go index 32868878..6573d5ac 100644 --- a/cmd/wfctl/infra_rotate_and_prune.go +++ b/cmd/wfctl/infra_rotate_and_prune.go @@ -62,9 +62,14 @@ func runInfraRotateAndPruneCmd(args []string) error { // Default is true (per ADR 0023): the safer behavior IS the new default; // the legacy quota-fragile behavior is opt-out via --prune-first=false. _ = fs.Bool("prune-first", true, "Prune orphans before rotating (default true; safer at quota — see ADR 0023)") + var pluginDirFlag string + fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") if err := fs.Parse(args); err != nil { return err } + prevInfraPluginDir := currentInfraPluginDir + currentInfraPluginDir = pluginDirFlag + defer func() { currentInfraPluginDir = prevInfraPluginDir }() ctx := context.Background() providers, closers, err := rotateAndPruneLoadProviders(ctx, fs, configFile, envName) diff --git a/docs/WFCTL.md b/docs/WFCTL.md index 1f47ea34..094dc0bf 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -1306,6 +1306,7 @@ wfctl infra [options] [config.yaml] | `--parallelism` | `10` | Number of parallel operations | | `--lock-timeout` | `0s` | Timeout for state lock acquisition | | `--force-rotate` | `` | (`bootstrap` only) Comma-separated list of secret names to regenerate, replacing existing values. Repeatable. Use to recover from known-bad secrets (empty value, leaked, dead key). Refuses `provider_credential` types. | +| `--plugin-dir` | _(env `WFCTL_PLUGIN_DIR` or `data/plugins`)_ | Override the plugin directory for plugin-loading commands (plan, apply, status, drift, destroy, import, bootstrap, refresh-outputs, cleanup, align, audit-keys, prune, rotate-and-prune). Useful for isolated CI smoke tests. | **State Subcommands:** @@ -1338,6 +1339,10 @@ wfctl infra state import --source state.json wfctl infra bootstrap -c infra.yaml --env staging --force-rotate NATS_AUTH_TOKEN wfctl infra bootstrap -c infra.yaml --env staging --force-rotate NATS_AUTH_TOKEN,DATABASE_URL wfctl infra bootstrap -c infra.yaml --force-rotate FOO --force-rotate BAR + +# Use an isolated plugin directory for CI smoke tests: +wfctl infra apply --dry-run --plugin-dir /tmp/ci-plugins -c infra.yaml +wfctl infra plan --plugin-dir /tmp/ci-plugins -c infra.yaml ``` #### `infra cleanup` @@ -1357,6 +1362,7 @@ wfctl infra cleanup --tag NAME [-c CONFIG] [--env ENV] [--dry-run | --fix] | `--env` | `` | Environment name for config and state resolution | | `--dry-run` | `true` | Preview only — list matched resources without deleting. | | `--fix` | `false` | Opt into deletion. Overrides `--dry-run`. | +| `--plugin-dir` | _(env `WFCTL_PLUGIN_DIR` or `data/plugins`)_ | Override the plugin directory for this invocation. Useful for isolated CI smoke tests. | **Behaviour:** @@ -1408,6 +1414,7 @@ wfctl infra apply [-c CONFIG] [--env ENV] [--auto-approve] [--plan FILE] | `--allow-protected-prune` | `false` | Allow pruning state entries for resources marked `protected: true` (requires `--refresh`) | | `--skip-refresh` | `false` | Skip the `WFCTL_REFRESH_OUTPUTS` pre-step refresh even if the env var is set | | `--allow-replace` | `` | Comma-separated list of resource names whose `protected: true` status is overridden for this apply (replace/delete actions only) | +| `--plugin-dir` | _(env `WFCTL_PLUGIN_DIR` or `data/plugins`)_ | Override the plugin directory for this invocation. Useful for isolated CI smoke tests. | **Protected-resource gate:** @@ -1586,6 +1593,7 @@ wfctl infra align [--config ] [--env ] [--plan ] [--strict | `--strict-health` | `false` | Treat `R-A2` health-check `WARN`s as `FAIL` | | `--strict-cidr` | `false` | Enable strict CIDR overlap checks (reserved) | | `--max-changes` | `50` | Warn when the plan has more than N actions | +| `--plugin-dir` | _(env `WFCTL_PLUGIN_DIR` or `data/plugins`)_ | Override the plugin directory for this invocation. Useful for isolated CI smoke tests. | | Rule | Name | Severity | |------|------|----------| @@ -1644,6 +1652,7 @@ wfctl infra audit-keys --type [-c CONFIG] [--env ENV] | `--type` | _(required)_ | Resource type to enumerate (e.g. `infra.spaces_key`) | | `--config`, `-c` | _(auto-detected)_ | Config file (searches `infra.yaml`, `config/infra.yaml`) | | `--env` | `` | Environment name for config resolution | +| `--plugin-dir` | _(env `WFCTL_PLUGIN_DIR` or `data/plugins`)_ | Override the plugin directory for this invocation. | `audit-keys` requires the loaded `iac.provider` to implement the optional `interfaces.EnumeratorAll` interface (per ADR 0016). Providers that don't @@ -1678,6 +1687,7 @@ wfctl infra prune --type --created-before --exclude-access-key --name --confirm [--non-interacti | `--preserve-names` | `` | Regex of resource names to preserve during the prune step (forwarded as `--preserve-names` to `infra prune`) | | `--confirm` | `false` | Required: paired with `WFCTL_CONFIRM_PRUNE=1` env var | | `--non-interactive` | `false` | Skip the y/N prompt (forwarded to the prune step) | +| `--plugin-dir` | _(env `WFCTL_PLUGIN_DIR` or `data/plugins`)_ | Override the plugin directory for this invocation. | ```bash WFCTL_CONFIRM_PRUNE=1 wfctl infra rotate-and-prune \