diff --git a/.github/conformance/cleanup.yaml b/.github/conformance/cleanup.yaml new file mode 100644 index 000000000..8b76e85e4 --- /dev/null +++ b/.github/conformance/cleanup.yaml @@ -0,0 +1,28 @@ +# Minimal infra config used solely by the conformance smoke gate's +# `wfctl infra cleanup --tag` step (.github/workflows/conformance-smoke.yml). +# +# Why this exists: +# `wfctl infra cleanup` loads providers from a config's `iac.provider` +# modules; when the smoke job runs at the repo root, no `infra.yaml` exists +# there. Without an explicit `--config`, cleanup would fail with +# "no infrastructure config found" once `DO_CONFORMANCE_API_TOKEN` is +# provisioned (workflow#542). Pinning this minimal config — single +# digitalocean iac.provider, no resources, no platform.* modules — gives +# the cleanup dispatcher the provider it needs to call EnumerateByTag +# without any ambient state. +# +# Token resolution: +# `${DIGITALOCEAN_TOKEN}` is expanded from the environment at apply time +# (config.ExpandEnvInMap). The smoke job exports `DIGITALOCEAN_TOKEN` +# directly from `secrets.DO_CONFORMANCE_API_TOKEN`, matching the +# canonical env var name the workflow-plugin-digitalocean iac.provider +# config consumes. +# +# Refs: workflow#536 (PR 4 Task 4.4 + Copilot review fix). + +modules: + - name: do + type: iac.provider + config: + provider: digitalocean + token: ${DIGITALOCEAN_TOKEN} diff --git a/.github/workflows/conformance-smoke.yml b/.github/workflows/conformance-smoke.yml index ac6e63dca..f528e0484 100644 --- a/.github/workflows/conformance-smoke.yml +++ b/.github/workflows/conformance-smoke.yml @@ -97,22 +97,26 @@ jobs: # success + ordinary failure paths). Uses wfctl rather than # doctl per the full-wfctl-dogfooding policy. # - # KNOWN GAP: `wfctl infra cleanup --tag ` is not yet - # implemented (tracked as a follow-up alongside T7.13 in the - # conformance-runbook). Until it lands, this step is a stub - # that logs the cleanup intent so the leak-scrubber cron - # (T7.14, runs hourly) catches anything missed. Once wfctl - # gains the command, swap this stub for the real call. + # The wiring is gated on DO_CONFORMANCE_API_TOKEN being + # provisioned (workflow#542). Until that lands the secret is + # unset; this step emits a ::notice:: indicating the wiring is + # in place but inactive, and the hourly leak-scrubber cron + # (T7.14, conformance-leak-scrubber.yml) is the active safety + # net. Once workflow#542 ships, the gate flips automatically: + # no further changes to this workflow are needed. - name: Force-delete tagged resources (cleanup safety net) if: always() env: - DO_CONFORMANCE_API_TOKEN: ${{ secrets.DO_CONFORMANCE_API_TOKEN }} + DO_TOKEN: ${{ secrets.DO_CONFORMANCE_API_TOKEN }} + # DIGITALOCEAN_TOKEN is what .github/conformance/cleanup.yaml + # expands at apply time (the canonical env var name in the + # workflow-plugin-digitalocean iac.provider config). + DIGITALOCEAN_TOKEN: ${{ secrets.DO_CONFORMANCE_API_TOKEN }} run: | set -euo pipefail + if [ -z "${DO_TOKEN:-}" ]; then + echo "::notice::DO_CONFORMANCE_API_TOKEN not provisioned (workflow#542 deferred); cleanup gate is wired but inactive. Hourly leak-scrubber catches orphans in the meantime." + exit 0 + fi echo "Cleanup target tag: ${CONFORMANCE_TAG}" - # TODO(W-7 follow-up): replace stub with - # ./wfctl infra cleanup --tag "${CONFORMANCE_TAG}" - # once the command exists. The hourly leak-scrubber - # (conformance-leak-scrubber.yml, T7.14) is the safety net - # that catches anything this stub misses. - echo "::notice::wfctl infra cleanup not yet implemented; relying on T7.14 hourly scrubber" + ./wfctl infra cleanup --config .github/conformance/cleanup.yaml --tag "${CONFORMANCE_TAG}" --fix diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index cc0ac103f..f42f52548 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -83,6 +83,8 @@ func runInfra(args []string) error { return runInfraAlign(args[1:]) case "security-check": return runInfraSecurityCheck(args[1:]) + case "cleanup": + return runInfraCleanup(args[1:]) default: return infraUsage() } @@ -105,6 +107,7 @@ Actions: refresh-outputs Read live outputs and reconcile state (no cloud writes) align Validate IaC config + plan alignment (8 rule families) security-check Scan plan.json for security policy violations + cleanup Tag-based force-cleanup across providers (--tag NAME [--fix]) Options: --config Config file (default: infra.yaml or config/infra.yaml) @@ -115,6 +118,9 @@ Options: --format Output format: table (default) or markdown (plan only) --output Write plan to JSON file (plan only) --show-sensitive/-S Show sensitive values in plaintext (plan/apply only) + --tag Tag to match resources (cleanup only; required) + --dry-run Preview only (cleanup; default true) + --fix Opt into deletion (cleanup; overrides --dry-run) `) return fmt.Errorf("missing or unknown action") } diff --git a/cmd/wfctl/infra_cleanup.go b/cmd/wfctl/infra_cleanup.go new file mode 100644 index 000000000..edeac21d0 --- /dev/null +++ b/cmd/wfctl/infra_cleanup.go @@ -0,0 +1,192 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// cleanupStdout / cleanupStderr are seam variables tests override to capture +// the subcommand's structured output without redirecting os.Stdout globally +// (other parallel tests in cmd/wfctl exercise os.Stdout for their own +// assertions; redirecting it here would race them). +var ( + cleanupStdout io.Writer = os.Stdout + cleanupStderr io.Writer = os.Stderr +) + +// cleanupLoadProviders is the seam used by runInfraCleanup to obtain the +// IaCProvider instances declared in the config's iac.provider modules. The +// default implementation walks cfg.Modules + loads each via the existing +// resolveIaCProvider plugin path; tests override this var to inject fakes +// without spinning up real plugin subprocesses. +// +// fs + cfgFile are passed through so the default implementation can defer to +// the canonical resolveInfraConfig helper (which honors --config / -c / +// auto-discovery / positional .yaml argument). envName is empty when no +// --env was passed. Tests that don't exercise the config-resolution path +// can ignore both arguments. +// +// Returned closers (one per provider, indices aligned) MAY be nil. Callers +// MUST close them after the cleanup run. +var cleanupLoadProviders = defaultCleanupLoadProviders + +// runInfraCleanup is the CLI entry point for `wfctl infra cleanup --tag`. +// It enumerates resources by tag across every loaded provider that implements +// the optional interfaces.Enumerator, and either lists them (default, +// --dry-run) or deletes them (--fix). Providers that do NOT implement +// Enumerator are skipped with a structured stdout log so operators see +// the explicit skip rather than silent under-cleanup. +// +// Exit semantics: +// - all enumerations succeed + (dry-run OR all deletes succeed): nil error +// - one or more providers' enumerate or delete fails: non-nil error joining +// all collected failures (other providers' work continues so a single +// bad-provider doesn't suppress the rest of the run). +// +// Flag shape: --dry-run defaults to true; --fix is the explicit opt-in to +// mutation. Passing --fix overrides --dry-run regardless of order. +func runInfraCleanup(args []string) error { //nolint:cyclop + fs := flag.NewFlagSet("infra cleanup", flag.ContinueOnError) + fs.SetOutput(cleanupStderr) + + var configFile string + fs.StringVar(&configFile, "config", "", "Config file (default: infra.yaml or config/infra.yaml)") + fs.StringVar(&configFile, "c", "", "Config file (short for --config)") + var envName string + fs.StringVar(&envName, "env", "", "Environment name for config and state resolution") + 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)") + + if err := fs.Parse(args); err != nil { + return err + } + if *tag == "" { + return errors.New("infra cleanup: --tag is required") + } + // --fix is the explicit opt-in to mutation; if absent, --dry-run remains + // true regardless of any explicit --dry-run=false. This keeps the safe- + // default invariant (cleanup is destructive: never delete without --fix). + *dryRun = !*fix + + ctx := context.Background() + providers, closers, err := cleanupLoadProviders(ctx, fs, configFile, envName) + if err != nil { + return fmt.Errorf("load providers: %w", err) + } + defer func() { + for _, c := range closers { + if c == nil { + continue + } + if cerr := c.Close(); cerr != nil { + fmt.Fprintf(cleanupStderr, "warning: provider shutdown: %v\n", cerr) + } + } + }() + + var totalErrs []error + for _, p := range providers { + enum, ok := p.(interfaces.Enumerator) + if !ok { + fmt.Fprintf(cleanupStdout, "skipped %s: provider does not implement Enumerator\n", p.Name()) + continue + } + refs, enumErr := enum.EnumerateByTag(ctx, *tag) + if enumErr != nil { + fmt.Fprintf(cleanupStderr, "%s: enumerate by tag %q: %v\n", p.Name(), *tag, enumErr) + totalErrs = append(totalErrs, fmt.Errorf("%s: enumerate: %w", p.Name(), enumErr)) + continue + } + if len(refs) == 0 { + fmt.Fprintf(cleanupStdout, "%s: no resources matched tag %q\n", p.Name(), *tag) + continue + } + for _, ref := range refs { + if *dryRun { + fmt.Fprintf(cleanupStdout, "[dry-run] would delete %s/%s (provider: %s)\n", ref.Type, ref.Name, p.Name()) + continue + } + drv, drvErr := p.ResourceDriver(ref.Type) + if drvErr != nil { + err := fmt.Errorf("%s: resolve driver for %s/%s: %w", p.Name(), ref.Type, ref.Name, drvErr) + fmt.Fprintln(cleanupStderr, err) + totalErrs = append(totalErrs, err) + continue + } + if delErr := drv.Delete(ctx, ref); delErr != nil { + err := fmt.Errorf("%s: delete %s/%s: %w", p.Name(), ref.Type, ref.Name, delErr) + fmt.Fprintln(cleanupStderr, err) + totalErrs = append(totalErrs, err) + continue + } + fmt.Fprintf(cleanupStdout, "deleted %s/%s (provider: %s)\n", ref.Type, ref.Name, p.Name()) + } + } + if len(totalErrs) > 0 { + return errors.Join(totalErrs...) + } + return nil +} + +// defaultCleanupLoadProviders walks the resolved config's iac.provider modules +// and loads each via the canonical resolveIaCProvider plugin path. Mirrors the +// pattern established by defaultAlignLoadProviders (R-A10) so the cleanup +// subcommand inherits the same env-resolution + plugin-discovery contract. +// +// Config resolution honors the standard precedence: explicit --config / -c +// flag → auto-discovered infra.yaml / config/infra.yaml → positional +// .yaml/.yml argument (consistent with the other infra subcommands). +// +// Best-effort behaviour: a provider that fails to load emits a stderr warning +// and is skipped (the rest of the cleanup proceeds). This matches the align +// path and prevents a single bad iac.provider entry from turning a smoke-gate +// cleanup into a hard failure that masks other providers' resources. +func defaultCleanupLoadProviders(ctx context.Context, fs *flag.FlagSet, cfgFile, envName string) ([]interfaces.IaCProvider, []io.Closer, error) { + resolved, resolveErr := resolveInfraConfig(fs, cfgFile) + if resolveErr != nil { + return nil, nil, resolveErr + } + cfg, err := config.LoadFromFile(resolved) + if err != nil { + return nil, nil, fmt.Errorf("load %s: %w", resolved, err) + } + + var providers []interfaces.IaCProvider + var closers []io.Closer + for i := range cfg.Modules { + m := &cfg.Modules[i] + if m.Type != "iac.provider" { + continue + } + var modCfg map[string]any + if envName != "" { + r, ok := m.ResolveForEnv(envName) + if !ok { + continue // disabled for this env + } + modCfg = config.ExpandEnvInMapPreservingKeys(r.Config, infraPreserveKeys) + } else { + modCfg = config.ExpandEnvInMapPreservingKeys(m.Config, infraPreserveKeys) + } + providerType, _ := modCfg["provider"].(string) + if providerType == "" { + continue + } + p, closer, loadErr := resolveIaCProvider(ctx, providerType, modCfg) + if loadErr != nil { + fmt.Fprintf(cleanupStderr, "warning: cleanup: load provider %q (%s): %v\n", m.Name, providerType, loadErr) + continue + } + providers = append(providers, p) + closers = append(closers, closer) + } + return providers, closers, nil +} diff --git a/cmd/wfctl/infra_cleanup_test.go b/cmd/wfctl/infra_cleanup_test.go new file mode 100644 index 000000000..5f45f4e0a --- /dev/null +++ b/cmd/wfctl/infra_cleanup_test.go @@ -0,0 +1,235 @@ +package main + +import ( + "bytes" + "context" + "errors" + "flag" + "io" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// fakeEnumeratingProvider is an IaCProvider that ALSO implements Enumerator. +// EnumerateByTag returns a canned slice; ResourceDriver returns a fake driver +// that records Delete calls (and may return an error per index). +type fakeEnumeratingProvider struct { + stubIaCProvider + resources []interfaces.ResourceRef + deleteCallCount int + deleteErrors map[int]error + enumerateErr error +} + +func (f *fakeEnumeratingProvider) EnumerateByTag(_ context.Context, _ string) ([]interfaces.ResourceRef, error) { + if f.enumerateErr != nil { + return nil, f.enumerateErr + } + return f.resources, nil +} + +func (f *fakeEnumeratingProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return &fakeDeleteDriver{owner: f}, nil +} + +// fakeDeleteDriver implements just enough of interfaces.ResourceDriver for +// the cleanup dispatch path: Delete is the only method exercised. Other +// methods are no-op stubs to satisfy the interface. +type fakeDeleteDriver struct{ owner *fakeEnumeratingProvider } + +func (d *fakeDeleteDriver) Create(context.Context, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + return nil, nil +} +func (d *fakeDeleteDriver) Read(context.Context, interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + return nil, nil +} +func (d *fakeDeleteDriver) Update(context.Context, interfaces.ResourceRef, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + return nil, nil +} +func (d *fakeDeleteDriver) Diff(context.Context, interfaces.ResourceSpec, *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + return nil, nil +} +func (d *fakeDeleteDriver) HealthCheck(context.Context, interfaces.ResourceRef) (*interfaces.HealthResult, error) { + return nil, nil +} +func (d *fakeDeleteDriver) Scale(context.Context, interfaces.ResourceRef, int) (*interfaces.ResourceOutput, error) { + return nil, nil +} +func (d *fakeDeleteDriver) SensitiveKeys() []string { return nil } +func (d *fakeDeleteDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { + idx := d.owner.deleteCallCount + d.owner.deleteCallCount++ + if d.owner.deleteErrors != nil { + if err, ok := d.owner.deleteErrors[idx]; ok { + return err + } + } + return nil +} + +// fakeNonEnumeratingProvider is an IaCProvider that does NOT implement +// Enumerator (uses the bare stubIaCProvider). The cleanup dispatcher must +// skip it with a structured stdout log line rather than failing. +type fakeNonEnumeratingProvider struct{ stubIaCProvider } + +// runInfraCleanupForTest invokes runInfraCleanup with a fake provider list +// and captures stdout/stderr. It overrides the cleanupLoadProviders seam so +// the test does not touch the live plugin loader / config file system. +func runInfraCleanupForTest(t *testing.T, providers []interfaces.IaCProvider, args ...string) (string, string, error) { + t.Helper() + orig := cleanupLoadProviders + t.Cleanup(func() { cleanupLoadProviders = orig }) + cleanupLoadProviders = func(_ context.Context, _ *flag.FlagSet, _, _ string) ([]interfaces.IaCProvider, []io.Closer, error) { + return providers, nil, nil + } + + var stdout, stderr bytes.Buffer + origOut, origErr := cleanupStdout, cleanupStderr + t.Cleanup(func() { cleanupStdout, cleanupStderr = origOut, origErr }) + cleanupStdout = &stdout + cleanupStderr = &stderr + + err := runInfraCleanup(args) + return stdout.String(), stderr.String(), err +} + +func TestInfraCleanup_DryRunByDefault_ListsResourcesWithoutDeleting(t *testing.T) { + fp := &fakeEnumeratingProvider{ + stubIaCProvider: stubIaCProvider{name: "do-fake"}, + resources: []interfaces.ResourceRef{ + {Name: "vpc-1", Type: "infra.vpc"}, + {Name: "db-1", Type: "infra.database"}, + }, + } + + out, _, err := runInfraCleanupForTest(t, []interfaces.IaCProvider{fp}, "--tag", "test-tag") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "vpc-1") { + t.Errorf("missing vpc-1 in output: %s", out) + } + if !strings.Contains(out, "db-1") { + t.Errorf("missing db-1 in output: %s", out) + } + if !strings.Contains(out, "[dry-run]") { + t.Errorf("expected [dry-run] marker in output: %s", out) + } + if fp.deleteCallCount != 0 { + t.Errorf("dry-run should not invoke Delete; got %d calls", fp.deleteCallCount) + } +} + +func TestInfraCleanup_FixMode_DeletesResources(t *testing.T) { + fp := &fakeEnumeratingProvider{ + stubIaCProvider: stubIaCProvider{name: "do-fake"}, + resources: []interfaces.ResourceRef{ + {Name: "vpc-1", Type: "infra.vpc"}, + {Name: "db-1", Type: "infra.database"}, + }, + } + + out, _, err := runInfraCleanupForTest(t, []interfaces.IaCProvider{fp}, "--tag", "test-tag", "--fix") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fp.deleteCallCount != 2 { + t.Errorf("expected 2 Delete calls, got %d", fp.deleteCallCount) + } + if !strings.Contains(out, "deleted") { + t.Errorf("expected 'deleted' in output: %s", out) + } + if strings.Contains(out, "[dry-run]") { + t.Errorf("--fix should not emit [dry-run] markers: %s", out) + } +} + +func TestInfraCleanup_NonEnumeratorProvider_SkipsWithStructuredLog(t *testing.T) { + fp := &fakeNonEnumeratingProvider{stubIaCProvider: stubIaCProvider{name: "non-enum"}} + + out, _, err := runInfraCleanupForTest(t, []interfaces.IaCProvider{fp}, "--tag", "test-tag") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "skipped") || !strings.Contains(out, "non-enum") || !strings.Contains(out, "Enumerator") { + t.Errorf("expected skip log mentioning provider and Enumerator; got: %s", out) + } +} + +func TestInfraCleanup_PartialFailure_ReturnsError(t *testing.T) { + fp := &fakeEnumeratingProvider{ + stubIaCProvider: stubIaCProvider{name: "do-fake"}, + resources: []interfaces.ResourceRef{ + {Name: "vpc-1", Type: "infra.vpc"}, + {Name: "db-1", Type: "infra.database"}, + }, + // Second Delete fails (idx 1). + deleteErrors: map[int]error{1: errors.New("simulated failure")}, + } + + _, _, err := runInfraCleanupForTest(t, []interfaces.IaCProvider{fp}, "--tag", "test-tag", "--fix") + if err == nil { + t.Errorf("expected non-nil error on partial failure") + } + if fp.deleteCallCount != 2 { + t.Errorf("expected dispatcher to attempt all 2 deletes despite mid-run failure; got %d", fp.deleteCallCount) + } +} + +func TestInfraCleanup_EnumerateError_ReturnsErrorAndContinuesOtherProviders(t *testing.T) { + failing := &fakeEnumeratingProvider{ + stubIaCProvider: stubIaCProvider{name: "fail"}, + enumerateErr: errors.New("simulated enumerate fail"), + } + working := &fakeEnumeratingProvider{ + stubIaCProvider: stubIaCProvider{name: "ok"}, + resources: []interfaces.ResourceRef{{Name: "ok-1", Type: "infra.compute"}}, + } + + out, _, err := runInfraCleanupForTest(t, []interfaces.IaCProvider{failing, working}, "--tag", "test-tag") + if err == nil { + t.Errorf("expected non-nil error from failing enumerator") + } + if !strings.Contains(out, "ok-1") { + t.Errorf("expected output to include resources from the working provider despite the failing one: %s", out) + } +} + +func TestInfraCleanup_TagRequired(t *testing.T) { + _, _, err := runInfraCleanupForTest(t, nil) + if err == nil { + t.Errorf("expected non-nil error when --tag is missing") + } + if err != nil && !strings.Contains(err.Error(), "--tag") { + t.Errorf("expected error to mention --tag; got: %v", err) + } +} + +// TestInfraCleanup_SafeDefault_DryRunFalseWithoutFixStillSkipsDelete pins the +// safe-default invariant: passing --dry-run=false WITHOUT --fix must NOT +// delete resources. Cleanup is destructive; mutation requires the explicit +// --fix opt-in regardless of any --dry-run override. A future refactor that +// accidentally honors --dry-run=false alone would silently start deleting +// production resources from a flag a user thought was a preview toggle. +func TestInfraCleanup_SafeDefault_DryRunFalseWithoutFixStillSkipsDelete(t *testing.T) { + fp := &fakeEnumeratingProvider{ + stubIaCProvider: stubIaCProvider{name: "do-fake"}, + resources: []interfaces.ResourceRef{ + {Name: "vpc-1", Type: "infra.vpc"}, + }, + } + + out, _, err := runInfraCleanupForTest(t, []interfaces.IaCProvider{fp}, + "--tag", "test-tag", "--dry-run=false") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fp.deleteCallCount != 0 { + t.Errorf("safe-default invariant violated: --dry-run=false without --fix invoked Delete %d times; expected 0", fp.deleteCallCount) + } + if !strings.Contains(out, "[dry-run]") { + t.Errorf("expected [dry-run] marker even with --dry-run=false (because --fix is absent); got: %s", out) + } +} diff --git a/docs/WFCTL.md b/docs/WFCTL.md index d50797360..e3deb67ea 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -1173,6 +1173,7 @@ wfctl infra [options] [config.yaml] | `state` | Manage state storage (list/export/import) | | `outputs` | Print resource outputs from state (yaml/json/env formats) | | `refresh-outputs` | Read live outputs from each provider and reconcile state (no cloud writes) | +| `cleanup` | Tag-based force-cleanup across providers that implement `interfaces.Enumerator` | | Flag | Default | Description | |------|---------|-------------| @@ -1218,6 +1219,51 @@ wfctl infra bootstrap -c infra.yaml --env staging --force-rotate NATS_AUTH_TOKEN wfctl infra bootstrap -c infra.yaml --force-rotate FOO --force-rotate BAR ``` +#### `infra cleanup` + +Tag-based force-cleanup across every provider declared in the config. For each `iac.provider` module, type-asserts to the optional `interfaces.Enumerator`; providers that implement it are queried via `EnumerateByTag`, and the matched resources are either listed (`--dry-run`, default) or deleted (`--fix`). Providers that do **not** implement `Enumerator` are skipped with `skipped : provider does not implement Enumerator` to stdout so operators see the explicit skip. + +Used by the conformance smoke gate (`.github/workflows/conformance-smoke.yml`) to scrub resources orphaned by panicking tests before the hourly leak-scrubber cron picks them up. Safe to call from any operator workstation against any IaC config. + +``` +wfctl infra cleanup --tag NAME [-c CONFIG] [--env ENV] [--dry-run | --fix] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--tag` | _(required)_ | Tag value to match resources against. Format is provider-specific (DO uses single-string tags). | +| `-c`, `--config` | _(auto-detected)_ | Config file (searches `infra.yaml`, `config/infra.yaml`) | +| `--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`. | + +**Behaviour:** + +- Per-provider failures (enumerate or delete) are collected but do **not** short-circuit the run — one bad provider must not suppress the rest. The exit code is non-zero when any provider failed. +- Skip-on-non-Enumerator is logged to **stdout** (not stderr) so CI step output captures it as run metadata rather than as a failure signal. +- `--dry-run` defaults to `true`. Even when explicitly setting `--dry-run=false`, the safe-default invariant requires `--fix` to actually mutate cloud resources. + +**Provider support (workflow v0.21.x):** + +| Provider plugin | Implements `Enumerator`? | +|---|---| +| `workflow-plugin-digitalocean` | Yes (PR 6b follow-up; uses `godo.Tags.Get`). | +| `workflow-plugin-aws` | Not yet — skipped. | +| `workflow-plugin-gcp` | Not yet — skipped. | +| `workflow-plugin-azure` | Not yet — skipped. | + +When AWS/GCP/Azure providers gain `Enumerator` implementations on their own per-plugin cycles, the cleanup subcommand picks them up automatically — no core change required. + +**Example:** + +```bash +# Preview what would be deleted for tag "conformance-pr-123": +wfctl infra cleanup --tag conformance-pr-123 + +# Actually delete: +wfctl infra cleanup --tag conformance-pr-123 --fix +``` + #### `infra apply` Reconcile cloud infrastructure to match the desired state declared in the config. Computes a diff plan via each `iac.provider` and dispatches creates/updates/replaces/deletes through the loaded provider plugin. State is persisted after every successful action so the next run sees the cloud-truth. diff --git a/docs/conformance-runbook.md b/docs/conformance-runbook.md index 5c842a4a4..ef9bab61e 100644 --- a/docs/conformance-runbook.md +++ b/docs/conformance-runbook.md @@ -162,8 +162,11 @@ Layers, in order of execution: provider's driver API. 2. **Outer-job `always()` step** in `conformance-smoke.yml` — safety net for panicking tests where `t.Cleanup` did not run. - Currently a STUB pending `wfctl infra cleanup --tag ` - (tracked as a W-7 follow-up); falls through to layer 3. + Calls `wfctl infra cleanup --tag --fix` (PR 4 of the IaC + deferred-cleanup plan, workflow#536). The wiring is gated on + `DO_CONFORMANCE_API_TOKEN`: when the secret is unset (workflow#542 + not yet merged), the step emits a `::notice::` and falls through to + layer 3. 3. **Hourly leak-scrubber cron** (`conformance-leak-scrubber.yml`, added in T7.14) — lists DO resources tagged `conformance-pr-*` older than 1 hour and deletes them. @@ -216,13 +219,13 @@ Steps: ## Known follow-ups -- **`wfctl infra cleanup --tag ` does not exist yet.** Until - implemented, smoke jobs rely on T7.14's hourly leak scrubber to - catch orphaned resources from panicking tests. The smoke - workflow's outer-job `always()` step currently logs the cleanup - intent and defers to the scrubber. Tracked as a workflow-repo - issue (filed at PR-create time) titled "implement wfctl infra - cleanup --tag for full-wfctl conformance gate cleanup". +- ~~**`wfctl infra cleanup --tag ` does not exist yet.**~~ **CLOSED.** + Implemented in workflow PR 4 of the IaC deferred-cleanup plan + (workflow#536); see `docs/WFCTL.md` `#### infra cleanup`. The smoke + gate calls the new subcommand directly when `DO_CONFORMANCE_API_TOKEN` + is provisioned. Until workflow#542 lands the conformance token, the + cleanup step in `conformance-smoke.yml` emits a `::notice::` and the + hourly leak-scrubber remains the active safety net. - **Sister workflow in `workflow-plugin-digitalocean`** (P-DO/TP5) — provisions the actual Droplet for the smoke run. This repo's workflow currently exercises the in-tree self-tests until the diff --git a/interfaces/iac_enumerator_test.go b/interfaces/iac_enumerator_test.go new file mode 100644 index 000000000..cc455a0aa --- /dev/null +++ b/interfaces/iac_enumerator_test.go @@ -0,0 +1,37 @@ +package interfaces_test + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestEnumerator_TypeAssertionCompiles verifies the optional-interface +// pattern for Enumerator: (1) a type CAN implement Enumerator, and (2) +// a type that implements IaCProvider is NOT required to implement +// Enumerator. The opt-in nature is what lets `wfctl infra cleanup --tag` +// degrade gracefully across providers that have no tag-query API: +// non-implementers are skipped with a structured stdout log. +// +// If a future change accidentally moved EnumerateByTag onto IaCProvider +// (or made Enumerator a required embedded interface), this runtime +// assertion would fail and the cleanup subcommand's skip-on-missing +// behaviour would silently break for plugins that haven't been updated. +func TestEnumerator_TypeAssertionCompiles(t *testing.T) { + // (1) fakeEnumerator implements Enumerator. + var _ interfaces.Enumerator = (*fakeEnumerator)(nil) + + // (2) mockProvider implements IaCProvider but NOT Enumerator. + // mockProvider is defined in iac_test.go (same package). + var p interfaces.IaCProvider = (*mockProvider)(nil) + if _, ok := p.(interfaces.Enumerator); ok { + t.Errorf("mockProvider should not satisfy Enumerator; the optional-interface idiom requires the negative case to assert false") + } +} + +type fakeEnumerator struct{} + +func (f *fakeEnumerator) EnumerateByTag(ctx context.Context, tag string) ([]interfaces.ResourceRef, error) { + return []interfaces.ResourceRef{{Name: "test", Type: "infra.compute"}}, nil +} diff --git a/interfaces/iac_provider.go b/interfaces/iac_provider.go index 10d2e330b..95b51a6ba 100644 --- a/interfaces/iac_provider.go +++ b/interfaces/iac_provider.go @@ -63,6 +63,27 @@ type ProviderPlanner interface { PlanV2(ctx context.Context, desired []ResourceSpec, current []ResourceState) (IaCPlan, error) } +// Enumerator is an OPTIONAL interface for providers that can list resources +// by tag across the cloud account. Used by `wfctl infra cleanup --tag `. +// Providers without a tag-query API simply do not implement it; the cleanup +// subcommand skips them with a structured stdout log line so operators see +// the explicit skip rather than silent under-cleanup. +// +// The contract is intentionally narrow: implementations MUST return refs that +// the same provider's ResourceDriver(type).Delete can act on. ProviderID is +// recommended (the cleanup command may use it for log correlation), but Name +// + Type are the load-bearing identifiers Delete needs. +// +// Callers MUST type-assert against this interface and treat the negative +// case as a skip — providers may or may not implement Enumerator depending +// on whether their cloud API exposes a tag-query primitive. The +// implementation status of individual provider plugins is documented in +// docs/WFCTL.md `#### infra cleanup`, not here, so the API comment does +// not go stale every time a new plugin gains tag-query support. +type Enumerator interface { + EnumerateByTag(ctx context.Context, tag string) ([]ResourceRef, error) +} + // BootstrapResult contains metadata returned by a successful BootstrapStateBackend call. type BootstrapResult struct { // Bucket is the name of the created or confirmed state bucket/container.